refactor(diagnostic): split diagnostic module

Extract the diagnostic implementation from
runtime/lua/vim/diagnostic.lua into focused internal modules covering
config, display, float rendering, jump/list helpers, namespace and
storage management, severity/shared utilities, and statusline support.

Move the builtin handlers into runtime/lua/vim/diagnostic/handlers/ and
keep runtime/lua/vim/diagnostic.lua as the public facade that lazily
dispatches to the split modules. This preserves the external
vim.diagnostic API while making the implementation easier to navigate
and reason about.

AI-assisted: Codex
This commit is contained in:
Lewis Russell
2026-04-02 17:27:49 +01:00
committed by Lewis Russell
parent 9e80f63c30
commit 1f53abf54b
10 changed files with 2418 additions and 2072 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
local store = require('vim.diagnostic._store')
--- @class vim.diagnostic.OptsResolved
--- @field float vim.diagnostic.Opts.Float
--- @field update_in_insert boolean
--- @field underline vim.diagnostic.Opts.Underline
--- @field virtual_text vim.diagnostic.Opts.VirtualText
--- @field virtual_lines vim.diagnostic.Opts.VirtualLines
--- @field signs vim.diagnostic.Opts.Signs
--- @field severity_sort {reverse?:boolean}
--- @class (private) vim.diagnostic._config
local M = {}
--- @type vim.diagnostic.Opts
local global_diagnostic_options = {
signs = true,
underline = true,
virtual_text = false,
virtual_lines = false,
float = true,
update_in_insert = false,
severity_sort = false,
jump = {
-- Wrap around buffer
wrap = true,
},
}
--- @param name 'signs'|'underline'|'virtual_text'|'virtual_lines'|'float'
function M.enable_handler(name)
if global_diagnostic_options[name] == nil then
global_diagnostic_options[name] = true
end
end
--- @param option string
--- @param namespace integer?
--- @return table
local function enabled_value(option, namespace)
local ns = namespace and vim.diagnostic.get_namespace(namespace) or {}
if ns.opts and type(ns.opts[option]) == 'table' then
return ns.opts[option]
end
local global_opt = global_diagnostic_options[option]
if type(global_opt) == 'table' then
return global_opt
end
return {}
end
--- @param option string
--- @param value any?
--- @param namespace integer?
--- @param bufnr integer
--- @return any
local function resolve_optional_value(option, value, namespace, bufnr)
if not value then
return false
elseif value == true then
return enabled_value(option, namespace)
elseif type(value) == 'function' then
local val = value(namespace, bufnr) --- @type any
if val == true then
return enabled_value(option, namespace)
else
return val
end
elseif type(value) == 'table' then
return value
end
error('Unexpected option type: ' .. vim.inspect(value))
end
--- @param opts vim.diagnostic.Opts?
--- @param namespace integer?
--- @param bufnr integer
--- @return vim.diagnostic.OptsResolved
function M.get_resolved_options(opts, namespace, bufnr)
local ns = namespace and vim.diagnostic.get_namespace(namespace) or {}
-- Do not use tbl_deep_extend so that an empty table can be used to reset to default values
local resolved = vim.tbl_extend('keep', opts or {}, ns.opts or {}, global_diagnostic_options) --- @type table<string,any>
for k in pairs(global_diagnostic_options) do
if resolved[k] ~= nil then
resolved[k] = resolve_optional_value(k, resolved[k], namespace, bufnr)
end
end
return resolved --[[@as vim.diagnostic.OptsResolved]]
end
--- @param opts vim.diagnostic.Opts? When omitted or `nil`, retrieve the current
--- configuration. Otherwise, a configuration table (see |vim.diagnostic.Opts|).
--- @param namespace integer? Update the options for the given namespace.
--- When omitted, update the global diagnostic options.
--- @return vim.diagnostic.Opts? : Current diagnostic config if {opts} is omitted.
function M.config(opts, namespace)
vim.validate('opts', opts, 'table', true)
vim.validate('namespace', namespace, 'number', true)
local t --- @type vim.diagnostic.Opts
if namespace then
local ns = vim.diagnostic.get_namespace(namespace)
t = ns.opts
else
t = global_diagnostic_options
end
if not opts then
-- Return current config
return vim.deepcopy(t, true)
end
local jump_opts = opts.jump --[[@as vim.diagnostic.JumpOpts1]]
if jump_opts and jump_opts.float ~= nil then --- @diagnostic disable-line
vim.deprecate('opts.jump.float', 'opts.jump.on_jump', '0.14')
local float_opts = jump_opts.float
if float_opts then
float_opts = type(float_opts) == 'table' and float_opts or {}
jump_opts.on_jump = function(_, bufnr)
vim.diagnostic.open_float(vim.tbl_extend('keep', float_opts, {
bufnr = bufnr,
scope = 'cursor',
focus = false,
}))
end
end
opts.jump.float = nil --- @diagnostic disable-line
end
for k, v in
pairs(opts --[[@as table<any,any>]])
do
t[k] = v
end
if namespace then
for _, bufnr in ipairs(store.get_bufnrs()) do
local namespaces = store.get_buf_namespaces(bufnr)
if vim.list_contains(namespaces, namespace) then
vim.diagnostic.show(namespace, bufnr)
end
end
else
for _, bufnr in ipairs(store.get_bufnrs()) do
for _, ns in ipairs(store.get_buf_namespaces(bufnr)) do
vim.diagnostic.show(ns, bufnr)
end
end
end
end
return M

View File

@@ -0,0 +1,193 @@
local api = vim.api
local diagnostic_modules = vim._defer_require('vim.diagnostic', {
_config = ..., --- @module 'vim.diagnostic._config'
_severity = ..., --- @module 'vim.diagnostic._severity'
_shared = ..., --- @module 'vim.diagnostic._shared'
_store = ..., --- @module 'vim.diagnostic._store'
})
--- @class (private) vim.diagnostic._display
local M = {}
-- Metatable that automatically creates an empty table when assigning to a missing key
local bufnr_and_namespace_cacher_mt = {
--- @param t table<integer,table>
--- @param bufnr integer
--- @return table
__index = function(t, bufnr)
assert(bufnr > 0, 'Invalid buffer number')
t[bufnr] = {}
return t[bufnr]
end,
}
--- @type table<integer,table<integer,table>>
local bufs_waiting_to_update = setmetatable({}, bufnr_and_namespace_cacher_mt)
--- @type table<string,true>
local registered_autocmds = {}
--- Table of autocmd events to fire the update for displaying new diagnostic information
local insert_leave_auto_cmds = { 'InsertLeave', 'CursorHoldI' }
--- @param namespace integer
--- @param bufnr integer
--- @return string
local function make_augroup_key(namespace, bufnr)
local ns = vim.diagnostic.get_namespace(namespace)
return string.format('nvim.diagnostic.insertleave.%s.%s', bufnr, ns.name)
end
--- @param namespace integer
--- @param bufnr integer
local function execute_scheduled_display(namespace, bufnr)
local args = bufs_waiting_to_update[bufnr][namespace]
if not args then
return
end
-- Clear the args so we don't display unnecessarily.
bufs_waiting_to_update[bufnr][namespace] = nil
M.show(namespace, bufnr, nil, args)
end
--- @param namespace integer
--- @param bufnr integer
--- @param args vim.diagnostic.OptsResolved
local function schedule_display(namespace, bufnr, args)
bufs_waiting_to_update[bufnr][namespace] = args
local key = make_augroup_key(namespace, bufnr)
if not registered_autocmds[key] then
local group = api.nvim_create_augroup(key, { clear = true })
api.nvim_create_autocmd(insert_leave_auto_cmds, {
group = group,
buffer = bufnr,
callback = function()
execute_scheduled_display(namespace, bufnr)
end,
desc = 'vim.diagnostic: display diagnostics',
})
registered_autocmds[key] = true
end
end
--- @param namespace integer
--- @param bufnr integer
local function clear_scheduled_display(namespace, bufnr)
local key = make_augroup_key(namespace, bufnr)
if registered_autocmds[key] then
api.nvim_del_augroup_by_name(key)
registered_autocmds[key] = nil
end
end
--- @param namespace integer? Diagnostic namespace. When omitted, hide
--- diagnostics from all namespaces.
--- @param bufnr integer? Buffer number, or 0 for current buffer. When
--- omitted, hide diagnostics in all buffers.
function M.hide(namespace, bufnr)
vim.validate('namespace', namespace, 'number', true)
vim.validate('bufnr', bufnr, 'number', true)
local buffers = bufnr and { vim._resolve_bufnr(bufnr) } or diagnostic_modules._store.get_bufnrs()
for _, iter_bufnr in ipairs(buffers) do
local namespaces = namespace and { namespace }
or diagnostic_modules._store.get_buf_namespaces(iter_bufnr)
for _, iter_namespace in ipairs(namespaces) do
for _, handler in pairs(vim.diagnostic.handlers) do
if handler.hide then
handler.hide(iter_namespace, iter_bufnr)
end
end
end
end
end
--- @param namespace integer? Diagnostic namespace. When omitted, show
--- diagnostics from all namespaces.
--- @param bufnr integer? Buffer number, or 0 for current buffer. When omitted, show
--- diagnostics in all buffers.
--- @param diagnostics vim.Diagnostic[]? The diagnostics to display. When omitted, use the
--- saved diagnostics for the given namespace and
--- buffer. This can be used to display a list of diagnostics
--- without saving them or to display only a subset of
--- diagnostics. May not be used when {namespace}
--- or {bufnr} is nil.
--- @param opts? vim.diagnostic.Opts Display options.
function M.show(namespace, bufnr, diagnostics, opts)
vim.validate('namespace', namespace, 'number', true)
vim.validate('bufnr', bufnr, 'number', true)
vim.validate('diagnostics', diagnostics, vim.islist, true, 'a list of diagnostics')
vim.validate('opts', opts, 'table', true)
if not bufnr or not namespace then
assert(not diagnostics, 'Cannot show diagnostics without a buffer and namespace')
if not bufnr then
for _, iter_bufnr in ipairs(diagnostic_modules._store.get_bufnrs()) do
M.show(namespace, iter_bufnr, nil, opts)
end
else
-- namespace is nil
bufnr = vim._resolve_bufnr(bufnr)
for _, iter_namespace in ipairs(diagnostic_modules._store.get_buf_namespaces(bufnr)) do
M.show(iter_namespace, bufnr, nil, opts)
end
end
return
end
if not vim.diagnostic.is_enabled({ bufnr = bufnr or 0, ns_id = namespace }) then
return
end
M.hide(namespace, bufnr)
diagnostics = diagnostics
or diagnostic_modules._store.get_diagnostics(bufnr, {
namespace = namespace,
}, true)
if vim.tbl_isempty(diagnostics) then
return
end
local opts_res = diagnostic_modules._config.get_resolved_options(opts, namespace, bufnr)
if opts_res.update_in_insert then
clear_scheduled_display(namespace, bufnr)
else
local mode = api.nvim_get_mode()
if mode.mode:sub(1, 1) == 'i' then
schedule_display(namespace, bufnr, opts_res)
return
end
end
if opts_res.severity_sort then
if type(opts_res.severity_sort) == 'table' and opts_res.severity_sort.reverse then
table.sort(diagnostics, function(a, b)
return diagnostic_modules._shared.diagnostic_cmp(a, b, 'severity', false)
end)
else
table.sort(diagnostics, function(a, b)
return diagnostic_modules._shared.diagnostic_cmp(a, b, 'severity', true)
end)
end
end
for handler_name, handler in pairs(vim.diagnostic.handlers) do
if handler.show and opts_res[handler_name] then
local filtered = diagnostic_modules._severity.filter_by_severity(
opts_res[handler_name].severity,
diagnostics
)
handler.show(namespace, bufnr, filtered, opts_res)
end
end
end
return M

