diagnostic.lua (17544B)
1 ---@brief This module provides functionality for requesting LSP diagnostics for a document/workspace 2 ---and populating them using |vim.Diagnostic|s. `DiagnosticRelatedInformation` is supported: it is 3 ---included in the window shown by |vim.diagnostic.open_float()|. When the cursor is on a line with 4 ---related information, |gf| jumps to the problem location. 5 6 local lsp = vim.lsp 7 local protocol = lsp.protocol 8 local util = lsp.util 9 10 local api = vim.api 11 12 local M = {} 13 14 local augroup = api.nvim_create_augroup('nvim.lsp.diagnostic', {}) 15 16 ---@class (private) vim.lsp.diagnostic.BufState 17 ---@field pull_kind 'document'|'workspace'|'disabled' Whether diagnostics are being updated via document pull, workspace pull, or disabled. 18 ---@field client_result_id table<integer, string?> Latest responded `resultId` 19 20 ---@type table<integer, vim.lsp.diagnostic.BufState> 21 local bufstates = {} 22 23 local DEFAULT_CLIENT_ID = -1 24 25 ---@param severity lsp.DiagnosticSeverity 26 ---@return vim.diagnostic.Severity 27 local function severity_lsp_to_vim(severity) 28 if type(severity) == 'string' then 29 return protocol.DiagnosticSeverity[severity] --[[@as vim.diagnostic.Severity]] 30 end 31 return severity 32 end 33 34 ---@param severity vim.diagnostic.Severity|vim.diagnostic.SeverityName 35 ---@return lsp.DiagnosticSeverity 36 local function severity_vim_to_lsp(severity) 37 if type(severity) == 'string' then 38 return vim.diagnostic.severity[severity] 39 end 40 return severity --[[@as lsp.DiagnosticSeverity]] 41 end 42 43 ---@param bufnr integer 44 ---@return string[]? 45 local function get_buf_lines(bufnr) 46 if api.nvim_buf_is_loaded(bufnr) then 47 return api.nvim_buf_get_lines(bufnr, 0, -1, false) 48 end 49 50 local filename = api.nvim_buf_get_name(bufnr) 51 local f = io.open(filename) 52 if not f then 53 return 54 end 55 56 local content = f:read('*a') 57 if not content then 58 -- Some LSP servers report diagnostics at a directory level, in which case 59 -- io.read() returns nil 60 f:close() 61 return 62 end 63 64 local lines = vim.split(content, '\n') 65 f:close() 66 return lines 67 end 68 69 --- @param diagnostic lsp.Diagnostic 70 --- @param client_id integer 71 --- @return table? 72 local function tags_lsp_to_vim(diagnostic, client_id) 73 local tags ---@type table? 74 for _, tag in ipairs(diagnostic.tags or {}) do 75 if tag == protocol.DiagnosticTag.Unnecessary then 76 tags = tags or {} 77 tags.unnecessary = true 78 elseif tag == protocol.DiagnosticTag.Deprecated then 79 tags = tags or {} 80 tags.deprecated = true 81 else 82 lsp.log.info(string.format('Unknown DiagnosticTag %d from LSP client %d', tag, client_id)) 83 end 84 end 85 return tags 86 end 87 88 ---@param diagnostics lsp.Diagnostic[] 89 ---@param bufnr integer 90 ---@param client_id integer 91 ---@return vim.Diagnostic.Set[] 92 local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) 93 local buf_lines = get_buf_lines(bufnr) 94 local client = lsp.get_client_by_id(client_id) 95 local position_encoding = client and client.offset_encoding or 'utf-16' 96 --- @param diagnostic lsp.Diagnostic 97 --- @return vim.Diagnostic.Set 98 return vim.tbl_map(function(diagnostic) 99 local start = diagnostic.range.start 100 local _end = diagnostic.range['end'] 101 local message = diagnostic.message 102 if type(message) ~= 'string' then 103 vim.notify_once( 104 string.format('Unsupported Markup message from LSP client %d', client_id), 105 lsp.log_levels.ERROR 106 ) 107 --- @diagnostic disable-next-line: undefined-field,no-unknown 108 message = diagnostic.message.value 109 end 110 local line = buf_lines and buf_lines[start.line + 1] or '' 111 local end_line = line 112 if _end.line > start.line then 113 end_line = buf_lines and buf_lines[_end.line + 1] or '' 114 end 115 --- @type vim.Diagnostic.Set 116 return { 117 lnum = start.line, 118 col = vim.str_byteindex(line, position_encoding, start.character, false), 119 end_lnum = _end.line, 120 end_col = vim.str_byteindex(end_line, position_encoding, _end.character, false), 121 severity = severity_lsp_to_vim(diagnostic.severity), 122 message = message, 123 source = diagnostic.source, 124 code = diagnostic.code, 125 _tags = tags_lsp_to_vim(diagnostic, client_id), 126 user_data = { 127 lsp = diagnostic, 128 }, 129 } 130 end, diagnostics) 131 end 132 133 --- @param diagnostic vim.Diagnostic 134 --- @return lsp.DiagnosticTag[]? 135 local function tags_vim_to_lsp(diagnostic) 136 if not diagnostic._tags then 137 return 138 end 139 140 local tags = {} --- @type lsp.DiagnosticTag[] 141 if diagnostic._tags.unnecessary then 142 tags[#tags + 1] = protocol.DiagnosticTag.Unnecessary 143 end 144 if diagnostic._tags.deprecated then 145 tags[#tags + 1] = protocol.DiagnosticTag.Deprecated 146 end 147 return tags 148 end 149 150 --- Converts the input `vim.Diagnostic`s to LSP diagnostics. 151 --- @param diagnostics vim.Diagnostic[] 152 --- @return lsp.Diagnostic[] 153 function M.from(diagnostics) 154 ---@param diagnostic vim.Diagnostic 155 ---@return lsp.Diagnostic 156 return vim.tbl_map(function(diagnostic) 157 local user_data = diagnostic.user_data or {} 158 if user_data.lsp then 159 return user_data.lsp 160 end 161 return { 162 range = { 163 start = { 164 line = diagnostic.lnum, 165 character = diagnostic.col, 166 }, 167 ['end'] = { 168 line = diagnostic.end_lnum, 169 character = diagnostic.end_col, 170 }, 171 }, 172 severity = severity_vim_to_lsp(diagnostic.severity), 173 message = diagnostic.message, 174 source = diagnostic.source, 175 code = diagnostic.code, 176 tags = tags_vim_to_lsp(diagnostic), 177 } 178 end, diagnostics) 179 end 180 181 ---@type table<integer, integer> 182 local client_push_namespaces = {} 183 184 ---@type table<string, integer> 185 local client_pull_namespaces = {} 186 187 --- Get the diagnostic namespace associated with an LSP client |vim.diagnostic| for diagnostics 188 --- 189 ---@param client_id integer The id of the LSP client 190 ---@param pull_id (boolean|string)? (default: nil) Pull diagnostics provider id 191 --- (indicates "pull" client), or `nil` for a "push" client. 192 function M.get_namespace(client_id, pull_id) 193 vim.validate('client_id', client_id, 'number') 194 vim.validate('pull_id', pull_id, { 'boolean', 'string' }, true) 195 196 if type(pull_id) == 'boolean' then 197 vim.deprecate('get_namespace(pull_id:boolean)', 'get_namespace(pull_id:string)', '0.14') 198 end 199 200 local client = lsp.get_client_by_id(client_id) 201 if pull_id then 202 local provider_id = type(pull_id) == 'string' and pull_id or 'nil' 203 local key = ('%d:%s'):format(client_id, provider_id) 204 local name = ('nvim.lsp.%s.%d.%s'):format( 205 client and client.name or 'unknown', 206 client_id, 207 provider_id 208 ) 209 local ns = client_pull_namespaces[key] 210 if not ns then 211 ns = api.nvim_create_namespace(name) 212 client_pull_namespaces[key] = ns 213 end 214 return ns 215 end 216 217 local ns = client_push_namespaces[client_id] 218 if not ns then 219 local name = ('nvim.lsp.%s.%d'):format(client and client.name or 'unknown', client_id) 220 ns = api.nvim_create_namespace(name) 221 client_push_namespaces[client_id] = ns 222 end 223 return ns 224 end 225 226 --- @param uri string 227 --- @param client_id? integer 228 --- @param diagnostics lsp.Diagnostic[] 229 --- @param pull_id boolean|string 230 local function handle_diagnostics(uri, client_id, diagnostics, pull_id) 231 local fname = vim.uri_to_fname(uri) 232 233 if #diagnostics == 0 and vim.fn.bufexists(fname) == 0 then 234 return 235 end 236 237 local bufnr = vim.fn.bufadd(fname) 238 if not bufnr then 239 return 240 end 241 242 client_id = client_id or DEFAULT_CLIENT_ID 243 244 local namespace = M.get_namespace(client_id, pull_id) 245 246 vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id)) 247 end 248 249 --- |lsp-handler| for the method "textDocument/publishDiagnostics" 250 --- 251 --- See |vim.diagnostic.config()| for configuration options. 252 --- 253 ---@param _ lsp.ResponseError? 254 ---@param params lsp.PublishDiagnosticsParams 255 ---@param ctx lsp.HandlerContext 256 function M.on_publish_diagnostics(_, params, ctx) 257 handle_diagnostics(params.uri, ctx.client_id, params.diagnostics, false) 258 end 259 260 --- |lsp-handler| for the method "textDocument/diagnostic" 261 --- 262 --- See |vim.diagnostic.config()| for configuration options. 263 --- 264 ---@param error lsp.ResponseError? 265 ---@param result lsp.DocumentDiagnosticReport 266 ---@param ctx lsp.HandlerContext 267 function M.on_diagnostic(error, result, ctx) 268 if error ~= nil and error.code == protocol.ErrorCodes.ServerCancelled then 269 if error.data == nil or error.data.retriggerRequest ~= false then 270 local client = assert(lsp.get_client_by_id(ctx.client_id)) 271 ---@diagnostic disable-next-line: param-type-mismatch 272 client:request(ctx.method, ctx.params, nil, ctx.bufnr) 273 end 274 return 275 end 276 277 if result == nil then 278 return 279 end 280 281 local client_id = ctx.client_id 282 local bufnr = assert(ctx.bufnr) 283 local bufstate = bufstates[bufnr] 284 bufstate.client_result_id[client_id] = result.resultId 285 286 if result.kind == 'unchanged' then 287 return 288 end 289 290 ---@type lsp.DocumentDiagnosticParams 291 local params = ctx.params 292 handle_diagnostics(params.textDocument.uri, client_id, result.items, params.identifier or true) 293 294 for uri, related_result in pairs(result.relatedDocuments or {}) do 295 if related_result.kind == 'full' then 296 handle_diagnostics(uri, client_id, related_result.items, params.identifier or true) 297 end 298 299 local related_bufnr = vim.uri_to_bufnr(uri) 300 local related_bufstate = bufstates[related_bufnr] 301 -- Create a new bufstate if it doesn't exist for the related document. This will not enable 302 -- diagnostic pulling by itself, but will allow previous result IDs to be passed correctly the 303 -- next time this buffer's diagnostics are pulled. 304 or { pull_kind = 'document', client_result_id = {} } 305 bufstates[related_bufnr] = related_bufstate 306 307 related_bufstate.client_result_id[client_id] = related_result.resultId 308 end 309 end 310 311 --- Get the diagnostics by line 312 --- 313 --- Marked private as this is used internally by the LSP subsystem, but 314 --- most users should instead prefer |vim.diagnostic.get()|. 315 --- 316 ---@param bufnr integer|nil The buffer number 317 ---@param line_nr integer|nil The line number 318 ---@param opts {severity?:lsp.DiagnosticSeverity}? 319 --- - severity: (lsp.DiagnosticSeverity) 320 --- - Only return diagnostics with this severity. 321 ---@param client_id integer|nil the client id 322 ---@return table Table with map of line number to list of diagnostics. 323 --- Structured: { [1] = {...}, [5] = {.... } } 324 ---@private 325 function M.get_line_diagnostics(bufnr, line_nr, opts, client_id) 326 vim.deprecate('vim.lsp.diagnostic.get_line_diagnostics', 'vim.diagnostic.get', '0.12') 327 local diag_opts = {} --- @type vim.diagnostic.GetOpts 328 329 if opts and opts.severity then 330 diag_opts.severity = severity_lsp_to_vim(opts.severity) 331 end 332 333 if client_id then 334 diag_opts.namespace = M.get_namespace(client_id, false) 335 end 336 337 diag_opts.lnum = line_nr or (api.nvim_win_get_cursor(0)[1] - 1) 338 339 return M.from(vim.diagnostic.get(bufnr, diag_opts)) 340 end 341 342 --- Clear diagnostics from pull based clients 343 local function clear(bufnr) 344 for _, namespace in pairs(client_pull_namespaces) do 345 vim.diagnostic.reset(namespace, bufnr) 346 end 347 end 348 349 --- Disable pull diagnostics for a buffer 350 --- @param bufnr integer 351 local function disable(bufnr) 352 local bufstate = bufstates[bufnr] 353 if bufstate then 354 bufstate.pull_kind = 'disabled' 355 end 356 clear(bufnr) 357 end 358 359 --- Refresh diagnostics, only if we have attached clients that support it 360 ---@param bufnr integer buffer number 361 ---@param client_id? integer Client ID to refresh (default: all clients) 362 ---@param only_visible? boolean Whether to only refresh for the visible regions of the buffer (default: false) 363 function M._refresh(bufnr, client_id, only_visible) 364 if 365 only_visible 366 and vim.iter(api.nvim_list_wins()):all(function(window) 367 return api.nvim_win_get_buf(window) ~= bufnr 368 end) 369 then 370 return 371 end 372 373 local method = 'textDocument/diagnostic' 374 local clients = lsp.get_clients({ bufnr = bufnr, method = method, id = client_id }) 375 local bufstate = bufstates[bufnr] 376 377 util._cancel_requests({ 378 bufnr = bufnr, 379 clients = clients, 380 method = method, 381 type = 'pending', 382 }) 383 for _, client in ipairs(clients) do 384 ---@type lsp.DocumentDiagnosticParams 385 local params = { 386 textDocument = util.make_text_document_params(bufnr), 387 previousResultId = bufstate.client_result_id[client.id], 388 } 389 client:request(method, params, nil, bufnr) 390 end 391 end 392 393 --- |lsp-handler| for the method `workspace/diagnostic/refresh` 394 ---@param ctx lsp.HandlerContext 395 ---@private 396 function M.on_refresh(err, _, ctx) 397 if err then 398 return vim.NIL 399 end 400 local client = vim.lsp.get_client_by_id(ctx.client_id) 401 if client == nil then 402 return vim.NIL 403 end 404 if client:supports_method('workspace/diagnostic') then 405 M._workspace_diagnostics({ client_id = ctx.client_id }) 406 else 407 for bufnr in pairs(client.attached_buffers or {}) do 408 if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then 409 M._refresh(bufnr) 410 end 411 end 412 end 413 414 return vim.NIL 415 end 416 417 --- Enable pull diagnostics for a buffer 418 ---@param bufnr (integer) Buffer handle, or 0 for current 419 function M._enable(bufnr) 420 bufnr = vim._resolve_bufnr(bufnr) 421 422 if bufstates[bufnr] then 423 -- If we're already pulling diagnostics for this buffer, nothing to do here. 424 if bufstates[bufnr].pull_kind == 'document' then 425 return 426 end 427 -- Else diagnostics were disabled or we were using workspace diagnostics. 428 bufstates[bufnr].pull_kind = 'document' 429 else 430 bufstates[bufnr] = { pull_kind = 'document', client_result_id = {} } 431 end 432 433 api.nvim_create_autocmd('LspNotify', { 434 buffer = bufnr, 435 callback = function(opts) 436 if 437 opts.data.method ~= 'textDocument/didChange' 438 and opts.data.method ~= 'textDocument/didOpen' 439 then 440 return 441 end 442 if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then 443 local client_id = opts.data.client_id --- @type integer? 444 M._refresh(bufnr, client_id, true) 445 end 446 end, 447 group = augroup, 448 }) 449 450 api.nvim_buf_attach(bufnr, false, { 451 on_reload = function() 452 if bufstates[bufnr] and bufstates[bufnr].pull_kind == 'document' then 453 M._refresh(bufnr) 454 end 455 end, 456 on_detach = function() 457 disable(bufnr) 458 end, 459 }) 460 461 api.nvim_create_autocmd('LspDetach', { 462 buffer = bufnr, 463 callback = function(args) 464 local clients = lsp.get_clients({ bufnr = bufnr, method = 'textDocument/diagnostic' }) 465 466 if 467 not vim.iter(clients):any(function(c) 468 return c.id ~= args.data.client_id 469 end) 470 then 471 disable(bufnr) 472 end 473 end, 474 group = augroup, 475 }) 476 end 477 478 --- Returns the result IDs from the reports provided by the given client. 479 --- @return lsp.PreviousResultId[] 480 local function previous_result_ids(client_id) 481 local results = {} ---@type lsp.PreviousResultId[] 482 483 for bufnr, state in pairs(bufstates) do 484 if state.pull_kind ~= 'disabled' then 485 for buf_client_id, result_id in pairs(state.client_result_id) do 486 if buf_client_id == client_id then 487 results[#results + 1] = { 488 uri = vim.uri_from_bufnr(bufnr), 489 value = result_id, 490 } 491 break 492 end 493 end 494 end 495 end 496 497 return results 498 end 499 500 --- Request workspace-wide diagnostics. 501 --- @param opts vim.lsp.WorkspaceDiagnosticsOpts 502 function M._workspace_diagnostics(opts) 503 local clients = lsp.get_clients({ method = 'workspace/diagnostic', id = opts.client_id }) 504 505 --- @param error lsp.ResponseError? 506 --- @param result lsp.WorkspaceDiagnosticReport 507 --- @param ctx lsp.HandlerContext 508 local function handler(error, result, ctx) 509 -- Check for retrigger requests on cancellation errors. 510 -- Unless `retriggerRequest` is explicitly disabled, try again. 511 if error ~= nil and error.code == protocol.ErrorCodes.ServerCancelled then 512 if error.data == nil or error.data.retriggerRequest ~= false then 513 local client = assert(lsp.get_client_by_id(ctx.client_id)) 514 client:request('workspace/diagnostic', ctx.params, handler) 515 end 516 return 517 end 518 519 if error == nil and result ~= nil then 520 ---@type lsp.WorkspaceDiagnosticParams 521 local params = ctx.params 522 for _, report in ipairs(result.items) do 523 local bufnr = vim.uri_to_bufnr(report.uri) 524 525 -- Start tracking the buffer (but don't send "textDocument/diagnostic" requests for it). 526 if not bufstates[bufnr] then 527 bufstates[bufnr] = { pull_kind = 'workspace', client_result_id = {} } 528 end 529 530 -- We favor document pull requests over workspace results, so only update the buffer 531 -- state if we're not pulling document diagnostics for this buffer. 532 if bufstates[bufnr].pull_kind == 'workspace' and report.kind == 'full' then 533 handle_diagnostics(report.uri, ctx.client_id, report.items, params.identifier or true) 534 bufstates[bufnr].client_result_id[ctx.client_id] = report.resultId 535 end 536 end 537 end 538 end 539 540 for _, client in ipairs(clients) do 541 local identifiers = client:_provider_value_get('workspace/diagnostic', 'identifier') 542 for _, id in ipairs(identifiers) do 543 --- @type lsp.WorkspaceDiagnosticParams 544 local params = { 545 identifier = type(id) == 'string' and id or nil, 546 previousResultIds = previous_result_ids(client.id), 547 } 548 549 client:request('workspace/diagnostic', params, handler) 550 end 551 end 552 end 553 554 return M