diff --git a/doc/peekstack.txt b/doc/peekstack.txt index 6cad5ff..c0520fb 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -216,14 +216,17 @@ 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 and +replace only the confirm action, so other `actions` you pass keep working. TELESCOPE ~ *peekstack-extensions-telescope* @@ -246,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 e5518b8..26b4ba6 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,27 @@ local function open_picker(fzf_picker, provider, opts) end local push_opts = { provider = provider, mode = opts.mode } + -- 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(vim.tbl_extend("force", opts, { - actions = { - ["default"] = function(selected) - push_action(selected, push_opts) - end, - }, - })) + fn(fzf_opts) end function M.push_file(opts) 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/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/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/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/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) diff --git a/tests/extensions_spec.lua b/tests/extensions_spec.lua index ff4ca6f..16c5cdf 100644 --- a/tests/extensions_spec.lua +++ b/tests/extensions_spec.lua @@ -113,4 +113,197 @@ 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("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" }) + + 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) + + 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) 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) 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")