From 150088551ec3734d75e4c57f9033fc96b1e71a8b Mon Sep 17 00:00:00 2001 From: jdrouhard Date: Fri, 26 Jun 2026 14:19:42 -0500 Subject: [PATCH] fix(lsp): define autocmds for capabilities once per buffer #40435 fix(lsp): define autocmds for capabilities in new(), not on_attach() Problem: Defining autocmds in on_attach() caused issues when multiple clients provide the same capability for a buffer. Each attaching client would "replace" the previously defined one since they are all identical. Then the first one to detach clears them out and any remaining attached clients would no longer trigger the autocmd for the capability. Further, the semantic tokens module itself didn't quite work with multiple clients since any LspNotify (from any client) would send a token request to all attached clients with no differentiation and the debounce timer was shared across all clients. Solution: Always define the buffer-local autocmds in the capability's `new()` function, and don't mess with the autocmds in on_attach or on_detach. The capability framework itself will clear the autocmds when the last client detaches. Also, refactor a bit of the semantic tokens module so that the various methods take a specific client_id to perform the work on, and split out timers so each client has its own. --- runtime/lua/vim/lsp/linked_editing_range.lua | 29 +++-- runtime/lua/vim/lsp/semantic_tokens.lua | 128 +++++++++++-------- 2 files changed, 92 insertions(+), 65 deletions(-) diff --git a/runtime/lua/vim/lsp/linked_editing_range.lua b/runtime/lua/vim/lsp/linked_editing_range.lua index 2a9ff1f7e6..10aace6096 100644 --- a/runtime/lua/vim/lsp/linked_editing_range.lua +++ b/runtime/lua/vim/lsp/linked_editing_range.lua @@ -164,6 +164,25 @@ function LinkedEditor:refresh() end) end +---@package +function LinkedEditor:new(bufnr) + self = Capability.new(self, bufnr) + + nvim_on({ 'TextChanged', 'TextChangedI' }, self.augroup, { buf = self.bufnr }, function() + for _, state in pairs(self.client_state) do + update_ranges(self.bufnr, state) + end + + self:refresh() + end) + + nvim_on('CursorMoved', self.augroup, { buf = self.bufnr }, function() + self:refresh() + end) + + return self +end + ---@package ---@param client_id integer function LinkedEditor:on_attach(client_id) @@ -176,15 +195,6 @@ function LinkedEditor:on_attach(client_id) self.client_state[client_id] = state end - nvim_on({ 'TextChanged', 'TextChangedI' }, self.augroup, { buf = self.bufnr }, function() - update_ranges(self.bufnr, state) - self:refresh() - end) - - nvim_on('CursorMoved', self.augroup, { buf = self.bufnr }, function() - self:refresh() - end) - self:refresh() end @@ -195,7 +205,6 @@ function LinkedEditor:on_detach(client_id) if client_state then --TODO: delete namespace if/when that becomes possible clear_ranges(self.bufnr, client_state) - api.nvim_clear_autocmds({ group = self.augroup }) self.client_state[client_id] = nil end end diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua index 685070da1e..b985a00762 100644 --- a/runtime/lua/vim/lsp/semantic_tokens.lua +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -36,12 +36,12 @@ local M = {} --- @field active_request STActiveRequest --- @field active_range_request STActiveRequest --- @field current_result STCurrentResult +--- @field timer uv.uv_timer_t? uv_timer for debouncing requests for new tokens --- @field has_full_result boolean ---@class (private) STHighlighter : vim.lsp.Capability ---@field active table ---@field debounce integer milliseconds to debounce requests for new tokens ----@field timer uv.uv_timer_t? uv_timer for debouncing requests for new tokens ---@field client_state table local STHighlighter = { name = 'semantic_tokens', @@ -207,6 +207,34 @@ local function tokens_to_ranges(data, bufnr, client, request, ranges) return ranges end +---@package +function STHighlighter:new(bufnr) + self = Capability.new(self, bufnr) + + nvim_on('LspNotify', self.augroup, { buf = self.bufnr }, function(opts) + local client_id = opts.data.client_id ---@type integer + if opts.data.method == 'textDocument/didClose' then + self:reset(client_id) + end + + if + opts.data.method == 'textDocument/didChange' or opts.data.method == 'textDocument/didOpen' + then + self:send_request(client_id) + end + end) + + nvim_on('WinScrolled', self.augroup, { buf = self.bufnr }, function() + for client_id, state in pairs(self.client_state) do + if state.supports_range then + self:debounce_request(client_id) + end + end + end) + + return self +end + ---@package function STHighlighter:on_attach(client_id) local client = vim.lsp.get_client_by_id(client_id) @@ -228,25 +256,7 @@ function STHighlighter:on_attach(client_id) self.client_state[client_id] = state end - nvim_on('LspNotify', self.augroup, { buf = self.bufnr }, function(opts) - if opts.data.method == 'textDocument/didClose' then - self:reset() - end - - if - opts.data.method == 'textDocument/didChange' or opts.data.method == 'textDocument/didOpen' - then - self:send_request() - end - end) - - if state.supports_range then - nvim_on('WinScrolled', self.augroup, { buf = self.bufnr }, function() - self:debounce_request() - end) - end - - self:send_request() + self:send_request(client_id) end ---@package @@ -255,7 +265,6 @@ function STHighlighter:on_detach(client_id) if state then --TODO: delete namespace if/when that becomes possible api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) - api.nvim_clear_autocmds({ group = self.augroup }) self.client_state[client_id] = nil end end @@ -277,28 +286,30 @@ end --- facilitate document synchronization in the response. --- ---@package -function STHighlighter:send_request() +---@param client_id integer +function STHighlighter:send_request(client_id) local version = util.buf_versions[self.bufnr] - self:reset_timer() + self:reset_timer(client_id) - for client_id, state in pairs(self.client_state) do - local client = vim.lsp.get_client_by_id(client_id) - if client then - -- If the server supports range and there's no full result yet, then start with a range - -- request - if state.supports_range and not state.has_full_result then - self:send_range_request(client, state, version) - end + local client = vim.lsp.get_client_by_id(client_id) + local state = self.client_state[client_id] - if - (not state.has_full_result or state.current_result.version ~= version) - and state.active_request.version ~= version - then - self:send_full_delta_request(client, state, version) - end + if client and state then + -- If the server supports range and there's no full result yet, then start with a range + -- request + if state.supports_range and not state.has_full_result then + self:send_range_request(client, state, version) + end + + if + (not state.has_full_result or state.current_result.version ~= version) + and state.active_request.version ~= version + then + self:send_full_delta_request(client, state, version) end end + -- end end --- Send a range request for the visible area @@ -713,13 +724,13 @@ end --- Reset the buffer's highlighting state and clears the extmark highlights. --- ---@package -function STHighlighter:reset() - for client_id, state in pairs(self.client_state) do - api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) - state.current_result = {} - state.has_full_result = false - self:cancel_active_request(client_id) - end +---@param client_id integer +function STHighlighter:reset(client_id) + local state = assert(self.client_state[client_id]) + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + state.current_result = {} + state.has_full_result = false + self:cancel_active_request(client_id) end --- Mark a client's results as dirty. This method will cancel any active @@ -747,22 +758,27 @@ function STHighlighter:mark_dirty(client_id) end ---@package -function STHighlighter:debounce_request() - self:reset_timer() +---@param client_id integer +function STHighlighter:debounce_request(client_id) + local state = assert(self.client_state[client_id]) + + self:reset_timer(client_id) if self.debounce > 0 then - self.timer = vim.defer_fn(function() - self:send_request() + state.timer = vim.defer_fn(function() + self:send_request(client_id) end, self.debounce) else - self:send_request() + self:send_request(client_id) end end ---@private -function STHighlighter:reset_timer() - local timer = self.timer +---@param client_id integer +function STHighlighter:reset_timer(client_id) + local state = self.client_state[client_id] + local timer = state.timer if timer then - self.timer = nil + state.timer = nil if not timer:is_closing() then timer:stop() timer:close() @@ -914,8 +930,10 @@ function M.force_refresh(bufnr) for _, buffer in ipairs(buffers) do local highlighter = STHighlighter.active[buffer] if highlighter then - highlighter:reset() - highlighter:send_request() + for client_id, _ in pairs(highlighter.client_state) do + highlighter:reset(client_id) + highlighter:send_request(client_id) + end end end end @@ -974,7 +992,7 @@ function M._refresh(err, _, ctx) if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then -- some LSPs send rapid fire refresh notifications, so we'll debounce them with debounce_request() - highlighter:debounce_request() + highlighter:debounce_request(ctx.client_id) end end end