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:
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)