From 15ff45444325a4395b564bafad9d38e04f2501f9 Mon Sep 17 00:00:00 2001 From: Mike J McGuirk <62523234+mikejmcguirk@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:10:41 -0500 Subject: [PATCH] feat(lsp): display codelens as virtual lines, not virtual text #36469 Problem: Code lenses currently display as virtual text on the same line and after the relevant item. While the spec does not say how lenses should be rendered, above the line is most typical. For longer lines, lenses rendered as virtual text can run off the side of the screen. Solution: Display lenses as virtual lines above the text. Closes https://github.com/neovim/neovim/issues/33923 Co-authored-by: Yi Ming --- runtime/doc/news.txt | 1 + runtime/lua/vim/lsp/codelens.lua | 43 ++++++++--- test/functional/plugin/lsp/codelens_spec.lua | 81 ++++++++++++++------ 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 2a98c30e58..c12e01a44d 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -302,6 +302,7 @@ LSP • Support for `textDocument/semanticTokens/range`. • Support for `textDocument/codeLens` |lsp-codelens| has been reimplemented: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_codeLens +• Code lenses now display as virtual lines • Support for `workspace/codeLens/refresh`: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeLens_refresh diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 87cbbe425b..ed66d023ae 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -210,9 +210,10 @@ function Provider:on_win(toprow, botrow) for row = toprow, botrow do if self.row_version[row] ~= self.version then for client_id, state in pairs(self.client_state) do + local bufnr = self.bufnr local namespace = state.namespace - api.nvim_buf_clear_namespace(self.bufnr, namespace, row, row + 1) + api.nvim_buf_clear_namespace(bufnr, namespace, row, row + 1) local lenses = state.row_lenses[row] if lenses then @@ -220,25 +221,37 @@ function Provider:on_win(toprow, botrow) return a.range.start.character < b.range.start.character end) - ---@type [string, string][] - local virt_text = {} + ---@type integer + local indent = api.nvim_buf_call(bufnr, function() + return vim.fn.indent(row + 1) + end) + + ---@type [string, string|integer][][] + local virt_lines = { { { string.rep(' ', indent), 'LspCodeLensSeparator' } } } + local virt_text = virt_lines[1] for _, lens in ipairs(lenses) do -- A code lens is unresolved when no command is associated to it. if not lens.command then - local client = assert(vim.lsp.get_client_by_id(client_id)) + local client = assert(vim.lsp.get_client_by_id(client_id)) ---@type vim.lsp.Client self:resolve(client, lens) else - vim.list_extend(virt_text, { - { lens.command.title, 'LspCodeLens' }, - { ' | ', 'LspCodeLensSeparator' }, - }) + virt_text[#virt_text + 1] = { lens.command.title, 'LspCodeLens' } + virt_text[#virt_text + 1] = { ' | ', 'LspCodeLensSeparator' } end end - -- Remove trailing separator. - table.remove(virt_text) - api.nvim_buf_set_extmark(self.bufnr, namespace, row, 0, { - virt_text = virt_text, + if #virt_text > 1 then + -- Remove trailing separator. + virt_text[#virt_text] = nil + else + -- Use a placeholder to prevent flickering caused by layout shifts. + virt_text[#virt_text + 1] = { '...', 'LspCodeLens' } + end + + api.nvim_buf_set_extmark(bufnr, namespace, row, 0, { + virt_lines = virt_lines, + virt_lines_above = true, + virt_lines_overflow = 'scroll', hl_mode = 'combine', }) end @@ -246,6 +259,12 @@ function Provider:on_win(toprow, botrow) end end end + + if botrow == api.nvim_buf_line_count(self.bufnr) - 1 then + for _, state in pairs(self.client_state) do + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, botrow, -1) + end + end end local namespace = api.nvim_create_namespace('nvim.lsp.codelens') diff --git a/test/functional/plugin/lsp/codelens_spec.lua b/test/functional/plugin/lsp/codelens_spec.lua index f9856ed90f..50c4f4d0bd 100644 --- a/test/functional/plugin/lsp/codelens_spec.lua +++ b/test/functional/plugin/lsp/codelens_spec.lua @@ -16,6 +16,7 @@ local create_server_definition = t_lsp.create_server_definition describe('vim.lsp.codelens', function() local text = dedent([[ + https://github.com/neovim/neovim/issues/16166 struct S { a: i32, b: String, @@ -34,7 +35,9 @@ describe('vim.lsp.codelens', function() ]]) local grid_with_lenses = dedent([[ - struct S { {1:1 implementation} | + ^https://github.com/neovim/neovim/issues/16166 | + {1:1 implementation} | + struct S { | a: i32, | b: String, | } | @@ -45,17 +48,18 @@ describe('vim.lsp.codelens', function() } | } | | - fn main() { {1:▶︎ Run } | + {1:▶︎ Run } | + fn main() { | let s = S::new(42, String::from("Hello, world!"))| ; | println!("S.a: {}, S.b: {}", s.a, s.b); | } | - ^ | - {1:~ }|*2 + | | ]]) local grid_without_lenses = dedent([[ + ^https://github.com/neovim/neovim/issues/16166 | struct S { | a: i32, | b: String, | @@ -72,7 +76,7 @@ describe('vim.lsp.codelens', function() ; | println!("S.a: {}, S.b: {}", s.a, s.b); | } | - ^ | + | {1:~ }|*2 | ]]) @@ -87,7 +91,7 @@ describe('vim.lsp.codelens', function() clear_notrace() exec_lua(create_server_definition) - screen = Screen.new(nil, 20) + screen = Screen.new(nil, 21) client_id = exec_lua(function() _G.server = _G._create_server({ @@ -105,7 +109,7 @@ describe('vim.lsp.codelens', function() impls = { position = { character = 7, - line = 0, + line = 1, }, }, }, @@ -114,11 +118,11 @@ describe('vim.lsp.codelens', function() range = { ['end'] = { character = 8, - line = 0, + line = 1, }, start = { character = 7, - line = 0, + line = 1, }, }, }, @@ -131,11 +135,11 @@ describe('vim.lsp.codelens', function() range = { ['end'] = { character = 7, - line = 11, + line = 12, }, start = { character = 3, - line = 11, + line = 12, }, }, }, @@ -152,11 +156,11 @@ describe('vim.lsp.codelens', function() range = { ['end'] = { character = 8, - line = 0, + line = 1, }, start = { character = 7, - line = 0, + line = 1, }, }, }) @@ -174,6 +178,7 @@ describe('vim.lsp.codelens', function() vim.lsp.codelens.enable() end) + feed('gg') screen:expect({ grid = grid_with_lenses }) end) @@ -211,11 +216,11 @@ describe('vim.lsp.codelens', function() range = { ['end'] = { character = 8, - line = 0, + line = 1, }, start = { character = 7, - line = 0, + line = 1, }, }, }, @@ -231,11 +236,11 @@ describe('vim.lsp.codelens', function() range = { ['end'] = { character = 7, - line = 11, + line = 12, }, start = { character = 3, - line = 11, + line = 12, }, }, }, @@ -244,10 +249,12 @@ describe('vim.lsp.codelens', function() end) it('refreshes code lenses on request', function() - feed('ggdd') + feed('2Gdd') screen:expect([[ - ^a: i32, {1:1 implementation} | + https://github.com/neovim/neovim/issues/16166 | + {1:1 implementation} | + ^a: i32, | b: String, | } | | @@ -257,13 +264,14 @@ describe('vim.lsp.codelens', function() } | } | | - fn main() { {1:▶︎ Run } | + {1:▶︎ Run } | + fn main() { | let s = S::new(42, String::from("Hello, world!"))| ; | println!("S.a: {}, S.b: {}", s.a, s.b); | } | | - {1:~ }|*3 + {1:~ }|*1 | ]]) exec_lua(function() @@ -274,7 +282,9 @@ describe('vim.lsp.codelens', function() ) end) screen:expect([[ - ^a: i32, {1:1 implementation} | + https://github.com/neovim/neovim/issues/16166 | + {1: 1 implementation} | + ^a: i32, | b: String, | } | | @@ -285,16 +295,39 @@ describe('vim.lsp.codelens', function() } | | fn main() { | + {1: ▶︎ Run } | let s = S::new(42, String::from("Hello, world!"))| - ; {1:▶︎ Run } | + ; | println!("S.a: {}, S.b: {}", s.a, s.b); | } | | - {1:~ }|*3 + {1:~ }|*1 | ]]) end) + it('clears extmarks beyond the bottom of the buffer', function() + feed('13G4dd') + screen:expect([[ + https://github.com/neovim/neovim/issues/16166 | + {1:1 implementation} | + struct S { | + a: i32, | + b: String, | + } | + | + impl S { | + fn new(a: i32, b: String) -> Self { | + S { a, b } | + } | + } | + | + ^ | + {1:~ }|*6 + 4 fewer lines | + ]]) + end) + after_each(function() api.nvim_exec_autocmds('VimLeavePre', { modeline = false }) end)