From 1e7edb2c52e0485542a300a330b6a66a76fa1ddb Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:56:16 +0800 Subject: [PATCH] fix(lsp): send didClose, didOpen when languageId changes #39499 Problem: If a buffer's filetype changes after the LSP client has already attached (e.g. from json to jsonc via a modeline), but the client supports both filetypes, it stays attached. It does not notify the server of the new languageId, causing the server to incorrectly process the file using the old languageId. Solution: Save the languageId used during textDocument/didOpen, and send textDocument/didClose + textDocument/didOpen when buffer's languageId changed. Lsp spec: https://github.com/microsoft/language-server-protocol/blob/0003fb53f18dc7a4ac7e51d0eb518deeea90fde5/_specifications/lsp/3.18/textDocument/didOpen.md#L5 > If the language id of a document changes, the client > needs to send a textDocument/didClose to the server followed by a > textDocument/didOpen with the new language id if the server handles > the new language id as well. AI-assisted: Gemini 3.1 Pro --- runtime/doc/lsp.txt | 3 +- runtime/doc/news.txt | 2 +- runtime/lua/vim/lsp.lua | 17 +++++++++-- runtime/lua/vim/lsp/client.lua | 23 ++++++++++----- test/functional/plugin/lsp_spec.lua | 46 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 98609a0d0b..623fef2962 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -1723,7 +1723,8 @@ Lua module: vim.lsp.client *lsp-client* *vim.lsp.Client* Fields: ~ - • {attached_buffers} (`table`) + • {attached_buffers} (`table`) Each buffer's last + used `languageId`. • {cancel_request} (`fun(self: vim.lsp.Client, id: integer): boolean`) See |Client:cancel_request()|. • {capabilities} (`lsp.ClientCapabilities`) Capabilities diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 2cf17325ca..af47bbe908 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -88,7 +88,7 @@ EVENTS LSP -• todo +• `client.attached_buffers[buf]` now stores `languageId` string (was boolean). LUA diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 6cbe98b6eb..c64429df41 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -531,9 +531,20 @@ local function lsp_enable_callback(bufnr) lsp.is_enabled(client.name) -- Check that the client is managed by vim.lsp.config before deciding to detach it! and lsp.config[client.name] - and not can_start(bufnr, lsp.config[client.name], false) then - lsp.buf_detach_client(bufnr, client.id) + if can_start(bufnr, lsp.config[client.name], false) then + -- When switch between lsp supported filetype (e.g. json to jsonc like #39498), + -- client should send `textDocument/didClose` + `textDocument/didOpen` with new language id + local new_language_id = client.get_language_id(bufnr, vim.bo[bufnr].filetype) + local old_language_id = client.attached_buffers[bufnr] ---@type string? + if old_language_id and old_language_id ~= new_language_id then + client:_text_document_did_close_handler(bufnr) + client.attached_buffers[bufnr] = new_language_id + client:_text_document_did_open_handler(bufnr) + end + else + lsp.buf_detach_client(bufnr, client.id) + end end end @@ -1002,7 +1013,7 @@ function lsp.buf_attach_client(bufnr, client_id) return true end - client.attached_buffers[bufnr] = true + client.attached_buffers[bufnr] = client.get_language_id(bufnr, vim.bo[bufnr].filetype) -- This is our first time attaching this client to this buffer. -- Send didOpen for the client if it is initialized. If it isn't initialized diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index bc8e68fb2d..7c0d36d5ff 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -167,7 +167,8 @@ local all_clients = {} --- @class vim.lsp.Client --- ---- @field attached_buffers table +--- Each buffer's last used `languageId`. +--- @field attached_buffers table --- --- Capabilities provided by the client (editor or tool), at startup. --- @field capabilities lsp.ClientCapabilities @@ -1124,6 +1125,18 @@ function Client:exec_cmd(cmd, context, handler) self:request('workspace/executeCommand', params, handler, context.bufnr) end +--- Default handler for the 'textDocument/didClose' LSP notification. +--- +--- @param buf integer Number of the buffer, or 0 for current +function Client:_text_document_did_close_handler(buf) + if not self:supports_method('textDocument/didClose') then + return + end + local uri = vim.uri_from_bufnr(buf) + local params = { textDocument = { uri = uri } } + self:notify('textDocument/didClose', params) +end + --- Default handler for the 'textDocument/didOpen' LSP notification. --- --- @param bufnr integer Number of the buffer, or 0 for current @@ -1192,7 +1205,7 @@ function Client:on_attach(bufnr) end end) - self.attached_buffers[bufnr] = true + self.attached_buffers[bufnr] = self:_get_language_id(bufnr) end --- @private @@ -1383,11 +1396,7 @@ function Client:_on_detach(bufnr) changetracking.reset_buf(self, bufnr) - if self:supports_method('textDocument/didClose') then - local uri = vim.uri_from_bufnr(bufnr) - local params = { textDocument = { uri = uri } } - self:notify('textDocument/didClose', params) - end + self:_text_document_did_close_handler(bufnr) self.attached_buffers[bufnr] = nil diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index 4dbb0dee3e..0e375d62da 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -4488,6 +4488,52 @@ describe('LSP', function() eq({ 0, 'foo', 1, 'bar' }, count_clients()) end) + it('sends didClose and didOpen when languageId changes', function() + exec_lua(create_server_definition) + + local tmp1 = t.tmpname(true) + + exec_lua(function() + _G.server = _G._create_server({ + handlers = { + initialize = function(_, _, callback) + callback(nil, { capabilities = { textDocumentSync = { openClose = true } } }) + end, + }, + }) + vim.lsp.config('foo', { cmd = _G.server.cmd, filetypes = { 'foo', 'bar' } }) + vim.lsp.enable('foo') + vim.cmd.edit(tmp1) + end) + + local function test_messages() + local opens = 0 + local closes = 0 + local msgs = exec_lua([[ return _G.server.messages ]]) + local num_clients = exec_lua([[ return #vim.lsp.get_clients() ]]) + for _, msg in ipairs(msgs) do + opens = opens + (msg.method == 'textDocument/didOpen' and 1 or 0) + closes = closes + (msg.method == 'textDocument/didClose' and 1 or 0) + end + return { opens, 'did_open', closes, 'did_close', num_clients, 'clients' } + end + + -- No filetype on the buffer yet. + eq({ 0, 'did_open', 0, 'did_close', 0, 'clients' }, test_messages()) + + -- Set the filetype to 'foo', confirm didOpen is sent. + exec_lua([[vim.bo.filetype = 'foo']]) + retry(nil, 1000, function() + eq({ 1, 'did_open', 0, 'did_close', 1, 'clients' }, test_messages()) + end) + + -- Set to anohter lsp-supported filetype 'bar', confirm didClose and didOpen are sent. + exec_lua([[vim.bo.filetype = 'bar']]) + retry(nil, 1000, function() + eq({ 2, 'did_open', 1, 'did_close', 1, 'clients' }, test_messages()) + end) + end) + it('validates config on attach', function() local tmp1 = t.tmpname(true) exec_lua(function()