View File

@@ -0,0 +1,284 @@
local api, if_nil = vim.api, vim.F.if_nil
local shared = require('vim.diagnostic._shared')
local store = require('vim.diagnostic._store')
--- @class (private) vim.diagnostic._float
local M = {}
local severity = vim.diagnostic.severity
--- @type table<vim.diagnostic.Severity, string>
local floating_highlight_map = {
[severity.ERROR] = 'DiagnosticFloatingError',
[severity.WARN] = 'DiagnosticFloatingWarn',
[severity.INFO] = 'DiagnosticFloatingInfo',
[severity.HINT] = 'DiagnosticFloatingHint',
}
--- @param opts vim.diagnostic.Opts.Float
--- @param bufnr integer
--- @return vim.diagnostic.Opts.Float, vim.diagnostic.Opts
local function resolve_float_opts(opts, bufnr)
-- Resolve options with user settings from vim.diagnostic.config
-- Unlike the other decoration functions (e.g. set_virtual_text, set_signs, etc.) `open_float`
-- does not have a dedicated table for configuration options; instead, the options are mixed in
-- with its `opts` table. We create a dedicated options table (`float_opts`) that inherits
-- missing keys from the global configuration (`global_diagnostic_options.float`), which can
-- be a table or a function.
local global_opts = assert(vim.diagnostic.config())
local float_opts = global_opts.float
local resolved_float_opts = type(float_opts) == 'table' and float_opts
or (type(float_opts) == 'function' and float_opts(opts.namespace, bufnr) or {})
return vim.tbl_extend('keep', opts, resolved_float_opts), global_opts
end
--- @param opts vim.diagnostic.Opts.Float?
--- @return integer? float_bufnr
--- @return integer? winid
function M.open(opts, ...)
-- Support old (bufnr, opts) signature
local bufnr --- @type integer?
if opts == nil or type(opts) == 'number' then
bufnr = opts
opts = ... --- @type vim.diagnostic.Opts.Float
else
vim.validate('opts', opts, 'table', true)
end
opts = opts or {}
bufnr = vim._resolve_bufnr(bufnr or opts.bufnr)
local global_opts --- @type vim.diagnostic.Opts
opts, global_opts = resolve_float_opts(opts, bufnr)
local scope = ({ l = 'line', c = 'cursor', b = 'buffer' })[opts.scope] or opts.scope or 'line'
local lnum, col --- @type integer, integer
local opts_pos = opts.pos
if scope == 'line' or scope == 'cursor' then
if not opts_pos then
local pos = api.nvim_win_get_cursor(0)
lnum = pos[1] - 1
col = pos[2]
elseif type(opts_pos) == 'number' then
lnum = opts_pos
elseif type(opts_pos) == 'table' then
lnum, col = opts_pos[1], opts_pos[2]
else
error("Invalid value for option 'pos'")
end
elseif scope ~= 'buffer' then
error("Invalid value for option 'scope'")
end
local diagnostics = store.get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], true)
if scope == 'line' then
--- @param diagnostic vim.Diagnostic
local function line_filter(diagnostic)
local d_lnum, _, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
return lnum >= d_lnum
and lnum <= d_end_lnum
and (d_lnum == d_end_lnum or lnum ~= d_end_lnum or d_end_col ~= 0)
end
diagnostics = vim.tbl_filter(line_filter, diagnostics)
elseif scope == 'cursor' then
-- If `col` is past the end of the line, show if the cursor is on the last char in the line
local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1]
--- @param diagnostic vim.Diagnostic
local function cursor_filter(diagnostic)
local d_lnum, d_col, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
return lnum >= d_lnum
and lnum <= d_end_lnum
and (lnum ~= d_lnum or col >= math.min(d_col, line_length - 1))
and ((d_lnum == d_end_lnum and d_col == d_end_col) or lnum ~= d_end_lnum or col < d_end_col)
end
diagnostics = vim.tbl_filter(cursor_filter, diagnostics)
end
if vim.tbl_isempty(diagnostics) then
return
end
local severity_sort = if_nil(opts.severity_sort, global_opts.severity_sort)
if severity_sort then
if type(severity_sort) == 'table' and severity_sort.reverse then
table.sort(diagnostics, function(a, b)
return shared.diagnostic_cmp(a, b, 'severity', true)
end)
else
table.sort(diagnostics, function(a, b)
return shared.diagnostic_cmp(a, b, 'severity', false)
end)
end
end
local lines = {} --- @type string[]
local highlights = {} --- @type { hlname: string, prefix?: { length: integer, hlname: string? }, suffix?: { length: integer, hlname: string? } }[]
local header = if_nil(opts.header, 'Diagnostics:')
if header then
vim.validate('header', header, { 'string', 'table' }, "'string' or 'table'")
if type(header) == 'table' then
-- Don't insert any lines for an empty string
if #(header[1] or '') > 0 then
lines[#lines + 1] = header[1]
highlights[#highlights + 1] = { hlname = header[2] or 'Bold' }
end
elseif #header > 0 then
lines[#lines + 1] = header
highlights[#highlights + 1] = { hlname = 'Bold' }
end
end
if opts.format then
diagnostics = shared.reformat_diagnostics(opts.format, diagnostics)
end
if opts.source and (opts.source ~= 'if_many' or shared.count_sources(bufnr) > 1) then
diagnostics = shared.prefix_source(diagnostics)
end
local prefix_opt = opts.prefix
or (scope == 'cursor' and #diagnostics <= 1) and ''
or function(_, i)
return string.format('%d. ', i)
end
local prefix, prefix_hl_group --- @type string?, string?
if prefix_opt then
vim.validate(
'prefix',
prefix_opt,
{ 'string', 'table', 'function' },
"'string' or 'table' or 'function'"
)
if type(prefix_opt) == 'string' then
prefix, prefix_hl_group = prefix_opt, 'NormalFloat'
elseif type(prefix_opt) == 'table' then
prefix, prefix_hl_group = prefix_opt[1] or '', prefix_opt[2] or 'NormalFloat'
end
end
local suffix_opt = opts.suffix
or function(diagnostic)
return diagnostic.code and string.format(' [%s]', diagnostic.code) or ''
end
local suffix, suffix_hl_group --- @type string?, string?
if suffix_opt then
vim.validate(
'suffix',
suffix_opt,
{ 'string', 'table', 'function' },
"'string' or 'table' or 'function'"
)
if type(suffix_opt) == 'string' then
suffix, suffix_hl_group = suffix_opt, 'NormalFloat'
elseif type(suffix_opt) == 'table' then
suffix, suffix_hl_group = suffix_opt[1] or '', suffix_opt[2] or 'NormalFloat'
end
end
local related_info_locations = {} --- @type table<integer, lsp.Location>
for i, diagnostic in ipairs(diagnostics) do
if type(prefix_opt) == 'function' then
local prefix0, prefix_hl_group0 = prefix_opt(diagnostic, i, #diagnostics)
prefix, prefix_hl_group = prefix0 or '', prefix_hl_group0 or 'NormalFloat'
end
if type(suffix_opt) == 'function' then
local suffix0, suffix_hl_group0 = suffix_opt(diagnostic, i, #diagnostics)
suffix, suffix_hl_group = suffix0 or '', suffix_hl_group0 or 'NormalFloat'
end
local hiname = floating_highlight_map[diagnostic.severity]
local message_lines = vim.split(diagnostic.message, '\n')
local default_pre = string.rep(' ', #prefix)
for j = 1, #message_lines do
local pre = j == 1 and prefix or default_pre
local suf = j == #message_lines and suffix or ''
lines[#lines + 1] = pre .. message_lines[j] .. suf
highlights[#highlights + 1] = {
hlname = hiname,
prefix = {
length = j == 1 and #prefix or 0,
hlname = prefix_hl_group,
},
suffix = {
length = #suf,
hlname = suffix_hl_group,
},
}
end
--- @type lsp.DiagnosticRelatedInformation[]
local related_info = vim.tbl_get(diagnostic, 'user_data', 'lsp', 'relatedInformation') or {}
-- Below the diagnostic, show its LSP related information (if any) in the form of file name and
-- range, plus description.
for _, info in ipairs(related_info) do
local location = info.location
local file_name = vim.fs.basename(vim.uri_to_fname(location.uri))
local info_suffix = ': ' .. info.message
related_info_locations[#lines + 1] = location
lines[#lines + 1] = string.format(
'%s%s:%s:%s%s',
default_pre,
file_name,
location.range.start.line + 1,
location.range.start.character + 1,
info_suffix
)
highlights[#highlights + 1] = {
hlname = '@string.special.path',
prefix = {
length = #default_pre,
hlname = prefix_hl_group,
},
suffix = {
length = #info_suffix,
hlname = 'NormalFloat',
},
}
end
end
-- Used by open_floating_preview to allow the float to be focused
if not opts.focus_id then
opts.focus_id = scope
end
--- @diagnostic disable-next-line: param-type-mismatch
local float_bufnr, winnr = vim.lsp.util.open_floating_preview(lines, 'plaintext', opts)
vim.bo[float_bufnr].path = vim.bo[bufnr].path
-- TODO: Handle this generally (like vim.ui.open()), rather than overriding gf.
vim.keymap.set('n', 'gf', function()
local cursor_row = api.nvim_win_get_cursor(0)[1]
local location = related_info_locations[cursor_row]
if location then
-- Split the window before calling `show_document` so the window doesn't disappear.
vim.cmd.split()
vim.lsp.util.show_document(location, 'utf-16', { focus = true })
else
vim.cmd.normal({ 'gf', bang = true })
end
end, { buf = float_bufnr, remap = false })
--- @diagnostic disable-next-line: deprecated
local add_highlight = api.nvim_buf_add_highlight
for i, hl in ipairs(highlights) do
local line = lines[i]
local prefix_len = hl.prefix and hl.prefix.length or 0
local suffix_len = hl.suffix and hl.suffix.length or 0
if prefix_len > 0 then
add_highlight(float_bufnr, -1, hl.prefix.hlname, i - 1, 0, prefix_len)
end
add_highlight(float_bufnr, -1, hl.hlname, i - 1, prefix_len, #line - suffix_len)
if suffix_len > 0 then
add_highlight(float_bufnr, -1, hl.suffix.hlname, i - 1, #line - suffix_len, -1)
end
end
return float_bufnr, winnr
end
return M

View File

@@ -0,0 +1,732 @@
local api = vim.api
local diagnostic = vim.diagnostic
local diagnostic_shared = require('vim.diagnostic._shared')
local severity = diagnostic.severity
--- @class vim.diagnostic.Handler
--- @field show? fun(namespace: integer, bufnr: integer, diagnostics: vim.Diagnostic[], opts?: vim.diagnostic.OptsResolved)
--- @field hide? fun(namespace:integer, bufnr:integer)
--- @class (private) vim.diagnostic._handlers._extmark : vim.api.keyset.get_extmark_item
--- @field [1] integer extmark_id
--- @field [2] integer row
--- @field [3] integer col
--- @field [4] vim.api.keyset.extmark_details
local M = {}
-- Default diagnostic highlights
--- @type table<vim.diagnostic.Severity, string>
local severity_names = {
[severity.ERROR] = 'Error',
[severity.WARN] = 'Warn',
[severity.INFO] = 'Info',
[severity.HINT] = 'Hint',
}
--- @param base_name string
--- @return table<vim.diagnostic.Severity, string>
local function make_highlight_map(base_name)
local result = {} --- @type table<vim.diagnostic.Severity, string>
for level, name in pairs(severity_names) do
result[level] = ('Diagnostic%s%s'):format(base_name, name)
end
return result
end
local sign_highlight_map = make_highlight_map('Sign')
local underline_highlight_map = make_highlight_map('Underline')
local virtual_text_highlight_map = make_highlight_map('VirtualText')
local virtual_lines_highlight_map = make_highlight_map('VirtualLines')
-- Metatable that automatically creates an empty table when assigning to a missing key
local bufnr_and_namespace_cacher_mt = {
--- @param t table<integer, table>
--- @param bufnr integer
--- @return table
__index = function(t, bufnr)
assert(bufnr > 0, 'Invalid buffer number')
t[bufnr] = {}
return t[bufnr]
end,
}
--- @type table<integer, table<integer, vim.diagnostic._handlers._extmark[]>>
local diagnostic_cache_extmarks = setmetatable({}, bufnr_and_namespace_cacher_mt)
--- @type table<integer, true>
local diagnostic_attached_buffers = {}
--- @param bufnr integer
--- @param last integer
local function restore_extmarks(bufnr, last)
for ns, extmarks in pairs(diagnostic_cache_extmarks[bufnr]) do
local extmarks_current = api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
local found = {} --- @type table<integer, true>
for _, extmark in ipairs(extmarks_current) do
-- nvim_buf_set_lines will move any extmark to the line after the last
-- nvim_buf_set_text will move any extmark to the last line
if extmark[2] ~= last + 1 then
found[extmark[1]] = true
end
end
for _, extmark in ipairs(extmarks) do
if not found[extmark[1]] then
local opts = extmark[4]
--- @diagnostic disable-next-line: inject-field
opts.id = extmark[1]
pcall(api.nvim_buf_set_extmark, bufnr, ns, extmark[2], extmark[3], opts)
end
end
end
end
--- @param namespace integer
--- @param bufnr? integer
local function save_extmarks(namespace, bufnr)
bufnr = vim._resolve_bufnr(bufnr)
if not diagnostic_attached_buffers[bufnr] then
api.nvim_buf_attach(bufnr, false, {
on_lines = function(_, _, _, _, _, last)
restore_extmarks(bufnr, last - 1)
end,
on_detach = function()
diagnostic_cache_extmarks[bufnr] = nil
end,
})
diagnostic_attached_buffers[bufnr] = true
end
diagnostic_cache_extmarks[bufnr][namespace] =
api.nvim_buf_get_extmarks(bufnr, namespace, 0, -1, { details = true })
end
--- @param bufnr integer
--- @param namespace integer
local function clear_extmarks(bufnr, namespace)
diagnostic_cache_extmarks[bufnr][namespace] = {}
if api.nvim_buf_is_valid(bufnr) then
api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
end
end
--- @param bufnr integer
--- @param fn fun()
--- @return integer?
local function once_buf_loaded(bufnr, fn)
if api.nvim_buf_is_loaded(bufnr) then
fn()
else
return api.nvim_create_autocmd('BufRead', {
buffer = bufnr,
once = true,
callback = function()
fn()
end,
})
end
end
--- @param autocmd_key string
--- @param ns vim.diagnostic.NS
local function cleanup_show_autocmd(autocmd_key, ns)
if ns.user_data[autocmd_key] then
api.nvim_del_autocmd(ns.user_data[autocmd_key])
--- @type integer?
ns.user_data[autocmd_key] = nil
end
end
--- @param autocmd_key string
--- @param ns vim.diagnostic.NS
--- @param bufnr integer
--- @param fn fun()
local function show_once_loaded(autocmd_key, ns, bufnr, fn)
cleanup_show_autocmd(autocmd_key, ns)
--- @type integer?
ns.user_data[autocmd_key] = once_buf_loaded(bufnr, function()
--- @type integer?
ns.user_data[autocmd_key] = nil
fn()
end)
end
--- @param priority integer
--- @param opts? { severity_sort?: {reverse?:boolean} }
--- @return fun(severity: vim.diagnostic.Severity): integer
local function severity_to_extmark_priority(priority, opts)
opts = opts or {}
if opts.severity_sort then
if type(opts.severity_sort) == 'table' and opts.severity_sort.reverse then
return function(level)
return priority + (level - severity.ERROR)
end
end
return function(level)
return priority + (severity.HINT - level)
end
end
return function()
return priority
end
end
M.signs = {}
--- @param namespace integer
--- @param bufnr integer
--- @param diagnostics vim.Diagnostic[]
--- @param opts? vim.diagnostic.OptsResolved
function M.signs.show(namespace, bufnr, diagnostics, opts)
vim.validate('namespace', namespace, 'number')
vim.validate('bufnr', bufnr, 'number')
vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics')
vim.validate('opts', opts, 'table', true)
vim.validate('opts.signs', (opts and opts or {}).signs, 'table', true)
bufnr = vim._resolve_bufnr(bufnr)
local sopts = opts and opts.signs or {}
local ns = diagnostic.get_namespace(namespace)
show_once_loaded('sign_show_autocmd', ns, bufnr, function()
-- 10 is the default sign priority when none is explicitly specified
local priority = sopts.priority or 10
local get_priority = severity_to_extmark_priority(priority, opts)
if not ns.user_data.sign_ns then
ns.user_data.sign_ns =
api.nvim_create_namespace(string.format('nvim.%s.diagnostic.signs', ns.name))
end
local text = {} --- @type table<vim.diagnostic.Severity|string, string>
for level in pairs(severity) do
if sopts.text and sopts.text[level] then
text[level] = sopts.text[level]
elseif type(level) == 'string' and not text[level] then
text[level] = level:sub(1, 1):upper()
end
end
local numhl = sopts.numhl or {}
local linehl = sopts.linehl or {}
local line_count = api.nvim_buf_line_count(bufnr)
for _, diagnostic0 in ipairs(diagnostics) do
if diagnostic0.lnum <= line_count then
api.nvim_buf_set_extmark(bufnr, ns.user_data.sign_ns, diagnostic0.lnum, 0, {
sign_text = text[diagnostic0.severity] or text[severity[diagnostic0.severity]] or 'U',
sign_hl_group = sign_highlight_map[diagnostic0.severity],
number_hl_group = numhl[diagnostic0.severity],
line_hl_group = linehl[diagnostic0.severity],
priority = get_priority(diagnostic0.severity),
})
end
end
end)
end
--- @param namespace integer
--- @param bufnr integer
function M.signs.hide(namespace, bufnr)
local ns = diagnostic.get_namespace(namespace)
cleanup_show_autocmd('sign_show_autocmd', ns)
if ns.user_data.sign_ns and api.nvim_buf_is_valid(bufnr) then
api.nvim_buf_clear_namespace(bufnr, ns.user_data.sign_ns, 0, -1)
end
end
M.underline = {}
--- @param namespace integer
--- @param bufnr integer
--- @param diagnostics vim.Diagnostic[]
--- @param opts? vim.diagnostic.OptsResolved
function M.underline.show(namespace, bufnr, diagnostics, opts)
vim.validate('namespace', namespace, 'number')
vim.validate('bufnr', bufnr, 'number')
vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics')
vim.validate('opts', opts, 'table', true)
bufnr = vim._resolve_bufnr(bufnr)
local ns = diagnostic.get_namespace(namespace)
show_once_loaded('underline_show_autocmd', ns, bufnr, function()
if not ns.user_data.underline_ns then
ns.user_data.underline_ns =
api.nvim_create_namespace(string.format('nvim.%s.diagnostic.underline', ns.name))
end
local underline_ns = ns.user_data.underline_ns
local get_priority = severity_to_extmark_priority(vim.hl.priorities.diagnostics, opts)
for _, diagnostic0 in ipairs(diagnostics) do
local higroups = { underline_highlight_map[diagnostic0.severity] }
if diagnostic0._tags then
if diagnostic0._tags.unnecessary then
table.insert(higroups, 'DiagnosticUnnecessary')
end
if diagnostic0._tags.deprecated then
table.insert(higroups, 'DiagnosticDeprecated')
end
end
local lines =
api.nvim_buf_get_lines(diagnostic0.bufnr, diagnostic0.lnum, diagnostic0.lnum + 1, true)
for _, higroup in ipairs(higroups) do
vim.hl.range(
bufnr,
underline_ns,
higroup,
{ diagnostic0.lnum, math.min(diagnostic0.col, #lines[1] - 1) },
{ diagnostic0.end_lnum, diagnostic0.end_col },
{ priority = get_priority(diagnostic0.severity) }
)
end
end
save_extmarks(underline_ns, bufnr)
end)
end
--- @param namespace integer
--- @param bufnr integer
function M.underline.hide(namespace, bufnr)
local ns = diagnostic.get_namespace(namespace)
cleanup_show_autocmd('underline_show_autocmd', ns)
if ns.user_data.underline_ns then
clear_extmarks(bufnr, ns.user_data.underline_ns)
end
end
--- @param line_diags table<integer, vim.Diagnostic>
--- @param opts vim.diagnostic.Opts.VirtualText
--- @return [string, any][]?
local function get_virt_text_chunks(line_diags, opts)
if #line_diags == 0 then
return
end
opts = opts or {}
local prefix = opts.prefix or ''
local suffix = opts.suffix 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 do
local resolved_prefix = prefix
if type(prefix) == 'function' then
resolved_prefix = prefix(line_diags[i], i, #line_diags) or ''
end
table.insert(
virt_texts,
{ resolved_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
if type(suffix) == 'function' then
suffix = suffix(last) or ''
end
table.insert(virt_texts, {
string.format(' %s%s', last.message:gsub('\r', ''):gsub('\n', ' '), suffix),
virtual_text_highlight_map[last.severity],
})
return virt_texts
end
end
--- @param namespace integer
--- @param bufnr integer
--- @param diagnostics table<integer, vim.Diagnostic[]>
--- @param opts vim.diagnostic.Opts.VirtualText
local function render_virtual_text(namespace, bufnr, diagnostics, opts)
local lnum = api.nvim_win_get_cursor(0)[1] - 1
local buf_len = api.nvim_buf_line_count(bufnr)
api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
--- @param line integer
--- @return boolean
local function should_render(line)
if
line >= buf_len
or (opts.current_line == true and line ~= lnum)
or (opts.current_line == false and line == lnum)
then
return false
end
return true
end
for line, line_diagnostics in pairs(diagnostics) do
if should_render(line) then
local virt_texts = get_virt_text_chunks(line_diagnostics, opts)
if virt_texts then
api.nvim_buf_set_extmark(bufnr, namespace, line, 0, {
hl_mode = opts.hl_mode or 'combine',
virt_text = virt_texts,
virt_text_pos = opts.virt_text_pos,
virt_text_hide = opts.virt_text_hide,
virt_text_win_col = opts.virt_text_win_col,
})
end
end
end
end
M.virtual_text = {}
--- @param namespace integer
--- @param bufnr integer
--- @param diagnostics vim.Diagnostic[]
--- @param opts? vim.diagnostic.OptsResolved
function M.virtual_text.show(namespace, bufnr, diagnostics, opts)
vim.validate('namespace', namespace, 'number')
vim.validate('bufnr', bufnr, 'number')
vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics')
vim.validate('opts', opts, 'table', true)
bufnr = vim._resolve_bufnr(bufnr)
local vopts = opts and opts.virtual_text or {}
local ns = diagnostic.get_namespace(namespace)
show_once_loaded('virtual_text_show_autocmd', ns, bufnr, function()
if vopts.format then
diagnostics = diagnostic_shared.reformat_diagnostics(vopts.format, diagnostics)
end
if
vopts.source and (vopts.source ~= 'if_many' or diagnostic_shared.count_sources(bufnr) > 1)
then
diagnostics = diagnostic_shared.prefix_source(diagnostics)
end
if not ns.user_data.virt_text_ns then
ns.user_data.virt_text_ns =
api.nvim_create_namespace(string.format('nvim.%s.diagnostic.virtual_text', ns.name))
end
if not ns.user_data.virt_text_augroup then
ns.user_data.virt_text_augroup = api.nvim_create_augroup(
string.format('nvim.%s.diagnostic.virt_text', ns.name),
{ clear = true }
)
end
api.nvim_clear_autocmds({ group = ns.user_data.virt_text_augroup, buffer = bufnr })
local line_diagnostics = diagnostic_shared.diagnostic_lines(diagnostics, true)
if vopts.current_line ~= nil then
api.nvim_create_autocmd('CursorMoved', {
buffer = bufnr,
group = ns.user_data.virt_text_augroup,
callback = function()
render_virtual_text(ns.user_data.virt_text_ns, bufnr, line_diagnostics, vopts)
end,
})
end
render_virtual_text(ns.user_data.virt_text_ns, bufnr, line_diagnostics, vopts)
save_extmarks(ns.user_data.virt_text_ns, bufnr)
end)
end
--- @param namespace integer
--- @param bufnr integer
function M.virtual_text.hide(namespace, bufnr)
local ns = diagnostic.get_namespace(namespace)
cleanup_show_autocmd('virtual_text_show_autocmd', ns)
if ns.user_data.virt_text_ns then
clear_extmarks(bufnr, ns.user_data.virt_text_ns)
if api.nvim_buf_is_valid(bufnr) then
api.nvim_clear_autocmds({ group = ns.user_data.virt_text_augroup, buffer = bufnr })
end
end
end
--- @param bufnr integer
--- @param lnum integer
--- @param start_col integer
--- @param end_col integer
--- @return integer
local function distance_between_cols(bufnr, lnum, start_col, end_col)
return api.nvim_buf_call(bufnr, function()
local s = vim.fn.virtcol({ lnum + 1, start_col })
local e = vim.fn.virtcol({ lnum + 1, end_col + 1 })
return e - 1 - s
end)
end
--- @param namespace integer
--- @param bufnr integer
--- @param diagnostics vim.Diagnostic[]
local function render_virtual_lines(namespace, bufnr, diagnostics)
table.sort(diagnostics, function(d1, d2)
return diagnostic_shared.diagnostic_cmp(d1, d2, 'lnum', false)
end)
api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
if not next(diagnostics) then
return
end
-- This loop reads each line, putting them into stacks with some extra data since
-- rendering each line requires understanding what is beneath it.
local ElementType = { Space = 1, Diagnostic = 2, Overlap = 3, Blank = 4 } --- @enum ElementType
--- @type table<integer, [ElementType, string|vim.diagnostic.Severity|vim.Diagnostic][]>
local line_stacks = {}
--- @type table<integer, integer>
local line_anchor = {}
local prev_lnum = -1
local prev_col = 0
for _, diag in ipairs(diagnostics) do
if not line_stacks[diag.lnum] then
line_stacks[diag.lnum] = {}
end
local stack = line_stacks[diag.lnum]
local end_lnum = diag.end_lnum or diag.lnum
if not line_anchor[diag.lnum] or end_lnum > line_anchor[diag.lnum] then
line_anchor[diag.lnum] = end_lnum
end
if diag.lnum ~= prev_lnum then
table.insert(stack, {
ElementType.Space,
string.rep(' ', distance_between_cols(bufnr, diag.lnum, 0, diag.col)),
})
elseif diag.col ~= prev_col then
table.insert(stack, {
ElementType.Space,
-- +1 because indexing starts at 0 in one API but at 1 in the other.
string.rep(' ', distance_between_cols(bufnr, diag.lnum, prev_col + 1, diag.col)),
})
else
table.insert(stack, { ElementType.Overlap, diag.severity })
end
if diag.message:find('^%s*$') then
table.insert(stack, { ElementType.Blank, diag })
else
table.insert(stack, { ElementType.Diagnostic, diag })
end
prev_lnum, prev_col = diag.lnum, diag.col
end
local chars = {
cross = '',
horizontal = '',
horizontal_up = '',
up_right = '',
vertical = '',
vertical_right = '',
}
for lnum, stack in pairs(line_stacks) do
local virt_lines = {}
-- Note that we read in the order opposite to insertion.
for i = #stack, 1, -1 do
if stack[i][1] == ElementType.Diagnostic then
local diagnostic0 = stack[i][2]
local left = {} --- @type [string, string]
local overlap = false
local multi = false
-- Iterate the stack for this line to find elements on the left.
for j = 1, i - 1 do
local element_type = stack[j][1]
local data = stack[j][2]
if element_type == ElementType.Space then
if multi then
--- @cast data string
table.insert(left, {
string.rep(chars.horizontal, data:len()),
virtual_lines_highlight_map[diagnostic0.severity],
})
else
table.insert(left, { data, '' })
end
elseif element_type == ElementType.Diagnostic then
-- If an overlap follows this line, don't add an extra column.
if stack[j + 1][1] ~= ElementType.Overlap then
table.insert(left, { chars.vertical, virtual_lines_highlight_map[data.severity] })
end
overlap = false
elseif element_type == ElementType.Blank then
if multi then
table.insert(
left,
{ chars.horizontal_up, virtual_lines_highlight_map[data.severity] }
)
else
table.insert(left, { chars.up_right, virtual_lines_highlight_map[data.severity] })
end
multi = true
elseif element_type == ElementType.Overlap then
overlap = true
end
end
local center_char --- @type string
if overlap and multi then
center_char = chars.cross
elseif overlap then
center_char = chars.vertical_right
elseif multi then
center_char = chars.horizontal_up
else
center_char = chars.up_right
end
local center = {
{
string.format('%s%s', center_char, string.rep(chars.horizontal, 4) .. ' '),
virtual_lines_highlight_map[diagnostic0.severity],
},
}
-- We can draw on the left side if and only if:
-- a. Is the last one stacked this line.
-- b. Has enough space on the left.
-- c. Is just one line.
-- d. Is not an overlap.
for msg_line in diagnostic0.message:gmatch('([^\n]+)') do
local vline = {}
vim.list_extend(vline, left)
vim.list_extend(vline, center)
vim.list_extend(vline, {
{ msg_line, virtual_lines_highlight_map[diagnostic0.severity] },
})
table.insert(virt_lines, vline)
-- Special-case for continuation lines:
if overlap then
center = {
{ chars.vertical, virtual_lines_highlight_map[diagnostic0.severity] },
{ ' ', '' },
}
else
center = { { ' ', '' } }
end
end
end
end
api.nvim_buf_set_extmark(bufnr, namespace, line_anchor[lnum] or lnum, 0, {
virt_lines_overflow = 'scroll',
virt_lines = virt_lines,
})
end
end
--- @param diagnostic0 vim.Diagnostic
--- @return string
local function format_virtual_lines(diagnostic0)
if diagnostic0.code then
return string.format('%s: %s', diagnostic0.code, diagnostic0.message)
end
return diagnostic0.message
end
M.virtual_lines = {}
--- @param namespace integer
--- @param bufnr integer
--- @param diagnostics vim.Diagnostic[]
--- @param opts? vim.diagnostic.OptsResolved
function M.virtual_lines.show(namespace, bufnr, diagnostics, opts)
vim.validate('namespace', namespace, 'number')
vim.validate('bufnr', bufnr, 'number')
vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics')
vim.validate('opts', opts, 'table', true)
bufnr = vim._resolve_bufnr(bufnr)
local vopts = opts and opts.virtual_lines or {}
local ns = diagnostic.get_namespace(namespace)
show_once_loaded('virtual_lines_show_autocmd', ns, bufnr, function()
if not ns.user_data.virt_lines_ns then
ns.user_data.virt_lines_ns =
api.nvim_create_namespace(string.format('nvim.%s.diagnostic.virtual_lines', ns.name))
end
if not ns.user_data.virt_lines_augroup then
ns.user_data.virt_lines_augroup = api.nvim_create_augroup(
string.format('nvim.%s.diagnostic.virt_lines', ns.name),
{ clear = true }
)
end
api.nvim_clear_autocmds({ group = ns.user_data.virt_lines_augroup, buffer = bufnr })
diagnostics =
diagnostic_shared.reformat_diagnostics(vopts.format or format_virtual_lines, diagnostics)
if vopts.current_line == true then
-- Create a mapping from line -> diagnostics so that we can quickly get the
-- diagnostics we need when the cursor line doesn't change.
local line_diagnostics = diagnostic_shared.diagnostic_lines(diagnostics, true)
api.nvim_create_autocmd('CursorMoved', {
buffer = bufnr,
group = ns.user_data.virt_lines_augroup,
callback = function()
render_virtual_lines(
ns.user_data.virt_lines_ns,
bufnr,
diagnostic_shared.diagnostics_at_cursor(line_diagnostics)
)
end,
})
-- Also show diagnostics for the current line before the first CursorMoved event.
render_virtual_lines(
ns.user_data.virt_lines_ns,
bufnr,
diagnostic_shared.diagnostics_at_cursor(line_diagnostics)
)
else
render_virtual_lines(ns.user_data.virt_lines_ns, bufnr, diagnostics)
end
save_extmarks(ns.user_data.virt_lines_ns, bufnr)
end)
end
--- @param namespace integer
--- @param bufnr integer
function M.virtual_lines.hide(namespace, bufnr)
local ns = diagnostic.get_namespace(namespace)
cleanup_show_autocmd('virtual_lines_show_autocmd', ns)
if ns.user_data.virt_lines_ns then
clear_extmarks(bufnr, ns.user_data.virt_lines_ns)
if api.nvim_buf_is_valid(bufnr) then
api.nvim_clear_autocmds({ group = ns.user_data.virt_lines_augroup, buffer = bufnr })
end
end
end
return M

View File

@@ -0,0 +1,289 @@
local api, if_nil = vim.api, vim.F.if_nil
local shared = require('vim.diagnostic._shared')
local store = require('vim.diagnostic._store')
--- @class (private) vim.diagnostic._JumpOpts : vim.diagnostic.JumpOpts
--- @field _highest? boolean
--- @field win_id? integer
--- @field cursor_position? [integer, integer]
--- @field float? table|boolean
--- @class (private) vim.diagnostic._jump
local M = {}
--- @param diagnostics vim.Diagnostic[]
local function filter_highest(diagnostics)
table.sort(diagnostics, function(a, b)
return shared.diagnostic_cmp(a, b, 'severity', false)
end)
-- Find the first diagnostic where the severity does not match the highest severity, and remove
-- that element and all subsequent elements from the array
local worst = (diagnostics[1] or {}).severity
local len = #diagnostics
for i = 2, len do
if diagnostics[i].severity ~= worst then
for j = i, len do
diagnostics[j] = nil
end
break
end
end
end
--- @param search_forward boolean
--- @param opts vim.diagnostic.JumpOpts?
--- @param use_logical_pos boolean
--- @return vim.Diagnostic?
local function next_diagnostic(search_forward, opts, use_logical_pos)
opts = opts or {}
--- @cast opts vim.diagnostic._JumpOpts
-- Support deprecated win_id alias
if opts.win_id then
vim.deprecate('opts.win_id', 'opts.winid', '0.13')
opts.winid = opts.win_id
opts.win_id = nil --- @diagnostic disable-line
end
-- Support deprecated cursor_position alias
if opts.cursor_position then
vim.deprecate('opts.cursor_position', 'opts.pos', '0.13')
opts.pos = opts.cursor_position
opts.cursor_position = nil --- @diagnostic disable-line
end
local winid = opts.winid or api.nvim_get_current_win()
local bufnr = api.nvim_win_get_buf(winid)
local position = opts.pos or api.nvim_win_get_cursor(winid)
-- Adjust row to be 0-indexed
position[1] = position[1] - 1
local wrap = if_nil(opts.wrap, true)
local diagnostics = store.get_diagnostics(bufnr, opts, true)
if opts._highest then
filter_highest(diagnostics)
end
local line_diagnostics = shared.diagnostic_lines(diagnostics, use_logical_pos)
--- @param diagnostic vim.Diagnostic
--- @return integer
local function col_fn(diagnostic)
return use_logical_pos and select(2, shared.get_logical_pos(diagnostic)) or diagnostic.col
end
local line_count = api.nvim_buf_line_count(bufnr)
for i = 0, line_count do
local offset = i * (search_forward and 1 or -1)
local lnum = position[1] + offset
if lnum < 0 or lnum >= line_count then
if not wrap then
return
end
lnum = (lnum + line_count) % line_count
end
if line_diagnostics[lnum] and not vim.tbl_isempty(line_diagnostics[lnum]) then
local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1]
local sort_diagnostics, is_next --- @type function, function
if search_forward then
sort_diagnostics = function(a, b)
return shared.diagnostic_cmp(a, b, 'col', false, col_fn)
end
is_next = function(diagnostic)
return math.min(col_fn(diagnostic), math.max(line_length - 1, 0)) > position[2]
end
else
sort_diagnostics = function(a, b)
return shared.diagnostic_cmp(a, b, 'col', true, col_fn)
end
is_next = function(diagnostic)
return math.min(col_fn(diagnostic), math.max(line_length - 1, 0)) < position[2]
end
end
table.sort(line_diagnostics[lnum], sort_diagnostics)
if i == 0 then
for _, diagnostic in ipairs(line_diagnostics[lnum]) do
if is_next(diagnostic) then
return diagnostic
end
end
else
return line_diagnostics[lnum][1]
end
end
end
end
--- @param diagnostic vim.Diagnostic?
--- @param opts vim.diagnostic.JumpOpts?
local function goto_diagnostic(diagnostic, opts)
if not diagnostic then
api.nvim_echo({ { 'No more valid diagnostics to move to', 'WarningMsg' } }, true, {})
return
end
opts = opts or {}
--- @cast opts vim.diagnostic._JumpOpts
-- Support deprecated win_id alias
if opts.win_id then
vim.deprecate('opts.win_id', 'opts.winid', '0.13')
opts.winid = opts.win_id
opts.win_id = nil --- @diagnostic disable-line
end
local winid = opts.winid or api.nvim_get_current_win()
local lnum, col = shared.get_logical_pos(diagnostic)
vim._with({ win = winid }, function()
-- Save position in the window's jumplist
vim.cmd("normal! m'")
api.nvim_win_set_cursor(winid, { lnum + 1, col })
-- Open folds under the cursor
vim.cmd('normal! zv')
end)
if opts.float then
vim.deprecate('opts.float', 'opts.on_jump', '0.14')
local float_opts = opts.float
float_opts = type(float_opts) == 'table' and float_opts or {}
opts.on_jump = function(_, bufnr)
vim.diagnostic.open_float(vim.tbl_extend('keep', float_opts, {
bufnr = bufnr,
scope = 'cursor',
focus = false,
}))
end
opts.float = nil --- @diagnostic disable-line
end
if opts.on_jump then
vim.schedule(function()
opts.on_jump(diagnostic, api.nvim_win_get_buf(winid))
end)
end
end
--- @param opts? vim.diagnostic.JumpOpts
--- @return vim.Diagnostic?
function M.get_prev(opts)
return next_diagnostic(false, opts, false)
end
--- @param opts? vim.diagnostic.JumpOpts
--- @return table|false
function M.get_prev_pos(opts)
vim.deprecate(
'vim.diagnostic.get_prev_pos()',
'access the lnum and col fields from get_prev() instead',
'0.13'
)
local prev = M.get_prev(opts)
if not prev then
return false
end
return { prev.lnum, prev.col }
end
--- @param opts? vim.diagnostic.JumpOpts
function M.goto_prev(opts)
vim.deprecate('vim.diagnostic.goto_prev()', 'vim.diagnostic.jump()', '0.13')
opts = opts or {}
opts.float = if_nil(opts.float, true) --- @diagnostic disable-line
goto_diagnostic(M.get_prev(opts), opts)
end
--- @param opts? vim.diagnostic.JumpOpts
--- @return vim.Diagnostic?
function M.get_next(opts)
return next_diagnostic(true, opts, false)
end
--- @param opts? vim.diagnostic.JumpOpts
--- @return table|false
function M.get_next_pos(opts)
vim.deprecate(
'vim.diagnostic.get_next_pos()',
'access the lnum and col fields from get_next() instead',
'0.13'
)
local next = M.get_next(opts)
if not next then
return false
end
return { next.lnum, next.col }
end
--- @param opts vim.diagnostic.JumpOpts
--- @return vim.Diagnostic?
function M.jump(opts)
vim.validate('opts', opts, 'table')
-- One of "diagnostic" or "count" must be provided
assert(
opts.diagnostic or opts.count,
'One of "diagnostic" or "count" must be specified in the options to vim.diagnostic.jump()'
)
-- Apply configuration options from vim.diagnostic.config()
local config = assert(vim.diagnostic.config()).jump or {}
opts = vim.tbl_deep_extend('keep', opts, config)
--- @cast opts vim.diagnostic._JumpOpts
if opts.diagnostic then
goto_diagnostic(opts.diagnostic, opts)
return opts.diagnostic
end
local count = opts.count
if count == 0 then
return nil
end
-- Support deprecated cursor_position alias
if opts.cursor_position then
vim.deprecate('opts.cursor_position', 'opts.pos', '0.13')
opts.pos = opts.cursor_position
opts.cursor_position = nil --- @diagnostic disable-line
end
local diagnostic --- @type vim.Diagnostic?
while count ~= 0 do
local next = next_diagnostic(count > 0, opts, true)
if not next then
break
end
-- Update cursor position
opts.pos = { next.lnum + 1, next.col }
if count > 0 then
count = count - 1
else
count = count + 1
end
diagnostic = next
end
goto_diagnostic(diagnostic, opts)
return diagnostic
end
--- @param opts? vim.diagnostic.JumpOpts
function M.goto_next(opts)
vim.deprecate('vim.diagnostic.goto_next()', 'vim.diagnostic.jump()', '0.13')
opts = opts or {}
opts.float = if_nil(opts.float, true) --- @diagnostic disable-line
goto_diagnostic(M.get_next(opts), opts)
end
return M

View File

@@ -0,0 +1,109 @@
local severity = vim.diagnostic.severity
--- @class (private) vim.diagnostic._severity
local M = {}
--- @param value string|vim.diagnostic.Severity?
--- @return vim.diagnostic.Severity?
function M.to_severity(value)
if type(value) == 'string' then
local ret = severity[value:upper()] --[[@as vim.diagnostic.Severity?]]
if not ret then
error(('Invalid severity: %s'):format(value))
end
return ret
end
return value --[[@as vim.diagnostic.Severity?]]
end
--- @param filter vim.diagnostic.SeverityFilter
--- @return fun(d: vim.Diagnostic):boolean
function M.severity_predicate(filter)
if type(filter) ~= 'table' then
local severity0 = M.to_severity(filter)
--- @param d vim.Diagnostic
return function(d)
return d.severity == severity0
end
end
--- @diagnostic disable-next-line: undefined-field
if filter.min or filter.max then
--- @cast filter {min:vim.diagnostic.Severity,max:vim.diagnostic.Severity}
local min_severity = M.to_severity(filter.min) or severity.HINT
local max_severity = M.to_severity(filter.max) or severity.ERROR
--- @param d vim.Diagnostic
return function(d)
return d.severity <= min_severity and d.severity >= max_severity
end
end
--- @cast filter vim.diagnostic.Severity[]
local severities = {} --- @type table<vim.diagnostic.Severity,true>
for _, s in ipairs(filter) do
severities[assert(M.to_severity(s))] = true
end
--- @param d vim.Diagnostic
return function(d)
return severities[d.severity]
end
end
--- @param filter vim.diagnostic.SeverityFilter?
--- @param diagnostics vim.Diagnostic[]
--- @return vim.Diagnostic[]
function M.filter_by_severity(filter, diagnostics)
if not filter then
return diagnostics
end
return vim.tbl_filter(M.severity_predicate(filter), diagnostics)
end
--- Parse a diagnostic from a string.
---
--- @param str string String to parse diagnostics from.
--- @param pat string Lua pattern with capture groups.
--- @param groups string[] List of fields in a |vim.Diagnostic| structure to associate with captures from {pat}.
--- @param severity_map table? A table mapping the severity field from {groups} with an item from |vim.diagnostic.severity|.
--- @param defaults table? Table of default values for any fields not listed in {groups}.
--- @return vim.Diagnostic?
function M.match(str, pat, groups, severity_map, defaults)
vim.validate('str', str, 'string')
vim.validate('pat', pat, 'string')
vim.validate('groups', groups, 'table')
vim.validate('severity_map', severity_map, 'table', true)
vim.validate('defaults', defaults, 'table', true)
--- @type table<string,vim.diagnostic.Severity>
severity_map = severity_map or severity
local matches = { str:match(pat) } --- @type any[]
if vim.tbl_isempty(matches) then
return
end
local diagnostic = {} --- @type table<string,any>
for i, match in ipairs(matches) do
local field = groups[i]
if field == 'severity' then
diagnostic[field] = severity_map[match]
elseif field == 'lnum' or field == 'end_lnum' or field == 'col' or field == 'end_col' then
diagnostic[field] = vim._assert_integer(match) - 1
elseif field then
diagnostic[field] = match
end
end
diagnostic = vim.tbl_extend('keep', diagnostic, defaults or {}) --- @type vim.Diagnostic
diagnostic.severity = diagnostic.severity or severity.ERROR
diagnostic.col = diagnostic.col or 0
diagnostic.end_lnum = diagnostic.end_lnum or diagnostic.lnum
diagnostic.end_col = diagnostic.end_col or diagnostic.col
return diagnostic
end
return M

View File

@@ -0,0 +1,175 @@
local api = vim.api
local store = require('vim.diagnostic._store')
--- @class (private) vim.diagnostic._shared
local M = {}
--- @param bufnr integer
--- @return integer
function M.count_sources(bufnr)
local count = 0
local seen = {} --- @type table<string, true>
for _, diagnostic in ipairs(store.get_diagnostics(bufnr, nil, false)) do
local source = diagnostic.source
if source and not seen[source] then
seen[source] = true
count = count + 1
end
end
return count
end
--- @param diagnostics vim.Diagnostic[]
--- @return vim.Diagnostic[]
function M.prefix_source(diagnostics)
--- @param diagnostic vim.Diagnostic
return vim.tbl_map(function(diagnostic)
if not diagnostic.source then
return diagnostic
end
local copied = vim.deepcopy(diagnostic, true)
copied.message = string.format('%s: %s', diagnostic.source, diagnostic.message)
return copied
end, diagnostics)
end
--- Get a position based on an extmark referenced by `_extmark_id` field
--- @param diagnostic vim.Diagnostic
--- @return integer lnum
--- @return integer col
--- @return integer end_lnum
--- @return integer end_col
--- @return boolean valid
function M.get_logical_pos(diagnostic)
if not diagnostic._extmark_id then
return diagnostic.lnum, diagnostic.col, diagnostic.end_lnum, diagnostic.end_col, true
end
local ns = vim.diagnostic.get_namespace(diagnostic.namespace)
local extmark = api.nvim_buf_get_extmark_by_id(
diagnostic.bufnr,
ns.user_data.location_ns,
diagnostic._extmark_id,
{ details = true }
)
if next(extmark) == nil then
return diagnostic.lnum, diagnostic.col, diagnostic.end_lnum, diagnostic.end_col, true
end
return extmark[1], extmark[2], extmark[3].end_row, extmark[3].end_col, not extmark[3].invalid
end
--- @param diagnostics vim.Diagnostic[]?
--- @param use_logical_pos boolean
--- @return table<integer, vim.Diagnostic[]>
function M.diagnostic_lines(diagnostics, use_logical_pos)
if not diagnostics then
return {}
end
local diagnostics_by_line = {} --- @type table<integer, vim.Diagnostic[]>
for _, diagnostic in ipairs(diagnostics) do
local lnum --- @type integer
local valid --- @type boolean
if use_logical_pos then
lnum, _, _, _, valid = M.get_logical_pos(diagnostic)
else
lnum, valid = diagnostic.lnum, true
end
if valid then
local line_diagnostics = diagnostics_by_line[lnum]
if not line_diagnostics then
line_diagnostics = {}
diagnostics_by_line[lnum] = line_diagnostics
end
line_diagnostics[#line_diagnostics + 1] = diagnostic
end
end
return diagnostics_by_line
end
--- @param diagnostics table<integer, vim.Diagnostic[]>
--- @return vim.Diagnostic[]
function M.diagnostics_at_cursor(diagnostics)
local lnum = api.nvim_win_get_cursor(0)[1] - 1
if diagnostics[lnum] ~= nil then
return diagnostics[lnum]
end
local cursor_diagnostics = {} --- @type vim.Diagnostic[]
for _, line_diags in pairs(diagnostics) do
for _, diagnostic in ipairs(line_diags) do
if diagnostic.end_lnum and lnum >= diagnostic.lnum and lnum <= diagnostic.end_lnum then
cursor_diagnostics[#cursor_diagnostics + 1] = diagnostic
end
end
end
return cursor_diagnostics
end
--- @param a vim.Diagnostic
--- @param b vim.Diagnostic
--- @param primary_key string
--- @param reverse boolean
--- @param col_fn? fun(diagnostic: vim.Diagnostic): integer
--- @return boolean
function M.diagnostic_cmp(a, b, primary_key, reverse, col_fn)
local a_val, b_val --- @type integer, integer
if col_fn then
a_val, b_val = col_fn(a), col_fn(b)
else
a_val = a[primary_key] --[[@as integer]]
b_val = b[primary_key] --[[@as integer]]
end
local cmp = function(x, y)
if reverse then
return x > y
end
return x < y
end
if a_val ~= b_val then
return cmp(a_val, b_val)
end
if a.lnum ~= b.lnum then
return cmp(a.lnum, b.lnum)
end
if a.col ~= b.col then
return cmp(a.col, b.col)
end
if a.end_lnum ~= b.end_lnum then
return cmp(a.end_lnum, b.end_lnum)
end
if a.end_col ~= b.end_col then
return cmp(a.end_col, b.end_col)
end
return cmp(a._extmark_id or 0, b._extmark_id or 0)
end
--- @param format fun(diagnostic: vim.Diagnostic): string?
--- @param diagnostics vim.Diagnostic[]
--- @return vim.Diagnostic[]
function M.reformat_diagnostics(format, diagnostics)
vim.validate('format', format, 'function')
vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics')
local formatted = {} --- @type vim.Diagnostic[]
for _, diagnostic in ipairs(diagnostics) do
local message = format(diagnostic)
if message ~= nil then
local formatted_diagnostic = vim.deepcopy(diagnostic, true)
formatted_diagnostic.message = message
formatted[#formatted + 1] = formatted_diagnostic
end
end
return formatted
end
return M

View File

@@ -0,0 +1,314 @@
local api = vim.api
local severity_module = require('vim.diagnostic._severity')
--- @class (private) vim.diagnostic._store
local M = {}
-- bufnr -> ns -> Diagnostic[]
local diagnostic_cache = {} --- @type table<integer,table<integer,vim.Diagnostic[]?>>
local group = api.nvim_create_augroup('nvim.diagnostic.buf_wipeout', {})
setmetatable(diagnostic_cache, {
--- @param t table<integer,vim.Diagnostic[]>
--- @param bufnr integer
__index = function(t, bufnr)
assert(bufnr > 0, 'Invalid buffer number')
api.nvim_create_autocmd('BufWipeout', {
group = group,
buffer = bufnr,
callback = function()
rawset(t, bufnr, nil)
end,
})
t[bufnr] = {}
return t[bufnr]
end,
})
--- @param bufnr integer
--- @param namespace integer
--- @param d vim.Diagnostic.Set
local function norm_diag(bufnr, namespace, d)
vim.validate('diagnostic.lnum', d.lnum, 'number')
local d1 = d --[[@as vim.Diagnostic]]
d1.severity = d.severity and severity_module.to_severity(d.severity)
or vim.diagnostic.severity.ERROR
d1.end_lnum = d.end_lnum or d.lnum
d1.col = d.col or 0
d1.end_col = d.end_col or d.col or 0
d1.namespace = namespace
d1.bufnr = bufnr
end
--- Execute a given function now if the given buffer is already loaded or once it is loaded later.
---
--- @param bufnr integer Buffer number
--- @param fn fun()
--- @return integer?
local function once_buf_loaded(bufnr, fn)
if api.nvim_buf_is_loaded(bufnr) then
fn()
else
return api.nvim_create_autocmd('BufRead', {
buffer = bufnr,
once = true,
callback = function()
fn()
end,
})
end
end
--- @param bufnr integer?
--- @param opts vim.diagnostic.GetOpts?
--- @param clamp boolean
--- @return vim.Diagnostic[]
function M.get_diagnostics(bufnr, opts, clamp)
opts = opts or {}
local namespace = opts.namespace
if type(namespace) == 'number' then
namespace = { namespace }
end
--- @cast namespace integer[]
--- @type vim.Diagnostic[]
local diagnostics = {}
-- Memoized results of buf_line_count per bufnr
--- @type table<integer,integer>
local buf_line_count = setmetatable({}, {
--- @param t table<integer,integer>
--- @param k integer
--- @return integer
__index = function(t, k)
t[k] = api.nvim_buf_line_count(k)
return rawget(t, k)
end,
})
local match_severity = opts.severity and severity_module.severity_predicate(opts.severity)
or function(_)
return true
end
--- @param b integer
--- @param d vim.Diagnostic
local match_enablement = function(d, b)
if opts.enabled == nil then
return true
end
local enabled = vim.diagnostic.is_enabled({ bufnr = b, ns_id = d.namespace })
return (enabled and opts.enabled) or (not enabled and not opts.enabled)
end
--- @param b integer
--- @param d vim.Diagnostic
local function add(b, d)
if
match_severity(d)
and match_enablement(d, b)
and (not opts.lnum or (opts.lnum >= d.lnum and opts.lnum <= (d.end_lnum or d.lnum)))
then
if clamp and api.nvim_buf_is_loaded(b) then
local line_count = buf_line_count[b] - 1
if
d.lnum > line_count
or d.end_lnum > line_count
or d.lnum < 0
or d.end_lnum < 0
or d.col < 0
or d.end_col < 0
then
d = vim.deepcopy(d, true)
d.lnum = math.max(math.min(d.lnum, line_count), 0)
d.end_lnum = math.max(math.min(d.end_lnum, line_count), 0)
d.col = math.max(d.col, 0)
d.end_col = math.max(d.end_col, 0)
end
end
table.insert(diagnostics, d)
end
end
--- @param buf integer
--- @param diags vim.Diagnostic[]
local function add_all_diags(buf, diags)
for _, diagnostic0 in pairs(diags) do
add(buf, diagnostic0)
end
end
if not namespace and not bufnr then
for buf, ns_diags in pairs(diagnostic_cache) do
for _, diags in pairs(ns_diags) do
add_all_diags(buf, diags)
end
end
elseif not namespace then
bufnr = vim._resolve_bufnr(bufnr)
for iter_namespace in pairs(diagnostic_cache[bufnr]) do
add_all_diags(bufnr, diagnostic_cache[bufnr][iter_namespace])
end
elseif bufnr == nil then
for b, t in pairs(diagnostic_cache) do
for _, iter_namespace in ipairs(namespace) do
add_all_diags(b, t[iter_namespace] or {})
end
end
else
bufnr = vim._resolve_bufnr(bufnr)
for _, iter_namespace in ipairs(namespace) do
add_all_diags(bufnr, diagnostic_cache[bufnr][iter_namespace] or {})
end
end
return diagnostics
end
--- @return integer[]
function M.get_bufnrs()
return vim.tbl_keys(diagnostic_cache)
end
--- @param bufnr integer
--- @return integer[]
function M.get_buf_namespaces(bufnr)
return vim.tbl_keys(diagnostic_cache[vim._resolve_bufnr(bufnr)])
end
--- @param namespace integer
--- @param bufnr integer
function M.clear(namespace, bufnr)
diagnostic_cache[vim._resolve_bufnr(bufnr)][namespace] = nil
end
--- @param bufnr integer
function M.drop_buf(bufnr)
diagnostic_cache[vim._resolve_bufnr(bufnr)] = nil
end
--- Set diagnostics for the given namespace and buffer.
---
--- @param namespace integer The diagnostic namespace
--- @param bufnr integer Buffer number
--- @param diagnostics vim.Diagnostic.Set[]
function M.set(namespace, bufnr, diagnostics)
vim.validate('namespace', namespace, 'number')
vim.validate('bufnr', bufnr, 'number')
vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics')
bufnr = vim._resolve_bufnr(bufnr)
for _, diagnostic0 in ipairs(diagnostics) do
norm_diag(bufnr, namespace, diagnostic0)
end
--- @cast diagnostics vim.Diagnostic[]
if vim.tbl_isempty(diagnostics) then
diagnostic_cache[bufnr][namespace] = nil
else
diagnostic_cache[bufnr][namespace] = diagnostics
end
-- Compute positions, set them as extmarks, and store in diagnostic._extmark_id
-- (used by get_logical_pos to adjust positions).
once_buf_loaded(bufnr, function()
local ns = vim.diagnostic.get_namespace(namespace)
if not ns.user_data.location_ns then
ns.user_data.location_ns =
api.nvim_create_namespace(string.format('nvim.%s.diagnostic', ns.name))
end
api.nvim_buf_clear_namespace(bufnr, ns.user_data.location_ns, 0, -1)
local lines = api.nvim_buf_get_lines(bufnr, 0, -1, true)
-- set extmarks at diagnostic locations to preserve logical positions despite text changes
for _, diagnostic0 in ipairs(diagnostics) do
local last_row = #lines - 1
local row = math.max(0, math.min(diagnostic0.lnum, last_row))
local row_len = #lines[row + 1]
local col = math.max(0, math.min(diagnostic0.col, row_len - 1))
local end_row = math.max(0, math.min(diagnostic0.end_lnum or row, last_row))
local end_row_len = #lines[end_row + 1]
local end_col = math.max(0, math.min(diagnostic0.end_col or col, end_row_len))
if end_row == row then
-- avoid starting an extmark beyond end of the line
if end_col == col then
end_col = math.min(end_col + 1, end_row_len)
end
else
-- avoid ending an extmark before start of the line
if end_col == 0 then
end_row = end_row - 1
local end_line = lines[end_row + 1]
if not end_line then
error(
'Failed to adjust diagnostic position to the end of a previous line. #lines in a buffer: '
.. #lines
.. ', lnum: '
.. diagnostic0.lnum
.. ', col: '
.. diagnostic0.col
.. ', end_lnum: '
.. diagnostic0.end_lnum
.. ', end_col: '
.. diagnostic0.end_col
)
end
end_col = #end_line
end
end
diagnostic0._extmark_id =
api.nvim_buf_set_extmark(bufnr, ns.user_data.location_ns, row, col, {
end_row = end_row,
end_col = end_col,
invalidate = true,
})
end
end)
end
--- @param bufnr integer? Buffer number to get diagnostics from. Use 0 for
--- current buffer or nil for all buffers.
--- @param opts? vim.diagnostic.GetOpts
--- @return vim.Diagnostic[] : Fields `bufnr`, `end_lnum`, `end_col`, and `severity`
--- are guaranteed to be present.
function M.get(bufnr, opts)
vim.validate('bufnr', bufnr, 'number', true)
vim.validate('opts', opts, 'table', true)
return vim.deepcopy(M.get_diagnostics(bufnr, opts, false), true)
end
--- @param bufnr? integer Buffer number to get diagnostics from. Use 0 for
--- current buffer or nil for all buffers.
--- @param opts? vim.diagnostic.GetOpts
--- @return table<integer, integer> : Table with actually present severity values as keys
--- (see |diagnostic-severity|) and integer counts as values.
function M.count(bufnr, opts)
vim.validate('bufnr', bufnr, 'number', true)
vim.validate('opts', opts, 'table', true)
local diagnostics = M.get_diagnostics(bufnr, opts, false)
local count = {} --- @type table<integer,integer>
for _, d in ipairs(diagnostics) do
count[d.severity] = (count[d.severity] or 0) + 1
end
return count
end
return M