diff options
-rw-r--r-- | runtime/doc/diagnostic.txt | 26 | ||||
-rw-r--r-- | runtime/doc/news.txt | 2 | ||||
-rw-r--r-- | runtime/lua/vim/diagnostic.lua | 276 | ||||
-rw-r--r-- | src/nvim/highlight_group.c | 5 | ||||
-rw-r--r-- | test/functional/lua/diagnostic_spec.lua | 109 | ||||
-rw-r--r-- | test/functional/ui/cmdline_spec.lua | 2 | ||||
-rw-r--r-- | test/functional/ui/messages_spec.lua | 4 |
7 files changed, 417 insertions, 7 deletions
diff --git a/runtime/doc/diagnostic.txt b/runtime/doc/diagnostic.txt index 6b1456d5a6..80197670ee 100644 --- a/runtime/doc/diagnostic.txt +++ b/runtime/doc/diagnostic.txt @@ -97,8 +97,8 @@ If a diagnostic handler is configured with a "severity" key then the list of diagnostics passed to that handler will be filtered using the value of that key (see example below). -Nvim provides these handlers by default: "virtual_text", "signs", and -"underline". +Nvim provides these handlers by default: "virtual_text", "virtual_lines", +"signs", and "underline". *diagnostic-handlers-example* The example below creates a new handler that notifies the user of diagnostics @@ -170,6 +170,16 @@ show a sign for the highest severity diagnostic on a given line: >lua } < + *diagnostic-toggle-virtual-lines-example* +Diagnostic handlers can also be toggled. For example, you might want to toggle +the `virtual_lines` handler with the following keymap: >lua + + vim.keymap.set('n', 'gK', function() + local new_config = not vim.diagnostic.config().virtual_lines + vim.diagnostic.config({ virtual_lines = new_config }) + end, { desc = 'Toggle diagnostic virtual_lines' }) +< + *diagnostic-loclist-example* Whenever the |location-list| is opened, the following `show` handler will show the most recent diagnostics: >lua @@ -469,6 +479,8 @@ Lua module: vim.diagnostic *diagnostic-api* diagnostics are set for a namespace, one prefix per diagnostic + the last diagnostic message are shown. + • {virtual_lines}? (`boolean|vim.diagnostic.Opts.VirtualLines|fun(namespace: integer, bufnr:integer): vim.diagnostic.Opts.VirtualLines`, default: `false`) + Use virtual lines for diagnostics. • {signs}? (`boolean|vim.diagnostic.Opts.Signs|fun(namespace: integer, bufnr:integer): vim.diagnostic.Opts.Signs`, default: `true`) Use signs for diagnostics |diagnostic-signs|. • {float}? (`boolean|vim.diagnostic.Opts.Float|fun(namespace: integer, bufnr:integer): vim.diagnostic.Opts.Float`) @@ -590,6 +602,16 @@ Lua module: vim.diagnostic *diagnostic-api* diagnostics matching the given severity |diagnostic-severity|. +*vim.diagnostic.Opts.VirtualLines* + + Fields: ~ + • {current_line}? (`boolean`, default: `false`) Only show diagnostics + for the current line. + • {format}? (`fun(diagnostic:vim.Diagnostic): string`) A function + that takes a diagnostic as input and returns a + string. The return value is the text used to display + the diagnostic. + *vim.diagnostic.Opts.VirtualText* Fields: ~ diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 1d53f168ff..adad08ddf2 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -235,6 +235,8 @@ DIAGNOSTICS • |vim.diagnostic.config()| accepts a "jump" table to specify defaults for |vim.diagnostic.jump()|. +• A "virtual_lines" diagnostic handler was added to render diagnostics using + virtual lines below the respective code. EDITOR diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index 04118999cf..2d86fbe38c 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -73,6 +73,10 @@ end --- (default: `false`) --- @field virtual_text? boolean|vim.diagnostic.Opts.VirtualText|fun(namespace: integer, bufnr:integer): vim.diagnostic.Opts.VirtualText --- +--- Use virtual lines for diagnostics. +--- (default: `false`) +--- @field virtual_lines? boolean|vim.diagnostic.Opts.VirtualLines|fun(namespace: integer, bufnr:integer): vim.diagnostic.Opts.VirtualLines +--- --- Use signs for diagnostics |diagnostic-signs|. --- (default: `true`) --- @field signs? boolean|vim.diagnostic.Opts.Signs|fun(namespace: integer, bufnr:integer): vim.diagnostic.Opts.Signs @@ -101,6 +105,7 @@ end --- @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} @@ -228,6 +233,16 @@ end --- See |nvim_buf_set_extmark()|. --- @field virt_text_hide? boolean +--- @class vim.diagnostic.Opts.VirtualLines +--- +--- Only show diagnostics for the current line. +--- (default: `false`) +--- @field current_line? boolean +--- +--- A function that takes a diagnostic as input and returns a string. +--- The return value is the text used to display the diagnostic. +--- @field format? fun(diagnostic:vim.Diagnostic): string + --- @class vim.diagnostic.Opts.Signs --- --- Only show virtual text for diagnostics matching the given @@ -313,6 +328,7 @@ local global_diagnostic_options = { signs = true, underline = true, virtual_text = false, + virtual_lines = false, float = true, update_in_insert = false, severity_sort = false, @@ -328,6 +344,7 @@ local global_diagnostic_options = { --- @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) +--- @field _augroup? integer --- @nodoc --- @type table<string,vim.diagnostic.Handler> @@ -581,6 +598,7 @@ end -- TODO(lewis6991): these highlight maps can only be indexed with an integer, however there usage -- implies they can be indexed with any vim.diagnostic.Severity 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') @@ -1603,6 +1621,264 @@ M.handlers.virtual_text = { 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) + if d1.lnum == d2.lnum then + return d1.col < d2.col + else + return d1.lnum < d2.lnum + end + 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 + local line_stacks = {} ---@type table<integer, {[1]:ElementType, [2]:string|vim.diagnostic.Severity|vim.Diagnostic}[]> + 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] + + 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. + -- -1 for non-first lines, since the previous column was already drawn. + distance_between_cols(bufnr, diag.lnum, prev_col + 1, diag.col) - 1 + ), + }) + 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 {[1]:string, [2]: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. + local msg ---@type string + if diagnostic.code then + msg = string.format('%s: %s', diagnostic.code, diagnostic.message) + else + msg = diagnostic.message + end + for msg_line in msg: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, lnum, 0, { virt_lines = virt_lines }) + end +end + +--- @param diagnostics vim.Diagnostic[] +--- @param namespace integer +--- @param bufnr integer +local function render_virtual_lines_at_current_line(diagnostics, namespace, bufnr) + local line_diagnostics = {} + local lnum = api.nvim_win_get_cursor(0)[1] - 1 + + for _, diag in ipairs(diagnostics) do + if (lnum == diag.lnum) or (diag.end_lnum and lnum >= diag.lnum and lnum <= diag.end_lnum) then + table.insert(line_diagnostics, diag) + end + end + + render_virtual_lines(namespace, bufnr, line_diagnostics) +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 {} + + if not api.nvim_buf_is_loaded(bufnr) then + return + end + + local ns = M.get_namespace(namespace) + 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 M.handlers.virtual_lines._augroup then + M.handlers.virtual_lines._augroup = + api.nvim_create_augroup('nvim.lsp.diagnostic.virt_lines', { clear = true }) + end + + api.nvim_clear_autocmds({ group = M.handlers.virtual_lines._augroup }) + + if opts.virtual_lines.format then + diagnostics = reformat_diagnostics(opts.virtual_lines.format, diagnostics) + end + + if opts.virtual_lines.current_line == true then + api.nvim_create_autocmd('CursorMoved', { + buffer = bufnr, + group = M.handlers.virtual_lines._augroup, + callback = function() + render_virtual_lines_at_current_line(diagnostics, ns.user_data.virt_lines_ns, bufnr) + end, + }) + -- Also show diagnostics for the current line before the first CursorMoved event. + render_virtual_lines_at_current_line(diagnostics, ns.user_data.virt_lines_ns, bufnr) + else + render_virtual_lines(ns.user_data.virt_lines_ns, bufnr, diagnostics) + end + + save_extmarks(ns.user_data.virt_lines_ns, bufnr) + end, + hide = function(namespace, bufnr) + local ns = M.get_namespace(namespace) + 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) + end + api.nvim_clear_autocmds({ group = M.handlers.virtual_lines._augroup }) + end + end, +} + --- Get virtual text chunks to display using |nvim_buf_set_extmark()|. --- --- Exported for backward compatibility with diff --git a/src/nvim/highlight_group.c b/src/nvim/highlight_group.c index ad4b2732f6..f3c0d66bb8 100644 --- a/src/nvim/highlight_group.c +++ b/src/nvim/highlight_group.c @@ -232,6 +232,11 @@ static const char *highlight_init_both[] = { "default link DiagnosticVirtualTextInfo DiagnosticInfo", "default link DiagnosticVirtualTextHint DiagnosticHint", "default link DiagnosticVirtualTextOk DiagnosticOk", + "default link DiagnosticVirtualLinesError DiagnosticError", + "default link DiagnosticVirtualLinesWarn DiagnosticWarn", + "default link DiagnosticVirtualLinesInfo DiagnosticInfo", + "default link DiagnosticVirtualLinesHint DiagnosticHint", + "default link DiagnosticVirtualLinesOk DiagnosticOk", "default link DiagnosticSignError DiagnosticError", "default link DiagnosticSignWarn DiagnosticWarn", "default link DiagnosticSignInfo DiagnosticInfo", diff --git a/test/functional/lua/diagnostic_spec.lua b/test/functional/lua/diagnostic_spec.lua index 80f4307d5b..08c287735e 100644 --- a/test/functional/lua/diagnostic_spec.lua +++ b/test/functional/lua/diagnostic_spec.lua @@ -113,6 +113,18 @@ describe('vim.diagnostic', function() ) end + function _G.get_virt_lines_extmarks(ns) + ns = vim.diagnostic.get_namespace(ns) + local virt_lines_ns = ns.user_data.virt_lines_ns + return vim.api.nvim_buf_get_extmarks( + _G.diagnostic_bufnr, + virt_lines_ns, + 0, + -1, + { details = true } + ) + end + ---@param ns integer function _G.get_underline_extmarks(ns) ---@type integer @@ -161,6 +173,11 @@ describe('vim.diagnostic', function() 'DiagnosticUnderlineOk', 'DiagnosticUnderlineWarn', 'DiagnosticUnnecessary', + 'DiagnosticVirtualLinesError', + 'DiagnosticVirtualLinesHint', + 'DiagnosticVirtualLinesInfo', + 'DiagnosticVirtualLinesOk', + 'DiagnosticVirtualLinesWarn', 'DiagnosticVirtualTextError', 'DiagnosticVirtualTextHint', 'DiagnosticVirtualTextInfo', @@ -582,7 +599,7 @@ describe('vim.diagnostic', function() vim.diagnostic.set( _G.diagnostic_ns, _G.diagnostic_bufnr, - { { lnum = 0, end_lnum = 0, col = 0, end_col = 0 } } + { { message = '', lnum = 0, end_lnum = 0, col = 0, end_col = 0 } } ) vim.cmd('bwipeout! ' .. _G.diagnostic_bufnr) @@ -1017,7 +1034,7 @@ describe('vim.diagnostic', function() vim.diagnostic.set( _G.diagnostic_ns, _G.diagnostic_bufnr, - { { lnum = 0, end_lnum = 0, col = 0, end_col = 0 } } + { { message = '', lnum = 0, end_lnum = 0, col = 0, end_col = 0 } } ) vim.cmd('bwipeout! ' .. _G.diagnostic_bufnr) @@ -2119,6 +2136,94 @@ describe('vim.diagnostic', function() end) end) + describe('handlers.virtual_lines', function() + it('includes diagnostic code and message', function() + local result = exec_lua(function() + vim.diagnostic.config({ virtual_lines = true }) + + vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, { + _G.make_error('Missed symbol `,`', 0, 0, 0, 0, 'lua_ls', 'miss-symbol'), + }) + + local extmarks = _G.get_virt_lines_extmarks(_G.diagnostic_ns) + return extmarks[1][4].virt_lines + end) + + eq('miss-symbol: Missed symbol `,`', result[1][3][1]) + end) + + it('adds space to the left of the diagnostic', function() + local error_offset = 5 + local result = exec_lua(function() + vim.diagnostic.config({ virtual_lines = true }) + + vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, { + _G.make_error('Error here!', 0, error_offset, 0, error_offset, 'foo_server'), + }) + + local extmarks = _G.get_virt_lines_extmarks(_G.diagnostic_ns) + return extmarks[1][4].virt_lines + end) + + eq(error_offset, result[1][1][1]:len()) + end) + + it('highlights diagnostics in multiple lines by default', function() + local result = exec_lua(function() + vim.diagnostic.config({ virtual_lines = true }) + + vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, { + _G.make_error('Error here!', 0, 0, 0, 0, 'foo_server'), + _G.make_error('Another error there!', 1, 0, 1, 0, 'foo_server'), + }) + + local extmarks = _G.get_virt_lines_extmarks(_G.diagnostic_ns) + return extmarks + end) + + eq(2, #result) + eq('Error here!', result[1][4].virt_lines[1][3][1]) + eq('Another error there!', result[2][4].virt_lines[1][3][1]) + end) + + it('can highlight diagnostics only in the current line', function() + local result = exec_lua(function() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + vim.diagnostic.config({ virtual_lines = { current_line = true } }) + + vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, { + _G.make_error('Error here!', 0, 0, 0, 0, 'foo_server'), + _G.make_error('Another error there!', 1, 0, 1, 0, 'foo_server'), + }) + + local extmarks = _G.get_virt_lines_extmarks(_G.diagnostic_ns) + return extmarks + end) + + eq(1, #result) + eq('Error here!', result[1][4].virt_lines[1][3][1]) + end) + + it('supports a format function for diagnostic messages', function() + local result = exec_lua(function() + vim.diagnostic.config({ + virtual_lines = { + format = function() + return 'Error here!' + end, + }, + }) + vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, { + _G.make_error('Invalid syntax', 0, 0, 0, 0), + }) + local extmarks = _G.get_virt_lines_extmarks(_G.diagnostic_ns) + return extmarks[1][4].virt_lines + end) + eq('Error here!', result[1][3][1]) + end) + end) + describe('set()', function() it('validation', function() matches( diff --git a/test/functional/ui/cmdline_spec.lua b/test/functional/ui/cmdline_spec.lua index d6dd62108c..ce7c9596bb 100644 --- a/test/functional/ui/cmdline_spec.lua +++ b/test/functional/ui/cmdline_spec.lua @@ -858,7 +858,7 @@ local function test_cmdline(linegrid) cmdline = { { content = { { '' } }, - hl_id = 237, + hl_id = 242, pos = 0, prompt = 'Prompt:', }, diff --git a/test/functional/ui/messages_spec.lua b/test/functional/ui/messages_spec.lua index 4038e596fa..c996d117f2 100644 --- a/test/functional/ui/messages_spec.lua +++ b/test/functional/ui/messages_spec.lua @@ -254,11 +254,11 @@ describe('ui/ext_messages', function() { content = { { '\n@character ' }, - { 'xxx', 26, 150 }, + { 'xxx', 26, 155 }, { ' ' }, { 'links to', 18, 5 }, { ' Character\n@character.special ' }, - { 'xxx', 16, 151 }, + { 'xxx', 16, 156 }, { ' ' }, { 'links to', 18, 5 }, { ' SpecialChar' }, |