From 6a6431cb99f411a85ffb3157023cd4726614485d Mon Sep 17 00:00:00 2001 From: jdrouhard Date: Tue, 7 Jul 2026 11:23:59 -0500 Subject: [PATCH] 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. --- runtime/lua/vim/lsp/codelens.lua | 215 +++++++------------ runtime/lua/vim/lsp/inlay_hint.lua | 5 +- runtime/lua/vim/lsp/semantic_tokens.lua | 5 +- test/functional/plugin/lsp/codelens_spec.lua | 88 ++++---- 4 files changed, 118 insertions(+), 195 deletions(-) diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 821a74771d..14be97c1fd 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -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 row -> lens +---@field row_lenses table ---@field namespace integer - +---@field version? integer `TextDocument` version current state corresponds to. +--- ---@class (private) vim.lsp.codelens.Provider : vim.lsp.Capability ----@field active table ---- ---- `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 +---@field active table --- --- Index In the form of client_id -> client_state ---@field client_state? table ---- ---- 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 diff --git a/runtime/lua/vim/lsp/inlay_hint.lua b/runtime/lua/vim/lsp/inlay_hint.lua index d5a0b672dd..28149a87e1 100644 --- a/runtime/lua/vim/lsp/inlay_hint.lua +++ b/runtime/lua/vim/lsp/inlay_hint.lua @@ -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 diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua index 5bacb34de6..57856c5c63 100644 --- a/runtime/lua/vim/lsp/semantic_tokens.lua +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -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 diff --git a/test/functional/plugin/lsp/codelens_spec.lua b/test/functional/plugin/lsp/codelens_spec.lua index e3731c1e16..14c94bcc10 100644 --- a/test/functional/plugin/lsp/codelens_spec.lua +++ b/test/functional/plugin/lsp/codelens_spec.lua @@ -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} |