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.
This commit is contained in:
jdrouhard
2026-06-26 14:19:42 -05:00
committed by GitHub
parent c98c93fcf8
commit 150088551e
2 changed files with 92 additions and 65 deletions

View File

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

View File

@@ -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<integer, STHighlighter>
---@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<integer, STClientState>
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