From 1f53abf54b7085b8fbf0ea7b27f18b0b7104389a Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Thu, 2 Apr 2026 17:27:49 +0100 Subject: [PATCH] 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 --- runtime/lua/vim/diagnostic.lua | 2233 ++-------------------- runtime/lua/vim/diagnostic/_config.lua | 157 ++ runtime/lua/vim/diagnostic/_display.lua | 193 ++ runtime/lua/vim/diagnostic/_float.lua | 284 +++ runtime/lua/vim/diagnostic/_handlers.lua | 732 +++++++ runtime/lua/vim/diagnostic/_jump.lua | 289 +++ runtime/lua/vim/diagnostic/_severity.lua | 109 ++ runtime/lua/vim/diagnostic/_shared.lua | 175 ++ runtime/lua/vim/diagnostic/_store.lua | 314 +++ test/functional/lua/diagnostic_spec.lua | 4 +- 10 files changed, 2418 insertions(+), 2072 deletions(-) create mode 100644 runtime/lua/vim/diagnostic/_config.lua create mode 100644 runtime/lua/vim/diagnostic/_display.lua create mode 100644 runtime/lua/vim/diagnostic/_float.lua create mode 100644 runtime/lua/vim/diagnostic/_handlers.lua create mode 100644 runtime/lua/vim/diagnostic/_jump.lua create mode 100644 runtime/lua/vim/diagnostic/_severity.lua create mode 100644 runtime/lua/vim/diagnostic/_shared.lua create mode 100644 runtime/lua/vim/diagnostic/_store.lua diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index 067b669f7e..52a1c7c2e6 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -1,20 +1,16 @@ -local api, if_nil = vim.api, vim.F.if_nil +local api = vim.api -local M = {} - ---- @param title string ---- @return integer? -local function get_qf_id_for_title(title) - local lastqflist = vim.fn.getqflist({ nr = '$' }) - for i = 1, lastqflist.nr do - local qflist = vim.fn.getqflist({ nr = i, id = 0, title = 0 }) - if qflist.title == title then - return qflist.id - end - end - - return nil -end +-- TODO(lewis6991): deprecate some top level functions in favour of the submodule version +-- e.g. vim.diagnostic.get_namespace() -> vim.diagnostic.namespace.get() +local M = vim._defer_require('vim.diagnostic', { + _config = ..., --- @module 'vim.diagnostic._config' + _display = ..., --- @module 'vim.diagnostic._display' + _float = ..., --- @module 'vim.diagnostic._float' + _jump = ..., --- @module 'vim.diagnostic._jump' + _severity = ..., --- @module 'vim.diagnostic._severity' + _store = ..., --- @module 'vim.diagnostic._store' + _handlers = ..., --- @module 'vim.diagnostic._handlers' +}) --- Diagnostics use the same indexing as the rest of the Nvim API (i.e. 0-based --- rows and columns). |api-indexing| @@ -113,15 +109,6 @@ end --- Default values for |vim.diagnostic.jump()|. See |vim.diagnostic.Opts.Jump|. --- @field jump? vim.diagnostic.Opts.Jump ---- @class (private) 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 vim.diagnostic.Opts.Float : vim.lsp.util.open_floating_preview.Opts --- --- Buffer number to show diagnostics from. @@ -355,6 +342,12 @@ end --- Buffer number, or 0 for current buffer, or `nil` for all buffers. --- @field bufnr? integer +--- See |diagnostic-severity| and |vim.diagnostic.get()| +--- @alias vim.diagnostic.SeverityFilter +--- | vim.diagnostic.Severity +--- | vim.diagnostic.Severity[] +--- | {min:vim.diagnostic.Severity,max:vim.diagnostic.Severity} + --- @nodoc --- @enum vim.diagnostic.Severity M.severity = { @@ -387,877 +380,37 @@ do s.N = 4 end ---- See |diagnostic-severity| and |vim.diagnostic.get()| ---- @alias vim.diagnostic.SeverityFilter ---- | vim.diagnostic.Severity ---- | vim.diagnostic.Severity[] ---- | {min:vim.diagnostic.Severity,max:vim.diagnostic.Severity} - ---- @type vim.diagnostic.Opts -local global_diagnostic_options = { +local builtin_handler_names = { 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, - }, + virtual_text = true, + virtual_lines = true, } ---- @class (private) vim.diagnostic.Handler ---- @field show? fun(namespace: integer, bufnr: integer, diagnostics: vim.Diagnostic[], opts?: vim.diagnostic.OptsResolved) ---- @field hide? fun(namespace:integer, bufnr:integer) - --- @nodoc --- @type table M.handlers = setmetatable({}, { __newindex = function(t, name, handler) vim.validate('handler', handler, 'table') rawset(t, name, handler) - if global_diagnostic_options[name] == nil then - global_diagnostic_options[name] = true + if not builtin_handler_names[name] then + M._config.enable_handler(name) end end, }) --- Metatable that automatically creates an empty table when assigning to a missing key -local bufnr_and_namespace_cacher_mt = { - --- @param t table - --- @param bufnr integer - --- @return table - __index = function(t, bufnr) - assert(bufnr > 0, 'Invalid buffer number') - t[bufnr] = {} - return t[bufnr] - end, -} - --- bufnr -> ns -> Diagnostic[] -local diagnostic_cache = {} --- @type table> -do - local group = api.nvim_create_augroup('nvim.diagnostic.buf_wipeout', {}) - setmetatable(diagnostic_cache, { - --- @param t table - --- @param bufnr integer - __index = function(t, bufnr) - assert(bufnr > 0, 'Invalid buffer number') - api.nvim_create_autocmd('BufWipeout', { - group = group, - buf = bufnr, - callback = function() - rawset(t, bufnr, nil) - end, - }) - t[bufnr] = {} - return t[bufnr] - end, - }) -end - ---- @class (private) vim.diagnostic._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 - ---- @type table> -local diagnostic_cache_extmarks = setmetatable({}, bufnr_and_namespace_cacher_mt) - ---- @type table -local diagnostic_attached_buffers = {} - --- @type table> local diagnostic_disabled = {} ---- @type table> -local bufs_waiting_to_update = setmetatable({}, bufnr_and_namespace_cacher_mt) - --- @class vim.diagnostic.NS --- @field name string --- @field opts vim.diagnostic.Opts --- @field user_data table --- @field disabled? boolean ---- @type table +--- @type table local all_namespaces = {} ----@param severity string|vim.diagnostic.Severity? ----@return vim.diagnostic.Severity? -local function to_severity(severity) - if type(severity) == 'string' then - local ret = M.severity[severity:upper()] --[[@as vim.diagnostic.Severity?]] - if not ret then - error(('Invalid severity: %s'):format(severity)) - end - return ret - end - return severity --[[@as vim.diagnostic.Severity?]] -end - ---- @param severity vim.diagnostic.SeverityFilter ---- @return fun(d: vim.Diagnostic):boolean -local function severity_predicate(severity) - if type(severity) ~= 'table' then - local severity0 = to_severity(severity) - ---@param d vim.Diagnostic - return function(d) - return d.severity == severity0 - end - end - --- @diagnostic disable-next-line: undefined-field - if severity.min or severity.max then - --- @cast severity {min:vim.diagnostic.Severity,max:vim.diagnostic.Severity} - local min_severity = to_severity(severity.min) or M.severity.HINT - local max_severity = to_severity(severity.max) or M.severity.ERROR - - --- @param d vim.Diagnostic - return function(d) - return d.severity <= min_severity and d.severity >= max_severity - end - end - - --- @cast severity vim.diagnostic.Severity[] - local severities = {} --- @type table - for _, s in ipairs(severity) do - severities[assert(to_severity(s))] = true - end - - --- @param d vim.Diagnostic - return function(d) - return severities[d.severity] - end -end - ---- @param severity vim.diagnostic.SeverityFilter ---- @param diagnostics vim.Diagnostic[] ---- @return vim.Diagnostic[] -local function filter_by_severity(severity, diagnostics) - if not severity then - return diagnostics - end - return vim.tbl_filter(severity_predicate(severity), diagnostics) -end - ---- @param bufnr integer ---- @return integer -local function count_sources(bufnr) - local seen = {} --- @type table - local count = 0 - for _, namespace_diagnostics in pairs(diagnostic_cache[bufnr]) do - for _, diagnostic in ipairs(namespace_diagnostics) do - local source = diagnostic.source - if source and not seen[source] then - seen[source] = true - count = count + 1 - end - end - end - return count -end - ---- @param diagnostics vim.Diagnostic[] ---- @return vim.Diagnostic[] -local function prefix_source(diagnostics) - --- @param d vim.Diagnostic - return vim.tbl_map(function(d) - if not d.source then - return d - end - - local t = vim.deepcopy(d, true) - t.message = string.format('%s: %s', d.source, d.message) - return t - end, diagnostics) -end - ---- @param format fun(diagnostic: vim.Diagnostic): string? ---- @param diagnostics vim.Diagnostic[] ---- @return vim.Diagnostic[] -local function reformat_diagnostics(format, diagnostics) - vim.validate('format', format, 'function') - vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics') - - local formatted = {} - 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 - table.insert(formatted, formatted_diagnostic) - end - end - return formatted -end - ---- @param option string ---- @param namespace integer? ---- @return table -local function enabled_value(option, namespace) - local ns = namespace and M.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 -local function get_resolved_options(opts, namespace, bufnr) - local ns = namespace and M.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 - 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 - --- Default diagnostic highlights -local diagnostic_severities = { - [M.severity.ERROR] = { ctermfg = 1, guifg = 'Red' }, - [M.severity.WARN] = { ctermfg = 3, guifg = 'Orange' }, - [M.severity.INFO] = { ctermfg = 4, guifg = 'LightBlue' }, - [M.severity.HINT] = { ctermfg = 7, guifg = 'LightGrey' }, -} - ---- Make a map from vim.diagnostic.Severity -> Highlight Name ---- @param base_name string ---- @return table -local function make_highlight_map(base_name) - local result = {} --- @type table - for k in pairs(diagnostic_severities) do - local name = severity_invert[k] - result[k] = ('Diagnostic%s%s%s'):format(base_name, name:sub(1, 1), name:sub(2):lower()) - end - - return result -end - -local virtual_text_highlight_map = make_highlight_map('VirtualText') -local virtual_lines_highlight_map = make_highlight_map('VirtualLines') -local underline_highlight_map = make_highlight_map('Underline') -local floating_highlight_map = make_highlight_map('Floating') -local sign_highlight_map = make_highlight_map('Sign') - ---- 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 -local function 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 = M.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 -local function diagnostic_lines(diagnostics, use_logical_pos) - if not diagnostics then - return {} - end - - local diagnostics_by_line = {} --- @type table - for _, diagnostic in ipairs(diagnostics) do - local lnum ---@type integer - local valid ---@type boolean - - if use_logical_pos then - lnum, _, _, _, valid = 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 - table.insert(line_diagnostics, diagnostic) - end - end - return diagnostics_by_line -end - ---- @param diagnostics table ---- @return vim.Diagnostic[] -local function 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 = {} - for _, line_diags in pairs(diagnostics) do - for _, diag in ipairs(line_diags) do - if diag.end_lnum and lnum >= diag.lnum and lnum <= diag.end_lnum then - table.insert(cursor_diagnostics, diag) - end - end - end - return cursor_diagnostics -end - ---- @param namespace integer ---- @param bufnr 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 to_severity(d.severity) or M.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 - ---- @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 - 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 - ---- Create a function that converts a diagnostic severity to an extmark priority. ---- @param priority integer Base priority ---- @param opts vim.diagnostic.OptsResolved ---- @return fun(severity: vim.diagnostic.Severity): integer -local function severity_to_extmark_priority(priority, opts) - if opts.severity_sort then - if type(opts.severity_sort) == 'table' and opts.severity_sort.reverse then - return function(severity) - return priority + (severity - vim.diagnostic.severity.ERROR) - end - end - - return function(severity) - return priority + (vim.diagnostic.severity.HINT - severity) - end - end - - return function() - return priority - end -end - ---- @type table -local registered_autocmds = {} - -local function make_augroup_key(namespace, bufnr) - local ns = M.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 - ---- 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 ---- @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, - buf = 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 bufnr integer? ---- @param opts vim.diagnostic.GetOpts? ---- @param clamp boolean ---- @return vim.Diagnostic[] -local function 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 - local buf_line_count = setmetatable({}, { - --- @param t table - --- @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_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 = M.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 _, diagnostic in pairs(diags) do - add(buf, diagnostic) - 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 - ---- @param loclist boolean ---- @param opts vim.diagnostic.setqflist.Opts|vim.diagnostic.setloclist.Opts? -local function set_list(loclist, opts) - opts = opts or {} - local open = if_nil(opts.open, true) - local title = opts.title or 'Diagnostics' - local winnr = opts.winnr or 0 - local bufnr --- @type integer? - if loclist then - bufnr = api.nvim_win_get_buf(winnr) - end - -- Don't clamp line numbers since the quickfix list can already handle line - -- numbers beyond the end of the buffer - local diagnostics = get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], false) - if opts.format then - diagnostics = reformat_diagnostics(opts.format, diagnostics) - end - local items = M.toqflist(diagnostics) - local qf_id = nil - if loclist then - vim.fn.setloclist(winnr, {}, 'u', { title = title, items = items }) - else - qf_id = get_qf_id_for_title(title) - - -- If we already have a diagnostics quickfix, update it rather than creating a new one. - -- This avoids polluting the finite set of quickfix lists, and preserves the currently selected - -- entry. - vim.fn.setqflist({}, qf_id and 'u' or ' ', { - title = title, - items = items, - id = qf_id, - }) - end - - if open then - if not loclist then - -- First navigate to the diagnostics quickfix list. - --- @type integer - local nr = vim.fn.getqflist({ id = qf_id, nr = 0 }).nr - api.nvim_command(('silent %dchistory'):format(nr)) - - -- Now open the quickfix list. - api.nvim_command('botright cwindow') - else - api.nvim_command('lwindow') - end - end -end - ---- @param a vim.Diagnostic ---- @param b vim.Diagnostic ---- @param primary_key string Primary sort key ('severity', 'col', etc) ---- @param reverse boolean Whether to reverse primary comparison ---- @param col_fn (fun(diagnostic: vim.Diagnostic): integer)? Optional function to get column value ---- @return boolean -local function 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 - else - return x < y - end - 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 - ---- Jump to the diagnostic with the highest severity. First sort the ---- diagnostics by severity. The first diagnostic then contains the highest severity, and we can ---- discard all diagnostics with a lower severity. ---- @param diagnostics vim.Diagnostic[] -local function filter_highest(diagnostics) - table.sort(diagnostics, function(a, b) - return 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.JumpOpts1 - - -- 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 = get_diagnostics(bufnr, opts, true) - - if opts._highest then - filter_highest(diagnostics) - end - - local line_diagnostics = diagnostic_lines(diagnostics, use_logical_pos) - - --- @param diagnostic vim.Diagnostic - --- @return integer - local function col_fn(diagnostic) - return use_logical_pos and select(2, 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] - --- @type function, function - local sort_diagnostics, is_next - if search_forward then - sort_diagnostics = function(a, b) - return diagnostic_cmp(a, b, 'col', false, col_fn) - end - is_next = function(d) - return math.min(col_fn(d), math.max(line_length - 1, 0)) > position[2] - end - else - sort_diagnostics = function(a, b) - return diagnostic_cmp(a, b, 'col', true, col_fn) - end - is_next = function(d) - return math.min(col_fn(d), math.max(line_length - 1, 0)) < position[2] - end - end - table.sort(line_diagnostics[lnum], sort_diagnostics) - if i == 0 then - for _, v in - pairs(line_diagnostics[lnum] --[[@as table]]) - do - if is_next(v) then - return v - end - end - else - return line_diagnostics[lnum][1] - end - end - end -end - ---- Move the cursor to the given diagnostic. ---- ---- @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.JumpOpts1 - - -- 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 = 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) - M.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 - --- Configure diagnostic options globally or for a specific diagnostic --- namespace. --- @@ -1286,80 +439,7 @@ end --- 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 = M.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) - M.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]]) - do - t[k] = v - end - - if namespace then - for bufnr, v in pairs(diagnostic_cache) do - if v[namespace] then - M.show(namespace, bufnr) - end - end - else - for bufnr, v in pairs(diagnostic_cache) do - for ns in pairs(v) do - M.show(ns, bufnr) - end - end - end -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 - api.nvim_create_autocmd('BufRead', { - buf = bufnr, - once = true, - callback = function() - fn() - end, - }) - end + return M._config.config(opts, namespace) end --- Set diagnostics for the given namespace and buffer. @@ -1369,93 +449,13 @@ end ---@param diagnostics vim.Diagnostic.Set[] ---@param opts? vim.diagnostic.Opts Display options to pass to |vim.diagnostic.show()| function M.set(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) - - for _, diagnostic in ipairs(diagnostics) do - norm_diag(bufnr, namespace, diagnostic) - 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 = M.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 _, diagnostic in ipairs(diagnostics) do - local last_row = #lines - 1 - local row = math.max(0, math.min(diagnostic.lnum, last_row)) - local row_len = #lines[row + 1] - local col = math.max(0, math.min(diagnostic.col, row_len - 1)) - - local end_row = math.max(0, math.min(diagnostic.end_lnum or row, last_row)) - local end_row_len = #lines[end_row + 1] - local end_col = math.max(0, math.min(diagnostic.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: ' - .. diagnostic.lnum - .. ', col: ' - .. diagnostic.col - .. ', end_lnum: ' - .. diagnostic.end_lnum - .. ', end_col: ' - .. diagnostic.end_col - ) - end - - end_col = #end_line - end - end - - diagnostic._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) - + M._store.set(namespace, bufnr, diagnostics) M.show(namespace, bufnr, nil, opts) api.nvim_exec_autocmds('DiagnosticChanged', { modeline = false, - buf = bufnr, + buffer = vim._resolve_bufnr(bufnr), -- TODO(lewis6991): should this be deepcopy()'d like they are in vim.diagnostic.get() data = { diagnostics = diagnostics }, }) @@ -1467,6 +467,7 @@ end ---@return vim.diagnostic.NS : Namespace metadata function M.get_namespace(namespace) vim.validate('namespace', namespace, 'number') + if not all_namespaces[namespace] then local name --- @type string? for k, v in pairs(api.nvim_get_namespaces()) do @@ -1484,6 +485,7 @@ function M.get_namespace(namespace) user_data = {}, } end + return all_namespaces[namespace] end @@ -1505,10 +507,7 @@ end ---@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(get_diagnostics(bufnr, opts, false), true) + return M._store.get(bufnr, opts) end --- Get current diagnostics count. @@ -1519,15 +518,7 @@ end ---@return table : 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 = get_diagnostics(bufnr, opts, false) - local count = {} --- @type table - for _, d in ipairs(diagnostics) do - count[d.severity] = (count[d.severity] or 0) + 1 - end - return count + return M._store.count(bufnr, opts) end --- Get the previous diagnostic closest to the cursor position. @@ -1535,7 +526,7 @@ end ---@param opts? vim.diagnostic.JumpOpts ---@return vim.Diagnostic? : Previous diagnostic function M.get_prev(opts) - return next_diagnostic(false, opts, false) + return M._jump.get_prev(opts) end --- Return the position of the previous diagnostic in the current buffer. @@ -1545,29 +536,14 @@ end --- or `false` if there is no prior diagnostic. ---@deprecated 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 } + return M._jump.get_prev_pos(opts) end --- Move to the previous diagnostic in the current buffer. ---@param opts? vim.diagnostic.JumpOpts ---@deprecated 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) + return M._jump.goto_prev(opts) end --- Get the next diagnostic closest to the cursor position. @@ -1575,7 +551,7 @@ end ---@param opts? vim.diagnostic.JumpOpts ---@return vim.Diagnostic? : Next diagnostic function M.get_next(opts) - return next_diagnostic(true, opts, false) + return M._jump.get_next(opts) end --- Return the position of the next diagnostic in the current buffer. @@ -1585,17 +561,7 @@ end --- diagnostic. ---@deprecated 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 } + return M._jump.get_next_pos(opts) end --- A table with the following keys: @@ -1662,56 +628,7 @@ end --- @param opts vim.diagnostic.JumpOpts --- @return vim.Diagnostic? # The diagnostic that was moved to. 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() - opts = vim.tbl_deep_extend('keep', opts, global_diagnostic_options.jump) - --- @cast opts vim.diagnostic.JumpOpts1 - - 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 diag = nil - 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 - diag = next - end - - goto_diagnostic(diag, opts) - - return diag + return M._jump.jump(opts) end --- Move to the next diagnostic. @@ -1719,577 +636,45 @@ end ---@param opts? vim.diagnostic.JumpOpts ---@deprecated 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 - ----@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) + return M._jump.goto_next(opts) end M.handlers.signs = { - show = function(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) - opts = opts or {} - - local ns = M.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 = opts.signs and opts.signs.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 - for k in pairs(M.severity) do - if opts.signs.text and opts.signs.text[k] then - text[k] = opts.signs.text[k] - elseif type(k) == 'string' and not text[k] then - text[k] = k:sub(1, 1):upper() - end - end - - local numhl = opts.signs.numhl or {} - local linehl = opts.signs.linehl or {} - - local line_count = api.nvim_buf_line_count(bufnr) - - for _, diagnostic in ipairs(diagnostics) do - if diagnostic.lnum <= line_count then - api.nvim_buf_set_extmark(bufnr, ns.user_data.sign_ns, diagnostic.lnum, 0, { - sign_text = text[diagnostic.severity] or text[M.severity[diagnostic.severity]] or 'U', - sign_hl_group = sign_highlight_map[diagnostic.severity], - number_hl_group = numhl[diagnostic.severity], - line_hl_group = linehl[diagnostic.severity], - priority = get_priority(diagnostic.severity), - }) - end - end - end) + show = function(...) + return M._handlers.signs.show(...) end, - - --- @param namespace integer - --- @param bufnr integer hide = function(namespace, bufnr) - local ns = M.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 + return M._handlers.signs.hide(namespace, bufnr) end, } M.handlers.underline = { - show = function(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) - opts = opts or {} - - local ns = M.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 _, diagnostic in ipairs(diagnostics) do - local higroups = { underline_highlight_map[diagnostic.severity] } - - if diagnostic._tags then - if diagnostic._tags.unnecessary then - table.insert(higroups, 'DiagnosticUnnecessary') - end - if diagnostic._tags.deprecated then - table.insert(higroups, 'DiagnosticDeprecated') - end - end - - local lines = - api.nvim_buf_get_lines(diagnostic.bufnr, diagnostic.lnum, diagnostic.lnum + 1, true) - - for _, higroup in ipairs(higroups) do - vim.hl.range( - bufnr, - underline_ns, - higroup, - { diagnostic.lnum, math.min(diagnostic.col, #lines[1] - 1) }, - { diagnostic.end_lnum, diagnostic.end_col }, - { priority = get_priority(diagnostic.severity) } - ) - end - end - save_extmarks(underline_ns, bufnr) - end) + show = function(...) + return M._handlers.underline.show(...) end, hide = function(namespace, bufnr) - local ns = M.get_namespace(namespace) - cleanup_show_autocmd('underline_show_autocmd', ns) - if ns.user_data.underline_ns then - diagnostic_cache_extmarks[bufnr][ns.user_data.underline_ns] = {} - if api.nvim_buf_is_valid(bufnr) then - api.nvim_buf_clear_namespace(bufnr, ns.user_data.underline_ns, 0, -1) - end - end + return M._handlers.underline.hide(namespace, bufnr) end, } ---- @param namespace integer ---- @param bufnr integer ---- @param diagnostics table ---- @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) - - 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 = M._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.handlers.virtual_text = { - show = function(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) - opts = opts or {} - - local ns = M.get_namespace(namespace) - show_once_loaded('virtual_text_show_autocmd', ns, bufnr, function() - if opts.virtual_text then - if opts.virtual_text.format then - diagnostics = reformat_diagnostics(opts.virtual_text.format, diagnostics) - end - if - opts.virtual_text.source - and (opts.virtual_text.source ~= 'if_many' or count_sources(bufnr) > 1) - then - diagnostics = prefix_source(diagnostics) - end - 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, buf = bufnr }) - - local line_diagnostics = diagnostic_lines(diagnostics, true) - - if opts.virtual_text.current_line ~= nil then - api.nvim_create_autocmd('CursorMoved', { - buf = bufnr, - group = ns.user_data.virt_text_augroup, - callback = function() - render_virtual_text( - ns.user_data.virt_text_ns, - bufnr, - line_diagnostics, - opts.virtual_text - ) - end, - }) - end - - render_virtual_text(ns.user_data.virt_text_ns, bufnr, line_diagnostics, opts.virtual_text) - - save_extmarks(ns.user_data.virt_text_ns, bufnr) - end) + show = function(...) + return M._handlers.virtual_text.show(...) end, hide = function(namespace, bufnr) - local ns = M.get_namespace(namespace) - cleanup_show_autocmd('virtual_text_show_autocmd', ns) - if ns.user_data.virt_text_ns then - diagnostic_cache_extmarks[bufnr][ns.user_data.virt_text_ns] = {} - if api.nvim_buf_is_valid(bufnr) then - api.nvim_buf_clear_namespace(bufnr, ns.user_data.virt_text_ns, 0, -1) - api.nvim_clear_autocmds({ group = ns.user_data.virt_text_augroup, buf = bufnr }) - end - end + return M._handlers.virtual_text.hide(namespace, bufnr) end, } ---- Some characters (like tabs) take up more than one cell. Additionally, inline ---- virtual text can make the distance between 2 columns larger. ---- A diagnostic aligned under such characters needs to account for that and that ---- many spaces to its left. ---- @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_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 - local line_stacks = {} - local line_anchor = {} ---@type table - 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, - string.rep( - ' ', - -- +1 because indexing starts at 0 in one API but at 1 in the other. - 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 diagnostic = 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 type = stack[j][1] - local data = stack[j][2] - if type == ElementType.Space then - if multi then - ---@cast data string - table.insert(left, { - string.rep(chars.horizontal, data:len()), - virtual_lines_highlight_map[diagnostic.severity], - }) - else - table.insert(left, { data, '' }) - end - elseif 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 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 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[diagnostic.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 diagnostic.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[diagnostic.severity] } }) - - table.insert(virt_lines, vline) - - -- Special-case for continuation lines: - if overlap then - center = { - { chars.vertical, virtual_lines_highlight_map[diagnostic.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 - ---- Default formatter for the virtual_lines handler. ---- @param diagnostic vim.Diagnostic -local function format_virtual_lines(diagnostic) - if diagnostic.code then - return string.format('%s: %s', diagnostic.code, diagnostic.message) - else - return diagnostic.message - end -end - M.handlers.virtual_lines = { - show = function(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) - opts = opts or {} - - local ns = M.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, buf = bufnr }) - - diagnostics = - reformat_diagnostics(opts.virtual_lines.format or format_virtual_lines, diagnostics) - - if opts.virtual_lines.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_lines(diagnostics, true) - api.nvim_create_autocmd('CursorMoved', { - buf = bufnr, - group = ns.user_data.virt_lines_augroup, - callback = function() - render_virtual_lines( - ns.user_data.virt_lines_ns, - bufnr, - 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, - 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) + show = function(...) + return M._handlers.virtual_lines.show(...) end, hide = function(namespace, bufnr) - local ns = M.get_namespace(namespace) - cleanup_show_autocmd('virtual_lines_show_autocmd', ns) - if ns.user_data.virt_lines_ns then - diagnostic_cache_extmarks[bufnr][ns.user_data.virt_lines_ns] = {} - if api.nvim_buf_is_valid(bufnr) then - api.nvim_buf_clear_namespace(bufnr, ns.user_data.virt_lines_ns, 0, -1) - api.nvim_clear_autocmds({ group = ns.user_data.virt_lines_augroup, buf = bufnr }) - end - end + return M._handlers.virtual_lines.hide(namespace, bufnr) end, } ---- Get virtual text chunks to display using |nvim_buf_set_extmark()|. ---- ---- Exported for backward compatibility with ---- vim.lsp.diagnostic.get_virtual_text_chunks_for_line(). When that function is eventually removed, ---- this can be made local. ---- @private ---- @param line_diags table ---- @param opts vim.diagnostic.Opts.VirtualText -function M._get_virt_text_chunks(line_diags, opts) - if #line_diags == 0 then - return nil - 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 - --- Hide currently displayed diagnostics. --- --- This only clears the decorations displayed in the buffer. Diagnostics can @@ -2304,20 +689,7 @@ end ---@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 vim.tbl_keys(diagnostic_cache) - for _, iter_bufnr in ipairs(buffers) do - local namespaces = namespace and { namespace } or vim.tbl_keys(diagnostic_cache[iter_bufnr]) - for _, iter_namespace in ipairs(namespaces) do - for _, handler in pairs(M.handlers) do - if handler.hide then - handler.hide(iter_namespace, iter_bufnr) - end - end - end - end + return M._display.hide(namespace, bufnr) end --- Check whether diagnostics are enabled. @@ -2356,69 +728,7 @@ end --- 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 pairs(diagnostic_cache) do - M.show(namespace, iter_bufnr, nil, opts) - end - else - -- namespace is nil - bufnr = vim._resolve_bufnr(bufnr) - for iter_namespace in pairs(diagnostic_cache[bufnr]) do - M.show(iter_namespace, bufnr, nil, opts) - end - end - return - end - - if not M.is_enabled { bufnr = bufnr or 0, ns_id = namespace } then - return - end - - M.hide(namespace, bufnr) - - diagnostics = diagnostics or get_diagnostics(bufnr, { namespace = namespace }, true) - - if vim.tbl_isempty(diagnostics) then - return - end - - local opts_res = 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_cmp(a, b, 'severity', false) - end) - else - table.sort(diagnostics, function(a, b) - return diagnostic_cmp(a, b, 'severity', true) - end) - end - end - - for handler_name, handler in pairs(M.handlers) do - if handler.show and opts_res[handler_name] then - local filtered = filter_by_severity(opts_res[handler_name].severity, diagnostics) - handler.show(namespace, bufnr, filtered, opts_res) - end - end + return M._display.show(namespace, bufnr, diagnostics, opts) end --- Show diagnostics in a floating window. @@ -2427,263 +737,7 @@ end ---@return integer? float_bufnr ---@return integer? winid function M.open_float(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) - - do - -- 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 o = global_diagnostic_options - local t = type(o.float) == 'table' and o.float - or (type(o.float) == 'function' and o.float(opts.namespace, bufnr) or {}) - local float_opts = vim.tbl_extend('keep', opts, t) - opts = get_resolved_options({ float = float_opts }, nil, bufnr).float - end - - 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 = get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], true) - - if scope == 'line' then - --- @param d vim.Diagnostic - diagnostics = vim.tbl_filter(function(d) - local d_lnum, _, d_end_lnum, d_end_col = get_logical_pos(d) - - 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) - 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 d vim.Diagnostic - diagnostics = vim.tbl_filter(function(d) - local d_lnum, d_col, d_end_lnum, d_end_col = get_logical_pos(d) - - 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) - end - - if vim.tbl_isempty(diagnostics) then - return - end - - local severity_sort = if_nil(opts.severity_sort, global_diagnostic_options.severity_sort) - if severity_sort then - if type(severity_sort) == 'table' and severity_sort.reverse then - table.sort(diagnostics, function(a, b) - return diagnostic_cmp(a, b, 'severity', true) - end) - else - table.sort(diagnostics, function(a, b) - return 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 = reformat_diagnostics(opts.format, diagnostics) - end - - if opts.source and (opts.source ~= 'if_many' or count_sources(bufnr) > 1) then - diagnostics = 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 - - ---@type table - local related_info_locations = {} - for i, diagnostic in ipairs(diagnostics) do - if type(prefix_opt) == 'function' then - --- @cast prefix_opt fun(...): string?, string? - 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 - --- @cast suffix_opt fun(...): string?, string? - 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 + return M._float.open(opts, ...) end --- Remove all diagnostics from the given namespace. @@ -2701,22 +755,92 @@ function M.reset(namespace, bufnr) vim.validate('namespace', namespace, 'number', true) vim.validate('bufnr', bufnr, 'number', true) - local buffers = bufnr and { vim._resolve_bufnr(bufnr) } or vim.tbl_keys(diagnostic_cache) + local buffers = bufnr and { vim._resolve_bufnr(bufnr) } or M._store.get_bufnrs() for _, iter_bufnr in ipairs(buffers) do - local namespaces = namespace and { namespace } or vim.tbl_keys(diagnostic_cache[iter_bufnr]) + local namespaces = namespace and { namespace } or M._store.get_buf_namespaces(iter_bufnr) for _, iter_namespace in ipairs(namespaces) do - diagnostic_cache[iter_bufnr][iter_namespace] = nil + M._store.clear(iter_namespace, iter_bufnr) M.hide(iter_namespace, iter_bufnr) end if api.nvim_buf_is_valid(iter_bufnr) then api.nvim_exec_autocmds('DiagnosticChanged', { modeline = false, - buf = iter_bufnr, + buffer = iter_bufnr, data = { diagnostics = {} }, }) else - diagnostic_cache[iter_bufnr] = nil + M._store.drop_buf(iter_bufnr) + end + end +end + +--- @type table +local errlist_type_map = { + [M.severity.ERROR] = 'E', + [M.severity.WARN] = 'W', + [M.severity.INFO] = 'I', + [M.severity.HINT] = 'N', +} + +--- @param title string +--- @return integer? +local function get_qf_id_for_title(title) + local lastqflist = vim.fn.getqflist({ nr = '$' }) + for i = 1, lastqflist.nr do + local qflist = vim.fn.getqflist({ nr = i, id = 0, title = 0 }) + if qflist.title == title then + return qflist.id + end + end + + return nil +end + +--- @param loclist boolean +--- @param opts? vim.diagnostic.setqflist.Opts|vim.diagnostic.setloclist.Opts +local function set_list(loclist, opts) + opts = opts or {} + local open = vim.F.if_nil(opts.open, true) + local title = opts.title or 'Diagnostics' + local winnr = opts.winnr or 0 + local bufnr --- @type integer? + if loclist then + bufnr = api.nvim_win_get_buf(winnr) + end + + -- Don't clamp line numbers since the quickfix list can already handle line + -- numbers beyond the end of the buffer + local diagnostics = M._store.get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], false) + if opts.format then + diagnostics = require('vim.diagnostic._shared').reformat_diagnostics(opts.format, diagnostics) + end + local items = M.toqflist(diagnostics) + local qf_id = nil + if loclist then + vim.fn.setloclist(winnr, {}, 'u', { title = title, items = items }) + else + qf_id = get_qf_id_for_title(title) + -- If we already have a diagnostics quickfix, update it rather than creating a new one. + -- This avoids polluting the finite set of quickfix lists, and preserves the currently selected + -- entry. + vim.fn.setqflist({}, qf_id and 'u' or ' ', { + title = title, + items = items, + id = qf_id, + }) + end + + if open then + if not loclist then + -- First navigate to the diagnostics quickfix list. + local qflist = vim.fn.getqflist({ id = qf_id, nr = 0 }) --- @type { nr: integer } + local nr = qflist.nr + api.nvim_command(('silent %dchistory'):format(nr)) + -- Now open the quickfix list. + api.nvim_command('botright cwindow') + else + api.nvim_command('lwindow') end end end @@ -2748,7 +872,7 @@ end --- ---@param opts? vim.diagnostic.setqflist.Opts function M.setqflist(opts) - set_list(false, opts) + return set_list(false, opts) end ---Configuration table with the following keys: @@ -2781,7 +905,7 @@ end --- ---@param opts? vim.diagnostic.setloclist.Opts function M.setloclist(opts) - set_list(true, opts) + return set_list(true, opts) end --- Enables or disables diagnostics. @@ -2872,48 +996,9 @@ end --- ERROR. ---@return vim.Diagnostic?: |vim.Diagnostic| structure or `nil` if {pat} fails to match {str}. 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 - severity_map = severity_map or M.severity - - local matches = { str:match(pat) } --- @type any[] - if vim.tbl_isempty(matches) then - return - end - - local diagnostic = {} --- @type table - - 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 M.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 + return M._severity.match(str, pat, groups, severity_map, defaults) end -local errlist_type_map = { - [M.severity.ERROR] = 'E', - [M.severity.WARN] = 'W', - [M.severity.INFO] = 'I', - [M.severity.HINT] = 'N', -} - --- Convert a list of diagnostics to a list of quickfix items that can be --- passed to |setqflist()| or |setloclist()|. --- @@ -2923,31 +1008,32 @@ function M.toqflist(diagnostics) vim.validate('diagnostics', diagnostics, vim.islist, 'a list of diagnostics') local list = {} --- @type table[] - for _, v in ipairs(diagnostics) do - local item = { - bufnr = v.bufnr, - lnum = v.lnum + 1, - col = v.col and (v.col + 1) or nil, - end_lnum = v.end_lnum and (v.end_lnum + 1) or nil, - end_col = v.end_col and (v.end_col + 1) or nil, - text = v.message, - nr = tonumber(v.code), - type = errlist_type_map[v.severity] or 'E', + for _, diagnostic in ipairs(diagnostics) do + list[#list + 1] = { + bufnr = diagnostic.bufnr, + lnum = diagnostic.lnum + 1, + col = diagnostic.col and (diagnostic.col + 1) or nil, + end_lnum = diagnostic.end_lnum and (diagnostic.end_lnum + 1) or nil, + end_col = diagnostic.end_col and (diagnostic.end_col + 1) or nil, + text = diagnostic.message, + nr = tonumber(diagnostic.code), + type = errlist_type_map[diagnostic.severity] or 'E', valid = 1, } - table.insert(list, item) end + table.sort(list, function(a, b) if a.bufnr == b.bufnr then if a.lnum == b.lnum then return a.col < b.col - else - return a.lnum < b.lnum end - else - return a.bufnr < b.bufnr + + return a.lnum < b.lnum end + + return a.bufnr < b.bufnr end) + return list end @@ -2979,14 +1065,15 @@ function M.fromqflist(list, opts) local end_lnum = item.end_lnum > 0 and (item.end_lnum - 1) or lnum local end_col = item.end_col > 0 and (item.end_col - 1) or col local code = item.nr > 0 and item.nr or nil - local severity = item.type ~= '' and M.severity[item.type:upper()] or M.severity.ERROR + local item_type = item.type or '' + --- @type vim.Diagnostic local diag = { bufnr = item.bufnr, lnum = lnum, col = col, end_lnum = end_lnum, end_col = end_col, - severity = severity, + severity = item_type ~= '' and M.severity[item_type:upper()] or M.severity.ERROR, message = item.text, code = code, } @@ -2996,15 +1083,19 @@ function M.fromqflist(list, opts) last_diag.message = last_diag.message .. '\n' .. item.text end end + return diagnostics end -local hl_map = { +--- @type table +local status_hl_map = { [M.severity.ERROR] = 'DiagnosticSignError', [M.severity.WARN] = 'DiagnosticSignWarn', [M.severity.INFO] = 'DiagnosticSignInfo', [M.severity.HINT] = 'DiagnosticSignHint', } + +--- @type table local default_status_signs = { [M.severity.ERROR] = 'E', [M.severity.WARN] = 'W', @@ -3025,26 +1116,28 @@ local default_status_signs = { function M.status(bufnr) vim.validate('bufnr', bufnr, 'number', true) bufnr = bufnr or 0 - local config = assert(M.config()).status or {} + local config = assert(vim.diagnostic.config()).status or {} --- @type vim.diagnostic.Opts.Status vim.validate('config.format', config.format, { 'table', 'function' }, true) + local counts = M.count(bufnr) local format = config.format or default_status_signs - --- @type string - local result_str + local result_str --- @type string if type(format) == 'table' then local signs = vim.tbl_extend('keep', format, default_status_signs) result_str = vim .iter(pairs(counts)) - :map(function(severity, count) - return ('%%#%s#%s:%s'):format(hl_map[severity], signs[severity], count) + :map(function(level, value) + return ('%%#%s#%s:%s'):format(status_hl_map[level], signs[level], value) end) :join(' ') - elseif type(format) == 'function' then + else result_str = format(counts) end + if result_str:len() > 0 then result_str = result_str .. '%##' end + return result_str end diff --git a/runtime/lua/vim/diagnostic/_config.lua b/runtime/lua/vim/diagnostic/_config.lua new file mode 100644 index 0000000000..1b31f050b7 --- /dev/null +++ b/runtime/lua/vim/diagnostic/_config.lua @@ -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 + 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]]) + 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 diff --git a/runtime/lua/vim/diagnostic/_display.lua b/runtime/lua/vim/diagnostic/_display.lua new file mode 100644 index 0000000000..86871398cb --- /dev/null +++ b/runtime/lua/vim/diagnostic/_display.lua @@ -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 + --- @param bufnr integer + --- @return table + __index = function(t, bufnr) + assert(bufnr > 0, 'Invalid buffer number') + t[bufnr] = {} + return t[bufnr] + end, +} + +--- @type table> +local bufs_waiting_to_update = setmetatable({}, bufnr_and_namespace_cacher_mt) + +--- @type table +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 diff --git a/runtime/lua/vim/diagnostic/_float.lua b/runtime/lua/vim/diagnostic/_float.lua new file mode 100644 index 0000000000..3cd1452cd1 --- /dev/null +++ b/runtime/lua/vim/diagnostic/_float.lua @@ -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 +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 + 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 diff --git a/runtime/lua/vim/diagnostic/_handlers.lua b/runtime/lua/vim/diagnostic/_handlers.lua new file mode 100644 index 0000000000..2735d500fa --- /dev/null +++ b/runtime/lua/vim/diagnostic/_handlers.lua @@ -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 +local severity_names = { + [severity.ERROR] = 'Error', + [severity.WARN] = 'Warn', + [severity.INFO] = 'Info', + [severity.HINT] = 'Hint', +} + +--- @param base_name string +--- @return table +local function make_highlight_map(base_name) + local result = {} --- @type table + + 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 + --- @param bufnr integer + --- @return table + __index = function(t, bufnr) + assert(bufnr > 0, 'Invalid buffer number') + t[bufnr] = {} + return t[bufnr] + end, +} + +--- @type table> +local diagnostic_cache_extmarks = setmetatable({}, bufnr_and_namespace_cacher_mt) + +--- @type table +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 + + 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 + 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 +--- @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 +--- @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 + local line_stacks = {} + --- @type table + 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 diff --git a/runtime/lua/vim/diagnostic/_jump.lua b/runtime/lua/vim/diagnostic/_jump.lua new file mode 100644 index 0000000000..e9703493e4 --- /dev/null +++ b/runtime/lua/vim/diagnostic/_jump.lua @@ -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 diff --git a/runtime/lua/vim/diagnostic/_severity.lua b/runtime/lua/vim/diagnostic/_severity.lua new file mode 100644 index 0000000000..3ddb5e7c75 --- /dev/null +++ b/runtime/lua/vim/diagnostic/_severity.lua @@ -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 + 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 + 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 + 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 diff --git a/runtime/lua/vim/diagnostic/_shared.lua b/runtime/lua/vim/diagnostic/_shared.lua new file mode 100644 index 0000000000..177cc61b32 --- /dev/null +++ b/runtime/lua/vim/diagnostic/_shared.lua @@ -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 + 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 +function M.diagnostic_lines(diagnostics, use_logical_pos) + if not diagnostics then + return {} + end + + local diagnostics_by_line = {} --- @type table + 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 +--- @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 diff --git a/runtime/lua/vim/diagnostic/_store.lua b/runtime/lua/vim/diagnostic/_store.lua new file mode 100644 index 0000000000..bc55d72787 --- /dev/null +++ b/runtime/lua/vim/diagnostic/_store.lua @@ -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> + +local group = api.nvim_create_augroup('nvim.diagnostic.buf_wipeout', {}) +setmetatable(diagnostic_cache, { + --- @param t table + --- @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 + local buf_line_count = setmetatable({}, { + --- @param t table + --- @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 : 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 + for _, d in ipairs(diagnostics) do + count[d.severity] = (count[d.severity] or 0) + 1 + end + return count +end + +return M diff --git a/test/functional/lua/diagnostic_spec.lua b/test/functional/lua/diagnostic_spec.lua index 515e25c8c1..959b95638f 100644 --- a/test/functional/lua/diagnostic_spec.lua +++ b/test/functional/lua/diagnostic_spec.lua @@ -3492,7 +3492,7 @@ describe('vim.diagnostic', function() ) eq( - '.../diagnostic.lua:0: prefix: expected string|table|function, got number', + '.../_float.lua:0: prefix: expected string|table|function, got number', pcall_err(exec_lua, [[ vim.diagnostic.open_float({ prefix = 42 }) ]]) ) end) @@ -3549,7 +3549,7 @@ describe('vim.diagnostic', function() ) eq( - '.../diagnostic.lua:0: suffix: expected string|table|function, got number', + '.../_float.lua:0: suffix: expected string|table|function, got number', pcall_err(exec_lua, [[ vim.diagnostic.open_float({ suffix = 42 }) ]]) ) end)