refactor(lsp): use LspNotify for codelens #40604

Problem: The codelens LSP module was using its own raw buffer events and
its own debounce mechanism for refreshing code lens in attached buffers.

Solution: Switch the module to using the LspNotify autocmd events.
LspNotify fires just after document versions are synced with the server
and provides a built in debounce mechanism for changes.

Additionally, this fixes some bugs with the previous implementation:

1. The workspace/codeLens/refresh handler  re-requested codelens for all
   buffers but when the response came back, it forced an extra redraw
   after clearing the work the handler had just done.
2. Document synchronization was reworked to be more resilient to
   multiple clients providing codelens for a single buffer. The latest
   document version is now separately tracked per client (and per
   client's lenses per row) instead of for the buffer as a whole. This
   allows the on_win() function to properly redraw all codelens even
   when different clients' responses for a particular document version
   come back at different times.
This commit is contained in:
jdrouhard
2026-07-07 11:23:59 -05:00
committed by GitHub
parent 5231ad1357
commit 6a6431cb99
4 changed files with 118 additions and 195 deletions

View File

@@ -1,32 +1,27 @@
local util = require('vim.lsp.util')
local log = require('vim.lsp.log')
local tableclear = require('vim._core.table').clear
local api = vim.api
local log = require('vim.lsp.log')
local nvim_on = require('vim._core.util').nvim_on
local tableclear = require('vim._core.table').clear
local util = require('vim.lsp.util')
local M = {}
local Capability = require('vim.lsp._capability')
---@class (private) vim.lsp.codelens.RowLenses
---@field lenses lsp.CodeLens[]
---@field version? integer `TextDocument` version most recently applied to this row.
---
---@class (private) vim.lsp.codelens.ClientState
---@field row_lenses table<integer, lsp.CodeLens[]?> row -> lens
---@field row_lenses table<integer, vim.lsp.codelens.RowLenses>
---@field namespace integer
---@field version? integer `TextDocument` version current state corresponds to.
---
---@class (private) vim.lsp.codelens.Provider : vim.lsp.Capability
---@field active table<integer, vim.lsp.codelens.Provider?>
---
--- `TextDocument` version current state corresponds to.
---@field version? integer
---
--- Last version of codelens applied to this line.
---
--- Index In the form of row -> true?
---@field row_version table<integer, integer?>
---@field active table<integer, vim.lsp.codelens.Provider>
---
--- Index In the form of client_id -> client_state
---@field client_state? table<integer, vim.lsp.codelens.ClientState?>
---
--- Timer for debouncing automatic requests.
---
---@field timer? uv.uv_timer_t
local Provider = {
name = 'codelens',
method = 'textDocument/codeLens',
@@ -42,46 +37,34 @@ Capability.all[Provider.name] = Provider
function Provider:new(bufnr)
---@type vim.lsp.codelens.Provider
self = Capability.new(self, bufnr)
self.client_state = {}
self.row_version = {}
api.nvim_buf_attach(bufnr, false, {
on_lines = function(_, buf)
local provider = Provider.active[buf]
if not provider then
return true
end
provider:automatic_request()
end,
on_reload = function(_, buf)
local provider = Provider.active[buf]
if provider then
provider:clear()
provider:automatic_request()
end
end,
})
nvim_on('LspNotify', self.augroup, { buf = self.bufnr }, function(ev)
local client_id = ev.data.client_id ---@type integer
if not self.client_state[client_id] then
return
end
if ev.data.method == 'textDocument/didClose' then
self:clear(client_id)
end
if ev.data.method == 'textDocument/didChange' or ev.data.method == 'textDocument/didOpen' then
self:request(client_id)
end
end)
return self
end
---@package
function Provider:destroy()
self:reset_timer()
api.nvim_del_augroup_by_id(self.augroup)
self.active[self.bufnr] = nil
end
---@package
---@param client_id integer
function Provider:on_attach(client_id)
local state = self.client_state[client_id]
if not state then
state = {
if not self.client_state[client_id] then
self.client_state[client_id] = {
namespace = api.nvim_create_namespace('nvim.lsp.codelens:' .. client_id),
row_lenses = {},
}
self.client_state[client_id] = state
end
self:request(client_id)
end
@@ -91,23 +74,20 @@ end
function Provider:on_detach(client_id)
local state = self.client_state[client_id]
if state then
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
self:clear(client_id)
self.client_state[client_id] = nil
end
end
---@package
function Provider:clear()
self:reset_timer()
self.version = nil
tableclear(self.row_version)
for _, state in pairs(self.client_state) do
---@param client_id integer
function Provider:clear(client_id)
local state = self.client_state[client_id]
if state then
state.version = nil
tableclear(state.row_lenses)
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
end
api.nvim__redraw({ buf = self.bufnr, valid = true, flush = false })
end
--- `lsp.Handler` for `textDocument/codeLens`.
@@ -131,71 +111,34 @@ function Provider:handler(err, result, ctx)
return
end
local row_lenses = state.row_lenses
tableclear(row_lenses)
tableclear(state.row_lenses)
-- Code lenses should only span a single line.
for _, lens in ipairs(result or {}) do
local row = lens.range.start.line
local lenses = row_lenses[row] or {}
table.insert(lenses, lens)
row_lenses[row] = lenses
local row_lenses = state.row_lenses[row] or { lenses = {} }
table.insert(row_lenses.lenses, lens)
state.row_lenses[row] = row_lenses
end
self.version = ctx.version
state.version = ctx.version
api.nvim__redraw({ buf = self.bufnr, valid = true, flush = false })
end
---@package
---@param client_id? integer
---@param on_response? function
function Provider:request(client_id, on_response)
---@param client_id integer
function Provider:request(client_id)
---@type lsp.CodeLensParams
local params = { textDocument = util.make_text_document_params(self.bufnr) }
for id in pairs(self.client_state) do
if not client_id or client_id == id then
local client = assert(vim.lsp.get_client_by_id(id))
client:request('textDocument/codeLens', params, function(...)
self:handler(...)
if on_response then
on_response()
end
end, self.bufnr)
end
local state = self.client_state[client_id]
local client = vim.lsp.get_client_by_id(client_id)
if state and client then
client:request('textDocument/codeLens', params, function(...)
self:handler(...)
end, self.bufnr)
end
end
---@private
function Provider:reset_timer()
local timer = self.timer
if timer then
self.timer = nil
if not timer:is_closing() then
timer:stop()
timer:close()
end
end
end
--- Automatically request with debouncing, used as callbacks in autocmd events.
---
---@package
function Provider:automatic_request()
self:reset_timer()
self.timer = vim.defer_fn(function()
self:request()
end, 200)
end
---@package
---@param client_id integer
---@param on_response function
function Provider:refresh(client_id, on_response)
self:reset_timer()
self:request(client_id, on_response)
end
---@private
---@param client vim.lsp.Client
---@param unresolved_lens lsp.CodeLens
@@ -217,17 +160,17 @@ function Provider:resolve(client, unresolved_lens)
end
local row = unresolved_lens.range.start.line
local lenses = state.row_lenses[row]
local row_lenses = state.row_lenses[row]
-- A newer textDocument/codeLens response can replace row_lenses while resolve is in flight.
if not lenses then
if not row_lenses then
return
end
for i, lens in ipairs(lenses) do
for i, lens in ipairs(row_lenses.lenses) do
-- Only apply if this exact unresolved lens still exists; otherwise response is stale.
if lens == unresolved_lens then
lenses[i] = resolved_lens
self.row_version[row] = nil
row_lenses.lenses[i] = resolved_lens
row_lenses.version = nil
api.nvim__redraw({
buf = self.bufnr,
range = { row, row + 1 },
@@ -245,27 +188,31 @@ end
---@param botrow integer
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
local lenses = state.row_lenses[row]
if not lenses then
api.nvim_buf_clear_namespace(bufnr, namespace, row, row + 1)
else
table.sort(lenses, function(a, b)
for client_id, state in pairs(self.client_state) do
if state.version == util.buf_versions[self.bufnr] then
local row_lenses = state.row_lenses[row]
if not row_lenses then
api.nvim_buf_clear_namespace(self.bufnr, state.namespace, row, row + 1)
elseif row_lenses.version ~= state.version then
row_lenses.version = state.version
local bufnr = self.bufnr
local namespace = state.namespace
table.sort(row_lenses.lenses, function(a, b)
return a.range.start.character < b.range.start.character
end)
local client = assert(vim.lsp.get_client_by_id(client_id))
local range = vim.range.lsp(bufnr, lenses[1].range, client.offset_encoding)
local range = vim.range.lsp(bufnr, row_lenses.lenses[1].range, client.offset_encoding)
---@type [string, string][]
local virt_text = {
{ string.rep(' ', range.start_col), 'LspCodeLensSeparator' },
}
local has_unresolved = false
for _, lens in ipairs(lenses) do
for _, lens in ipairs(row_lenses.lenses) do
-- A code lens is unresolved when no command is associated to it.
if not lens.command then
has_unresolved = true
@@ -310,7 +257,6 @@ function Provider:on_win(toprow, botrow)
end
end
end
self.row_version[row] = self.version
end
end
end
@@ -393,8 +339,8 @@ function M.get(filter)
---@type lsp.CodeLens[]
local result = {}
for _, state in pairs(provider.client_state) do
for _, lenses in pairs(state.row_lenses) do
result = vim.list_extend(result, lenses)
for _, row_lenses in pairs(state.row_lenses) do
result = vim.list_extend(result, row_lenses.lenses)
end
end
return result
@@ -412,8 +358,8 @@ function M.get(filter)
local result = {}
for client_id, state in pairs(provider.client_state) do
if not filter.client_id or filter.client_id == client_id then
for _, lenses in pairs(state.row_lenses) do
for _, lens in ipairs(lenses) do
for _, row_lenses in pairs(state.row_lenses) do
for _, lens in ipairs(row_lenses.lenses) do
table.insert(result, { client_id = client_id, lens = lens })
end
end
@@ -516,18 +462,13 @@ function M.on_refresh(err, _, ctx)
return vim.NIL
end
for bufnr, provider in pairs(Provider.active) do
for client_id in pairs(provider.client_state) do
if client_id == ctx.client_id then
provider:refresh(client_id, function()
if api.nvim_buf_is_valid(bufnr) then
tableclear(provider.row_version)
vim.api.nvim__redraw({ buf = bufnr, valid = true, flush = false })
end
end)
end
for _, provider in pairs(Provider.active) do
local state = provider.client_state[ctx.client_id]
if state then
provider:request(ctx.client_id)
end
end
return vim.NIL
end

View File

@@ -235,9 +235,8 @@ function M.on_refresh(err, _, ctx)
return vim.NIL
end
for bufnr in pairs(vim.lsp.get_client_by_id(ctx.client_id).attached_buffers or {}) do
local provider = InlayHint.active[bufnr]
if provider and provider.client_state[ctx.client_id] then
for bufnr, provider in pairs(InlayHint.active) do
if provider.client_state[ctx.client_id] then
provider:reset(ctx.client_id)
if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then

View File

@@ -994,9 +994,8 @@ function M._refresh(err, _, ctx)
return vim.NIL
end
for bufnr in pairs(vim.lsp.get_client_by_id(ctx.client_id).attached_buffers or {}) do
local highlighter = STHighlighter.active[bufnr]
if highlighter and highlighter.client_state[ctx.client_id] then
for bufnr, highlighter in pairs(STHighlighter.active) do
if highlighter.client_state[ctx.client_id] then
highlighter:mark_dirty(ctx.client_id)
if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then

View File

@@ -87,12 +87,18 @@ describe('vim.lsp.codelens', function()
before_each(function()
clear_notrace()
exec_lua(create_server_definition)
exec_lua(function()
vim.lsp.config('*', {
flags = { debounce_text_changes = 0 },
})
end)
screen = Screen.new(nil, 20)
client_id = exec_lua(function()
_G.server = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
codeLensProvider = {
resolveProvider = true,
},
@@ -251,8 +257,27 @@ describe('vim.lsp.codelens', function()
end)
it('refreshes code lenses on request', function()
local get_message_count = function()
local messages = exec_lua('return _G.server.messages')
local count = 0
for _, m in ipairs(messages) do
if m.method == 'textDocument/codeLens' then
count = count + 1
end
end
return count
end
eq(2, get_message_count())
feed('ggdd')
-- Second codelens (▶︎ Run) is not resolved immediately, so its previous extmark (same row) isn't
-- cleared and it stays anchored to where it was. The new one (which would effectively look like
-- it moved a row down) needs to be resolved first, which goes through another screen update.
-- This is a quirk of the test since it returns the exact same result no matter what the text in
-- the buffer actually is
screen:expect([[
{1: 1 implementation} |
^a: i32, |
@@ -275,13 +300,9 @@ describe('vim.lsp.codelens', function()
{1:~ }|
|
]])
exec_lua(function()
vim.lsp.codelens.on_refresh(
nil,
nil,
{ method = 'workspace/codeLens/refresh', client_id = client_id }
)
end)
eq(2, get_message_count())
screen:expect([[
{1: 1 implementation} |
^a: i32, |
@@ -304,58 +325,18 @@ describe('vim.lsp.codelens', function()
{1:~ }|
|
]])
end)
it('refreshes immediately and cancels a pending automatic refresh', function()
eq(3, get_message_count())
exec_lua(function()
local deferred
local request_count = 0
local defer_fn = vim.defer_fn
local client = assert(vim.lsp.get_client_by_id(client_id))
local request = client.request
--- @diagnostic disable-next-line: duplicate-set-field
vim.defer_fn = function(callback)
deferred = {
callback = callback,
closed = false,
stopped = false,
is_closing = function(self)
return self.closed
end,
stop = function(self)
self.stopped = true
end,
close = function(self)
self.closed = true
end,
}
return deferred
end
client.request = function(self, method, ...)
if method == 'textDocument/codeLens' then
request_count = request_count + 1
end
return request(self, method, ...)
end
vim.api.nvim_buf_set_lines(0, 0, 0, false, { '// changed' })
assert(deferred, 'expected pending automatic codelens refresh')
vim.lsp.codelens.on_refresh(
nil,
nil,
{ method = 'workspace/codeLens/refresh', client_id = client_id }
)
vim.defer_fn = defer_fn
client.request = request
assert(deferred.stopped, 'expected pending codelens refresh to stop')
assert(deferred.closed, 'expected pending codelens refresh to close')
assert(request_count == 1, 'expected exactly one immediate codelens refresh request')
end)
eq(4, get_message_count())
end)
it('ignores stale codeLens/resolve responses', function()
@@ -369,6 +350,7 @@ describe('vim.lsp.codelens', function()
_G.stale_resolve_sent = false
_G.server = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
codeLensProvider = {
resolveProvider = true,
},
@@ -446,6 +428,7 @@ describe('vim.lsp.codelens', function()
_G.refresh_response_sent = false
_G.server = _G._create_server({
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
codeLensProvider = {
resolveProvider = true,
},
@@ -529,11 +512,12 @@ describe('vim.lsp.codelens', function()
} |
} |
|
{1: ▶︎ Run } |
{1:▶︎ Run } |
^ |
{1:~ }|*5
4 fewer lines |
]])
feed('dd')
screen:expect([[
{1: 1 implementation} |