From 3963bb8a0108f04080d14dd9343e64e14852eee8 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 4 Oct 2026 14:02:32 +0900 Subject: [PATCH 1/5] fix(grep): resolve results in the directory the search started in The grep provider ran rg in whatever directory was current when the prompt was answered and resolved its relative paths against the directory current when the results arrived. Changing directory while the prompt or rg was pending opened files with the same name in the other directory. The cwd is now pinned before prompting and used both to run rg and to resolve its output. --- lua/peekstack/providers/grep.lua | 35 ++++++++++---- tests/grep_provider_spec.lua | 80 ++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/lua/peekstack/providers/grep.lua b/lua/peekstack/providers/grep.lua index e98ada3..8e8ccc5 100644 --- a/lua/peekstack/providers/grep.lua +++ b/lua/peekstack/providers/grep.lua @@ -34,9 +34,20 @@ local function format_failure_message(stderr) return "rg failed: " .. message end +---@param path string +---@param cwd string +---@return string +local function resolve_path(path, cwd) + if vim.fn.isabsolutepath(path) ~= 1 then + path = vim.fs.joinpath(cwd, path) + end + return vim.fs.normalize(path, { expand_env = false }) +end + ---@param line string +---@param cwd string ---@return string?, integer?, integer?, string? -local function parse_rg_line(line) +local function parse_rg_line(line, cwd) local candidates = {} local search_from = 1 @@ -60,25 +71,27 @@ local function parse_rg_line(line) for i = #candidates, 1, -1 do local candidate = candidates[i] - local resolved = vim.fn.fnamemodify(vim.fn.expand(candidate.path), ":p") + local resolved = resolve_path(candidate.path, cwd) local stat = vim.uv.fs_stat(resolved) if stat and stat.type == "file" then - return candidate.path, candidate.lnum, candidate.col, candidate.text + return resolved, candidate.lnum, candidate.col, candidate.text end end local candidate = candidates[1] - return candidate.path, candidate.lnum, candidate.col, candidate.text + return resolve_path(candidate.path, cwd), candidate.lnum, candidate.col, candidate.text end +---Parse `rg --vimgrep` output, resolving relative paths against the directory rg ran in. ---@param output string +---@param cwd string ---@return PeekstackLocation[] -local function parse_rg_output(output) +local function parse_rg_output(output, cwd) local items = {} for _, line in ipairs(vim.split(output, "\n", { trimempty = true })) do - local path, lnum, col, text = parse_rg_line(line) + local path, lnum, col, text = parse_rg_line(line, cwd) if path and lnum and col then - local uri = fs.fname_to_uri(vim.fn.fnamemodify(path, ":p")) + local uri = fs.fname_to_uri(path) local loc = location.normalize({ uri = uri, range = { @@ -104,20 +117,24 @@ function M.search(_, cb) return end + -- Pin the directory before prompting so a later :cd cannot split the search + -- and the path resolution between two directories. + local cwd = vim.fn.getcwd() + vim.ui.input({ prompt = "rg > " }, function(query) if not query or query == "" then cb({}) return end - vim.system({ "rg", "--vimgrep", "--max-count=1000", "--", query }, { text = true }, function(result) + vim.system({ "rg", "--vimgrep", "--max-count=1000", "--", query }, { cwd = cwd, text = true }, function(result) vim.schedule(function() if result.code ~= 0 and result.code ~= 1 then notify.warn(format_failure_message(result.stderr)) cb({}) return end - cb(parse_rg_output(result.stdout or "")) + cb(parse_rg_output(result.stdout or "", cwd)) end) end) end) diff --git a/tests/grep_provider_spec.lua b/tests/grep_provider_spec.lua index ccff9f0..2de741c 100644 --- a/tests/grep_provider_spec.lua +++ b/tests/grep_provider_spec.lua @@ -26,7 +26,7 @@ describe("peekstack.providers.grep", function() it("parses vimgrep output with Unix paths", function() local output = "/tmp/sample.lua:3:5:hello" - local items = grep._parse_output(output) + local items = grep._parse_output(output, vim.fn.getcwd()) assert.equals(1, #items) assert.equals("grep.search", items[1].provider) @@ -37,7 +37,7 @@ describe("peekstack.providers.grep", function() it("parses vimgrep output with Windows drive paths", function() local output = "C:\\Users\\dev\\sample.lua:12:34:hit" - local items = grep._parse_output(output) + local items = grep._parse_output(output, vim.fn.getcwd()) assert.equals(1, #items) assert.equals("grep.search", items[1].provider) @@ -54,7 +54,7 @@ describe("peekstack.providers.grep", function() vim.fn.writefile({ "first", "second", "third" }, target) local output = target .. ":2:6:match:9:8:payload" - local items = grep._parse_output(output) + local items = grep._parse_output(output, vim.fn.getcwd()) assert.equals(1, #items) assert.equals("grep.search", items[1].provider) @@ -104,4 +104,78 @@ describe("peekstack.providers.grep", function() assert.equals(vim.log.levels.WARN, notifications[1].level) assert.is_true(notifications[1].msg:find("check .gitignore/.ignore patterns or encoding", 1, true) ~= nil) end) + + describe("working directory", function() + -- Every Neovim cd path expands the "$HOME" in the directory names, so + -- change the process directory directly like starting nvim there would. + local cd = vim.uv.chdir + + local original_cwd + local dir_a + local dir_b + + before_each(function() + original_cwd = vim.fn.getcwd() + -- "$HOME" checks that the directory name is not expanded as a variable. + local root = vim.fn.resolve(vim.fn.tempname()) + dir_a = root .. "/$HOME/a" + dir_b = root .. "/b" + vim.fn.mkdir(dir_a, "p") + vim.fn.mkdir(dir_b, "p") + vim.fn.writefile({ "needle" }, dir_a .. "/same.lua") + vim.fn.writefile({ "needle" }, dir_b .. "/same.lua") + vim.fn.executable = function(_) + return 1 + end + end) + + after_each(function() + cd(original_cwd) + vim.fn.delete(vim.fs.dirname(dir_b), "rf") + end) + + local function run_search(on_input, on_system) + local items = nil + local system_opts = nil + vim.ui.input = function(_, cb) + on_input() + cb("needle") + end + vim.system = function(_, opts, cb) + system_opts = opts + on_system() + cb({ code = 0, stdout = "same.lua:1:1:needle\n", stderr = "" }) + end + + grep.search({}, function(result) + items = result + end) + vim.wait(100, function() + return items ~= nil + end) + return items, system_opts + end + + it("keeps the directory the search started in when cwd changes during input", function() + cd(dir_a) + local items, system_opts = run_search(function() + cd(dir_b) + end, function() end) + + assert.equals(dir_a, system_opts.cwd) + assert.equals(1, #items) + assert.equals(vim.uri_from_fname(dir_a .. "/same.lua"), items[1].uri) + end) + + it("resolves results against the search directory when cwd changes while rg runs", function() + cd(dir_a) + local items, system_opts = run_search(function() end, function() + cd(dir_b) + end) + + assert.equals(dir_a, system_opts.cwd) + assert.equals(1, #items) + assert.equals(vim.uri_from_fname(dir_a .. "/same.lua"), items[1].uri) + end) + end) end) From 54b99e01fef1727887c802a7de28f8afa28258b1 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 4 Oct 2026 14:04:01 +0900 Subject: [PATCH 2/5] fix(fzf-lua): resolve entries against the picker cwd The fzf-lua wrappers and the generic push action parsed the selected entry without the picker opts, so a picker opened with its own cwd resolved relative files and grep matches against the editor cwd. The action now receives the opts fzf-lua passes to it, and peekstack's provider and mode move to a separate argument. The mode option is no longer forwarded to fzf-lua, and a missing line or column no longer becomes position -1. --- doc/peekstack.txt | 6 ++- lua/peekstack/extensions/fzf_lua.lua | 35 ++++++++---- tests/extensions_spec.lua | 79 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/doc/peekstack.txt b/doc/peekstack.txt index 6cad5ff..82a9880 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -216,14 +216,16 @@ Custom action: >lua require("fzf-lua").live_grep({ actions = { - ["default"] = function(selected) - require("peekstack.extensions.fzf_lua").actions.push(selected, { + ["default"] = function(selected, opts) + require("peekstack.extensions.fzf_lua").actions.push(selected, opts, { provider = "my_grep", }) end, }, }) < +Pass the picker `opts` fzf-lua gives the action so relative entries are +resolved against the picker `cwd`. The wrappers above do this for you. TELESCOPE ~ *peekstack-extensions-telescope* diff --git a/lua/peekstack/extensions/fzf_lua.lua b/lua/peekstack/extensions/fzf_lua.lua index e5518b8..b21d583 100644 --- a/lua/peekstack/extensions/fzf_lua.lua +++ b/lua/peekstack/extensions/fzf_lua.lua @@ -3,9 +3,19 @@ local notify = require("peekstack.util.notify") local M = {} +---@param value integer? +---@return integer? +local function positive(value) + return value and value > 0 and value or nil +end + +---Push the selected fzf-lua entry onto the stack. +---`fzf_opts` are the picker opts fzf-lua passes to the action; they carry the +---picker cwd that relative entries are resolved against. ---@param selected string[] ----@param opts? table -local function push_action(selected, opts) +---@param fzf_opts? table +---@param push_opts? { provider?: string, mode?: string } +local function push_action(selected, fzf_opts, push_opts) if not selected or not selected[1] then return end @@ -13,13 +23,14 @@ local function push_action(selected, opts) if not ok then return end - local entry = fzf.path.entry_to_file(selected[1]) + local entry = fzf.path.entry_to_file(selected[1], fzf_opts) if entry then + -- fzf-lua reports 0 for a missing line or column. ext.push_entry({ filename = entry.path, - lnum = entry.line, - col = entry.col, - }, opts) + lnum = positive(entry.line), + col = positive(entry.col), + }, push_opts) end end @@ -40,14 +51,16 @@ local function open_picker(fzf_picker, provider, opts) end local push_opts = { provider = provider, mode = opts.mode } - - fn(vim.tbl_extend("force", opts, { + local fzf_opts = vim.tbl_extend("force", opts, { actions = { - ["default"] = function(selected) - push_action(selected, push_opts) + ["default"] = function(selected, picker_opts) + push_action(selected, picker_opts, push_opts) end, }, - })) + }) + fzf_opts.mode = nil + + fn(fzf_opts) end function M.push_file(opts) diff --git a/tests/extensions_spec.lua b/tests/extensions_spec.lua index ff4ca6f..4db87f9 100644 --- a/tests/extensions_spec.lua +++ b/tests/extensions_spec.lua @@ -113,4 +113,83 @@ describe("peekstack.extensions", function() assert.is_nil(captured_loc) end) end) + + describe("fzf-lua", function() + local fzf_ext = require("peekstack.extensions.fzf_lua") + local captured + local fzf_calls + local original_peek + local original_fzf + + -- Mirrors fzf-lua's path.entry_to_file: relative entries are joined with the picker cwd. + local function entry_to_file(entry, opts) + opts = opts or {} + local path, line, col = entry:match("^(.-):(%d+):(%d+)") + path = path or entry + if opts.cwd and path:sub(1, 1) ~= "/" then + path = opts.cwd .. "/" .. path + end + return { path = path, line = tonumber(line) or 0, col = tonumber(col) or 0 } + end + + local function picker(name) + return function(opts) + table.insert(fzf_calls, { name = name, opts = opts }) + end + end + + before_each(function() + captured = {} + fzf_calls = {} + original_peek = require("peekstack").peek_location + require("peekstack").peek_location = function(loc, opts) + table.insert(captured, { loc = loc, opts = opts }) + end + original_fzf = package.loaded["fzf-lua"] + package.loaded["fzf-lua"] = { + path = { entry_to_file = entry_to_file }, + files = picker("files"), + live_grep = picker("live_grep"), + lsp_references = picker("lsp_references"), + } + end) + + after_each(function() + require("peekstack").peek_location = original_peek + package.loaded["fzf-lua"] = original_fzf + end) + + ---Simulate fzf-lua confirming `line`: actions receive the picker opts. + local function confirm(call, line) + call.opts.actions["default"]({ line }, call.opts) + end + + for _, case in ipairs({ + { fn = "push_file", entry = "same.lua", line = 0, col = 0 }, + { fn = "push_grep", entry = "same.lua:3:5:text", line = 2, col = 4 }, + { fn = "push_lsp_references", entry = "same.lua:7:2:text", line = 6, col = 1 }, + }) do + it(case.fn .. " resolves entries against the picker cwd", function() + fzf_ext[case.fn]({ cwd = "/picker/root", mode = "copy" }) + assert.equals(1, #fzf_calls) + assert.is_nil(fzf_calls[1].opts.mode) + + confirm(fzf_calls[1], case.entry) + + assert.equals(1, #captured) + assert.equals(vim.uri_from_fname("/picker/root/same.lua"), captured[1].loc.uri) + assert.equals(case.line, captured[1].loc.range.start.line) + assert.equals(case.col, captured[1].loc.range.start.character) + assert.equals("copy", captured[1].opts.mode) + end) + end + + it("actions.push takes the picker opts and the push opts separately", function() + fzf_ext.actions.push({ "src/a.lua:2:3:text" }, { cwd = "/picker/root" }, { provider = "my_grep" }) + + assert.equals(1, #captured) + assert.equals(vim.uri_from_fname("/picker/root/src/a.lua"), captured[1].loc.uri) + assert.equals("my_grep", captured[1].loc.provider) + end) + end) end) From ca02d56fc374a85d45d3544ee6618291066f2541 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 4 Oct 2026 14:04:40 +0900 Subject: [PATCH 3/5] fix(extensions): keep the caller's picker actions and mappings The fzf-lua wrappers replaced the whole actions table and the Telescope pickers replaced attach_mappings, so custom actions such as ctrl-q or mappings passed by the caller were silently dropped. fzf-lua now gets the caller's actions with only the confirm action swapped, and Telescope runs the caller's attach_mappings after mapping , returning its result. The mode option is no longer forwarded to Telescope either. --- doc/peekstack.txt | 5 +- lua/peekstack/extensions/fzf_lua.lua | 25 ++++-- lua/telescope/_extensions/peekstack.lua | 18 ++-- tests/extensions_spec.lua | 114 ++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 13 deletions(-) diff --git a/doc/peekstack.txt b/doc/peekstack.txt index 82a9880..c0520fb 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -225,7 +225,8 @@ Custom action: }) < Pass the picker `opts` fzf-lua gives the action so relative entries are -resolved against the picker `cwd`. The wrappers above do this for you. +resolved against the picker `cwd`. The wrappers above do this for you and +replace only the confirm action, so other `actions` you pass keep working. TELESCOPE ~ *peekstack-extensions-telescope* @@ -248,6 +249,8 @@ Custom action: end, }) < +The `push_*` pickers map `` and then run the `attach_mappings` you pass, +so your own mappings are kept and may override ``. GENERIC PUSH ~ *peekstack-extensions-push-entry* diff --git a/lua/peekstack/extensions/fzf_lua.lua b/lua/peekstack/extensions/fzf_lua.lua index b21d583..26b4ba6 100644 --- a/lua/peekstack/extensions/fzf_lua.lua +++ b/lua/peekstack/extensions/fzf_lua.lua @@ -51,13 +51,24 @@ local function open_picker(fzf_picker, provider, opts) end local push_opts = { provider = provider, mode = opts.mode } - local fzf_opts = vim.tbl_extend("force", opts, { - actions = { - ["default"] = function(selected, picker_opts) - push_action(selected, picker_opts, push_opts) - end, - }, - }) + -- Replace only the confirm action so the caller's other actions keep working. + -- fzf-lua maps "default" to "enter" and lets it win over an "enter" entry. + local confirm = { + ["default"] = function(selected, picker_opts) + push_action(selected, picker_opts, push_opts) + end, + } + local user_actions = opts.actions + local actions + if type(user_actions) == "function" then + -- fzf-lua also accepts actions as a function of the picker opts. + actions = function(...) + return vim.tbl_extend("force", user_actions(...) or {}, confirm) + end + else + actions = vim.tbl_extend("force", user_actions or {}, confirm) + end + local fzf_opts = vim.tbl_extend("force", opts, { actions = actions }) fzf_opts.mode = nil fn(fzf_opts) diff --git a/lua/telescope/_extensions/peekstack.lua b/lua/telescope/_extensions/peekstack.lua index fc027a9..b014448 100644 --- a/lua/telescope/_extensions/peekstack.lua +++ b/lua/telescope/_extensions/peekstack.lua @@ -28,20 +28,28 @@ local function open_builtin(builtin_name, provider, opts) local push_opts = { provider = provider, mode = opts.mode } - fn(vim.tbl_extend("force", opts, { - attach_mappings = function(_, map) + local user_attach = opts.attach_mappings + local picker_opts = vim.tbl_extend("force", opts, { + attach_mappings = function(prompt_bufnr, map) local actions = require("telescope.actions") local state = require("telescope.actions.state") - local function on_select(prompt_bufnr) + local function on_select(bufnr) local entry = state.get_selected_entry() - actions.close(prompt_bufnr) + actions.close(bufnr) push_from_telescope(entry, push_opts) end map("i", "", on_select) map("n", "", on_select) + -- Run the caller's mappings last so they can still override . + if user_attach then + return user_attach(prompt_bufnr, map) + end return true end, - })) + }) + picker_opts.mode = nil + + fn(picker_opts) end --- Generic action for use in custom telescope mappings. diff --git a/tests/extensions_spec.lua b/tests/extensions_spec.lua index 4db87f9..16c5cdf 100644 --- a/tests/extensions_spec.lua +++ b/tests/extensions_spec.lua @@ -184,6 +184,30 @@ describe("peekstack.extensions", function() end) end + it("keeps the caller's actions and replaces only the confirm action", function() + local user_default = function() end + local send_to_qf = function() end + fzf_ext.push_grep({ actions = { ["default"] = user_default, ["ctrl-q"] = send_to_qf } }) + + local actions = fzf_calls[1].opts.actions + assert.equals(send_to_qf, actions["ctrl-q"]) + assert.is_function(actions["default"]) + assert.are_not.equal(user_default, actions["default"]) + end) + + it("merges the confirm action into actions given as a function", function() + local send_to_qf = function() end + fzf_ext.push_grep({ + actions = function() + return { ["ctrl-q"] = send_to_qf } + end, + }) + + local actions = fzf_calls[1].opts.actions({}) + assert.equals(send_to_qf, actions["ctrl-q"]) + assert.is_function(actions["default"]) + end) + it("actions.push takes the picker opts and the push opts separately", function() fzf_ext.actions.push({ "src/a.lua:2:3:text" }, { cwd = "/picker/root" }, { provider = "my_grep" }) @@ -192,4 +216,94 @@ describe("peekstack.extensions", function() assert.equals("my_grep", captured[1].loc.provider) end) end) + + describe("telescope", function() + local saved = {} + local captured + local builtin_calls + + local function stub(name, mod) + saved[name] = package.loaded[name] + package.loaded[name] = mod + end + + before_each(function() + captured = {} + builtin_calls = {} + stub( + "peekstack", + vim.tbl_extend("force", require("peekstack"), { + peek_location = function(loc, opts) + table.insert(captured, { loc = loc, opts = opts }) + end, + }) + ) + stub("telescope", { + register_extension = function(ext) + return ext + end, + }) + stub("telescope.builtin", { + live_grep = function(opts) + table.insert(builtin_calls, opts) + end, + }) + stub("telescope.actions", { close = function() end }) + stub("telescope.actions.state", { + get_selected_entry = function() + return { filename = "/tmp/a.lua", lnum = 3, col = 2 } + end, + }) + stub("telescope._extensions.peekstack", nil) + end) + + after_each(function() + for name, mod in pairs(saved) do + package.loaded[name] = mod + end + saved = {} + end) + + ---@return table, any + local function attach(opts) + local ext = require("telescope._extensions.peekstack") + ext.exports.push_grep(opts) + assert.equals(1, #builtin_calls) + local maps = {} + local result = builtin_calls[1].attach_mappings(42, function(mode, lhs, fn) + maps[mode .. lhs] = fn + end) + return maps, result + end + + it("pushes the selection on in insert and normal mode", function() + local maps, result = attach({ mode = "copy" }) + assert.is_true(result) + assert.is_nil(builtin_calls[1].mode) + + maps["i"](42) + maps["n"](42) + + assert.equals(2, #captured) + assert.equals("extension.grep", captured[1].loc.provider) + assert.equals("copy", captured[1].opts.mode) + end) + + it("runs the caller's attach_mappings after its own mapping", function() + local user_fn = function() end + local received_bufnr + local maps, result = attach({ + attach_mappings = function(prompt_bufnr, map) + received_bufnr = prompt_bufnr + map("i", "", user_fn) + return false + end, + }) + + assert.equals(42, received_bufnr) + assert.is_false(result) + assert.equals(user_fn, maps["i"]) + assert.is_function(maps["i"]) + end) + end) end) From e7ad48dfc90c078f5a95193b8d718fce44ff77a6 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 4 Oct 2026 14:05:34 +0900 Subject: [PATCH 4/5] fix(stack-view): keep highlights of unchanged lines on partial redraw The stack view cleared highlights for the replaced range after the new lines were in place, so when entries or preview lines were removed the unchanged lines that moved up into that range lost their highlights, including Tree-sitter preview colors. The old range is now cleared before it is replaced. The first render also replaces the empty line of the new buffer instead of leaving it below the list. --- lua/peekstack/ui/stack_view/diff.lua | 9 +- tests/stack_view_render_spec.lua | 130 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/lua/peekstack/ui/stack_view/diff.lua b/lua/peekstack/ui/stack_view/diff.lua index a603b66..080de01 100644 --- a/lua/peekstack/ui/stack_view/diff.lua +++ b/lua/peekstack/ui/stack_view/diff.lua @@ -131,12 +131,17 @@ function M.apply(bufnr, old_keys, model, preview_cache) local start_idx, old_end, new_end = diff_range(old_keys, new_keys) if start_idx then local replace = slice_lines(model.lines, start_idx, new_end) + -- Without previous keys the buffer only holds the empty line of a new + -- buffer, so replace it instead of inserting above it. + local old_stop = #old_keys == 0 and -1 or old_end + -- Clear while the old rows are still in place: after the replacement the + -- unchanged suffix may have moved into the old range. + vim.api.nvim_buf_clear_namespace(bufnr, NS, start_idx - 1, old_stop) vim.bo[bufnr].modifiable = true - vim.api.nvim_buf_set_lines(bufnr, start_idx - 1, old_end, false, replace) + vim.api.nvim_buf_set_lines(bufnr, start_idx - 1, old_stop, false, replace) vim.bo[bufnr].modifiable = false - vim.api.nvim_buf_clear_namespace(bufnr, NS, start_idx - 1, old_end) apply_highlights_in_range(bufnr, model.highlights, model.preview_lines, start_idx, new_end, preview_cache) end diff --git a/tests/stack_view_render_spec.lua b/tests/stack_view_render_spec.lua index 6e68d7e..225a9e7 100644 --- a/tests/stack_view_render_spec.lua +++ b/tests/stack_view_render_spec.lua @@ -224,6 +224,136 @@ describe("peekstack.ui.stack_view.render", function() end end) + describe("incremental updates", function() + local ns = vim.api.nvim_create_namespace("PeekstackStackView") + local original_get_parser + local original_query_get + + -- Stub Tree-sitter so every preview line gets a keyword capture on its first 5 bytes. + before_each(function() + original_get_parser = vim.treesitter.get_parser + original_query_get = vim.treesitter.query.get + vim.api.nvim_set_hl(0, "@keyword.peekstack_test_ts", { link = "Keyword" }) + local tree = { + root = function() + return { + range = function() + return 0, 0, 100, 0 + end, + } + end, + lang = function() + return "peekstack_test_ts" + end, + } + vim.treesitter.get_parser = function() + return { + parse = function() end, + trees = function() + return { tree } + end, + } + end + vim.treesitter.query.get = function() + return { + captures = { "keyword" }, + iter_captures = function(_, _, _, start_row) + local emitted = false + return function() + if emitted then + return nil + end + emitted = true + return 1, + { + range = function() + return start_row, 0, start_row, 5 + end, + } + end + end, + } + end + end) + + after_each(function() + vim.treesitter.get_parser = original_get_parser + vim.treesitter.query.get = original_query_get + end) + + ---@return string[] + local function rendered_marks(bufnr) + local marks = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })) do + local details = mark[4] + marks[#marks + 1] = string.format("%d:%d-%d:%s", mark[2], mark[3], details.end_col or -1, details.hl_group) + end + table.sort(marks) + return marks + end + + ---Render `after` incrementally on top of `before` and compare with a fresh render. + local function assert_matches_fresh_render(before, after) + local bufnr = new_target_buffer() + local keys = diff.apply(bufnr, {}, before, {}) + diff.apply(bufnr, keys, after, {}) + + local fresh = new_target_buffer() + diff.apply(fresh, {}, after, {}) + + assert.are.same(after.lines, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) + assert.are.same(rendered_marks(fresh), rendered_marks(bufnr)) + return rendered_marks(bufnr) + end + + local function items_with_previews(ids) + local source_bufnr = new_source_buffer({ "local value = 42", "return value" }, "lua") + local items = {} + for _, id in ipairs(ids) do + items[#items + 1] = popup(id, { + title = "Item " .. id, + source_bufnr = source_bufnr, + bufnr = source_bufnr, + location = location_for("/tmp/item.lua", 0, 0), + }) + end + return items + end + + it("renders exactly the model lines on the first render", function() + local bufnr = new_target_buffer() + local model = build_model({ items = { popup(1, { title = "Alpha" }) } }) + + diff.apply(bufnr, {}, model, {}) + + assert.are.same(model.lines, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) + end) + + it("keeps highlights of the unchanged suffix when a middle entry is removed", function() + local before = build_model({ items = items_with_previews({ 1, 2, 3 }), focused_id = 3 }) + local after = build_model({ items = items_with_previews({ 1, 3 }), focused_id = 3 }) + + local marks = assert_matches_fresh_render(before, after) + local has_treesitter = false + for _, mark in ipairs(marks) do + has_treesitter = has_treesitter or mark:find(":@", 1, true) ~= nil + end + assert.is_true(has_treesitter) + end) + + it("keeps highlights of the unchanged suffix when preview lines are removed", function() + local items = items_with_previews({ 1, 2 }) + local before = build_model({ items = items, focused_id = 2 }) + local without_preview = vim.deepcopy(items) + without_preview[1].source_bufnr = nil + without_preview[1].bufnr = nil + local after = build_model({ items = without_preview, focused_id = 2 }) + assert.is_true(#after.lines < #before.lines) + + assert_matches_fresh_render(before, after) + end) + end) + it("renders the empty state with a header", function() local model = build_model({ items = {} }) local joined = table.concat(model.lines, "\n") From 868c2323c62f750200ba93427b7f24395ce9321c Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 4 Oct 2026 14:06:24 +0900 Subject: [PATCH 5/5] fix(diagnostics): keep the end column of multi-line diagnostics The popup underline compared the start and end columns even when they were on different lines, so a range ending on a later line before its start column was stretched to that column. On a short end line the extmark then failed and the whole underline disappeared. Columns are now only ordered within one line, each is clamped to its own line, and a range crossing the top or bottom of the popup buffer is clipped to it. --- lua/peekstack/ui/diagnostics.lua | 68 +++++++++++++++++++++---------- tests/diagnostics_popup_spec.lua | 70 +++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 22 deletions(-) diff --git a/lua/peekstack/ui/diagnostics.lua b/lua/peekstack/ui/diagnostics.lua index 5e75167..e390769 100644 --- a/lua/peekstack/ui/diagnostics.lua +++ b/lua/peekstack/ui/diagnostics.lua @@ -36,6 +36,43 @@ local function is_diagnostic_location(location) return type(location.provider) == "string" and location.provider:match("^diagnostics%.") ~= nil end +---@param bufnr integer +---@param row integer +---@return integer +local function row_length(bufnr, row) + return #(vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or "") +end + +---Map a diagnostic range onto the popup buffer, keeping only the part inside it. +---A range that starts above or ends below the buffer is clipped to its first or +---last line, and columns are clamped to each line's byte length. +---@param bufnr integer +---@param range table +---@param line_offset integer +---@return { row: integer, col: integer, end_row: integer, end_col: integer }? +local function underline_range(bufnr, range, line_offset) + local last_row = vim.api.nvim_buf_line_count(bufnr) - 1 + local start = range.start or {} + local finish = range["end"] or start + local row = (start.line or 0) - line_offset + local end_row = (finish.line or start.line or 0) - line_offset + if end_row < 0 or row > last_row then + return nil + end + + local col = row < 0 and 0 or (start.character or 0) + local end_col = end_row > last_row and math.huge or (finish.character or col) + row = math.max(row, 0) + end_row = math.min(end_row, last_row) + col = math.min(math.max(col, 0), row_length(bufnr, row)) + end_col = math.min(math.max(end_col, 0), row_length(bufnr, end_row)) + -- Columns are only ordered within a single line. + if end_row < row or (end_row == row and end_col < col) then + end_row, end_col = row, col + end + return { row = row, col = col, end_row = end_row, end_col = end_col } +end + ---@param popup PeekstackPopupModel ---@return PeekstackDiagnosticExtmarks? function M.decorate(popup) @@ -58,24 +95,16 @@ function M.decorate(popup) return nil end - local range = location.range or {} - local start = range.start or {} - local finish = range["end"] or start - local line_offset = popup.line_offset or 0 - local line = (start.line or 0) - line_offset - local col = start.character or 0 - local end_line = (finish.line or start.line or 0) - line_offset - local end_col = finish.character or col - local line_count = vim.api.nvim_buf_line_count(bufnr) if line_count == 0 then return nil end - line = math.min(math.max(line, 0), line_count - 1) - end_line = math.min(math.max(end_line, line), line_count - 1) - col = math.max(col, 0) - end_col = math.max(end_col, col) + local range = location.range or {} + local line_offset = popup.line_offset or 0 + local start_row = ((range.start or {}).line or 0) - line_offset + local line = math.min(math.max(start_row, 0), line_count - 1) + local underline = underline_range(bufnr, range, line_offset) local ids = {} @@ -97,14 +126,11 @@ function M.decorate(popup) end end - local underline = severity_hl(location.kind, "DiagnosticUnderline") - if underline ~= "" then - -- end_col may exceed the line length when the popup buffer is truncated; - -- pcall keeps decoration best-effort instead of erroring on the whole popup. - local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, NS, line, col, { - end_row = end_line, - end_col = end_col, - hl_group = underline, + if underline then + local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, NS, underline.row, underline.col, { + end_row = underline.end_row, + end_col = underline.end_col, + hl_group = severity_hl(location.kind, "DiagnosticUnderline"), }) if ok then table.insert(ids, id) diff --git a/tests/diagnostics_popup_spec.lua b/tests/diagnostics_popup_spec.lua index 4644c96..db49291 100644 --- a/tests/diagnostics_popup_spec.lua +++ b/tests/diagnostics_popup_spec.lua @@ -74,12 +74,80 @@ describe("peekstack.ui.diagnostics", function() local ok, result = pcall(diagnostics.decorate, model) assert.is_true(ok) - -- The virt_lines marker is still added even though the underline is dropped. assert.is_not_nil(result) vim.api.nvim_buf_delete(bufnr, { force = true }) end) + describe("underline range", function() + local diagnostics = require("peekstack.ui.diagnostics") + local bufnr + + before_each(function() + bufnr = vim.api.nvim_create_buf(false, true) + end) + + after_each(function() + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + ---@return { row: integer, col: integer, end_row: integer, end_col: integer }? + local function underline_for(lines, range, line_offset) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + local result = diagnostics.decorate({ + bufnr = bufnr, + line_offset = line_offset or 0, + location = { + provider = "diagnostics.under_cursor", + text = "boom", + kind = vim.diagnostic.severity.ERROR, + range = range, + }, + }) + assert.is_not_nil(result) + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(bufnr, result.ns, 0, -1, { details = true })) do + local details = mark[4] + if details.hl_group then + return { row = mark[2], col = mark[3], end_row = details.end_row, end_col = details.end_col } + end + end + return nil + end + + local function range(sl, sc, el, ec) + return { start = { line = sl, character = sc }, ["end"] = { line = el, character = ec } } + end + + it("keeps an end column smaller than the start column on a later line", function() + local mark = underline_for({ "local value = 1", "abc" }, range(0, 8, 1, 2)) + assert.are.same({ row = 0, col = 8, end_row = 1, end_col = 2 }, mark) + end) + + it("clamps the end column to the length of a short end line", function() + local mark = underline_for({ "local value = 1", "abc" }, range(0, 8, 1, 20)) + assert.are.same({ row = 0, col = 8, end_row = 1, end_col = 3 }, mark) + end) + + it("keeps the start column when the range ends before it on the same line", function() + local mark = underline_for({ "local value = 1" }, range(0, 8, 0, 2)) + assert.are.same({ row = 0, col = 8, end_row = 0, end_col = 8 }, mark) + end) + + it("clips a range that starts above the visible lines", function() + local mark = underline_for({ "visible one", "visible two" }, range(8, 4, 10, 3), 10) + assert.are.same({ row = 0, col = 0, end_row = 0, end_col = 3 }, mark) + end) + + it("clips a range that ends below the visible lines", function() + local mark = underline_for({ "visible one", "visible two" }, range(10, 8, 15, 1), 10) + assert.are.same({ row = 0, col = 8, end_row = 1, end_col = 11 }, mark) + end) + + it("does not underline a range entirely outside the visible lines", function() + assert.is_nil(underline_for({ "visible one", "visible two" }, range(20, 0, 21, 4), 10)) + end) + end) + it("clears diagnostic extmarks on close in source mode", function() local tmpfile = vim.fn.tempname() .. ".lua" vim.fn.writefile({ "line1", "line2" }, tmpfile)