fix(diagnostics): position diagnostics using extmarks #34014

Problem:
Diagnostic positions are not being updated after text changes, which
means `vim.diagnostic.open_float` and `vim.diagnostic.jump` will work
with outdated positions when text is changed until diagnostics are
updated again (if ever).

Solution:
Create extmarks in `vim.diagnostic.set` and use their positions for
`vim.diagnostic.open_float` and `next_diagnostic` (used by
`vim.diagnostic.jump`, `vim.diagnostic.get_next` and
`vim.diagnostic.get_prev`).
This commit is contained in:
Sergei Slipchenko
2025-07-25 18:56:50 +04:00
committed by GitHub
parent e4a100a1e1
commit 0a113013fb
2 changed files with 264 additions and 18 deletions

View File

@@ -64,6 +64,7 @@ end
--- @field end_col integer The final column of the diagnostic (0-indexed) --- @field end_col integer The final column of the diagnostic (0-indexed)
--- @field severity vim.diagnostic.Severity The severity of the diagnostic |vim.diagnostic.severity| --- @field severity vim.diagnostic.Severity The severity of the diagnostic |vim.diagnostic.severity|
--- @field namespace? integer --- @field namespace? integer
--- @field _extmark_id? integer
--- Many of the configuration options below accept one of the following: --- Many of the configuration options below accept one of the following:
--- - `false`: Disable this feature --- - `false`: Disable this feature
@@ -642,6 +643,23 @@ local underline_highlight_map = make_highlight_map('Underline')
local floating_highlight_map = make_highlight_map('Floating') local floating_highlight_map = make_highlight_map('Floating')
local sign_highlight_map = make_highlight_map('Sign') local sign_highlight_map = make_highlight_map('Sign')
--- @param diagnostic vim.Diagnostic
--- @return integer lnum
--- @return integer col
--- @return integer end_lnum
--- @return integer end_col
local function get_logical_pos(diagnostic)
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 }
)
return extmark[1], extmark[2], extmark[3].end_row, extmark[3].end_col
end
--- @param diagnostics vim.Diagnostic[] --- @param diagnostics vim.Diagnostic[]
--- @return table<integer,vim.Diagnostic[]> --- @return table<integer,vim.Diagnostic[]>
local function diagnostic_lines(diagnostics) local function diagnostic_lines(diagnostics)
@@ -651,10 +669,11 @@ local function diagnostic_lines(diagnostics)
local diagnostics_by_line = {} --- @type table<integer,vim.Diagnostic[]> local diagnostics_by_line = {} --- @type table<integer,vim.Diagnostic[]>
for _, diagnostic in ipairs(diagnostics) do for _, diagnostic in ipairs(diagnostics) do
local line_diagnostics = diagnostics_by_line[diagnostic.lnum] local lnum = get_logical_pos(diagnostic)
local line_diagnostics = diagnostics_by_line[lnum]
if not line_diagnostics then if not line_diagnostics then
line_diagnostics = {} line_diagnostics = {}
diagnostics_by_line[diagnostic.lnum] = line_diagnostics diagnostics_by_line[lnum] = line_diagnostics
end end
table.insert(line_diagnostics, diagnostic) table.insert(line_diagnostics, diagnostic)
end end
@@ -1038,6 +1057,12 @@ local function next_diagnostic(search_forward, opts)
local line_diagnostics = diagnostic_lines(diagnostics) local line_diagnostics = diagnostic_lines(diagnostics)
--- @param diagnostic vim.Diagnostic
--- @return integer
local function col(diagnostic)
return select(2, get_logical_pos(diagnostic))
end
local line_count = api.nvim_buf_line_count(bufnr) local line_count = api.nvim_buf_line_count(bufnr)
for i = 0, line_count do for i = 0, line_count do
local offset = i * (search_forward and 1 or -1) local offset = i * (search_forward and 1 or -1)
@@ -1054,17 +1079,17 @@ local function next_diagnostic(search_forward, opts)
local sort_diagnostics, is_next local sort_diagnostics, is_next
if search_forward then if search_forward then
sort_diagnostics = function(a, b) sort_diagnostics = function(a, b)
return a.col < b.col return col(a) < col(b)
end end
is_next = function(d) is_next = function(d)
return math.min(d.col, math.max(line_length - 1, 0)) > position[2] return math.min(col(d), math.max(line_length - 1, 0)) > position[2]
end end
else else
sort_diagnostics = function(a, b) sort_diagnostics = function(a, b)
return a.col > b.col return col(a) > col(b)
end end
is_next = function(d) is_next = function(d)
return math.min(d.col, math.max(line_length - 1, 0)) < position[2] return math.min(col(d), math.max(line_length - 1, 0)) < position[2]
end end
end end
table.sort(line_diagnostics[lnum], sort_diagnostics) table.sort(line_diagnostics[lnum], sort_diagnostics)
@@ -1105,10 +1130,12 @@ local function goto_diagnostic(diagnostic, opts)
local winid = opts.winid or api.nvim_get_current_win() local winid = opts.winid or api.nvim_get_current_win()
local lnum, col = get_logical_pos(diagnostic)
vim._with({ win = winid }, function() vim._with({ win = winid }, function()
-- Save position in the window's jumplist -- Save position in the window's jumplist
vim.cmd("normal! m'") vim.cmd("normal! m'")
api.nvim_win_set_cursor(winid, { diagnostic.lnum + 1, diagnostic.col }) api.nvim_win_set_cursor(winid, { lnum + 1, col })
-- Open folds under the cursor -- Open folds under the cursor
vim.cmd('normal! zv') vim.cmd('normal! zv')
end) end)
@@ -1221,6 +1248,24 @@ function M.config(opts, namespace)
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()
local function once_buf_loaded(bufnr, fn)
if api.nvim_buf_is_loaded(bufnr) then
fn()
else
api.nvim_create_autocmd('BufRead', {
buffer = bufnr,
once = true,
callback = function()
fn()
end,
})
end
end
--- Set diagnostics for the given namespace and buffer. --- Set diagnostics for the given namespace and buffer.
--- ---
---@param namespace integer The diagnostic namespace ---@param namespace integer The diagnostic namespace
@@ -1247,6 +1292,50 @@ function M.set(namespace, bufnr, diagnostics, opts)
diagnostic_cache[bufnr][namespace] = diagnostics diagnostic_cache[bufnr][namespace] = diagnostics
end 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
end_col = #lines[end_row + 1]
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,
})
end
end)
M.show(namespace, bufnr, nil, opts) M.show(namespace, bufnr, nil, opts)
api.nvim_exec_autocmds('DiagnosticChanged', { api.nvim_exec_autocmds('DiagnosticChanged', {
@@ -2242,19 +2331,23 @@ function M.open_float(opts, ...)
if scope == 'line' then if scope == 'line' then
--- @param d vim.Diagnostic --- @param d vim.Diagnostic
diagnostics = vim.tbl_filter(function(d) diagnostics = vim.tbl_filter(function(d)
return lnum >= d.lnum local d_lnum, _, d_end_lnum, d_end_col = get_logical_pos(d)
and lnum <= d.end_lnum
and (d.lnum == d.end_lnum or lnum ~= d.end_lnum or d.end_col ~= 0) 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) end, diagnostics)
elseif scope == 'cursor' then 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 -- 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] local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1]
--- @param d vim.Diagnostic --- @param d vim.Diagnostic
diagnostics = vim.tbl_filter(function(d) diagnostics = vim.tbl_filter(function(d)
return lnum >= d.lnum local d_lnum, d_col, d_end_lnum, d_end_col = get_logical_pos(d)
and lnum <= d.end_lnum
and (lnum ~= d.lnum or col >= math.min(d.col, line_length - 1)) return lnum >= d_lnum
and ((d.lnum == d.end_lnum and d.col == d.end_col) or lnum ~= d.end_lnum or col < d.end_col) 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, diagnostics)
end end

View File

@@ -1430,6 +1430,77 @@ describe('vim.diagnostic', function()
eq(true, exec_lua('return _G.jumped')) eq(true, exec_lua('return _G.jumped'))
end) end)
end) end)
describe('after inserting text before diagnostic position', function()
before_each(function()
exec_lua(function()
vim.api.nvim_set_current_buf(_G.diagnostic_bufnr)
vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, {
_G.make_error('Diagnostic #1', 1, 4, 1, 7),
_G.make_error('Diagnostic #2', 3, 0, 3, 3),
})
end)
api.nvim_buf_set_text(0, 3, 0, 3, 0, { 'new line', 'new ' })
end)
it('finds next diagnostic at a logical location', function()
eq(
{ 5, 4 },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 2, 4 })
vim.diagnostic.jump({ count = 1 })
return vim.api.nvim_win_get_cursor(0)
end)
)
end)
it('finds previous diagnostic at a logical location', function()
eq(
{ 5, 4 },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 6, 4 })
vim.diagnostic.jump({ count = -1 })
return vim.api.nvim_win_get_cursor(0)
end)
)
end)
end)
describe('if diagnostic is set after last character in line', function()
before_each(function()
exec_lua(function()
vim.api.nvim_set_current_buf(_G.diagnostic_bufnr)
vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, {
_G.make_error('Diagnostic #1', 2, 3, 3, 4),
})
end)
end)
it('finds next diagnostic at the end of the line', function()
eq(
{ 3, 2 },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 3, 0 })
vim.diagnostic.jump({ count = 1 })
return vim.api.nvim_win_get_cursor(0)
end)
)
end)
it('finds previous diagnostic at the end of the line', function()
eq(
{ 3, 2 },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 4, 2 })
vim.diagnostic.jump({ count = -1 })
return vim.api.nvim_win_get_cursor(0)
end)
)
end)
end)
end) end)
describe('get()', function() describe('get()', function()
@@ -2924,7 +2995,7 @@ describe('vim.diagnostic', function()
local float_bufnr, winnr = vim.diagnostic.open_float({ local float_bufnr, winnr = vim.diagnostic.open_float({
header = false, header = false,
scope = 'cursor', scope = 'cursor',
pos = { 0, first_line_len }, pos = { 0, first_line_len - 1 },
}) })
local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false)
vim.api.nvim_win_close(winnr, true) vim.api.nvim_win_close(winnr, true)
@@ -2959,7 +3030,7 @@ describe('vim.diagnostic', function()
vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, diagnostics) vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, diagnostics)
vim.api.nvim_win_set_cursor(0, { 1, 1 }) vim.api.nvim_win_set_cursor(0, { 1, 1 })
local float_bufnr, winnr = local float_bufnr, winnr =
vim.diagnostic.open_float({ header = false, scope = 'cursor', pos = { 2, 1 } }) vim.diagnostic.open_float({ header = false, scope = 'cursor', pos = { 2, 0 } })
local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false)
vim.api.nvim_win_close(winnr, true) vim.api.nvim_win_close(winnr, true)
return lines return lines
@@ -3564,6 +3635,88 @@ describe('vim.diagnostic', function()
end) end)
) )
end) end)
it('shows diagnostics at their logical locations after text changes before', function()
exec_lua(function()
vim.api.nvim_set_current_buf(_G.diagnostic_bufnr)
vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, {
_G.make_error('Diagnostic #1', 1, 4, 1, 7),
_G.make_error('Diagnostic #2', 3, 0, 3, 3),
})
end)
api.nvim_buf_set_text(0, 3, 0, 3, 0, { 'new line', 'new ' })
eq(
{ 'Diagnostic #1' },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 2, 4 })
local float_bufnr, winnr = vim.diagnostic.open_float({ header = '', scope = 'cursor' })
local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false)
vim.api.nvim_win_close(winnr, true)
return lines
end)
)
eq(
{ 'Diagnostic #2' },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 5, 4 })
local float_bufnr, winnr = vim.diagnostic.open_float({ header = '', scope = 'cursor' })
local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false)
vim.api.nvim_win_close(winnr, true)
return lines
end)
)
end)
it('shows diagnostics at their logical locations after text changes inside', function()
exec_lua(function()
vim.api.nvim_set_current_buf(_G.diagnostic_bufnr)
vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, {
_G.make_error('Diagnostic #1', 1, 0, 1, 7),
})
end)
api.nvim_buf_set_text(0, 1, 4, 1, 4, { 'new ' })
eq(
{ 'Diagnostic #1' },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 2, 10 })
local float_bufnr, winnr = vim.diagnostic.open_float({ header = '', scope = 'cursor' })
local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false)
vim.api.nvim_win_close(winnr, true)
return lines
end)
)
end)
it(
'shows diagnostics at the end of the line if diagnostic is set after last character in line',
function()
exec_lua(function()
vim.api.nvim_set_current_buf(_G.diagnostic_bufnr)
vim.diagnostic.set(_G.diagnostic_ns, _G.diagnostic_bufnr, {
_G.make_error('Diagnostic #1', 2, 3, 3, 4),
})
end)
eq(
{ 'Diagnostic #1' },
exec_lua(function()
vim.api.nvim_win_set_cursor(0, { 3, 2 })
local float_bufnr, winnr = vim.diagnostic.open_float({ header = '', scope = 'cursor' })
local lines = vim.api.nvim_buf_get_lines(float_bufnr, 0, -1, false)
vim.api.nvim_win_close(winnr, true)
return lines
end)
)
end
)
end) end)
describe('setloclist()', function() describe('setloclist()', function()
@@ -3840,9 +3993,9 @@ describe('vim.diagnostic', function()
local list = vim.fn.getqflist() local list = vim.fn.getqflist()
local new_diagnostics = vim.diagnostic.fromqflist(list) local new_diagnostics = vim.diagnostic.fromqflist(list)
-- Remove namespace since it isn't present in the return value of -- Remove extra properties not present in the return value of fromlist()
-- fromlist()
for _, v in ipairs(diagnostics) do for _, v in ipairs(diagnostics) do
v._extmark_id = nil
v.namespace = nil v.namespace = nil
end end