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:
0003fb53f1/_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
This commit is contained in:
phanium
2026-04-30 17:56:16 +08:00
committed by GitHub
parent 1e06e95662
commit 1e7edb2c52
5 changed files with 79 additions and 12 deletions

View File

@@ -1723,7 +1723,8 @@ Lua module: vim.lsp.client *lsp-client*
*vim.lsp.Client*
Fields: ~
• {attached_buffers} (`table<integer,true>`)
• {attached_buffers} (`table<integer,string>`) Each buffer's last
used `languageId`.
• {cancel_request} (`fun(self: vim.lsp.Client, id: integer): boolean`)
See |Client:cancel_request()|.
• {capabilities} (`lsp.ClientCapabilities`) Capabilities

View File

@@ -88,7 +88,7 @@ EVENTS
LSP
todo
`client.attached_buffers[buf]` now stores `languageId` string (was boolean).
LUA

View File

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

View File

@@ -167,7 +167,8 @@ local all_clients = {}
--- @class vim.lsp.Client
---
--- @field attached_buffers table<integer,true>
--- Each buffer's last used `languageId`.
--- @field attached_buffers table<integer,string>
---
--- 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

View File

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