neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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