From cb4559bc32049d2268ab002207bb7445027e9264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maria=20Jos=C3=A9=20Solano?= Date: Mon, 9 Jun 2025 10:02:00 -0700 Subject: [PATCH] feat(lsp): workspace diagnostic support (#34262) * refactor(lsp): remove underscore prefix from local variables * feat(lsp): workspace diagnostic support --- runtime/doc/lsp.txt | 12 ++ runtime/doc/news.txt | 2 + runtime/lua/vim/lsp/buf.lua | 15 ++ runtime/lua/vim/lsp/diagnostic.lua | 219 +++++++++++++++++++--------- runtime/lua/vim/lsp/protocol.lua | 3 + test/functional/plugin/lsp_spec.lua | 124 ++++++++++++++++ 6 files changed, 304 insertions(+), 71 deletions(-) diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 62d2da07c9..51e383f239 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -1855,6 +1855,18 @@ typehierarchy({kind}) *vim.lsp.buf.typehierarchy()* Parameters: ~ • {kind} (`"subtypes"|"supertypes"`) +workspace_diagnostics({opts}) *vim.lsp.buf.workspace_diagnostics()* + Request workspace-wide diagnostics. + + Parameters: ~ + • {opts} (`table?`) A table with the following fields: + • {client_id}? (`integer`) Only request diagnostics from the + indicated client. If nil, the request is sent to all + clients. + + See also: ~ + • https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_dagnostics + workspace_symbol({query}, {opts}) *vim.lsp.buf.workspace_symbol()* Lists all symbols in the current workspace in the quickfix window. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 4b3f29e3ed..331a577bcd 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -165,6 +165,8 @@ LSP non-applicable LSP clients. • |vim.lsp.is_enabled()| checks if a LSP config is enabled (without "resolving" it). +• Support for `workspace/diagnostic`: |vim.lsp.buf.workspace_diagnostics()| + https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_dagnostics LUA diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index ca70789506..d1a44f656e 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -1015,6 +1015,21 @@ function M.workspace_symbol(query, opts) request_with_opts(ms.workspace_symbol, params, opts) end +--- @class vim.lsp.WorkspaceDiagnosticsOpts +--- @inlinedoc +--- +--- Only request diagnostics from the indicated client. If nil, the request is sent to all clients. +--- @field client_id? integer + +--- Request workspace-wide diagnostics. +--- @param opts? vim.lsp.WorkspaceDiagnosticsOpts +--- @see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_dagnostics +function M.workspace_diagnostics(opts) + vim.validate('opts', opts, 'table', true) + + lsp.diagnostic._workspace_diagnostics(opts or {}) +end + --- Send request to the server to resolve document highlights for the current --- text document position. This request can be triggered by a key mapping or --- by events such as `CursorHold`, e.g.: diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index bdddb704a5..661495ecb9 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -1,6 +1,7 @@ -local protocol = require('vim.lsp.protocol') +local lsp = vim.lsp +local protocol = lsp.protocol local ms = protocol.Methods -local util = vim.lsp.util +local util = lsp.util local api = vim.api @@ -9,10 +10,10 @@ local M = {} local augroup = api.nvim_create_augroup('nvim.lsp.diagnostic', {}) ---@class (private) vim.lsp.diagnostic.BufState ----@field enabled boolean Whether diagnostics are enabled for this buffer +---@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` ----@type table +---@type table local bufstates = {} local DEFAULT_CLIENT_ID = -1 @@ -38,11 +39,11 @@ end ---@param bufnr integer ---@return string[]? local function get_buf_lines(bufnr) - if vim.api.nvim_buf_is_loaded(bufnr) then - return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + if api.nvim_buf_is_loaded(bufnr) then + return api.nvim_buf_get_lines(bufnr, 0, -1, false) end - local filename = vim.api.nvim_buf_get_name(bufnr) + local filename = api.nvim_buf_get_name(bufnr) local f = io.open(filename) if not f then return @@ -74,7 +75,7 @@ local function tags_lsp_to_vim(diagnostic, client_id) tags = tags or {} tags.deprecated = true else - vim.lsp.log.info(string.format('Unknown DiagnosticTag %d from LSP client %d', tag, client_id)) + lsp.log.info(string.format('Unknown DiagnosticTag %d from LSP client %d', tag, client_id)) end end return tags @@ -86,7 +87,7 @@ end ---@return vim.Diagnostic.Set[] local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) local buf_lines = get_buf_lines(bufnr) - local client = vim.lsp.get_client_by_id(client_id) + local client = lsp.get_client_by_id(client_id) local position_encoding = client and client.offset_encoding or 'utf-16' --- @param diagnostic lsp.Diagnostic --- @return vim.Diagnostic.Set @@ -97,7 +98,7 @@ local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) if type(message) ~= 'string' then vim.notify_once( string.format('Unsupported Markup message from LSP client %d', client_id), - vim.lsp.log_levels.ERROR + lsp.log_levels.ERROR ) --- @diagnostic disable-next-line: undefined-field,no-unknown message = diagnostic.message.value @@ -174,10 +175,10 @@ function M.from(diagnostics) end ---@type table -local _client_push_namespaces = {} +local client_push_namespaces = {} ---@type table -local _client_pull_namespaces = {} +local client_pull_namespaces = {} --- Get the diagnostic namespace associated with an LSP client |vim.diagnostic| for diagnostics --- @@ -186,7 +187,7 @@ local _client_pull_namespaces = {} function M.get_namespace(client_id, is_pull) vim.validate('client_id', client_id, 'number') - local client = vim.lsp.get_client_by_id(client_id) + local client = lsp.get_client_by_id(client_id) if is_pull then local server_id = vim.tbl_get((client or {}).server_capabilities or {}, 'diagnosticProvider', 'identifier') @@ -196,19 +197,19 @@ function M.get_namespace(client_id, is_pull) client_id, server_id or 'nil' ) - local ns = _client_pull_namespaces[key] + local ns = client_pull_namespaces[key] if not ns then ns = api.nvim_create_namespace(name) - _client_pull_namespaces[key] = ns + client_pull_namespaces[key] = ns end return ns end - local ns = _client_push_namespaces[client_id] + local ns = client_push_namespaces[client_id] if not ns then local name = ('nvim.lsp.%s.%d'):format(client and client.name or 'unknown', client_id) ns = api.nvim_create_namespace(name) - _client_push_namespaces[client_id] = ns + client_push_namespaces[client_id] = ns end return ns end @@ -257,7 +258,7 @@ end function M.on_diagnostic(error, result, ctx) if error ~= nil and error.code == protocol.ErrorCodes.ServerCancelled then if error.data == nil or error.data.retriggerRequest ~= false then - local client = assert(vim.lsp.get_client_by_id(ctx.client_id)) + local client = assert(lsp.get_client_by_id(ctx.client_id)) client:request(ctx.method, ctx.params) end return @@ -271,7 +272,7 @@ function M.on_diagnostic(error, result, ctx) handle_diagnostics(ctx.params.textDocument.uri, client_id, result.items, true) local bufnr = assert(ctx.bufnr) - local bufstate = assert(bufstates[bufnr]) + local bufstate = bufstates[bufnr] bufstate.client_result_id[client_id] = result.resultId end @@ -329,7 +330,7 @@ end --- Clear diagnostics from pull based clients local function clear(bufnr) - for _, namespace in pairs(_client_pull_namespaces) do + for _, namespace in pairs(client_pull_namespaces) do vim.diagnostic.reset(namespace, bufnr) end end @@ -339,7 +340,7 @@ end local function disable(bufnr) local bufstate = bufstates[bufnr] if bufstate then - bufstate.enabled = false + bufstate.pull_kind = 'disabled' end clear(bufnr) end @@ -348,7 +349,7 @@ end ---@param bufnr integer buffer number ---@param client_id? integer Client ID to refresh (default: all clients) ---@param only_visible? boolean Whether to only refresh for the visible regions of the buffer (default: false) -local function _refresh(bufnr, client_id, only_visible) +local function refresh(bufnr, client_id, only_visible) if only_visible and vim.iter(api.nvim_list_wins()):all(function(window) @@ -359,8 +360,8 @@ local function _refresh(bufnr, client_id, only_visible) end local method = ms.textDocument_diagnostic - local clients = vim.lsp.get_clients({ bufnr = bufnr, method = method, id = client_id }) - local bufstate = assert(bufstates[bufnr]) + local clients = lsp.get_clients({ bufnr = bufnr, method = method, id = client_id }) + local bufstate = bufstates[bufnr] util._cancel_requests({ bufnr = bufnr, @@ -383,54 +384,130 @@ end function M._enable(bufnr) bufnr = vim._resolve_bufnr(bufnr) - if not bufstates[bufnr] then - bufstates[bufnr] = { enabled = true, client_result_id = {} } - - api.nvim_create_autocmd('LspNotify', { - buffer = bufnr, - callback = function(opts) - if - opts.data.method ~= ms.textDocument_didChange - and opts.data.method ~= ms.textDocument_didOpen - then - return - end - if bufstates[bufnr] and bufstates[bufnr].enabled then - local client_id = opts.data.client_id --- @type integer? - _refresh(bufnr, client_id, true) - end - end, - group = augroup, - }) - - api.nvim_buf_attach(bufnr, false, { - on_reload = function() - if bufstates[bufnr] and bufstates[bufnr].enabled then - _refresh(bufnr) - end - end, - on_detach = function() - disable(bufnr) - end, - }) - - api.nvim_create_autocmd('LspDetach', { - buffer = bufnr, - callback = function(args) - local clients = vim.lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_diagnostic }) - - if - not vim.iter(clients):any(function(c) - return c.id ~= args.data.client_id - end) - then - disable(bufnr) - end - end, - group = augroup, - }) + if bufstates[bufnr] then + -- If we're already pulling diagnostics for this buffer, nothing to do here. + if bufstates[bufnr].pull_kind == 'document' then + return + end + -- Else diagnostics were disabled or we were using workspace diagnostics. + bufstates[bufnr].pull_kind = 'document' else - bufstates[bufnr].enabled = true + bufstates[bufnr] = { pull_kind = 'document', client_result_id = {} } + end + + api.nvim_create_autocmd('LspNotify', { + buffer = bufnr, + callback = function(opts) + if + opts.data.method ~= ms.textDocument_didChange + and opts.data.method ~= ms.textDocument_didOpen + then + return + end + if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then + local client_id = opts.data.client_id --- @type integer? + refresh(bufnr, client_id, true) + end + end, + group = augroup, + }) + + api.nvim_buf_attach(bufnr, false, { + on_reload = function() + if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then + refresh(bufnr) + end + end, + on_detach = function() + disable(bufnr) + end, + }) + + api.nvim_create_autocmd('LspDetach', { + buffer = bufnr, + callback = function(args) + local clients = lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_diagnostic }) + + if + not vim.iter(clients):any(function(c) + return c.id ~= args.data.client_id + end) + then + disable(bufnr) + end + end, + group = augroup, + }) +end + +--- Returns the result IDs from the reports provided by the given client. +--- @return lsp.PreviousResultId[] +local function previous_result_ids(client_id) + local results = {} + + 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 + table.insert(results, { + textDocument = util.make_text_document_params(bufnr), + previousResultId = result_id, + }) + break + end + end + end + end + + return results +end + +--- Request workspace-wide diagnostics. +--- @param opts vim.lsp.WorkspaceDiagnosticsOpts +function M._workspace_diagnostics(opts) + local clients = lsp.get_clients({ method = ms.workspace_diagnostic, id = opts.client_id }) + + --- @param error lsp.ResponseError? + --- @param result lsp.WorkspaceDiagnosticReport + --- @param ctx lsp.HandlerContext + local function handler(error, result, ctx) + -- Check for retrigger requests on cancellation errors. + -- Unless `retriggerRequest` is explicitly disabled, try again. + if error ~= nil and error.code == lsp.protocol.ErrorCodes.ServerCancelled then + if error.data == nil or error.data.retriggerRequest ~= false then + local client = assert(lsp.get_client_by_id(ctx.client_id)) + client:request(ms.workspace_diagnostic, ctx.params, handler) + end + return + end + + if error == nil and result ~= nil then + for _, report in ipairs(result.items) do + local bufnr = vim.uri_to_bufnr(report.uri) + + -- Start tracking the buffer (but don't send "textDocument/diagnostic" requests for it). + if not bufstates[bufnr] then + bufstates[bufnr] = { pull_kind = 'workspace', client_result_id = {} } + end + + -- We favor document pull requests over workspace results, so only update the buffer + -- 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, true) + bufstates[bufnr].client_result_id[ctx.client_id] = report.resultId + end + end + end + end + + for _, client in ipairs(clients) do + --- @type lsp.WorkspaceDiagnosticParams + local params = { + identifier = vim.tbl_get(client, 'server_capabilities, diagnosticProvider', 'identifier'), + previousResultIds = previous_result_ids(client.id), + } + + client:request(ms.workspace_diagnostic, params, handler) end end diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 418aaf8282..3e2aded639 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -566,6 +566,9 @@ function protocol.make_client_capabilities() inlayHint = { refreshSupport = true, }, + workspace = { + refreshSupport = false, + }, }, experimental = nil, window = { diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index 96b1f12f44..b54a3b2bd8 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -6801,4 +6801,128 @@ describe('LSP', function() eq(false, exec_lua([[return vim.lsp.is_enabled('foo')]])) end) end) + + describe('vim.lsp.buf.workspace_diagnostics()', function() + local fake_uri = 'file:///fake/uri' + + --- @param kind lsp.DocumentDiagnosticReportKind + --- @param msg string + --- @param pos integer + --- @return lsp.WorkspaceDocumentDiagnosticReport + local function make_report(kind, msg, pos) + return { + kind = kind, + uri = fake_uri, + items = { + { + range = { + start = { line = pos, character = pos }, + ['end'] = { line = pos, character = pos }, + }, + message = msg, + severity = 1, + }, + }, + } + end + + --- @param items lsp.WorkspaceDocumentDiagnosticReport[] + --- @return integer + local function setup_server(items) + exec_lua(create_server_definition) + return exec_lua(function() + _G.server = _G._create_server({ + capabilities = { + diagnosticProvider = { workspaceDiagnostics = true }, + }, + handlers = { + ['workspace/diagnostic'] = function(_, _, callback) + callback(nil, { items = items }) + end, + }, + }) + local client_id = assert(vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })) + vim.lsp.buf.workspace_diagnostics() + return client_id + end, { items }) + end + + it('updates diagnostics obtained with vim.diagnostic.get()', function() + setup_server({ make_report('full', 'Error here', 1) }) + + retry(nil, nil, function() + eq( + 1, + exec_lua(function() + return #vim.diagnostic.get() + end) + ) + end) + + eq( + 'Error here', + exec_lua(function() + return vim.diagnostic.get()[1].message + end) + ) + end) + + it('ignores unchanged diagnostic reports', function() + setup_server({ make_report('unchanged', '', 1) }) + + eq( + 0, + exec_lua(function() + -- Wait for diagnostics to be processed. + vim.uv.sleep(50) + + return #vim.diagnostic.get() + end) + ) + end) + + it('favors document diagnostics over workspace diagnostics', function() + local client_id = setup_server({ make_report('full', 'Workspace error', 1) }) + local diagnostic_bufnr = exec_lua(function() + return vim.uri_to_bufnr(fake_uri) + end) + + exec_lua(function() + vim.lsp.diagnostic.on_diagnostic(nil, { + kind = 'full', + items = { + { + range = { + start = { line = 2, character = 2 }, + ['end'] = { line = 2, character = 2 }, + }, + message = 'Document error', + severity = 1, + }, + }, + }, { + method = 'textDocument/diagnostic', + params = { + textDocument = { uri = fake_uri }, + }, + client_id = client_id, + bufnr = diagnostic_bufnr, + }) + end) + + eq( + 1, + exec_lua(function() + return #vim.diagnostic.get(diagnostic_bufnr) + end) + ) + + eq( + 'Document error', + exec_lua(function() + return vim.diagnostic.get(vim.uri_to_bufnr(fake_uri))[1].message + end) + ) + end) + end) end)