Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 106 additions & 6 deletions lua/peekstack/providers/file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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("<cfile>")
local wide_target = vim.fn.expand("<cWORD>")
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("<cfile>")
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(
Expand Down
33 changes: 30 additions & 3 deletions lua/peekstack/providers/grep.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion lua/peekstack/providers/lsp.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
54 changes: 54 additions & 0 deletions tests/file_provider_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 18 additions & 0 deletions tests/grep_provider_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading
Loading