commit cb4559bc32049d2268ab002207bb7445027e9264
parent d75ffa59344b9cefc8d1e29b2b0110dee6d81775
Author: Maria José Solano <majosolano99@gmail.com>
Date: Mon, 9 Jun 2025 10:02:00 -0700
feat(lsp): workspace diagnostic support (#34262)
* refactor(lsp): remove underscore prefix from local variables
* feat(lsp): workspace diagnostic support
Diffstat:
6 files changed, 302 insertions(+), 69 deletions(-)
diff --git 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
@@ -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
@@ -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
@@ -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<integer, string?> Latest responded `resultId`
----@type table<integer,vim.lsp.diagnostic.BufState>
+---@type table<integer, vim.lsp.diagnostic.BufState>
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<integer, integer>
-local _client_push_namespaces = {}
+local client_push_namespaces = {}
---@type table<string, integer>
-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)
+ 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] = { 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,
- group = augroup,
- })
-
- api.nvim_buf_attach(bufnr, false, {
- on_reload = function()
- if bufstates[bufnr] and bufstates[bufnr].enabled then
- _refresh(bufnr)
+ 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
- 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)
+
+ -- 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,
- group = augroup,
- })
- else
- bufstates[bufnr].enabled = true
+ 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
@@ -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
@@ -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)