local api = vim.api local nvim_on = require('vim._core.util').nvim_on 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 nvim_on('BufRead', nil, { buf = bufnr, once = true }, 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, buf = bufnr }) local line_diagnostics = diagnostic_shared.diagnostic_lines(diagnostics, true) if vopts.current_line ~= nil then nvim_on('CursorMoved', ns.user_data.virt_text_augroup, { buf = bufnr }, 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, buf = 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, buf = 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) nvim_on('CursorMoved', ns.user_data.virt_lines_augroup, { buf = bufnr }, 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, buf = bufnr }) end end end return M