Files
neovim/runtime/lua/vim/lsp/diagnostic.lua
TJ DeVries f75be5e9d5 lsp: vim.lsp.diagnostic (#12655)
Breaking Changes:
- Deprecated all `vim.lsp.util.{*diagnostics*}()` functions.
    - Instead, all functions must be found in vim.lsp.diagnostic
    - For now, they issue a warning ONCE per neovim session. In a
      "little while" we will remove them completely.
- `vim.lsp.callbacks` has moved to `vim.lsp.handlers`.
    - For a "little while" we will just redirect `vim.lsp.callbacks` to
      `vim.lsp.handlers`. However, we will remove this at some point, so
      it is recommended that you change all of your references to
      `callbacks` into `handlers`.
    - This also means that for functions like |vim.lsp.start_client()|
      and similar, keyword style arguments have moved from "callbacks"
      to "handlers". Once again, these are currently being forward, but
      will cease to be forwarded in a "little while".
- Changed the highlight groups for LspDiagnostic highlight as they were
  inconsistently named.
    - For more information, see |lsp-highlight-diagnostics|
- Changed the sign group names as well, to be consistent with
  |lsp-highlight-diagnostics|

General Enhancements:
- Rewrote much of the getting started help document for lsp. It also
  provides a much nicer configuration strategy, so as to not recommend
  globally overwriting builtin neovim mappings.

LSP Enhancements:
- Introduced the concept of |lsp-handlers| which will allow much better
  customization for users without having to copy & paste entire files /
  functions / etc.

Diagnostic Enhancements:
- "goto next diagnostic" |vim.lsp.diagnostic.goto_next()|
- "goto prev diagnostic" |vim.lsp.diagnostic.goto_prev()|
    - For each of the gotos, auto open diagnostics is available as a
      configuration option
- Configurable diagnostic handling:
    - See |vim.lsp.diagnostic.on_publish_diagnostics()|
    - Delay display until after insert mode
    - Configure signs
    - Configure virtual text
    - Configure underline
- Set the location list with the buffers diagnostics.
    - See |vim.lsp.diagnostic.set_loclist()|
- Better performance for getting counts and line diagnostics
    - They are now cached on save, to enhance lookups.
    - Particularly useful for checking in statusline, etc.
- Actual testing :)
    - See ./test/functional/plugin/lsp/diagnostic_spec.lua
- Added `guisp` for underline highlighting

NOTE: "a little while" means enough time to feel like most plugins and
plugin authors have had a chance to refactor their code to use the
updated calls. Then we will remove them completely. There is no need to
keep them, because we don't have any released version of neovim that
exposes these APIs. I'm trying to be nice to people following HEAD :)

