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>
This commit is contained in:
Mike J McGuirk
2026-02-08 16:10:41 -05:00
committed by GitHub
parent 1519a34e43
commit 15ff454443
3 changed files with 89 additions and 36 deletions

View File

@@ -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

View File

@@ -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')

View File

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