Files
neovim/runtime/lua/vim/diagnostic/_float.lua
Lewis Russell d039f19af5 build: enable more EmmyLua checks
Enable checks for unused code, deprecated calls, return types, and
annotations.

Fix the warnings and replace the deprecated highlight function. Keep
exceptions where old APIs are still needed or the checker gets it wrong.

AI-assisted
2026-09-09 22:31:58 +01:00

300 lines
10 KiB
Lua

local api, nonnil = vim.api, vim.nonnil
local shared = require('vim.diagnostic._shared')
local store = require('vim.diagnostic._store')
--- @class (private) vim.diagnostic._float
local M = {}
local float_ns = api.nvim_create_namespace('nvim.diagnostic.float')
local severity = vim.diagnostic.severity
--- @type table<vim.diagnostic.Severity, string>
local floating_highlight_map = {
[severity.ERROR] = 'DiagnosticFloatingError',
[severity.WARN] = 'DiagnosticFloatingWarn',
[severity.INFO] = 'DiagnosticFloatingInfo',
[severity.HINT] = 'DiagnosticFloatingHint',
}
--- @param opts vim.diagnostic.Opts.Float
--- @param bufnr integer
--- @return vim.diagnostic.Opts.Float, vim.diagnostic.Opts
local function resolve_float_opts(opts, bufnr)
-- Resolve options with user settings from vim.diagnostic.config
-- Unlike the other decoration functions (e.g. set_virtual_text, set_signs, etc.) `open_float`
-- does not have a dedicated table for configuration options; instead, the options are mixed in
-- with its `opts` table. We create a dedicated options table (`float_opts`) that inherits
-- missing keys from the global configuration (`global_diagnostic_options.float`), which can
-- be a table or a function.
local global_opts = assert(vim.diagnostic.config())
local float_opts = global_opts.float
local resolved_float_opts = type(float_opts) == 'table' and float_opts
or (type(float_opts) == 'function' and float_opts(opts.namespace, bufnr) or {})
return vim.tbl_extend('keep', opts, resolved_float_opts), global_opts
end
--- @param opts vim.diagnostic.Opts.Float?
--- @return integer? float_bufnr
--- @return integer? winid
function M.open(opts, ...)
-- Support old (bufnr, opts) signature
local bufnr --- @type integer?
if opts == nil or type(opts) == 'number' then
bufnr = opts
opts = ... --- @type vim.diagnostic.Opts.Float
else
vim.validate('opts', opts, 'table', true)
end
opts = opts or {}
bufnr = vim._resolve_bufnr(bufnr or opts.bufnr)
local global_opts --- @type vim.diagnostic.Opts
opts, global_opts = resolve_float_opts(opts, bufnr)
local scope = ({ l = 'line', c = 'cursor', b = 'buffer' })[opts.scope] or opts.scope or 'line'
local lnum, col --- @type integer, integer
local opts_pos = opts.pos
if scope == 'line' or scope == 'cursor' then
if not opts_pos then
local pos = api.nvim_win_get_cursor(0)
lnum = pos[1] - 1
col = pos[2]
elseif type(opts_pos) == 'number' then
lnum = opts_pos
elseif type(opts_pos) == 'table' then
lnum, col = opts_pos[1], opts_pos[2]
else
error("Invalid value for option 'pos'")
end
elseif scope ~= 'buffer' then
error("Invalid value for option 'scope'")
end
local diagnostics = store.get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], true)
if scope == 'line' then
--- @param diagnostic vim.Diagnostic
local function line_filter(diagnostic)
local d_lnum, _, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
return lnum >= d_lnum
and lnum <= d_end_lnum
and (d_lnum == d_end_lnum or lnum ~= d_end_lnum or d_end_col ~= 0)
end
diagnostics = vim.tbl_filter(line_filter, diagnostics)
elseif scope == 'cursor' then
-- If `col` is past the end of the line, show if the cursor is on the last char in the line
local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1]
--- @param diagnostic vim.Diagnostic
local function cursor_filter(diagnostic)
local d_lnum, d_col, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
return lnum >= d_lnum
and lnum <= d_end_lnum
and (lnum ~= d_lnum or col >= math.min(d_col, line_length - 1))
and ((d_lnum == d_end_lnum and d_col == d_end_col) or lnum ~= d_end_lnum or col < d_end_col)
end
diagnostics = vim.tbl_filter(cursor_filter, diagnostics)
end
if vim.tbl_isempty(diagnostics) then
return
end
local severity_sort = nonnil(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 = nonnil(opts.header, 'Diagnostics:')
if header then
vim.validate('header', header, { 'string', 'table' }, "'string' or 'table'")
if type(header) == 'table' then
-- Don't insert any lines for an empty string
if #(header[1] or '') > 0 then
lines[#lines + 1] = header[1]
highlights[#highlights + 1] = { hlname = header[2] or 'Bold' }
end
elseif #header > 0 then
lines[#lines + 1] = header
highlights[#highlights + 1] = { hlname = 'Bold' }
end
end
if opts.format then
diagnostics = shared.reformat_diagnostics(opts.format, diagnostics)
end
if opts.source and (opts.source ~= 'if_many' or shared.count_sources(bufnr) > 1) then
diagnostics = shared.prefix_source(diagnostics)
end
local prefix_opt = opts.prefix
or (scope == 'cursor' and #diagnostics <= 1) and ''
or function(_, i)
return string.format('%d. ', i)
end
local prefix, prefix_hl_group --- @type string?, string?
if prefix_opt then
vim.validate(
'prefix',
prefix_opt,
{ 'string', 'table', 'function' },
"'string' or 'table' or 'function'"
)
if type(prefix_opt) == 'string' then
prefix, prefix_hl_group = prefix_opt, 'NormalFloat'
elseif type(prefix_opt) == 'table' then
prefix, prefix_hl_group = prefix_opt[1] or '', prefix_opt[2] or 'NormalFloat'
end
end
local suffix_opt = opts.suffix
or function(diagnostic)
return diagnostic.code and string.format(' [%s]', diagnostic.code) or ''
end
local suffix, suffix_hl_group --- @type string?, string?
if suffix_opt then
vim.validate(
'suffix',
suffix_opt,
{ 'string', 'table', 'function' },
"'string' or 'table' or 'function'"
)
if type(suffix_opt) == 'string' then
suffix, suffix_hl_group = suffix_opt, 'NormalFloat'
elseif type(suffix_opt) == 'table' then
suffix, suffix_hl_group = suffix_opt[1] or '', suffix_opt[2] or 'NormalFloat'
end
end
local related_info_locations = {} --- @type table<integer, lsp.Location>
for i, diagnostic in ipairs(diagnostics) do
if type(prefix_opt) == 'function' then
local prefix0, prefix_hl_group0 = prefix_opt(diagnostic, i, #diagnostics)
prefix, prefix_hl_group = prefix0 or '', prefix_hl_group0 or 'NormalFloat'
end
if type(suffix_opt) == 'function' then
local suffix0, suffix_hl_group0 = suffix_opt(diagnostic, i, #diagnostics)
suffix, suffix_hl_group = suffix0 or '', suffix_hl_group0 or 'NormalFloat'
end
local hiname = floating_highlight_map[diagnostic.severity]
local message_lines = vim.split(diagnostic.message, '\n')
local default_pre = string.rep(' ', #prefix)
for j = 1, #message_lines do
local pre = j == 1 and prefix or default_pre
local suf = j == #message_lines and suffix or ''
lines[#lines + 1] = pre .. message_lines[j] .. suf
highlights[#highlights + 1] = {
hlname = hiname,
prefix = {
length = j == 1 and #prefix or 0,
hlname = prefix_hl_group,
},
suffix = {
length = #suf,
hlname = suffix_hl_group,
},
}
end
--- @type lsp.DiagnosticRelatedInformation[]
local related_info = vim.tbl_get(diagnostic, 'user_data', 'lsp', 'relatedInformation') or {}
assert(related_info ~= vim.NIL, 'server response has invalid (null) relatedInformation')
-- 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 })
-- The preview may trim empty lines or reuse an older buffer, so highlight ranges
-- can extend past its contents. Use strict = false to tolerate this.
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
api.nvim_buf_set_extmark(float_bufnr, float_ns, i - 1, 0, {
hl_group = hl.prefix.hlname,
end_col = prefix_len,
strict = false,
})
end
api.nvim_buf_set_extmark(float_bufnr, float_ns, i - 1, prefix_len, {
hl_group = hl.hlname,
end_col = #line - suffix_len,
strict = false,
})
if suffix_len > 0 then
api.nvim_buf_set_extmark(float_bufnr, float_ns, i - 1, #line - suffix_len, {
hl_group = hl.suffix.hlname,
end_row = i,
end_col = 0,
strict = false,
})
end
end
return float_bufnr, winnr
end
return M