Skip to content

Instantly share code, notes, and snippets.

@OXY2DEV
Last active April 15, 2025 08:27
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

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 = "Comments",
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 lsp_hover.window and vim.api.nvim_win_is_valid(lsp_hover.window) then
--- If Hover window is active then switch to that
--- window.
vim.api.nvim_set_current_win(lsp_hover.window);
return;
elseif 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[]
local lines = {};
local ft;
if type(result.contents) == "table" and result.contents.kind == "plaintext" then
ft = "text";
else
ft = "markdown";
end
lines = vim.split(result.contents.value or "", "\n", { trimempty = true });
---@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 ()
vim.lsp.buf_request(0, 'textDocument/hover', vim.lsp.util.make_position_params(), lsp_hover.hover)
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;
@edte
Copy link

edte commented Jan 24, 2025

Hello, it not useful after config

image

my config

    {
        name = "hover",
        dir = "lsp.lsp_hover",
        virtual = true,
        config = function()
            require("lsp.lsp_hover").setup();
        end,
    },
image

@OXY2DEV
Copy link
Author

OXY2DEV commented Jan 24, 2025

Why not just add require("lsp.lsp_hover").setup() to the end of your init.lua?

There shouldn't be any gains from lazy loading.

Also dir expects a plugin directory. So, you probably need something like this,

📂 lsp
    📂 lsp_hover
        📂 lua
            📜 lsp_hover.lua

@N1v3x2
Copy link

N1v3x2 commented Apr 14, 2025

Unfortunately no longer works in neovim 0.11 since we can't override the LSP handlers I believe

@OXY2DEV
Copy link
Author

OXY2DEV commented Apr 14, 2025

@N1v3x2 that's due to vim.lsp.buf.hover() no longer triggering global handlers(see :h news-breaking).

You can however manually trigger it(see the first example in :h vim.lsp.handlers).

@N1v3x2
Copy link

N1v3x2 commented Apr 14, 2025

Sorry if this is a dumb question, but could you tell me what's wrong with this config? Double checked that the require path is right

require('custom.lsp.lsp_hover').setup()
vim.api.nvim_create_autocmd('LspAttach', {
  ---@diagnostic disable: unused-local
  callback = function(event)
    local client = assert(vim.lsp.get_client_by_id(event.data.client_id))
    client:request 'textDocument/hover'
  end,
})

@OXY2DEV
Copy link
Author

OXY2DEV commented Apr 14, 2025

As far as I can tell you are supposed to use set it as a keymap.

But I haven't managed to get it working for me.

I will update the gist after I figure out how to get it to work like before.

@OXY2DEV
Copy link
Author

OXY2DEV commented Apr 14, 2025

@N1v3x2 I have updated the gist. It should work properly now.

@N1v3x2
Copy link

N1v3x2 commented Apr 14, 2025

@OXY2DEV How hard would it be to port this custom UI over to nvim-cmp? Love the way it looks, but wish it was consistent with my completions.

@OXY2DEV
Copy link
Author

OXY2DEV commented Apr 14, 2025

It's not very hard since all it's doing is just calling require("markview").render({...}).

You can use an autocmd(maybe check :h WinNew) to manually draw the preview. You can set ignore_buftypes = {}(see here) in markview's config which should automatically show previews.

You can also check nvim-cmp's repo and check if they have callbacks for this.

Note

I have tested this in blink.cmp and the description's are mostly plaintext, so you won't see any drastic changes(this is for lua_ls, other LSP's may have different behavior).

@edte
Copy link

edte commented Apr 15, 2025

@N1v3x2 I have updated the gist. It should work properly now.

hello, it report error Undefined global hover on 345 line number

@N1v3x2
Copy link

N1v3x2 commented Apr 15, 2025

@edte it's a small typo; change it to lsp_hover.hover and it'll work

@OXY2DEV
Copy link
Author

OXY2DEV commented Apr 15, 2025

@N1v3x2 I have updated the gist. It should work properly now.

hello, it report error Undefined global hover on 345 line number

I have fixed the typo.

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