Co-authored: [Twitch Chat 2020](https://twitch.tv/teej_dv)
2020-11-12 22:21:34 -05:00

1196 lines
36 KiB
Lua

local api = vim.api
local validate = vim.validate
local highlight = vim.highlight
local log = require('vim.lsp.log')
local protocol = require('vim.lsp.protocol')
local util = require('vim.lsp.util')
local if_nil = vim.F.if_nil
--@class DiagnosticSeverity
local DiagnosticSeverity = protocol.DiagnosticSeverity
local to_severity = function(severity)
if not severity then return nil end
return type(severity) == 'string' and DiagnosticSeverity[severity] or severity
end
local to_position = function(position, bufnr)
vim.validate { position = {position, 't'} }
return {
position.line,
util._get_line_byte_from_position(bufnr, position)
}
end
---@brief lsp-diagnostic
---
--@class Diagnostic
--@field range Range
--@field message string
--@field severity DiagnosticSeverity|nil
--@field code number | string
--@field source string
--@field tags DiagnosticTag[]
--@field relatedInformation DiagnosticRelatedInformation[]
local M = {}
-- Diagnostic Highlights {{{
-- TODO(tjdevries): Determine how to generate documentation for these
-- and how to configure them to be easy for users.
--
-- For now, just use the following script. It should work pretty good.
--[[
local levels = {"Error", "Warning", "Information", "Hint" }
local all_info = {
{ "Default", "Used as the base highlight group, other highlight groups link to", },
{ "VirtualText", 'Used for "%s" diagnostic virtual text.\n See |vim.lsp.diagnostic.set_virtual_text()|', },
{ "Underline", 'Used to underline "%s" diagnostics.\n See |vim.lsp.diagnostic.set_underline()|', },
{ "Floating", 'Used to color "%s" diagnostic messages in diagnostics float.\n See |vim.lsp.diagnostic.show_line_diagnostics()|', },
{ "Sign", 'Used for "%s" signs in sing column.\n See |vim.lsp.diagnostic.set_signs()|', },
}
local results = {}
for _, info in ipairs(all_info) do
for _, level in ipairs(levels) do
local name = info[1]
local description = info[2]
local fullname = string.format("Lsp%s%s", name, level)
table.insert(results, string.format(
"%78s", string.format("*hl-%s*", fullname))
)
table.insert(results, fullname)
table.insert(results, string.format(" %s", description))
table.insert(results, "")
end
end
-- print(table.concat(results, '\n'))
vim.fn.setreg("*", table.concat(results, '\n'))
--]]
local diagnostic_severities = {
[DiagnosticSeverity.Error] = { guifg = "Red" };
[DiagnosticSeverity.Warning] = { guifg = "Orange" };
[DiagnosticSeverity.Information] = { guifg = "LightBlue" };
[DiagnosticSeverity.Hint] = { guifg = "LightGrey" };
}
-- Make a map from DiagnosticSeverity -> Highlight Name
local make_highlight_map = function(base_name)
local result = {}
for k, _ in pairs(diagnostic_severities) do
result[k] = "LspDiagnostics" .. base_name .. DiagnosticSeverity[k]
end
return result
end
local default_highlight_map = make_highlight_map("Default")
local virtual_text_highlight_map = make_highlight_map("VirtualText")
local underline_highlight_map = make_highlight_map("Underline")
local floating_highlight_map = make_highlight_map("Floating")
local sign_highlight_map = make_highlight_map("Sign")
-- }}}
-- Diagnostic Namespaces {{{
local DEFAULT_CLIENT_ID = -1
local get_client_id = function(client_id)
if client_id == nil then
client_id = DEFAULT_CLIENT_ID
end
return client_id
end
local get_bufnr = function(bufnr)
if not bufnr then
return api.nvim_get_current_buf()
elseif bufnr == 0 then
return api.nvim_get_current_buf()
end
return bufnr
end
--- Create a namespace table, used to track a client's buffer local items
local _make_namespace_table = function(namespace, api_namespace)
vim.validate { namespace = { namespace, 's' } }
return setmetatable({
[DEFAULT_CLIENT_ID] = api.nvim_create_namespace(namespace)
}, {
__index = function(t, client_id)
client_id = get_client_id(client_id)
if rawget(t, client_id) == nil then
local value = string.format("%s:%s", namespace, client_id)
if api_namespace then
value = api.nvim_create_namespace(value)
end
rawset(t, client_id, value)
end
return rawget(t, client_id)
end
})
end
local _diagnostic_namespaces = _make_namespace_table("vim_lsp_diagnostics", true)
local _sign_namespaces = _make_namespace_table("vim_lsp_signs", false)
--@private
function M._get_diagnostic_namespace(client_id)
return _diagnostic_namespaces[client_id]
end
--@private
function M._get_sign_namespace(client_id)
return _sign_namespaces[client_id]
end
-- }}}
-- Diagnostic Buffer & Client metatables {{{
local bufnr_and_client_cacher_mt = {
__index = function(t, bufnr)
if bufnr == 0 or bufnr == nil then
bufnr = vim.api.nvim_get_current_buf()
end
if rawget(t, bufnr) == nil then
rawset(t, bufnr, {})
end
return rawget(t, bufnr)
end,
__newindex = function(t, bufnr, v)
if bufnr == 0 or bufnr == nil then
bufnr = vim.api.nvim_get_current_buf()
end
rawset(t, bufnr, v)
end,
}
-- }}}
-- Diagnostic Saving & Caching {{{
local _diagnostic_cleanup = setmetatable({}, bufnr_and_client_cacher_mt)
local diagnostic_cache = setmetatable({}, bufnr_and_client_cacher_mt)
local diagnostic_cache_lines = setmetatable({}, bufnr_and_client_cacher_mt)
local diagnostic_cache_counts = setmetatable({}, bufnr_and_client_cacher_mt)
local _bufs_waiting_to_update = setmetatable({}, bufnr_and_client_cacher_mt)
--- Store Diagnostic[] by line
---
---@param diagnostics Diagnostic[]
---@return table<number, Diagnostic[]>
local _diagnostic_lines = function(diagnostics)
if not diagnostics then return end
local diagnostics_by_line = {}
for _, diagnostic in ipairs(diagnostics) do
local start = diagnostic.range.start
local line_diagnostics = diagnostics_by_line[start.line]
if not line_diagnostics then
line_diagnostics = {}
diagnostics_by_line[start.line] = line_diagnostics
end
table.insert(line_diagnostics, diagnostic)
end
return diagnostics_by_line
end
--- Get the count of M by Severity
---
---@param diagnostics Diagnostic[]
---@return table<DiagnosticSeverity, number>
local _diagnostic_counts = function(diagnostics)
if not diagnostics then return end
local counts = {}
for _, diagnostic in pairs(diagnostics) do
if diagnostic.severity then
local val = counts[diagnostic.severity]
if val == nil then
val = 0
end
counts[diagnostic.severity] = val + 1
end
end
return counts
end
--@private
--- Set the different diagnostic cache after `textDocument/publishDiagnostics`
---@param diagnostics Diagnostic[]
---@param bufnr number
---@param client_id number
---@return nil
local function set_diagnostic_cache(diagnostics, bufnr, client_id)
client_id = get_client_id(client_id)
-- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#diagnostic
--
-- The diagnostic's severity. Can be omitted. If omitted it is up to the
-- client to interpret diagnostics as error, warning, info or hint.
-- TODO: Replace this with server-specific heuristics to infer severity.
for _, diagnostic in ipairs(diagnostics) do
if diagnostic.severity == nil then
diagnostic.severity = DiagnosticSeverity.Error
end
end
diagnostic_cache[bufnr][client_id] = diagnostics
diagnostic_cache_lines[bufnr][client_id] = _diagnostic_lines(diagnostics)
diagnostic_cache_counts[bufnr][client_id] = _diagnostic_counts(diagnostics)
end
--@private
--- Clear the cached diagnostics
---@param bufnr number
---@param client_id number
local function clear_diagnostic_cache(bufnr, client_id)
client_id = get_client_id(client_id)
diagnostic_cache[bufnr][client_id] = nil
diagnostic_cache_lines[bufnr][client_id] = nil
diagnostic_cache_counts[bufnr][client_id] = nil
end
--- Save diagnostics to the current buffer.
---
--- Handles saving diagnostics from multiple clients in the same buffer.
---@param diagnostics Diagnostic[]
---@param bufnr number
---@param client_id number
function M.save(diagnostics, bufnr, client_id)
validate {
diagnostics = {diagnostics, 't'},
bufnr = {bufnr, 'n'},
client_id = {client_id, 'n', true},
}
if not diagnostics then return end
bufnr = get_bufnr(bufnr)
client_id = get_client_id(client_id)
if not _diagnostic_cleanup[bufnr][client_id] then
_diagnostic_cleanup[bufnr][client_id] = true
-- Clean up our data when the buffer unloads.
api.nvim_buf_attach(bufnr, false, {
on_detach = function(b)
clear_diagnostic_cache(b, client_id)
_diagnostic_cleanup[bufnr][client_id] = nil
end
})
end
set_diagnostic_cache(diagnostics, bufnr, client_id)
end
-- }}}
-- Diagnostic Retrieval {{{
--- Return associated diagnostics for bufnr
---
---@param bufnr number
---@param client_id number|nil If nil, then return all of the diagnostics.
--- Else, return just the diagnostics associated with the client_id.
function M.get(bufnr, client_id)
if client_id == nil then
local all_diagnostics = {}
for iter_client_id, _ in pairs(diagnostic_cache[bufnr]) do
local iter_diagnostics = M.get(bufnr, iter_client_id)
for _, diagnostic in ipairs(iter_diagnostics) do
table.insert(all_diagnostics, diagnostic)
end
end
return all_diagnostics
end
return diagnostic_cache[bufnr][client_id] or {}
end
--- Get the diagnostics by line
---
---@param bufnr number The buffer number
---@param line_nr number The line number
---@param opts table|nil Configuration keys
--- - severity: (DiagnosticSeverity, default nil)
--- - Only return diagnostics with this severity. Overrides severity_limit
--- - severity_limit: (DiagnosticSeverity, default nil)
--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid.
---@param client_id number the client id
---@return table Table with map of line number to list of diagnostics.
-- Structured: { [1] = {...}, [5] = {.... } }
function M.get_line_diagnostics(bufnr, line_nr, opts, client_id)
opts = opts or {}
bufnr = bufnr or vim.api.nvim_get_current_buf()
line_nr = line_nr or vim.api.nvim_win_get_cursor(0)[1] - 1
local client_get_diags = function(iter_client_id)
return (diagnostic_cache_lines[bufnr][iter_client_id] or {})[line_nr] or {}
end
local line_diagnostics
if client_id == nil then
line_diagnostics = {}
for iter_client_id, _ in pairs(diagnostic_cache_lines[bufnr]) do
for _, diagnostic in ipairs(client_get_diags(iter_client_id)) do
table.insert(line_diagnostics, diagnostic)
end
end
else
line_diagnostics = vim.deepcopy(client_get_diags(client_id))
end
if opts.severity then
local filter_level = to_severity(opts.severity)
line_diagnostics = vim.tbl_filter(function(t) return t.severity == filter_level end, line_diagnostics)
elseif opts.severity_limit then
local filter_level = to_severity(opts.severity_limit)
line_diagnostics = vim.tbl_filter(function(t) return t.severity <= filter_level end, line_diagnostics)
end
if opts.severity_sort then
table.sort(line_diagnostics, function(a, b) return a.severity < b.severity end)
end
return line_diagnostics
end
--- Get the counts for a particular severity
---
--- Useful for showing diagnostic counts in statusline. eg:
---
--- <pre>
--- function! LspStatus() abort
--- let sl = ''
--- if luaeval('not vim.tbl_isempty(vim.lsp.buf_get_clients(0))')
--- let sl.='%#MyStatuslineLSP#E:'
--- let sl.='%#MyStatuslineLSPErrors#%{luaeval("vim.lsp.diagnostic.get_count([[Error]])")}'
--- let sl.='%#MyStatuslineLSP# W:'
--- let sl.='%#MyStatuslineLSPWarnings#%{luaeval("vim.lsp.diagnostic.get_count([[Warning]])")}'
--- else
--- let sl.='%#MyStatuslineLSPErrors#off'
--- endif
--- return sl
--- endfunction
--- let &l:statusline = '%#MyStatuslineLSP#LSP '.LspStatus()
--- </pre>
---
---@param bufnr number The buffer number
---@param severity DiagnosticSeverity
---@param client_id number the client id
function M.get_count(bufnr, severity, client_id)
if client_id == nil then
local total = 0
for iter_client_id, _ in pairs(diagnostic_cache_counts[bufnr]) do
total = total + M.get_count(bufnr, severity, iter_client_id)
end
return total
end
return (diagnostic_cache_counts[bufnr][client_id] or {})[DiagnosticSeverity[severity]] or 0
end
-- }}}
-- Diagnostic Movements {{{
--- Helper function to iterate through all of the diagnostic lines
---@return table list of diagnostics
local _iter_diagnostic_lines = function(start, finish, step, bufnr, opts, client_id)
if bufnr == nil then
bufnr = vim.api.nvim_get_current_buf()
end
local wrap = if_nil(opts.wrap, true)
local search = function(search_start, search_finish, search_step)
for line_nr = search_start, search_finish, search_step do
local line_diagnostics = M.get_line_diagnostics(bufnr, line_nr, opts, client_id)
if line_diagnostics and not vim.tbl_isempty(line_diagnostics) then
return line_diagnostics
end
end
end
local result = search(start, finish, step)
if wrap then
local wrap_start, wrap_finish
if step == 1 then
wrap_start, wrap_finish = 1, start
else
wrap_start, wrap_finish = vim.api.nvim_buf_line_count(bufnr), start
end
if not result then
result = search(wrap_start, wrap_finish, step)
end
end
return result
end
--@private
--- Helper function to ierate through diagnostic lines and return a position
---
---@return table {row, col}
local function _iter_diagnostic_lines_pos(opts, line_diagnostics)
opts = opts or {}
local win_id = opts.win_id or vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_win_get_buf(win_id)
if line_diagnostics == nil or vim.tbl_isempty(line_diagnostics) then
return false
end
local iter_diagnostic = line_diagnostics[1]
return to_position(iter_diagnostic.range.start, bufnr)
end
--@private
-- Move to the diagnostic position
local function _iter_diagnostic_move_pos(name, opts, pos)
opts = opts or {}
local enable_popup = if_nil(opts.enable_popup, true)
local win_id = opts.win_id or vim.api.nvim_get_current_win()
if not pos then
print(string.format("%s: No more valid diagnostics to move to.", name))
return
end
vim.api.nvim_win_set_cursor(win_id, {pos[1] + 1, pos[2]})
if enable_popup then
-- This is a bit weird... I'm surprised that we need to wait til the next tick to do this.
vim.schedule(function()
M.show_line_diagnostics(opts.popup_opts, vim.api.nvim_win_get_buf(win_id))
end)
end
end
--- Get the previous diagnostic closest to the cursor_position
---
---@param opts table See |vim.lsp.diagnostics.goto_next()|
---@return table Previous diagnostic
function M.get_prev(opts)
opts = opts or {}
local win_id = opts.win_id or vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_win_get_buf(win_id)
local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id)
return _iter_diagnostic_lines(cursor_position[1] - 2, 0, -1, bufnr, opts, opts.client_id)
end
--- Return the pos, {row, col}, for the prev diagnostic in the current buffer.
---@param opts table See |vim.lsp.diagnostics.goto_next()|
---@return table Previous diagnostic position
function M.get_prev_pos(opts)
return _iter_diagnostic_lines_pos(
opts,
M.get_prev(opts)
)
end
--- Move to the previous diagnostic
---@param opts table See |vim.lsp.diagnostics.goto_next()|
function M.goto_prev(opts)
return _iter_diagnostic_move_pos(
"DiagnosticPrevious",
opts,
M.get_prev_pos(opts)
)
end
--- Get the previous diagnostic closest to the cursor_position
---@param opts table See |vim.lsp.diagnostics.goto_next()|
---@return table Next diagnostic
function M.get_next(opts)
opts = opts or {}
local win_id = opts.win_id or vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_win_get_buf(win_id)
local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id)
return _iter_diagnostic_lines(cursor_position[1], vim.api.nvim_buf_line_count(bufnr), 1, bufnr, opts, opts.client_id)
end
--- Return the pos, {row, col}, for the next diagnostic in the current buffer.
---@param opts table See |vim.lsp.diagnostics.goto_next()|
---@return table Next diagnostic position
function M.get_next_pos(opts)
return _iter_diagnostic_lines_pos(
opts,
M.get_next(opts)
)
end
--- Move to the next diagnostic
---@param opts table|nil Configuration table. Keys:
--- - {client_id}: (number)
--- - If nil, will consider all clients attached to buffer.
--- - {cursor_position}: (Position, default current position)
--- - See |nvim_win_get_cursor()|
--- - {wrap}: (boolean, default true)
--- - Whether to loop around file or not. Similar to 'wrapscan'
--- - {severity}: (DiagnosticSeverity)
--- - Exclusive severity to consider. Overrides {severity_limit}
--- - {severity_limit}: (DiagnosticSeverity)
--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid.
--- - {enable_popup}: (boolean, default true)
--- - Call |vim.lsp.diagnostic.show_line_diagnostics()| on jump
--- - {popup_opts}: (table)
--- - Table to pass as {opts} parameter to |vim.lsp.diagnostic.show_line_diagnostics()|
--- - {win_id}: (number, default 0)
--- - Window ID
function M.goto_next(opts)
return _iter_diagnostic_move_pos(
"DiagnosticNext",
opts,
M.get_next_pos(opts)
)
end
-- }}}
-- Diagnostic Setters {{{
--- Set signs for given diagnostics
---
--- Sign characters can be customized with the following commands:
---
--- <pre>
--- sign define LspDiagnosticsErrorSign text=E texthl=LspDiagnosticsError linehl= numhl=
--- sign define LspDiagnosticsWarningSign text=W texthl=LspDiagnosticsWarning linehl= numhl=
--- sign define LspDiagnosticsInformationSign text=I texthl=LspDiagnosticsInformation linehl= numhl=
--- sign define LspDiagnosticsHintSign text=H texthl=LspDiagnosticsHint linehl= numhl=
--- </pre>
---@param diagnostics Diagnostic[]
---@param bufnr number The buffer number
---@param client_id number the client id
---@param sign_ns number|nil
---@param opts table Configuration for signs. Keys:
--- - priority: Set the priority of the signs.
function M.set_signs(diagnostics, bufnr, client_id, sign_ns, opts)
opts = opts or {}
sign_ns = sign_ns or M._get_sign_namespace(client_id)
if not diagnostics then
diagnostics = diagnostic_cache[bufnr][client_id]
end
if not diagnostics then
return
end
bufnr = get_bufnr(bufnr)
local ok = true
for _, diagnostic in ipairs(diagnostics) do
ok = ok and pcall(vim.fn.sign_place,
0,
sign_ns,
sign_highlight_map[diagnostic.severity],
bufnr,
{
priority = opts.priority,
lnum = diagnostic.range.start.line + 1
}
)
end
if not ok then
log.debug("Failed to place signs:", diagnostics)
end
end
--- Set underline for given diagnostics
---
--- Underline highlights can be customized by changing the following |:highlight| groups.
---
--- <pre>
--- LspDiagnosticsUnderlineError
--- LspDiagnosticsUnderlineWarning
--- LspDiagnosticsUnderlineInformation
--- LspDiagnosticsUnderlineHint
--- </pre>
---
---@param diagnostics Diagnostic[]
---@param bufnr number The buffer number
---@param client_id number the client id
---@param diagnostic_ns number|nil
---@param opts table Currently unused.
function M.set_underline(diagnostics, bufnr, client_id, diagnostic_ns, opts)
opts = opts or {}
assert(opts) -- lint
diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id)
for _, diagnostic in ipairs(diagnostics) do
local start = diagnostic.range["start"]
local finish = diagnostic.range["end"]
local higroup = underline_highlight_map[diagnostic.severity]
if higroup == nil then
-- Default to error if we don't have a highlight associated
higroup = underline_highlight_map[DiagnosticSeverity.Error]
end
highlight.range(
bufnr,
diagnostic_ns,
higroup,
to_position(start, bufnr),
to_position(finish, bufnr)
)
end
end
-- Virtual Text {{{
--- Set virtual text given diagnostics
---
--- Virtual text highlights can be customized by changing the following |:highlight| groups.
---
--- <pre>
--- LspDiagnosticsVirtualTextError
--- LspDiagnosticsVirtualTextWarning
--- LspDiagnosticsVirtualTextInformation
--- LspDiagnosticsVirtualTextHint
--- </pre>
---
---@param diagnostics Diagnostic[]
---@param bufnr number
---@param client_id number
---@param diagnostic_ns number
---@param opts table Options on how to display virtual text. Keys:
--- - prefix (string): Prefix to display before virtual text on line
--- - spacing (number): Number of spaces to insert before virtual text
function M.set_virtual_text(diagnostics, bufnr, client_id, diagnostic_ns, opts)
opts = opts or {}
client_id = get_client_id(client_id)
diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id)
local buffer_line_diagnostics
if diagnostics then
buffer_line_diagnostics = _diagnostic_lines(diagnostics)
else
buffer_line_diagnostics = diagnostic_cache_lines[bufnr][client_id]
end
if not buffer_line_diagnostics then
return nil
end
for line, line_diagnostics in pairs(buffer_line_diagnostics) do
local virt_texts = M.get_virtual_text_chunks_for_line(bufnr, line, line_diagnostics, opts)
if virt_texts then
api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {})
end
end
end
--- Default function to get text chunks to display using `nvim_buf_set_virtual_text`.
---@param bufnr number The buffer to display the virtual text in
---@param line number The line number to display the virtual text on
---@param line_diags Diagnostic[] The diagnostics associated with the line
---@param opts table See {opts} from |vim.lsp.diagnostic.set_virtual_text()|
---@return table chunks, as defined by |nvim_buf_set_virtual_text()|
function M.get_virtual_text_chunks_for_line(bufnr, line, line_diags, opts)
assert(bufnr or line)
if #line_diags == 0 then
return nil
end
opts = opts or {}
local prefix = opts.prefix or ""
local spacing = opts.spacing or 4
-- Create a little more space between virtual text and contents
local virt_texts = {{string.rep(" ", spacing)}}
for i = 1, #line_diags - 1 do
table.insert(virt_texts, {prefix, virtual_text_highlight_map[line_diags[i].severity]})
end
local last = line_diags[#line_diags]
-- TODO(tjdevries): Allow different servers to be shown first somehow?
-- TODO(tjdevries): Display server name associated with these?
if last.message then
table.insert(
virt_texts,
{
string.format("%s %s", prefix, last.message:gsub("\r", ""):gsub("\n", " ")),
virtual_text_highlight_map[last.severity]
}
)
return virt_texts
end
end
-- }}}
-- }}}
-- Diagnostic Clear {{{
--- Clears the currently displayed diagnostics
---@param bufnr number The buffer number
---@param client_id number the client id
---@param diagnostic_ns number|nil Associated diagnostic namespace
---@param sign_ns number|nil Associated sign namespace
function M.clear(bufnr, client_id, diagnostic_ns, sign_ns)
validate { bufnr = { bufnr, 'n' } }
bufnr = (bufnr == 0 and api.nvim_get_current_buf()) or bufnr
if client_id == nil then
return vim.lsp.for_each_buffer_client(bufnr, function(_, iter_client_id, _)
return M.clear(bufnr, iter_client_id)
end)
end
diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id)
sign_ns = sign_ns or M._get_sign_namespace(client_id)
assert(bufnr, "bufnr is required")
assert(diagnostic_ns, "Need diagnostic_ns, got nil")
assert(sign_ns, string.format("Need sign_ns, got nil %s", sign_ns))
-- clear sign group
vim.fn.sign_unplace(sign_ns, {buffer=bufnr})
-- clear virtual text namespace
api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1)
end
-- }}}
-- Diagnostic Insert Leave Handler {{{
--- Callback scheduled for after leaving insert mode
---
--- Used to handle
--@private
function M._execute_scheduled_display(bufnr, client_id)
local args = _bufs_waiting_to_update[bufnr][client_id]
if not args then
return
end
-- Clear the args so we don't display unnecessarily.
_bufs_waiting_to_update[bufnr][client_id] = nil
M.display(nil, bufnr, client_id, args)
end
local registered = {}
local make_augroup_key = function(bufnr, client_id)
return string.format("LspDiagnosticInsertLeave:%s:%s", bufnr, client_id)
end
--- Table of autocmd events to fire the update for displaying new diagnostic information
M.insert_leave_auto_cmds = { "InsertLeave", "CursorHoldI" }
--- Used to schedule diagnostic updates upon leaving insert mode.
---
--- For parameter description, see |M.display()|
function M._schedule_display(bufnr, client_id, args)
_bufs_waiting_to_update[bufnr][client_id] = args
local key = make_augroup_key(bufnr, client_id)
if not registered[key] then
vim.cmd(string.format("augroup %s", key))
vim.cmd(" au!")
vim.cmd(
string.format(
[[autocmd %s <buffer=%s> :lua vim.lsp.diagnostic._execute_scheduled_display(%s, %s)]],
table.concat(M.insert_leave_auto_cmds, ","),
bufnr,
bufnr,
client_id
)
)
vim.cmd("augroup END")
registered[key] = true
end
end
--- Used in tandem with
---
--- For parameter description, see |M.display()|
function M._clear_scheduled_display(bufnr, client_id)
local key = make_augroup_key(bufnr, client_id)
if registered[key] then
vim.cmd(string.format("augroup %s", key))
vim.cmd(" au!")
vim.cmd("augroup END")
registered[key] = nil
end
end
-- }}}
-- Diagnostic Private Highlight Utilies {{{
--- Get the severity highlight name
--@private
function M._get_severity_highlight_name(severity)
return virtual_text_highlight_map[severity]
end
--- Get floating severity highlight name
--@private
function M._get_floating_severity_highlight_name(severity)
return floating_highlight_map[severity]
end
--- This should be called to update the highlights for the LSP client.
function M._define_default_signs_and_highlights()
--@private
local function define_default_sign(name, properties)
if vim.tbl_isempty(vim.fn.sign_getdefined(name)) then
vim.fn.sign_define(name, properties)
end
end
-- Initialize default diagnostic highlights
for severity, hi_info in pairs(diagnostic_severities) do
local default_highlight_name = default_highlight_map[severity]
highlight.create(default_highlight_name, hi_info, true)
-- Default link all corresponding highlights to the default highlight
highlight.link(virtual_text_highlight_map[severity], default_highlight_name, false)
highlight.link(floating_highlight_map[severity], default_highlight_name, false)
highlight.link(sign_highlight_map[severity], default_highlight_name, false)
end
-- Create all signs
for severity, sign_hl_name in pairs(sign_highlight_map) do
local severity_name = DiagnosticSeverity[severity]
define_default_sign(sign_hl_name, {
text = (severity_name or 'U'):sub(1, 1),
texthl = sign_hl_name,
linehl = '',
numhl = '',
})
end
-- Initialize Underline highlights
for severity, underline_highlight_name in pairs(underline_highlight_map) do
highlight.create(underline_highlight_name, {
cterm = 'underline',
gui = 'underline',
guisp = diagnostic_severities[severity].guifg
}, true)
end
end
-- }}}
-- Diagnostic Display {{{
--- |lsp-handler| for the method "textDocument/publishDiagnostics"
---
---@note Each of the configuration options accepts:
--- - `false`: Disable this feature
--- - `true`: Enable this feature, use default settings.
--- - `table`: Enable this feature, use overrides.
--- - `function`: Function with signature (bufnr, client_id) that returns any of the above.
--- <pre>
--- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with(
--- vim.lsp.diagnostic.on_publish_diagnostics, {
--- -- Enable underline, use default values
--- underline = true,
--- -- Enable virtual text, override spacing to 4
--- virtual_text = {
--- spacing = 4,
--- },
--- -- Use a function to dynamically turn signs off
--- -- and on, using buffer local variables
--- signs = function(bufnr, client_id)
--- return vim.bo[bufnr].show_signs == false
--- end,
--- -- Disable a feature
--- update_in_insert = false,
--- }
--- )
--- </pre>
---
---@param config table Configuration table.
--- - underline: (default=true)
--- - Apply underlines to diagnostics.
--- - See |vim.lsp.diagnostic.set_underline()|
--- - virtual_text: (default=true)
--- - Apply virtual text to line endings.
--- - See |vim.lsp.diagnostic.set_virtual_text()|
--- - signs: (default=true)
--- - Apply signs for diagnostics.
--- - See |vim.lsp.diagnostic.set_signs()|
--- - update_in_insert: (default=false)
--- - Update diagnostics in InsertMode or wait until InsertLeave
function M.on_publish_diagnostics(_, _, params, client_id, _, config)
local uri = params.uri
local bufnr = vim.uri_to_bufnr(uri)
if not bufnr then
return
end
local diagnostics = params.diagnostics
-- Always save the diagnostics, even if the buf is not loaded.
-- Language servers may report compile or build errors via diagnostics
-- Users should be able to find these, even if they're in files which
-- are not loaded.
M.save(diagnostics, bufnr, client_id)
-- Unloaded buffers should not handle diagnostics.
-- When the buffer is loaded, we'll call on_attach, which sends textDocument/didOpen.
-- This should trigger another publish of the diagnostics.
--
-- In particular, this stops a ton of spam when first starting a server for current
-- unloaded buffers.
if not api.nvim_buf_is_loaded(bufnr) then
return
end
M.display(diagnostics, bufnr, client_id, config)
end
--@private
--- Display diagnostics for the buffer, given a configuration.
function M.display(diagnostics, bufnr, client_id, config)
config = vim.lsp._with_extend('vim.lsp.diagnostic.on_publish_diagnostics', {
signs = true,
underline = true,
virtual_text = true,
update_in_insert = false,
}, config)
if diagnostics == nil then
diagnostics = M.get(bufnr, client_id)
end
-- TODO(tjdevries): Consider how we can make this a "standardized" kind of thing for |lsp-handlers|.
-- It seems like we would probably want to do this more often as we expose more of them.
-- It provides a very nice functional interface for people to override configuration.
local resolve_optional_value = function(option)
local enabled_val = {}
if not option then
return false
elseif option == true then
return enabled_val
elseif type(option) == 'function' then
local val = option(bufnr, client_id)
if val == true then
return enabled_val
else
return val
end
elseif type(option) == 'table' then
return option
else
error("Unexpected option type: " .. vim.inspect(option))
end
end
if resolve_optional_value(config.update_in_insert) then
M._clear_scheduled_display(bufnr, client_id)
else
local mode = vim.api.nvim_get_mode()
if string.sub(mode.mode, 1, 1) == 'i' then
M._schedule_display(bufnr, client_id, config)
return
end
end
M.clear(bufnr, client_id)
diagnostics = diagnostics or diagnostic_cache[bufnr][client_id]
if not diagnostics or vim.tbl_isempty(diagnostics) then
return
end
local underline_opts = resolve_optional_value(config.underline)
if underline_opts then
M.set_underline(diagnostics, bufnr, client_id, nil, underline_opts)
end
local virtual_text_opts = resolve_optional_value(config.virtual_text)
if virtual_text_opts then
M.set_virtual_text(diagnostics, bufnr, client_id, nil, virtual_text_opts)
end
local signs_opts = resolve_optional_value(config.signs)
if signs_opts then
M.set_signs(diagnostics, bufnr, client_id, nil, signs_opts)
end
vim.api.nvim_command("doautocmd User LspDiagnosticsChanged")
end
-- }}}
-- Diagnostic User Functions {{{
--- Open a floating window with the diagnostics from {line_nr}
---
--- The floating window can be customized with the following highlight groups:
--- <pre>
--- LspDiagnosticsFloatingError
--- LspDiagnosticsFloatingWarning
--- LspDiagnosticsFloatingInformation
--- LspDiagnosticsFloatingHint
--- </pre>
---@param opts table Configuration table
--- - show_header (boolean, default true): Show "Diagnostics:" header.
---@param bufnr number The buffer number
---@param line_nr number The line number
---@param client_id number|nil the client id
---@return {popup_bufnr, win_id}
function M.show_line_diagnostics(opts, bufnr, line_nr, client_id)
opts = opts or {}
opts.severity_sort = if_nil(opts.severity_sort, true)
local show_header = if_nil(opts.show_header, true)
bufnr = bufnr or 0
line_nr = line_nr or (vim.api.nvim_win_get_cursor(0)[1] - 1)
local lines = {}
local highlights = {}
if show_header then
table.insert(lines, "Diagnostics:")
table.insert(highlights, {0, "Bold"})
end
local line_diagnostics = M.get_line_diagnostics(bufnr, line_nr, opts, client_id)
if vim.tbl_isempty(line_diagnostics) then return end
for i, diagnostic in ipairs(line_diagnostics) do
local prefix = string.format("%d. ", i)
local hiname = M._get_floating_severity_highlight_name(diagnostic.severity)
assert(hiname, 'unknown severity: ' .. tostring(diagnostic.severity))
local message_lines = vim.split(diagnostic.message, '\n', true)
table.insert(lines, prefix..message_lines[1])
table.insert(highlights, {#prefix + 1, hiname})
for j = 2, #message_lines do
table.insert(lines, message_lines[j])
table.insert(highlights, {0, hiname})
end
end
local popup_bufnr, winnr = util.open_floating_preview(lines, 'plaintext')
for i, hi in ipairs(highlights) do
local prefixlen, hiname = unpack(hi)
-- Start highlight after the prefix
api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1)
end
return popup_bufnr, winnr
end
local loclist_type_map = {
[DiagnosticSeverity.Error] = 'E',
[DiagnosticSeverity.Warning] = 'W',
[DiagnosticSeverity.Information] = 'I',
[DiagnosticSeverity.Hint] = 'I',
}
--- Sets the location list
---@param opts table|nil Configuration table. Keys:
--- - {open_loclist}: (boolean, default true)
--- - Open loclist after set
--- - {client_id}: (number)
--- - If nil, will consider all clients attached to buffer.
--- - {severity}: (DiagnosticSeverity)
--- - Exclusive severity to consider. Overrides {severity_limit}
--- - {severity_limit}: (DiagnosticSeverity)
--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid.
function M.set_loclist(opts)
opts = opts or {}
local open_loclist = if_nil(opts.open_loclist, true)
local bufnr = vim.api.nvim_get_current_buf()
local buffer_diags = M.get(bufnr, opts.client_id)
local severity = to_severity(opts.severity)
local severity_limit = to_severity(opts.severity_limit)
local items = {}
local insert_diag = function(diag)
if severity then
-- Handle missing severities
if not diag.severity then
return
end
if severity ~= diag.severity then
return
end
elseif severity_limit then
if not diag.severity then
return
end
if severity_limit < diag.severity then
return
end
end
local pos = diag.range.start
local row = pos.line
local col = util.character_offset(bufnr, row, pos.character)
local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1]
table.insert(items, {
bufnr = bufnr,
lnum = row + 1,
col = col + 1,
text = line .. " | " .. diag.message,
type = loclist_type_map[diag.severity or DiagnosticSeverity.Error] or 'E',
})
end
for _, diag in ipairs(buffer_diags) do
insert_diag(diag)
end
table.sort(items, function(a, b) return a.lnum < b.lnum end)
util.set_loclist(items)
if open_loclist then
vim.cmd [[lopen]]
end
end
-- }}}
return M