From c8d9ade16a7df060798ce431d08594e411c87d59 Mon Sep 17 00:00:00 2001 From: tris203 Date: Mon, 23 Feb 2026 23:03:40 +0000 Subject: [PATCH 1/5] refactor(lsp): replace _provider_value_get with _provider_foreach Introduce _provider_foreach to iterate over all matching provider capabilities for a given LSP method, handling both static and dynamic registrations. Update diagnostic logic and tests to use the new iteration approach, simplifying capability access and improving consistency across features. --- runtime/lua/vim/lsp/client.lua | 47 ++++++++++++------- runtime/lua/vim/lsp/diagnostic.lua | 8 ++-- .../functional/plugin/lsp/diagnostic_spec.lua | 6 ++- test/functional/plugin/lsp_spec.lua | 15 +++++- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index 36a4c28686..8920596434 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -1224,38 +1224,49 @@ function Client:supports_method(method, bufnr) return required_capability == nil end ---- Retrieves all capability values for a given LSP method, handling both static and dynamic registrations. ---- This function abstracts over differences between capabilities declared in `server_capabilities` ---- and those registered dynamically at runtime, returning all matching capability values. ---- It also handles cases where the registration method differs from the calling method by abstracting to the Provider. ---- For example, `workspace/diagnostic` uses capabilities registered under `textDocument/diagnostic`. ---- This is useful for features like diagnostics and formatting, where servers may register multiple providers ---- with different options (such as specific filetypes or document selectors). ---- @param method vim.lsp.protocol.Method.ClientToServer | vim.lsp.protocol.Method.Registration LSP method name ---- @param ... any Additional keys to index into the capability ---- @return lsp.LSPAny[] # The capability value if it exists, empty table if not found -function Client:_provider_value_get(method, ...) - local matched_regs = {} --- @type any[] +--- Executes callback fn for all registrations for a given LSP method. +--- +--- This handles both static capabilities (declared in server_capabilities during +--- initialization) and dynamic registrations (registered at runtime via +--- `client/registerCapability`). +--- +--- Some methods may have multiple registrations (e.g., different documentSelectors +--- or configurations). The callback is invoked once for each registration. +--- +--- Example: Getting diagnostic identifiers from all registrations +--- client:_provider_foreach('textDocument/diagnostic', function(cap) +--- print(cap.identifier) -- "static-id", "dynamic-id-1", "dynamic-id-2" +--- end) +--- +--- Note: Some capabilities alias to different providers. For example, +--- `workspace/diagnostic` uses the same `diagnosticProvider` as `textDocument/diagnostic`. +--- +---@param method vim.lsp.protocol.Method.ClientToServer | vim.lsp.protocol.Method.Registration LSP method name +---@param fn fun(capability_value: lsp.LSPAny) Callback invoked for each matching capability +function Client:_provider_foreach(method, fn) local provider = self:_registration_provider(method) + local required_capability = lsp.protocol._request_name_to_server_capability[method] local dynamic_regs = self:_get_registrations(provider) if not provider then - return matched_regs + return elseif not dynamic_regs then -- First check static capabilities local static_reg = vim.tbl_get(self.server_capabilities, provider) if static_reg then - matched_regs[1] = vim.tbl_get(static_reg, ...) or vim.NIL + if vim.tbl_get(static_reg, unpack(required_capability, 2)) then + fn(static_reg) + end end else - local required_capability = lsp.protocol._request_name_to_server_capability[method] for _, reg in ipairs(dynamic_regs) do if vim.tbl_get(reg, 'registerOptions', unpack(required_capability, 2)) then - matched_regs[#matched_regs + 1] = vim.tbl_get(reg, 'registerOptions', ...) or vim.NIL + local cap = vim.tbl_get(reg, 'registerOptions') + if cap then + fn(cap) + end end end end - - return matched_regs end --- @private diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index eab69fdc62..7a84cc9f80 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -538,16 +538,16 @@ function M._workspace_diagnostics(opts) end for _, client in ipairs(clients) do - local identifiers = client:_provider_value_get('workspace/diagnostic', 'identifier') - for _, id in ipairs(identifiers) do + ---@param cap lsp.DiagnosticRegistrationOptions + client:_provider_foreach('workspace/diagnostic', function(cap) --- @type lsp.WorkspaceDiagnosticParams local params = { - identifier = type(id) == 'string' and id or nil, + identifier = cap.identifier, previousResultIds = previous_result_ids(client.id), } client:request('workspace/diagnostic', params, handler) - end + end) end end diff --git a/test/functional/plugin/lsp/diagnostic_spec.lua b/test/functional/plugin/lsp/diagnostic_spec.lua index 12726bc2c6..b8300574b0 100644 --- a/test/functional/plugin/lsp/diagnostic_spec.lua +++ b/test/functional/plugin/lsp/diagnostic_spec.lua @@ -862,7 +862,11 @@ describe('vim.lsp.diagnostic', function() exec_lua(function() local client = vim.lsp.get_client_by_id(client_id) assert(client) - return client:_provider_value_get('workspace/diagnostic', 'identifier') + local result = {} + client:_provider_foreach('workspace/diagnostic', function(cap) + table.insert(result, cap.identifier or vim.NIL) + end) + return result end) ) diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index 5e8d7e9fa6..7249ac2d47 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -5694,11 +5694,18 @@ describe('LSP', function() local function check(method, fname, ...) local bufnr = fname and vim.fn.bufadd(fname) or nil local client = assert(vim.lsp.get_client_by_id(client_id)) + local keys = { ... } + local caps = {} + if #keys > 0 then + client:_provider_foreach(method, function(cap) + table.insert(caps, vim.tbl_get(cap, unpack(keys)) or vim.NIL) + end) + end result[#result + 1] = { method = method, fname = fname, supported = client:supports_method(method, bufnr), - cap = select('#', ...) > 0 and client:_provider_value_get(method, ...) or nil, + cap = #keys > 0 and caps or nil, } end @@ -5978,7 +5985,11 @@ describe('LSP', function() { 'diag-ident-static' }, exec_lua(function() local client = assert(vim.lsp.get_client_by_id(client_id)) - return client:_provider_value_get('textDocument/diagnostic', 'identifier') + local result = {} + client:_provider_foreach('textDocument/diagnostic', function(cap) + table.insert(result, cap.identifier) + end) + return result end) ) end) From 6a49a277f517bd5fd51c62a3dcda5d00d91a8401 Mon Sep 17 00:00:00 2001 From: tris203 Date: Sun, 15 Mar 2026 21:51:47 +0000 Subject: [PATCH 2/5] fix(lsp): request diagnostics from all registrations Update diagnostic refresh to request diagnostics from all provider registrations using _provider_foreach. This ensures diagnostics are fetched from every registered provider during a refresh. Co-authored-by: ZieMcd --- runtime/lua/vim/lsp/diagnostic.lua | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 7a84cc9f80..2d224722ea 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -381,12 +381,16 @@ function M._refresh(bufnr, client_id, only_visible) type = 'pending', }) for _, client in ipairs(clients) do - ---@type lsp.DocumentDiagnosticParams - local params = { - textDocument = util.make_text_document_params(bufnr), - previousResultId = bufstate.client_result_id[client.id], - } - client:request(method, params, nil, bufnr) + ---@param cap lsp.DiagnosticRegistrationOptions + client:_provider_foreach(method, function(cap) + ---@type lsp.DocumentDiagnosticParams + local params = { + identifier = cap.identifier, + textDocument = util.make_text_document_params(bufnr), + previousResultId = bufstate.client_result_id[client.id], + } + client:request(method, params, nil, bufnr) + end) end end From 95dce376f330029b54af1f00310d26536c713f5c Mon Sep 17 00:00:00 2001 From: tris203 Date: Sun, 15 Mar 2026 22:42:18 +0000 Subject: [PATCH 3/5] fix(lsp): handle providers without subcapabilities Previously, the LSP client assumed all providers had subcapabilities, which could cause issues when a provider did not. This change adds a check for the presence of subcapabilities before attempting to access them, ensuring correct handling of both cases. This improves compatibility with servers that register providers without additional capabilities. --- runtime/lua/vim/lsp/client.lua | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index 8920596434..4a5b259f3c 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -1247,23 +1247,21 @@ function Client:_provider_foreach(method, fn) local provider = self:_registration_provider(method) local required_capability = lsp.protocol._request_name_to_server_capability[method] local dynamic_regs = self:_get_registrations(provider) + local has_subcap = required_capability and #required_capability > 1 if not provider then return elseif not dynamic_regs then -- First check static capabilities local static_reg = vim.tbl_get(self.server_capabilities, provider) if static_reg then - if vim.tbl_get(static_reg, unpack(required_capability, 2)) then + if not has_subcap or vim.tbl_get(static_reg, unpack(required_capability, 2)) then fn(static_reg) end end else for _, reg in ipairs(dynamic_regs) do - if vim.tbl_get(reg, 'registerOptions', unpack(required_capability, 2)) then - local cap = vim.tbl_get(reg, 'registerOptions') - if cap then - fn(cap) - end + if not has_subcap or vim.tbl_get(reg, 'registerOptions', unpack(required_capability, 2)) then + fn(vim.tbl_get(reg, 'registerOptions') or {}) end end end From 0cda0183451443df81aeeba077bd3b93fa620eb8 Mon Sep 17 00:00:00 2001 From: tris203 Date: Mon, 16 Mar 2026 16:19:52 +0000 Subject: [PATCH 4/5] fix(lsp/diagnostic): key resultId by client and identifier Previously, resultId for diagnostics was keyed only by client_id, which could cause issues when multiple identifiers are used by the same client. This change introduces a composite key of client_id and identifier for client_result_id, ensuring correct tracking of diagnostic results per identifier. Updates all relevant logic to use the new keying scheme. --- runtime/lua/vim/lsp/diagnostic.lua | 45 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 2d224722ea..1a17da9e03 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -15,7 +15,7 @@ local augroup = api.nvim_create_augroup('nvim.lsp.diagnostic', {}) ---@class (private) vim.lsp.diagnostic.BufState ---@field pull_kind 'document'|'workspace'|'disabled' Whether diagnostics are being updated via document pull, workspace pull, or disabled. ----@field client_result_id table Latest responded `resultId` +---@field client_result_id table Latest responded `resultId`, keyed by `client_id.identifier` ---@type table local bufstates = {} @@ -147,6 +147,13 @@ local function tags_vim_to_lsp(diagnostic) return tags end +---@param client_id integer +---@param identifier string|nil +---@return string +local function result_id_key(client_id, identifier) + return string.format('%d.%s', client_id, identifier or 'nil') +end + --- Converts the input `vim.Diagnostic`s to LSP diagnostics. --- @param diagnostics vim.Diagnostic[] --- @return lsp.Diagnostic[] @@ -281,14 +288,15 @@ function M.on_diagnostic(error, result, ctx) local client_id = ctx.client_id local bufnr = assert(ctx.bufnr) local bufstate = bufstates[bufnr] - bufstate.client_result_id[client_id] = result.resultId + ---@type lsp.DocumentDiagnosticParams + local params = ctx.params + local key = result_id_key(client_id, params.identifier) + bufstate.client_result_id[key] = result.resultId if result.kind == 'unchanged' then return end - ---@type lsp.DocumentDiagnosticParams - local params = ctx.params handle_diagnostics(params.textDocument.uri, client_id, result.items, params.identifier or true) for uri, related_result in pairs(result.relatedDocuments or {}) do @@ -304,7 +312,7 @@ function M.on_diagnostic(error, result, ctx) or { pull_kind = 'document', client_result_id = {} } bufstates[related_bufnr] = related_bufstate - related_bufstate.client_result_id[client_id] = related_result.resultId + related_bufstate.client_result_id[key] = related_result.resultId end end @@ -383,11 +391,12 @@ function M._refresh(bufnr, client_id, only_visible) for _, client in ipairs(clients) do ---@param cap lsp.DiagnosticRegistrationOptions client:_provider_foreach(method, function(cap) + local key = result_id_key(client.id, cap.identifier) ---@type lsp.DocumentDiagnosticParams local params = { identifier = cap.identifier, textDocument = util.make_text_document_params(bufnr), - previousResultId = bufstate.client_result_id[client.id], + previousResultId = bufstate.client_result_id[key], } client:request(method, params, nil, bufnr) end) @@ -481,19 +490,20 @@ end --- Returns the result IDs from the reports provided by the given client. --- @return lsp.PreviousResultId[] -local function previous_result_ids(client_id) +--- @param client_id integer +--- @param identifier string|nil +local function previous_result_ids(client_id, identifier) local results = {} ---@type lsp.PreviousResultId[] + local key = result_id_key(client_id, identifier) for bufnr, state in pairs(bufstates) do if state.pull_kind ~= 'disabled' then - for buf_client_id, result_id in pairs(state.client_result_id) do - if buf_client_id == client_id then - results[#results + 1] = { - uri = vim.uri_from_bufnr(bufnr), - value = result_id, - } - break - end + local result_id = state.client_result_id[key] + if result_id then + results[#results + 1] = { + uri = vim.uri_from_bufnr(bufnr), + value = result_id, + } end end end @@ -535,7 +545,8 @@ function M._workspace_diagnostics(opts) -- state if we're not pulling document diagnostics for this buffer. if bufstates[bufnr].pull_kind == 'workspace' and report.kind == 'full' then handle_diagnostics(report.uri, ctx.client_id, report.items, params.identifier or true) - bufstates[bufnr].client_result_id[ctx.client_id] = report.resultId + local key = result_id_key(ctx.client_id, params.identifier) + bufstates[bufnr].client_result_id[key] = report.resultId end end end @@ -547,7 +558,7 @@ function M._workspace_diagnostics(opts) --- @type lsp.WorkspaceDiagnosticParams local params = { identifier = cap.identifier, - previousResultIds = previous_result_ids(client.id), + previousResultIds = previous_result_ids(client.id, cap.identifier), } client:request('workspace/diagnostic', params, handler) From 1f558f8d09603c425096ed1c5c5d8fcf70153310 Mon Sep 17 00:00:00 2001 From: tris203 Date: Thu, 19 Mar 2026 17:50:40 +0000 Subject: [PATCH 5/5] fix(lsp): improve diagnostics handling and comments - Add TODO comments for aggregating diagnostics from all pull namespaces and for clearing diagnostics when an empty array is received, referencing the LSP specification. - Update diagnostics refresh logic to safely access previousResultId, preventing potential nil errors. --- runtime/lua/vim/lsp/buf.lua | 1 + runtime/lua/vim/lsp/diagnostic.lua | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 1d99afd39a..14bbb279f1 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -1365,6 +1365,7 @@ function M.code_action(opts) params.context = context else local ns_push = lsp.diagnostic.get_namespace(client.id, false) + -- TODO(tris203): should we aggregate diagnostics from all the possible pull namespaces? local ns_pull = lsp.diagnostic.get_namespace(client.id, true) local diagnostics = {} local lnum = api.nvim_win_get_cursor(0)[1] - 1 diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 1a17da9e03..4f6a2a346c 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -261,6 +261,8 @@ end ---@param params lsp.PublishDiagnosticsParams ---@param ctx lsp.HandlerContext function M.on_publish_diagnostics(_, params, ctx) + -- TODO(tris203): if empty array then clear diags + -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics handle_diagnostics(params.uri, ctx.client_id, params.diagnostics, false) end @@ -396,7 +398,9 @@ function M._refresh(bufnr, client_id, only_visible) local params = { identifier = cap.identifier, textDocument = util.make_text_document_params(bufnr), - previousResultId = bufstate.client_result_id[key], + previousResultId = bufstate + and bufstate.client_result_id + and bufstate.client_result_id[key], } client:request(method, params, nil, bufnr) end)