Skip to content

Instantly share code, notes, and snippets.

@OXY2DEV
Last active October 20, 2025 07:58
Show Gist options
  • Save OXY2DEV/645c90df32095a8a397735d0be646452 to your computer and use it in GitHub Desktop.
Save OXY2DEV/645c90df32095a8a397735d0be646452 to your computer and use it in GitHub Desktop.
A slightly fancier LSP hover for Neovim

✏️ Overview

Note

A more bleeding-edge version of this is available here.

A pretty simple custom LSP hover window that tries to solve the issues I face with the built-in one.

showcase_1 showcase_2

Note

This was designed with small screen size in mind!

❄️ How does this work?

This is done by replacing the TextDocument/hover handler with a cusom function.

See :h lsp-handlers & :h vim.lsp.handlers.hover() for more information.

📜 Features

  • Fancier LSP window(with custom footers & decorations).
  • Quadrant aware window. The LSP window can open on any of the quadrants around the cursor. Don't worry the border changes with the quadrant.
  • Per language server/hover provider configuration. Allows changing how the hover window looks based on the server name.
  • Minimum & maximum width/height. Allows clamping the hover window between a minimum & maximum width/height. No more flooding the entire screen with a single hover.
  • Wrapped text! No more needing to switch to the hover window just to see the message.
  • markview.nvim support for markdown preview support(For v25(dev branch at the moment) only)!

💻 Usage

Note

The lsp_hover.lua file must be on your $RUNTIMEPATH. Also, make sure you don't have another file with the same name.

require("lsp_hover").setup();

🔩 Configuration

