backport: fix(lsp): make LspNotify more robust (#40341)

Problem: LspNotify never passed a buffer when executing the autocmds, so
buffer-local LspNotify autocmd subscriptions didn't have the correct buf
in the event metadata. It was also wrapped in a schedule() so the actual
autocmd was delayed until after the event loop.

This could result in the wrong buffer receiving the notification if
multiple LspNotify autocmds with buffer filters were added. Only the
"latest" one would actually receive non-buffer-filtered autocmds, not
the matching one. It also caused listeners to receive the notification
"out of sync" with when the notification is actually sent. If a buffer
is being deleted (which fires a textDocument/didClose notification), the
notification is scheduled and fired after the buffer is already gone.

Solution: For LSP notifications that pertain to a particular buffer, set
it when executing the LspNotify autocmds so the callback functions that
are filtered on that buffer will get the correct notifications and the
metadata buf field will be correct. Additionally, there is no need to
wrap the LspNotify callback in vim.schedule when it can be called inline
when the notification to the rpc server is fired.

This is tested by removing now-unnecessary autocmds from semantic tokens
(InsertEnter and BufWinEnter should no longer be necessary now that
requests are fired by LspNotify). Without this fix, simply modifying a
buffer doesn't actually trigger LspNotify correctly, and the test for
that fails.

(cherry picked from commit 54188fa242)

Co-authored-by: jdrouhard <john@drouhard.dev>
This commit is contained in:
Justin M. Keyes
2026-06-20 16:19:02 -04:00
committed by GitHub
co-authored by jdrouhard
parent 41dbb828bd
commit 3dd3a6ceab
6 changed files with 27 additions and 33 deletions
+3 -2
View File
@@ -1743,7 +1743,7 @@ Lua module: vim.lsp.client *lsp-client*
• {is_stopped} (`fun(self: vim.lsp.Client): boolean`) See • {is_stopped} (`fun(self: vim.lsp.Client): boolean`) See
|Client:is_stopped()|. |Client:is_stopped()|.
• {name} (`string`) See |vim.lsp.ClientConfig|. • {name} (`string`) See |vim.lsp.ClientConfig|.
• {notify} (`fun(self: vim.lsp.Client, method: string, params: table?): boolean`) • {notify} (`fun(self: vim.lsp.Client, method: string, params: table?, bufnr: integer?): boolean`)
See |Client:notify()|. See |Client:notify()|.
• {offset_encoding} (`'utf-8'|'utf-16'|'utf-32'`) See • {offset_encoding} (`'utf-8'|'utf-16'|'utf-32'`) See
|vim.lsp.ClientConfig|. |vim.lsp.ClientConfig|.
@@ -1967,12 +1967,13 @@ Client:is_stopped() *Client:is_stopped()*
(`boolean`) true if client is stopped or in the process of being (`boolean`) true if client is stopped or in the process of being
stopped; false otherwise stopped; false otherwise
Client:notify({method}, {params}) *Client:notify()* Client:notify({method}, {params}, {bufnr}) *Client:notify()*
Sends a notification to an LSP server. Sends a notification to an LSP server.
Parameters: ~ Parameters: ~
• {method} (`string`) LSP method name. • {method} (`string`) LSP method name.
• {params} (`table?`) LSP request params. • {params} (`table?`) LSP request params.
• {bufnr} (`integer?`) Buffer associated with notification.
Return: ~ Return: ~
(`boolean`) status indicating if the notification was successful. If (`boolean`) status indicating if the notification was successful. If
+2 -2
View File
@@ -922,7 +922,7 @@ local function buf_attach(bufnr)
reason = protocol.TextDocumentSaveReason.Manual, ---@type integer reason = protocol.TextDocumentSaveReason.Manual, ---@type integer
} }
if client:supports_method('textDocument/willSave') then if client:supports_method('textDocument/willSave') then
client:notify('textDocument/willSave', params) client:notify('textDocument/willSave', params, bufnr)
end end
if client:supports_method('textDocument/willSaveWaitUntil') then if client:supports_method('textDocument/willSaveWaitUntil') then
local result, err = local result, err =
@@ -961,7 +961,7 @@ local function buf_attach(bufnr)
for _, client in ipairs(clients) do for _, client in ipairs(clients) do
changetracking.reset_buf(client, bufnr) changetracking.reset_buf(client, bufnr)
if client:supports_method('textDocument/didClose') then if client:supports_method('textDocument/didClose') then
client:notify('textDocument/didClose', params) client:notify('textDocument/didClose', params, bufnr)
end end
end end
for _, client in ipairs(clients) do for _, client in ipairs(clients) do
+4 -4
View File
@@ -193,7 +193,7 @@ function M._send_did_save(bufnr)
textDocument = { textDocument = {
uri = vim.uri_from_fname(old_name), uri = vim.uri_from_fname(old_name),
}, },
}) }, bufnr)
client:notify('textDocument/didOpen', { client:notify('textDocument/didOpen', {
textDocument = { textDocument = {
version = 0, version = 0,
@@ -201,7 +201,7 @@ function M._send_did_save(bufnr)
languageId = client.get_language_id(bufnr, vim.bo[bufnr].filetype), languageId = client.get_language_id(bufnr, vim.bo[bufnr].filetype),
text = vim.lsp._buf_get_full_text(bufnr), text = vim.lsp._buf_get_full_text(bufnr),
}, },
}) }, bufnr)
util.buf_versions[bufnr] = 0 util.buf_versions[bufnr] = 0
end end
local save_capability = vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'save') local save_capability = vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'save')
@@ -215,7 +215,7 @@ function M._send_did_save(bufnr)
uri = uri, uri = uri,
}, },
text = included_text, text = included_text,
}) }, bufnr)
end end
end end
end end
@@ -327,7 +327,7 @@ local function send_changes(bufnr, sync_kind, state, buf_state)
version = util.buf_versions[bufnr], version = util.buf_versions[bufnr],
}, },
contentChanges = changes, contentChanges = changes,
}) }, bufnr)
end end
end end
end end
+16 -16
View File
@@ -825,9 +825,10 @@ end
--- ---
--- @param method vim.lsp.protocol.Method.ClientToServer.Notification LSP method name. --- @param method vim.lsp.protocol.Method.ClientToServer.Notification LSP method name.
--- @param params table? LSP request params. --- @param params table? LSP request params.
--- @param bufnr integer? Buffer associated with notification.
--- @return boolean status indicating if the notification was successful. --- @return boolean status indicating if the notification was successful.
--- If it is false, then the client has shutdown. --- If it is false, then the client has shutdown.
function Client:notify(method, params) function Client:notify(method, params, bufnr)
if method ~= 'textDocument/didChange' then if method ~= 'textDocument/didChange' then
changetracking.flush(self) changetracking.flush(self)
end end
@@ -835,16 +836,15 @@ function Client:notify(method, params)
local client_active = self.rpc.notify(method, params) local client_active = self.rpc.notify(method, params)
if client_active then if client_active then
vim.schedule(function() api.nvim_exec_autocmds('LspNotify', {
api.nvim_exec_autocmds('LspNotify', { buf = bufnr,
modeline = false, modeline = false,
data = { data = {
client_id = self.id, client_id = self.id,
method = method, method = method,
params = params, params = params,
}, },
}) })
end)
end end
return client_active return client_active
@@ -1116,14 +1116,14 @@ end
--- Default handler for the 'textDocument/didClose' LSP notification. --- Default handler for the 'textDocument/didClose' LSP notification.
--- ---
--- @param buf integer Number of the buffer, or 0 for current --- @param bufnr integer Number of the buffer, or 0 for current
function Client:_text_document_did_close_handler(buf) function Client:_text_document_did_close_handler(bufnr)
if not self:supports_method('textDocument/didClose') then if not self:supports_method('textDocument/didClose') then
return return
end end
local uri = vim.uri_from_bufnr(buf) local uri = vim.uri_from_bufnr(bufnr)
local params = { textDocument = { uri = uri } } local params = { textDocument = { uri = uri } }
self:notify('textDocument/didClose', params) self:notify('textDocument/didClose', params, bufnr)
end end
--- Default handler for the 'textDocument/didOpen' LSP notification. --- Default handler for the 'textDocument/didOpen' LSP notification.
@@ -1145,7 +1145,7 @@ function Client:_text_document_did_open_handler(bufnr)
languageId = self:_get_language_id(bufnr), languageId = self:_get_language_id(bufnr),
text = lsp._buf_get_full_text(bufnr), text = lsp._buf_get_full_text(bufnr),
}, },
}) }, bufnr)
-- Next chance we get, we should re-do the diagnostics -- Next chance we get, we should re-do the diagnostics
vim.schedule(function() vim.schedule(function()
+1 -9
View File
@@ -245,14 +245,6 @@ function STHighlighter:on_attach(client_id)
end, end,
}) })
api.nvim_create_autocmd({ 'BufWinEnter', 'InsertLeave' }, {
buf = self.bufnr,
group = self.augroup,
callback = function()
self:send_request()
end,
})
if state.supports_range then if state.supports_range then
api.nvim_create_autocmd('WinScrolled', { api.nvim_create_autocmd('WinScrolled', {
buf = self.bufnr, buf = self.bufnr,
@@ -811,7 +803,7 @@ function M.start(bufnr, client_id, opts)
M.enable(true, { bufnr = bufnr, client_id = client_id }) M.enable(true, { bufnr = bufnr, client_id = client_id })
if opts and opts.debounce then if opts and opts.debounce and STHighlighter.active[bufnr] then
local highlighter = STHighlighter.active[bufnr] local highlighter = STHighlighter.active[bufnr]
local prev = rawget(highlighter, 'debounce') local prev = rawget(highlighter, 'debounce')
highlighter.debounce = prev and math.max(prev, opts.debounce) or opts.debounce highlighter.debounce = prev and math.max(prev, opts.debounce) or opts.debounce
@@ -311,6 +311,7 @@ describe('semantic token highlighting', function()
exec_lua(function() exec_lua(function()
_G.server_full = _G._create_server({ _G.server_full = _G._create_server({
capabilities = { capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = { semanticTokensProvider = {
full = { delta = false }, full = { delta = false },
range = true, range = true,