neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit 15ff45444325a4395b564bafad9d38e04f2501f9
parent 1519a34e43df315649ba09d4865d4dd4268a76b4
Author: Mike J McGuirk <62523234+mikejmcguirk@users.noreply.github.com>
Date:   Sun,  8 Feb 2026 16:10:41 -0500

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 <ofseed@foxmail.com>
Diffstat:
Mruntime/doc/news.txt | 1+
Mruntime/lua/vim/lsp/codelens.lua | 43+++++++++++++++++++++++++++++++------------
Mtest/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 @@ -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 @@ -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 @@ -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,13 +295,36 @@ 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)