From 2058898ad48d268b5352e8ae7f77d6f00d33d775 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Thu, 19 Mar 2026 15:03:26 +0900 Subject: [PATCH 1/4] fix(file): parse path line suffixes --- lua/peekstack/providers/file.lua | 112 +++++++++++++++++++++++++++++-- tests/file_provider_spec.lua | 54 +++++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/lua/peekstack/providers/file.lua b/lua/peekstack/providers/file.lua index a78c6a4..83ba095 100644 --- a/lua/peekstack/providers/file.lua +++ b/lua/peekstack/providers/file.lua @@ -3,25 +3,125 @@ local location = require("peekstack.core.location") local M = {} +---@param path string +---@return boolean +local function is_absolute(path) + return path:sub(1, 1) == "/" or path:sub(1, 1) == "~" or path:match("^%a:[/\\]") ~= nil or path:sub(1, 2) == "\\\\" +end + +---@param target string +---@return string, integer?, integer? +local function parse_target_spec(target) + local path, lnum, col = target:match("^(.*):(%d+):(%d+)$") + if path then + -- Avoid treating Windows drive letter colon as line separator + if not path:match("^%a:$") then + return path, tonumber(lnum), tonumber(col) + end + end + + path, lnum = target:match("^(.*):(%d+)$") + if path then + if not path:match("^%a:$") then + return path, tonumber(lnum), nil + end + end + + return target, nil, nil +end + +---@param path string +---@param source_name string +---@return string +local function resolve_path(path, source_name) + if is_absolute(path) then + return vim.fn.fnamemodify(vim.fn.expand(path), ":p") + end + + local base = vim.fn.fnamemodify(source_name, ":p:h") + if base == "" then + return vim.fn.fnamemodify(path, ":p") + end + + return vim.fn.fnamemodify(base .. "/" .. path, ":p") +end + +---@param target string +---@param source_name string +---@return string?, integer?, integer? +local function resolve_target(target, source_name) + -- 1) Try the target string as-is (handles absolute paths and env-var expansions) + local exact = vim.fn.expand(target) + local stat = vim.uv.fs_stat(exact) + if stat then + if stat.type ~= "file" then + return nil, nil, nil + end + return vim.fn.fnamemodify(exact, ":p"), nil, nil + end + + -- 2) Resolve relative to the source buffer's directory (the target may be a + -- relative path that doesn't exist from cwd but does from the source file) + local raw_resolved = resolve_path(target, source_name) + local raw_stat = vim.uv.fs_stat(raw_resolved) + if raw_stat then + if raw_stat.type ~= "file" then + return nil, nil, nil + end + return raw_resolved, nil, nil + end + + -- 3) Strip :line[:col] suffix and retry – the suffix prevented fs_stat above + local path, lnum, col = parse_target_spec(target) + local resolved = resolve_path(path, source_name) + local resolved_stat = vim.uv.fs_stat(resolved) + if not resolved_stat or resolved_stat.type ~= "file" then + return nil, nil, nil + end + + return resolved, lnum, col +end + +---@return string +local function cursor_target() + local target = vim.fn.expand("") + local wide_target = vim.fn.expand("") + if wide_target ~= "" and wide_target:find(":%d+") and not target:find(":%d+") then + return wide_target + end + return target +end + ---Get file path under cursor ---@param ctx PeekstackProviderContext ---@param cb fun(locations: PeekstackLocation[]) function M.under_cursor(ctx, cb) - local target = vim.fn.expand("") + local target = cursor_target() if not target or target == "" then cb({}) return end if not target:match("^%a+://") then local source_name = vim.api.nvim_buf_get_name(ctx.bufnr) - local base = vim.fn.fnamemodify(source_name, ":p:h") - target = vim.fn.fnamemodify(base .. "/" .. target, ":p") - - local stat = vim.uv.fs_stat(target) - if not stat or stat.type ~= "file" then + local resolved, lnum, col = resolve_target(target, source_name) + if not resolved then cb({}) return end + target = resolved + lnum = lnum or 1 + col = col or 1 + + local uri = fs.fname_to_uri(target) + local loc = location.normalize({ + uri = uri, + range = { + start = { line = lnum - 1, character = col - 1 }, + ["end"] = { line = lnum - 1, character = col - 1 }, + }, + }, "file.under_cursor") + cb(loc and { loc } or {}) + return end local uri = fs.fname_to_uri(target) local loc = location.normalize( diff --git a/tests/file_provider_spec.lua b/tests/file_provider_spec.lua index a08ed57..5a7844e 100644 --- a/tests/file_provider_spec.lua +++ b/tests/file_provider_spec.lua @@ -92,4 +92,58 @@ describe("peekstack.providers.file", function() vim.api.nvim_buf_delete(bufnr, { force = true }) vim.fn.delete(tmpdir, "rf") end) + + it("resolves path suffix with line and column", function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + local target = tmpdir .. "/target.lua" + vim.fn.writefile({ "first", "second", "third", "fourth" }, target) + local source = tmpdir .. "/source.lua" + vim.fn.writefile({ "target.lua:3:4" }, source) + + local bufnr = vim.fn.bufadd(source) + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local result = nil + file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations) + result = locations + end) + + assert.is_table(result) + assert.equals(1, #result) + assert.equals(2, result[1].range.start.line) + assert.equals(3, result[1].range.start.character) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + vim.fn.delete(tmpdir, "rf") + end) + + it("defaults column to 1 when only a line number is present", function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + local target = tmpdir .. "/target.lua" + vim.fn.writefile({ "first", "second", "third" }, target) + local source = tmpdir .. "/source.lua" + vim.fn.writefile({ "target.lua:2" }, source) + + local bufnr = vim.fn.bufadd(source) + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local result = nil + file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations) + result = locations + end) + + assert.is_table(result) + assert.equals(1, #result) + assert.equals(1, result[1].range.start.line) + assert.equals(0, result[1].range.start.character) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + vim.fn.delete(tmpdir, "rf") + end) end) From 96d34201855260c7287d2e9c9e6af60f6f4a0f2f Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Thu, 19 Mar 2026 15:03:33 +0900 Subject: [PATCH 2/4] fix(grep): harden rg line parsing --- lua/peekstack/providers/grep.lua | 33 +++++++++++++++++++++++++++++--- tests/grep_provider_spec.lua | 18 +++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lua/peekstack/providers/grep.lua b/lua/peekstack/providers/grep.lua index 5cb2dc0..e98ada3 100644 --- a/lua/peekstack/providers/grep.lua +++ b/lua/peekstack/providers/grep.lua @@ -37,11 +37,38 @@ end ---@param line string ---@return string?, integer?, integer?, string? local function parse_rg_line(line) - local path, lnum, col, text = line:match("^(.*):(%d+):(%d+):(.*)$") - if not path then + local candidates = {} + local search_from = 1 + + while true do + local start_idx, end_idx, lnum, col = line:find(":(%d+):(%d+):", search_from) + if not start_idx then + break + end + table.insert(candidates, { + path = line:sub(1, start_idx - 1), + lnum = tonumber(lnum), + col = tonumber(col), + text = line:sub(end_idx + 1), + }) + search_from = end_idx + 1 + end + + if #candidates == 0 then return nil, nil, nil, nil end - return path, tonumber(lnum), tonumber(col), text + + for i = #candidates, 1, -1 do + local candidate = candidates[i] + local resolved = vim.fn.fnamemodify(vim.fn.expand(candidate.path), ":p") + local stat = vim.uv.fs_stat(resolved) + if stat and stat.type == "file" then + return candidate.path, candidate.lnum, candidate.col, candidate.text + end + end + + local candidate = candidates[1] + return candidate.path, candidate.lnum, candidate.col, candidate.text end ---@param output string diff --git a/tests/grep_provider_spec.lua b/tests/grep_provider_spec.lua index 33c72d3..ccff9f0 100644 --- a/tests/grep_provider_spec.lua +++ b/tests/grep_provider_spec.lua @@ -47,6 +47,24 @@ describe("peekstack.providers.grep", function() assert.is_true(items[1].uri:find("sample.lua", 1, true) ~= nil) end) + it("prefers the actual file path when text also contains colon-separated numbers", function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + local target = tmpdir .. "/sample:12:34.lua" + vim.fn.writefile({ "first", "second", "third" }, target) + + local output = target .. ":2:6:match:9:8:payload" + local items = grep._parse_output(output) + + assert.equals(1, #items) + assert.equals("grep.search", items[1].provider) + assert.equals(1, items[1].range.start.line) + assert.equals(5, items[1].range.start.character) + assert.equals("match:9:8:payload", items[1].text) + + vim.fn.delete(tmpdir, "rf") + end) + it("formats ignore-file failures with a targeted hint", function() local message = grep._format_failure_message("error reading .gitignore: invalid UTF-8") From 281930ae704f8c420e0e8e183ab339d718d563ad Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Thu, 19 Mar 2026 15:07:08 +0900 Subject: [PATCH 3/4] test(popup): cover source-mode close focus edge case --- tests/popup_source_mode_spec.lua | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/popup_source_mode_spec.lua b/tests/popup_source_mode_spec.lua index 1ef4627..01d029b 100644 --- a/tests/popup_source_mode_spec.lua +++ b/tests/popup_source_mode_spec.lua @@ -313,6 +313,36 @@ describe("popup source mode", function() vim.fn.delete(temp) end) + it("keeps the remaining source popup focused when the active one closes", function() + local temp = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ "print('peekstack')" }, temp) + vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) + local source_bufnr = vim.api.nvim_get_current_buf() + local close_key = config.get().ui.keys.close + local loc = { + uri = vim.uri_from_fname(temp), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + provider = "test", + } + + local first = stack.push(loc, { buffer_mode = "source" }) + local second = stack.push(loc, { buffer_mode = "source" }) + assert.is_not_nil(first) + assert.is_not_nil(second) + assert.equals(second.winid, vim.api.nvim_get_current_win()) + assert.equals("Peekstack close", get_buffer_map(source_bufnr, close_key).desc) + + stack.close(second.id) + + assert.is_false(vim.api.nvim_win_is_valid(second.winid)) + assert.is_true(vim.api.nvim_win_is_valid(first.winid)) + assert.equals(first.winid, vim.api.nvim_get_current_win()) + assert.equals("Peekstack close", get_buffer_map(source_bufnr, close_key).desc) + + stack.close(first.id) + vim.fn.delete(temp) + end) + it("deletes copy-mode scratch buffer when render.open fails", function() local render = require("peekstack.ui.render") local loc = make_location() From 7a5f4ed084932526fb7e4bd28e4f95865b83eb60 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Thu, 19 Mar 2026 15:07:12 +0900 Subject: [PATCH 4/4] fix(lsp): add timeout for multi-client requests --- lua/peekstack/providers/lsp.lua | 31 +++++++++++- tests/lsp_provider_spec.lua | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/lua/peekstack/providers/lsp.lua b/lua/peekstack/providers/lsp.lua index 15b11df..7853810 100644 --- a/lua/peekstack/providers/lsp.lua +++ b/lua/peekstack/providers/lsp.lua @@ -1,9 +1,11 @@ local location = require("peekstack.core.location") local notify = require("peekstack.util.notify") +local timer = require("peekstack.util.timer") local M = {} ---@alias PeekstackLspResultMapper fun(result: any, provider: string, ctx: PeekstackProviderContext): PeekstackLocation[] +local REQUEST_TIMEOUT_MS = 1500 ---@param symbol table ---@param uri string @@ -103,6 +105,29 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb local all_locations = {} local remaining = #clients local mapper = result_mapper or default_result_mapper + local finished = false + local timeout_ms = M._request_timeout_ms or REQUEST_TIMEOUT_MS + local timeout_handle = vim.uv.new_timer() + + local function finish(timed_out) + if finished then + return + end + finished = true + timer.close(timeout_handle) + if timed_out then + notify.warn("LSP request timed out; opening partial results") + end + cb(all_locations) + end + + if timeout_handle then + timeout_handle:start(timeout_ms, 0, function() + vim.schedule(function() + finish(true) + end) + end) + end for _, client in ipairs(clients) do local params = { @@ -116,6 +141,9 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb params_modifier(params) end client:request(method, params, function(err, result) + if finished then + return + end if not err and result then local ok, locs = pcall(mapper, result, provider, ctx) if ok and type(locs) == "table" then @@ -124,7 +152,7 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb end remaining = remaining - 1 if remaining == 0 then - cb(all_locations) + finish(false) end end, bufnr) end @@ -151,5 +179,6 @@ end) M.symbols_document = create_provider("textDocument/documentSymbol", "lsp.symbols_document", function(params) params.position = nil end, document_symbol_result_mapper) +M._request_timeout_ms = REQUEST_TIMEOUT_MS return M diff --git a/tests/lsp_provider_spec.lua b/tests/lsp_provider_spec.lua index a80e6f5..74824a6 100644 --- a/tests/lsp_provider_spec.lua +++ b/tests/lsp_provider_spec.lua @@ -3,7 +3,10 @@ local lsp_provider = require("peekstack.providers.lsp") describe("peekstack.providers.lsp", function() local original_get_clients local original_notify + local original_new_timer + local original_timeout_ms local notifications + local timeout_handle local function make_ctx() local winid = vim.api.nvim_get_current_win() @@ -24,7 +27,10 @@ describe("peekstack.providers.lsp", function() before_each(function() original_get_clients = vim.lsp.get_clients original_notify = vim.notify + original_new_timer = vim.uv.new_timer + original_timeout_ms = lsp_provider._request_timeout_ms notifications = {} + timeout_handle = nil vim.notify = function(msg, level) table.insert(notifications, { msg = msg, level = level }) end @@ -33,6 +39,8 @@ describe("peekstack.providers.lsp", function() after_each(function() vim.lsp.get_clients = original_get_clients vim.notify = original_notify + vim.uv.new_timer = original_new_timer + lsp_provider._request_timeout_ms = original_timeout_ms end) it("maps DocumentSymbol results with hierarchy flattening", function() @@ -161,4 +169,81 @@ describe("peekstack.providers.lsp", function() end assert.is_true(found) end) + + it("opens partial results when one client never responds", function() + local callback_result + local delayed_handler + + vim.uv.new_timer = function() + timeout_handle = { + start = function(_, _, _, cb) + timeout_handle._cb = cb + end, + stop = function() end, + is_closing = function() + return false + end, + close = function() end, + } + return timeout_handle + end + + vim.lsp.get_clients = function(opts) + assert.equals("textDocument/definition", opts.method) + return { + { + request = function(_, _method, _params, handler, _bufnr) + handler(nil, { + uri = "file:///tmp/one.lua", + range = { + start = { line = 1, character = 2 }, + ["end"] = { line = 1, character = 5 }, + }, + }) + end, + }, + { + request = function(_, _method, _params, handler, _bufnr) + delayed_handler = handler + end, + }, + } + end + + lsp_provider._request_timeout_ms = 10 + + local ctx = make_ctx() + lsp_provider.definition(ctx, function(locations) + callback_result = locations + end) + + assert.is_nil(callback_result) + assert.is_not_nil(timeout_handle) + timeout_handle._cb() + + vim.wait(100, function() + return callback_result ~= nil + end) + + assert.is_table(callback_result) + assert.equals(1, #callback_result) + assert.equals("file:///tmp/one.lua", callback_result[1].uri) + assert.equals(1, callback_result[1].range.start.line) + assert.equals(2, callback_result[1].range.start.character) + assert.is_true(notifications[1].msg:find("timed out", 1, true) ~= nil) + + if delayed_handler then + delayed_handler(nil, { + uri = "file:///tmp/two.lua", + range = { + start = { line = 3, character = 4 }, + ["end"] = { line = 3, character = 6 }, + }, + }) + end + + -- Result unchanged after timeout; late response ignored + assert.equals(1, #callback_result) + assert.equals("file:///tmp/one.lua", callback_result[1].uri) + end) end)