mirror of
https://github.com/neovim/neovim.git
synced 2026-03-28 03:12:00 +00:00
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.
555 lines
17 KiB
Lua
555 lines
17 KiB
Lua
---@brief This module provides functionality for requesting LSP diagnostics for a document/workspace
|
|
---and populating them using |vim.Diagnostic|s. `DiagnosticRelatedInformation` is supported: it is
|
|
---included in the window shown by |vim.diagnostic.open_float()|. When the cursor is on a line with
|
|
---related information, |gf| jumps to the problem location.
|
|
|
|
local lsp = vim.lsp
|
|
local protocol = lsp.protocol
|
|
local util = lsp.util
|
|
|
|
local api = vim.api
|
|
|
|
local M = {}
|
|
|
|
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<integer, string?> Latest responded `resultId`
|
|
|
|
---@type table<integer, vim.lsp.diagnostic.BufState>
|
|
local bufstates = {}
|
|
|
|
local DEFAULT_CLIENT_ID = -1
|
|
|
|
---@param severity lsp.DiagnosticSeverity
|
|
---@return vim.diagnostic.Severity
|
|
local function severity_lsp_to_vim(severity)
|
|
if type(severity) == 'string' then
|
|
return protocol.DiagnosticSeverity[severity] --[[@as vim.diagnostic.Severity]]
|
|
end
|
|
return severity
|
|
end
|
|
|
|
---@param severity vim.diagnostic.Severity|vim.diagnostic.SeverityName
|
|
---@return lsp.DiagnosticSeverity
|
|
local function severity_vim_to_lsp(severity)
|
|
if type(severity) == 'string' then
|
|
return vim.diagnostic.severity[severity]
|
|
end
|
|
return severity --[[@as lsp.DiagnosticSeverity]]
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@return string[]?
|
|
local function get_buf_lines(bufnr)
|
|
if api.nvim_buf_is_loaded(bufnr) then
|
|
return api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
|
end
|
|
|
|
local filename = api.nvim_buf_get_name(bufnr)
|
|
local f = io.open(filename)
|
|
if not f then
|
|
return
|
|
end
|
|
|
|
local content = f:read('*a')
|
|
if not content then
|
|
-- Some LSP servers report diagnostics at a directory level, in which case
|
|
-- io.read() returns nil
|
|
f:close()
|
|
return
|
|
end
|
|
|
|
local lines = vim.split(content, '\n')
|
|
f:close()
|
|
return lines
|
|
end
|
|
|
|
--- @param diagnostic lsp.Diagnostic
|
|
--- @param client_id integer
|
|
--- @return table?
|
|
local function tags_lsp_to_vim(diagnostic, client_id)
|
|
local tags ---@type table?
|
|
for _, tag in ipairs(diagnostic.tags or {}) do
|
|
if tag == protocol.DiagnosticTag.Unnecessary then
|
|
tags = tags or {}
|
|
tags.unnecessary = true
|
|
elseif tag == protocol.DiagnosticTag.Deprecated then
|
|
tags = tags or {}
|
|
tags.deprecated = true
|
|
else
|
|
lsp.log.info(string.format('Unknown DiagnosticTag %d from LSP client %d', tag, client_id))
|
|
end
|
|
end
|
|
return tags
|
|
end
|
|
|
|
---@param diagnostics lsp.Diagnostic[]
|
|
---@param bufnr integer
|
|
---@param client_id integer
|
|
---@return vim.Diagnostic.Set[]
|
|
local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id)
|
|
local buf_lines = get_buf_lines(bufnr)
|
|
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
|
|
return vim.tbl_map(function(diagnostic)
|
|
local start = diagnostic.range.start
|
|
local _end = diagnostic.range['end']
|
|
local message = diagnostic.message
|
|
if type(message) ~= 'string' then
|
|
vim.notify_once(
|
|
string.format('Unsupported Markup message from LSP client %d', client_id),
|
|
lsp.log_levels.ERROR
|
|
)
|
|
--- @diagnostic disable-next-line: undefined-field,no-unknown
|
|
message = diagnostic.message.value
|
|
end
|
|
local line = buf_lines and buf_lines[start.line + 1] or ''
|
|
local end_line = line
|
|
if _end.line > start.line then
|
|
end_line = buf_lines and buf_lines[_end.line + 1] or ''
|
|
end
|
|
--- @type vim.Diagnostic.Set
|
|
return {
|
|
lnum = start.line,
|
|
col = vim.str_byteindex(line, position_encoding, start.character, false),
|
|
end_lnum = _end.line,
|
|
end_col = vim.str_byteindex(end_line, position_encoding, _end.character, false),
|
|
severity = severity_lsp_to_vim(diagnostic.severity),
|
|
message = message,
|
|
source = diagnostic.source,
|
|
code = diagnostic.code,
|
|
_tags = tags_lsp_to_vim(diagnostic, client_id),
|
|
user_data = {
|
|
lsp = diagnostic,
|
|
},
|
|
}
|
|
end, diagnostics)
|
|
end
|
|
|
|
--- @param diagnostic vim.Diagnostic
|
|
--- @return lsp.DiagnosticTag[]?
|
|
local function tags_vim_to_lsp(diagnostic)
|
|
if not diagnostic._tags then
|
|
return
|
|
end
|
|
|
|
local tags = {} --- @type lsp.DiagnosticTag[]
|
|
if diagnostic._tags.unnecessary then
|
|
tags[#tags + 1] = protocol.DiagnosticTag.Unnecessary
|
|
end
|
|
if diagnostic._tags.deprecated then
|
|
tags[#tags + 1] = protocol.DiagnosticTag.Deprecated
|
|
end
|
|
return tags
|
|
end
|
|
|
|
--- Converts the input `vim.Diagnostic`s to LSP diagnostics.
|
|
--- @param diagnostics vim.Diagnostic[]
|
|
--- @return lsp.Diagnostic[]
|
|
function M.from(diagnostics)
|
|
---@param diagnostic vim.Diagnostic
|
|
---@return lsp.Diagnostic
|
|
return vim.tbl_map(function(diagnostic)
|
|
local user_data = diagnostic.user_data or {}
|
|
if user_data.lsp then
|
|
return user_data.lsp
|
|
end
|
|
return {
|
|
range = {
|
|
start = {
|
|
line = diagnostic.lnum,
|
|
character = diagnostic.col,
|
|
},
|
|
['end'] = {
|
|
line = diagnostic.end_lnum,
|
|
character = diagnostic.end_col,
|
|
},
|
|
},
|
|
severity = severity_vim_to_lsp(diagnostic.severity),
|
|
message = diagnostic.message,
|
|
source = diagnostic.source,
|
|
code = diagnostic.code,
|
|
tags = tags_vim_to_lsp(diagnostic),
|
|
}
|
|
end, diagnostics)
|
|
end
|
|
|
|
---@type table<integer, integer>
|
|
local client_push_namespaces = {}
|
|
|
|
---@type table<string, integer>
|
|
local client_pull_namespaces = {}
|
|
|
|
--- Get the diagnostic namespace associated with an LSP client |vim.diagnostic| for diagnostics
|
|
---
|
|
---@param client_id integer The id of the LSP client
|
|
---@param pull_id (boolean|string)? (default: nil) Pull diagnostics provider id
|
|
--- (indicates "pull" client), or `nil` for a "push" client.
|
|
function M.get_namespace(client_id, pull_id)
|
|
vim.validate('client_id', client_id, 'number')
|
|
vim.validate('pull_id', pull_id, { 'boolean', 'string' }, true)
|
|
|
|
if type(pull_id) == 'boolean' then
|
|
vim.deprecate('get_namespace(pull_id:boolean)', 'get_namespace(pull_id:string)', '0.14')
|
|
end
|
|
|
|
local client = lsp.get_client_by_id(client_id)
|
|
if pull_id then
|
|
local provider_id = type(pull_id) == 'string' and pull_id or 'nil'
|
|
local key = ('%d:%s'):format(client_id, provider_id)
|
|
local name = ('nvim.lsp.%s.%d.%s'):format(
|
|
client and client.name or 'unknown',
|
|
client_id,
|
|
provider_id
|
|
)
|
|
local ns = client_pull_namespaces[key]
|
|
if not ns then
|
|
ns = api.nvim_create_namespace(name)
|
|
client_pull_namespaces[key] = ns
|
|
end
|
|
return ns
|
|
end
|
|
|
|
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
|
|
end
|
|
return ns
|
|
end
|
|
|
|
--- @param uri string
|
|
--- @param client_id? integer
|
|
--- @param diagnostics lsp.Diagnostic[]
|
|
--- @param pull_id boolean|string
|
|
local function handle_diagnostics(uri, client_id, diagnostics, pull_id)
|
|
local fname = vim.uri_to_fname(uri)
|
|
|
|
if #diagnostics == 0 and vim.fn.bufexists(fname) == 0 then
|
|
return
|
|
end
|
|
|
|
local bufnr = vim.fn.bufadd(fname)
|
|
if not bufnr then
|
|
return
|
|
end
|
|
|
|
client_id = client_id or DEFAULT_CLIENT_ID
|
|
|
|
local namespace = M.get_namespace(client_id, pull_id)
|
|
|
|
vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id))
|
|
end
|
|
|
|
--- |lsp-handler| for the method "textDocument/publishDiagnostics"
|
|
---
|
|
--- See |vim.diagnostic.config()| for configuration options.
|
|
---
|
|
---@param _ lsp.ResponseError?
|
|
---@param params lsp.PublishDiagnosticsParams
|
|
---@param ctx lsp.HandlerContext
|
|
function M.on_publish_diagnostics(_, params, ctx)
|
|
handle_diagnostics(params.uri, ctx.client_id, params.diagnostics, false)
|
|
end
|
|
|
|
--- |lsp-handler| for the method "textDocument/diagnostic"
|
|
---
|
|
--- See |vim.diagnostic.config()| for configuration options.
|
|
---
|
|
---@param error lsp.ResponseError?
|
|
---@param result lsp.DocumentDiagnosticReport
|
|
---@param ctx lsp.HandlerContext
|
|
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(lsp.get_client_by_id(ctx.client_id))
|
|
---@diagnostic disable-next-line: param-type-mismatch
|
|
client:request(ctx.method, ctx.params, nil, ctx.bufnr)
|
|
end
|
|
return
|
|
end
|
|
|
|
if result == nil then
|
|
return
|
|
end
|
|
|
|
local client_id = ctx.client_id
|
|
local bufnr = assert(ctx.bufnr)
|
|
local bufstate = bufstates[bufnr]
|
|
bufstate.client_result_id[client_id] = 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
|
|
if related_result.kind == 'full' then
|
|
handle_diagnostics(uri, client_id, related_result.items, params.identifier or true)
|
|
end
|
|
|
|
local related_bufnr = vim.uri_to_bufnr(uri)
|
|
local related_bufstate = bufstates[related_bufnr]
|
|
-- Create a new bufstate if it doesn't exist for the related document. This will not enable
|
|
-- diagnostic pulling by itself, but will allow previous result IDs to be passed correctly the
|
|
-- next time this buffer's diagnostics are pulled.
|
|
or { pull_kind = 'document', client_result_id = {} }
|
|
bufstates[related_bufnr] = related_bufstate
|
|
|
|
related_bufstate.client_result_id[client_id] = related_result.resultId
|
|
end
|
|
end
|
|
|
|
--- Get the diagnostics by line
|
|
---
|
|
--- Marked private as this is used internally by the LSP subsystem, but
|
|
--- most users should instead prefer |vim.diagnostic.get()|.
|
|
---
|
|
---@param bufnr integer|nil The buffer number
|
|
---@param line_nr integer|nil The line number
|
|
---@param opts {severity?:lsp.DiagnosticSeverity}?
|
|
--- - severity: (lsp.DiagnosticSeverity)
|
|
--- - Only return diagnostics with this severity.
|
|
---@param client_id integer|nil the client id
|
|
---@return table Table with map of line number to list of diagnostics.
|
|
--- Structured: { [1] = {...}, [5] = {.... } }
|
|
---@private
|
|
function M.get_line_diagnostics(bufnr, line_nr, opts, client_id)
|
|
vim.deprecate('vim.lsp.diagnostic.get_line_diagnostics', 'vim.diagnostic.get', '0.12')
|
|
local diag_opts = {} --- @type vim.diagnostic.GetOpts
|
|
|
|
if opts and opts.severity then
|
|
diag_opts.severity = severity_lsp_to_vim(opts.severity)
|
|
end
|
|
|
|
if client_id then
|
|
diag_opts.namespace = M.get_namespace(client_id, false)
|
|
end
|
|
|
|
diag_opts.lnum = line_nr or (api.nvim_win_get_cursor(0)[1] - 1)
|
|
|
|
return M.from(vim.diagnostic.get(bufnr, diag_opts))
|
|
end
|
|
|
|
--- Clear diagnostics from pull based clients
|
|
local function clear(bufnr)
|
|
for _, namespace in pairs(client_pull_namespaces) do
|
|
vim.diagnostic.reset(namespace, bufnr)
|
|
end
|
|
end
|
|
|
|
--- Disable pull diagnostics for a buffer
|
|
--- @param bufnr integer
|
|
local function disable(bufnr)
|
|
local bufstate = bufstates[bufnr]
|
|
if bufstate then
|
|
bufstate.pull_kind = 'disabled'
|
|
end
|
|
clear(bufnr)
|
|
end
|
|
|
|
--- Refresh diagnostics, only if we have attached clients that support it
|
|
---@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)
|
|
function M._refresh(bufnr, client_id, only_visible)
|
|
if
|
|
only_visible
|
|
and vim.iter(api.nvim_list_wins()):all(function(window)
|
|
return api.nvim_win_get_buf(window) ~= bufnr
|
|
end)
|
|
then
|
|
return
|
|
end
|
|
|
|
local method = 'textDocument/diagnostic'
|
|
local clients = lsp.get_clients({ bufnr = bufnr, method = method, id = client_id })
|
|
local bufstate = bufstates[bufnr]
|
|
|
|
util._cancel_requests({
|
|
bufnr = bufnr,
|
|
clients = clients,
|
|
method = method,
|
|
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)
|
|
end
|
|
end
|
|
|
|
--- |lsp-handler| for the method `workspace/diagnostic/refresh`
|
|
---@param ctx lsp.HandlerContext
|
|
---@private
|
|
function M.on_refresh(err, _, ctx)
|
|
if err then
|
|
return vim.NIL
|
|
end
|
|
local client = vim.lsp.get_client_by_id(ctx.client_id)
|
|
if client == nil then
|
|
return vim.NIL
|
|
end
|
|
if client:supports_method('workspace/diagnostic') then
|
|
M._workspace_diagnostics({ client_id = ctx.client_id })
|
|
else
|
|
for bufnr in pairs(client.attached_buffers or {}) do
|
|
if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then
|
|
M._refresh(bufnr)
|
|
end
|
|
end
|
|
end
|
|
|
|
return vim.NIL
|
|
end
|
|
|
|
--- Enable pull diagnostics for a buffer
|
|
---@param bufnr (integer) Buffer handle, or 0 for current
|
|
function M._enable(bufnr)
|
|
bufnr = vim._resolve_bufnr(bufnr)
|
|
|
|
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 ~= 'textDocument/didChange'
|
|
and opts.data.method ~= 'textDocument/didOpen'
|
|
then
|
|
return
|
|
end
|
|
if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then
|
|
local client_id = opts.data.client_id --- @type integer?
|
|
M._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
|
|
M._refresh(bufnr)
|
|
end
|
|
end,
|
|
on_detach = function()
|
|
disable(bufnr)
|
|
end,
|
|
})
|
|
|
|
api.nvim_create_autocmd('LspDetach', {
|
|
buffer = bufnr,
|
|
callback = function(ev)
|
|
local clients = lsp.get_clients({ bufnr = bufnr, method = 'textDocument/diagnostic' })
|
|
|
|
if
|
|
not vim.iter(clients):any(function(c)
|
|
return c.id ~= ev.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 = {} ---@type lsp.PreviousResultId[]
|
|
|
|
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
|
|
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 = '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 == 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('workspace/diagnostic', ctx.params, handler)
|
|
end
|
|
return
|
|
end
|
|
|
|
if error == nil and result ~= nil then
|
|
---@type lsp.WorkspaceDiagnosticParams
|
|
local params = ctx.params
|
|
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, params.identifier or true)
|
|
bufstates[bufnr].client_result_id[ctx.client_id] = report.resultId
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, client in ipairs(clients) do
|
|
---@param cap lsp.DiagnosticRegistrationOptions
|
|
client:_provider_foreach('workspace/diagnostic', function(cap)
|
|
--- @type lsp.WorkspaceDiagnosticParams
|
|
local params = {
|
|
identifier = cap.identifier,
|
|
previousResultIds = previous_result_ids(client.id),
|
|
}
|
|
|
|
client:request('workspace/diagnostic', params, handler)
|
|
end)
|
|
end
|
|
end
|
|
|
|
return M
|