require("lsp_hover").setup({
  ["^lua_ls"] = {
    border_hl = "Special"
  }
});
--- Slightly *fancier* LSP hover handler.
local lsp_hover = {};
---@class hover.opts
---
---@field border_hl? string Highlight group for the window borders.
---@field name_hl? string Highlight group for the `name`. Defaults to `border_hl`.
---@field name string
---
---@field min_width? integer
---@field max_width? integer
---
---@field min_height? integer
---@field max_height? integer
--- Configuration for lsp_hovers from different
--- servers.
---
---@type { default: hover.opts, [string]: hover.opts }
lsp_hover.config = {
default = {
border_hl = "@comment",
name = "󰗊 LSP/Hover",
min_width = 20,
max_width = math.floor(vim.o.columns * 0.75),
min_height = 1,
max_height = math.floor(vim.o.lines * 0.5)
},
["^lua_ls"] = {
name = " LuaLS",
border_hl = "@function"
}
};
--- Finds matching configuration.
--- NOTE: The output is the merge of the {config} and {default}.
---@param str string
---@return hover.opts
local match = function (str)
---+${lua}
local ignore = { "default" };
local config = lsp_hover.config.default or {};
---@type string[]
local keys = vim.tbl_keys(lsp_hover.config);
--- Sorting is nice in-case the same pattern can
--- match multiple servers.
table.sort(keys);
for _, k in ipairs(keys) do
if vim.list_contains(ignore, k) == false and string.match(str, k) then
return vim.tbl_extend("force", config, lsp_hover.config[k]);
end
end
return config;
---_
end
--- Get which quadrant to open the window on.
---
--- ```txt
--- top, left ↑ top, right
--- ← █ →
--- bottom, left ↓ bottom, right
--- ```
---@param w integer
---@param h integer
---@return [ "left" | "right" | "center", "top" | "bottom" | "center" ]
local function get_quadrant (w, h)
---+${lua}
---@type integer
local window = vim.api.nvim_get_current_win();
---@type [ integer, integer ]
local src_c = vim.api.nvim_win_get_cursor(window);
--- (Terminal) Screen position.
---@class screen.pos
---
---@field row integer Screen row.
---@field col integer First screen column.
---@field endcol integer Last screen column.
---
---@field curscol integer Cursor screen column.
local scr_p = vim.fn.screenpos(window, src_c[1], src_c[2]);
---@type integer, integer Vim's width & height.
local vW, vH = vim.o.columns, vim.o.lines - (vim.o.cmdheight or 0);
---@type "left" | "right", "top" | "bottom"
local x, y;
if scr_p.curscol - w <= 0 then
--- Not enough spaces on `left`.
if scr_p.curscol + w >= vW then
--- Not enough space on `right`.
return { "center", "center" };
else
--- Enough spaces on `right`.
x = "right";
end
else
--- Enough space on `left`.
x = "left";
end
if scr_p.row + h >= vH then
--- Not enough spaces on `top`.
if scr_p.row - h <= 0 then
--- Not enough spaces on `bottom`.
return { "center", "center" };
else
y = "top";
end
else
y = "bottom";
end
return { x, y }
---_
end
---@type integer? LSP hover buffer.
lsp_hover.buffer = nil;
---@type integer? LSP hover window.
lsp_hover.window = nil;
--- Initializes the hover buffer & window.
---@param config table
lsp_hover.__init = function (config)
---+${lua}
if not config then
return;
end
if not lsp_hover.buffer or vim.api.nvim_buf_is_valid(lsp_hover.buffer) then
pcall(vim.api.nvim_buf_delete, lsp_hover.buffer, { force = true });
lsp_hover.buffer = vim.api.nvim_create_buf(false, true);
vim.api.nvim_buf_set_keymap(lsp_hover.buffer, "n", "q", "", {
desc = "Closes LSP hover window",
callback = function ()
pcall(vim.api.nvim_win_close, lsp_hover.window, true);
lsp_hover.window = nil;
end
});
end
if not lsp_hover.window then
lsp_hover.window = vim.api.nvim_open_win(lsp_hover.buffer, false, config);
elseif vim.api.nvim_win_is_valid(lsp_hover.window) == false then
pcall(vim.api.nvim_win_close, lsp_hover.window, true);
lsp_hover.window = vim.api.nvim_open_win(lsp_hover.buffer, false, config);
else
vim.api.nvim_win_set_config(lsp_hover.window, config);
end
---_
end
--- Custom hover function.
---@param error table Error.
---@param result table Result of the hover.
---@param context table Context for this hover.
---@param _ table Hover config(we won't use this).
lsp_hover.hover = function (error, result, context, _)
---+${lua}
if error then
--- Emit error message.
vim.api.nvim_echo({
{ "  Lsp hover: ", "DiagnosticVirtualTextError" },
{ " " },
{ error.message, "Comment" }
}, true, {})
end
if vim.api.nvim_get_current_buf() ~= context.bufnr then
--- Buffer was changed before the request was
--- resolved.
return;
elseif not result or not result.contents then
--- No result.
vim.api.nvim_echo({
{ "  Lsp hover: ", "DiagnosticVirtualTextInfo" },
{ " " },
{ "No information available!", "Comment" }
}, true, {})
return;
end
---@type string | table
local content = result.contents;
---@type string[]
local lines = {};
local ft;
--[[
NOTE: LSP hover contents can be any of the followings,
1. Literal string.
2. A table(`{ kind = ..., value = ... }`).
3. A list(`{ kind = ..., value = ... }[]`).
]]
if type(content) == "string" then
lines = vim.split(content or "", "\n", { trimempty = true });
ft = "markdown";
elseif vim.islist(content) then
content = content[1];
lines = vim.split(content.value or "", "\n", { trimempty = true });
ft = content.kind;
else
lines = vim.split(content.value or "", "\n", { trimempty = true });
ft = content.kind;
end
---@type integer LSP client ID.
local client_id = context.client_id;
---@type { name: string } LSP client info.
local client = vim.lsp.get_client_by_id(client_id) or { name = "Unknown" };
---@type hover.opts
local config = match(client.name);
local w = config.min_width or 20;
local h = config.min_height or 1;
local max_height = config.max_height or 10;
local max_width = config.max_width or 60;
for _, line in ipairs(lines) do
if vim.fn.strdisplaywidth(line) >= max_width then
w = max_width;
break;
elseif vim.fn.strdisplaywidth(line) > w then
w = vim.fn.strdisplaywidth(line);
end
end
h = math.max(math.min(#lines, max_height), h);
--- Window configuration.
local win_conf = {
relative = "cursor",
row = 1, col = 0,
width = w, height = h,
style = "minimal",
footer = {
{ "╼ ", config.border_hl or "FloatBorder" },
{ config.name, config.name_hl or config.border_hl or "FloatBorder" },
{ " ╾", config.border_hl or "FloatBorder" },
},
footer_pos = "right"
};
--- Window borders.
local border = {
{ "╭", config.border_hl or "FloatBorder" },
{ "─", config.border_hl or "FloatBorder" },
{ "╮", config.border_hl or "FloatBorder" },
{ "│", config.border_hl or "FloatBorder" },
{ "╯", config.border_hl or "FloatBorder" },
{ "─", config.border_hl or "FloatBorder" },
{ "╰", config.border_hl or "FloatBorder" },
{ "│", config.border_hl or "FloatBorder" },
};
--- Which quadrant to open the window on.
---@type [ "left" | "right" | "center", "top" | "bottom" | "center" ]
local quad = get_quadrant(w + 2, h + 2);
if quad[1] == "left" then
win_conf.col = (w * -1) - 1;
elseif quad[1] == "right" then
win_conf.col = 0;
else
win_conf.relative = "editor";
win_conf.col = math.ceil((vim.o.columns - w) / 2);
end
if quad[2] == "top" then
win_conf.row = (h * -1) - 2;
if quad[1] == "left" then
border[5][1] = "┤";
else
border[7][1] = "├";
end
elseif quad[2] == "bottom" then
win_conf.row = 1;
if quad[1] == "left" then
border[3][1] = "┤";
else
border[1][1] = "├";
end
else
win_conf.relative = "editor";
win_conf.row = math.ceil((vim.o.lines - h) / 2);
end
win_conf.border = border;
lsp_hover.__init(win_conf);
vim.api.nvim_buf_set_lines(lsp_hover.buffer, 0, -1, false, lines);
vim.bo[lsp_hover.buffer].ft = ft;
vim.wo[lsp_hover.window].conceallevel = 3;
vim.wo[lsp_hover.window].concealcursor = "n";
vim.wo[lsp_hover.window].signcolumn = "no";
vim.wo[lsp_hover.window].wrap = true;
vim.wo[lsp_hover.window].linebreak = true;
if package.loaded["markview"] and package.loaded["markview"].render then
--- If markview is available use it to render stuff.
--- This is for `v25`.
require("markview").render(lsp_hover.buffer, { enable = true, hybrid_mode = false });
end
---_
end
--- Setup function.
---@param config { default: hover.opts, [string]: hover.opts } | nil
lsp_hover.setup = function (config)
---+${lua}
if config then
lsp_hover.config = vim.tbl_deep_extend("force", lsp_hover.config, config);
end
if vim.fn.has("nvim-0.11") == 1 then
vim.api.nvim_create_autocmd("LspAttach", {
callback = function (ev)
vim.api.nvim_buf_set_keymap(ev.buf, "n", "K", "", {
callback = function ()
local window = vim.api.nvim_get_current_win();
if lsp_hover.window and vim.api.nvim_win_is_valid(lsp_hover.window) then
vim.api.nvim_set_current_win(lsp_hover.window);
else
vim.lsp.buf_request(0, 'textDocument/hover', vim.lsp.util.make_position_params(window, "utf-8"), lsp_hover.hover);
end
end
});
end
});
end
--- TODO, maybe we should remove this.
--- Set-up the new provider.
vim.lsp.handlers["textDocument/hover"] = lsp_hover.hover;
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
callback = function (event)
if event.buf == lsp_hover.buffer then
--- Don't do anything if the current buffer
--- is the hover buffer.
return;
elseif lsp_hover.window and vim.api.nvim_win_is_valid(lsp_hover.window) then
pcall(vim.api.nvim_win_close, lsp_hover.window, true);
lsp_hover.window = nil;
end
end
});
---_
end
return lsp_hover;
@azdanov
Copy link

azdanov commented Aug 30, 2025

Awesome work! Was looking how to make markview render on lsp hover and this is just so much better. Thanks for making this.

@azdanov
Copy link

azdanov commented Aug 30, 2025

Would you happen to know why it might display blank when using vtsls? It shows up nicely when not using the fancy hover. And similar for jsonls maybe others too. It works for gopls and rust-analyzer nicely.

image

@OXY2DEV
Copy link
Author

OXY2DEV commented Aug 30, 2025

@azdanov it should be fixed now.

The issue happened if an LSP returned a string as the hover information instead of a table.

Note

If you have multiple LSPs attached to the same buffer and all of them return a hover information, only the last one will be shown.
Some LSPs may be slower then other. So, you might notice the text changing.

@azdanov
Copy link

azdanov commented Aug 30, 2025

Thank you 🙏

@michaelfortunato
Copy link

Hi! Could this be extended to render code docs written in restructured text?

@OXY2DEV
Copy link
Author

OXY2DEV commented Aug 31, 2025

@michaelfortunato I am not aware of any tree-sitter parsers for restructured text.

So, at the moment no.

@michaelfortunato
Copy link

michaelfortunato commented Aug 31, 2025

@michaelfortunato I am not aware of any tree-sitter parsers for restructured text.

So, at the moment no.

Got it thanks!

@xsalman01
Copy link

The issue raised by @azdanov still happens when using typescript-tools. https://github.com/pmizio/typescript-tools.nvim

@OXY2DEV
Copy link
Author

OXY2DEV commented Oct 15, 2025

@xsalman01 It should be fixed now.

@xsalman01
Copy link

thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment