feat(lsp): workspace diagnostic support (#34262)

* refactor(lsp): remove underscore prefix from local variables

* feat(lsp): workspace diagnostic support
This commit is contained in:
Maria José Solano
2025-06-09 10:02:00 -07:00
committed by GitHub
parent d75ffa5934
commit cb4559bc32
6 changed files with 304 additions and 71 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -566,6 +566,9 @@ function protocol.make_client_capabilities()
inlayHint = {
refreshSupport = true,
},
workspace = {
refreshSupport = false,
},
},
experimental = nil,
window = {

View File

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