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