Skip to content

Commit 4646695

Browse files
authored
Merge pull request #28 from mhiro2/fix/provider-parsing-and-timeout
fix(provider): Harden file/grep parsing and add LSP request timeout
2 parents 15b8299 + 7a5f4ed commit 4646695

7 files changed

Lines changed: 353 additions & 10 deletions

File tree

lua/peekstack/providers/file.lua

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,125 @@ local location = require("peekstack.core.location")
33

44
local M = {}
55

6+
---@param path string
7+
---@return boolean
8+
local function is_absolute(path)
9+
return path:sub(1, 1) == "/" or path:sub(1, 1) == "~" or path:match("^%a:[/\\]") ~= nil or path:sub(1, 2) == "\\\\"
10+
end
11+
12+
---@param target string
13+
---@return string, integer?, integer?
14+
local function parse_target_spec(target)
15+
local path, lnum, col = target:match("^(.*):(%d+):(%d+)$")
16+
if path then
17+
-- Avoid treating Windows drive letter colon as line separator
18+
if not path:match("^%a:$") then
19+
return path, tonumber(lnum), tonumber(col)
20+
end
21+
end
22+
23+
path, lnum = target:match("^(.*):(%d+)$")
24+
if path then
25+
if not path:match("^%a:$") then
26+
return path, tonumber(lnum), nil
27+
end
28+
end
29+
30+
return target, nil, nil
31+
end
32+
33+
---@param path string
34+
---@param source_name string
35+
---@return string
36+
local function resolve_path(path, source_name)
37+
if is_absolute(path) then
38+
return vim.fn.fnamemodify(vim.fn.expand(path), ":p")
39+
end
40+
41+
local base = vim.fn.fnamemodify(source_name, ":p:h")
42+
if base == "" then
43+
return vim.fn.fnamemodify(path, ":p")
44+
end
45+
46+
return vim.fn.fnamemodify(base .. "/" .. path, ":p")
47+
end
48+
49+
---@param target string
50+
---@param source_name string
51+
---@return string?, integer?, integer?
52+
local function resolve_target(target, source_name)
53+
-- 1) Try the target string as-is (handles absolute paths and env-var expansions)
54+
local exact = vim.fn.expand(target)
55+
local stat = vim.uv.fs_stat(exact)
56+
if stat then
57+
if stat.type ~= "file" then
58+
return nil, nil, nil
59+
end
60+
return vim.fn.fnamemodify(exact, ":p"), nil, nil
61+
end
62+
63+
-- 2) Resolve relative to the source buffer's directory (the target may be a
64+
-- relative path that doesn't exist from cwd but does from the source file)
65+
local raw_resolved = resolve_path(target, source_name)
66+
local raw_stat = vim.uv.fs_stat(raw_resolved)
67+
if raw_stat then
68+
if raw_stat.type ~= "file" then
69+
return nil, nil, nil
70+
end
71+
return raw_resolved, nil, nil
72+
end
73+
74+
-- 3) Strip :line[:col] suffix and retry – the suffix prevented fs_stat above
75+
local path, lnum, col = parse_target_spec(target)
76+
local resolved = resolve_path(path, source_name)
77+
local resolved_stat = vim.uv.fs_stat(resolved)
78+
if not resolved_stat or resolved_stat.type ~= "file" then
79+
return nil, nil, nil
80+
end
81+
82+
return resolved, lnum, col
83+
end
84+
85+
---@return string
86+
local function cursor_target()
87+
local target = vim.fn.expand("<cfile>")
88+
local wide_target = vim.fn.expand("<cWORD>")
89+
if wide_target ~= "" and wide_target:find(":%d+") and not target:find(":%d+") then
90+
return wide_target
91+
end
92+
return target
93+
end
94+
695
---Get file path under cursor
796
---@param ctx PeekstackProviderContext
897
---@param cb fun(locations: PeekstackLocation[])
998
function M.under_cursor(ctx, cb)
10-
local target = vim.fn.expand("<cfile>")
99+
local target = cursor_target()
11100
if not target or target == "" then
12101
cb({})
13102
return
14103
end
15104
if not target:match("^%a+://") then
16105
local source_name = vim.api.nvim_buf_get_name(ctx.bufnr)
17-
local base = vim.fn.fnamemodify(source_name, ":p:h")
18-
target = vim.fn.fnamemodify(base .. "/" .. target, ":p")
19-
20-
local stat = vim.uv.fs_stat(target)
21-
if not stat or stat.type ~= "file" then
106+
local resolved, lnum, col = resolve_target(target, source_name)
107+
if not resolved then
22108
cb({})
23109
return
24110
end
111+
target = resolved
112+
lnum = lnum or 1
113+
col = col or 1
114+
115+
local uri = fs.fname_to_uri(target)
116+
local loc = location.normalize({
117+
uri = uri,
118+
range = {
119+
start = { line = lnum - 1, character = col - 1 },
120+
["end"] = { line = lnum - 1, character = col - 1 },
121+
},
122+
}, "file.under_cursor")
123+
cb(loc and { loc } or {})
124+
return
25125
end
26126
local uri = fs.fname_to_uri(target)
27127
local loc = location.normalize(

lua/peekstack/providers/grep.lua

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,38 @@ end
3737
---@param line string
3838
---@return string?, integer?, integer?, string?
3939
local function parse_rg_line(line)
40-
local path, lnum, col, text = line:match("^(.*):(%d+):(%d+):(.*)$")
41-
if not path then
40+
local candidates = {}
41+
local search_from = 1
42+
43+
while true do
44+
local start_idx, end_idx, lnum, col = line:find(":(%d+):(%d+):", search_from)
45+
if not start_idx then
46+
break
47+
end
48+
table.insert(candidates, {
49+
path = line:sub(1, start_idx - 1),
50+
lnum = tonumber(lnum),
51+
col = tonumber(col),
52+
text = line:sub(end_idx + 1),
53+
})
54+
search_from = end_idx + 1
55+
end
56+
57+
if #candidates == 0 then
4258
return nil, nil, nil, nil
4359
end
44-
return path, tonumber(lnum), tonumber(col), text
60+
61+
for i = #candidates, 1, -1 do
62+
local candidate = candidates[i]
63+
local resolved = vim.fn.fnamemodify(vim.fn.expand(candidate.path), ":p")
64+
local stat = vim.uv.fs_stat(resolved)
65+
if stat and stat.type == "file" then
66+
return candidate.path, candidate.lnum, candidate.col, candidate.text
67+
end
68+
end
69+
70+
local candidate = candidates[1]
71+
return candidate.path, candidate.lnum, candidate.col, candidate.text
4572
end
4673

4774
---@param output string

lua/peekstack/providers/lsp.lua

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
local location = require("peekstack.core.location")
22
local notify = require("peekstack.util.notify")
3+
local timer = require("peekstack.util.timer")
34

45
local M = {}
56

67
---@alias PeekstackLspResultMapper fun(result: any, provider: string, ctx: PeekstackProviderContext): PeekstackLocation[]
8+
local REQUEST_TIMEOUT_MS = 1500
79

810
---@param symbol table
911
---@param uri string
@@ -103,6 +105,29 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb
103105
local all_locations = {}
104106
local remaining = #clients
105107
local mapper = result_mapper or default_result_mapper
108+
local finished = false
109+
local timeout_ms = M._request_timeout_ms or REQUEST_TIMEOUT_MS
110+
local timeout_handle = vim.uv.new_timer()
111+
112+
local function finish(timed_out)
113+
if finished then
114+
return
115+
end
116+
finished = true
117+
timer.close(timeout_handle)
118+
if timed_out then
119+
notify.warn("LSP request timed out; opening partial results")
120+
end
121+
cb(all_locations)
122+
end
123+
124+
if timeout_handle then
125+
timeout_handle:start(timeout_ms, 0, function()
126+
vim.schedule(function()
127+
finish(true)
128+
end)
129+
end)
130+
end
106131

107132
for _, client in ipairs(clients) do
108133
local params = {
@@ -116,6 +141,9 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb
116141
params_modifier(params)
117142
end
118143
client:request(method, params, function(err, result)
144+
if finished then
145+
return
146+
end
119147
if not err and result then
120148
local ok, locs = pcall(mapper, result, provider, ctx)
121149
if ok and type(locs) == "table" then
@@ -124,7 +152,7 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb
124152
end
125153
remaining = remaining - 1
126154
if remaining == 0 then
127-
cb(all_locations)
155+
finish(false)
128156
end
129157
end, bufnr)
130158
end
@@ -151,5 +179,6 @@ end)
151179
M.symbols_document = create_provider("textDocument/documentSymbol", "lsp.symbols_document", function(params)
152180
params.position = nil
153181
end, document_symbol_result_mapper)
182+
M._request_timeout_ms = REQUEST_TIMEOUT_MS
154183

155184
return M

tests/file_provider_spec.lua

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,58 @@ describe("peekstack.providers.file", function()
9292
vim.api.nvim_buf_delete(bufnr, { force = true })
9393
vim.fn.delete(tmpdir, "rf")
9494
end)
95+
96+
it("resolves path suffix with line and column", function()
97+
local tmpdir = vim.fn.tempname()
98+
vim.fn.mkdir(tmpdir, "p")
99+
local target = tmpdir .. "/target.lua"
100+
vim.fn.writefile({ "first", "second", "third", "fourth" }, target)
101+
local source = tmpdir .. "/source.lua"
102+
vim.fn.writefile({ "target.lua:3:4" }, source)
103+
104+
local bufnr = vim.fn.bufadd(source)
105+
vim.fn.bufload(bufnr)
106+
vim.api.nvim_set_current_buf(bufnr)
107+
vim.api.nvim_win_set_cursor(0, { 1, 0 })
108+
109+
local result = nil
110+
file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations)
111+
result = locations
112+
end)
113+
114+
assert.is_table(result)
115+
assert.equals(1, #result)
116+
assert.equals(2, result[1].range.start.line)
117+
assert.equals(3, result[1].range.start.character)
118+
119+
vim.api.nvim_buf_delete(bufnr, { force = true })
120+
vim.fn.delete(tmpdir, "rf")
121+
end)
122+
123+
it("defaults column to 1 when only a line number is present", function()
124+
local tmpdir = vim.fn.tempname()
125+
vim.fn.mkdir(tmpdir, "p")
126+
local target = tmpdir .. "/target.lua"
127+
vim.fn.writefile({ "first", "second", "third" }, target)
128+
local source = tmpdir .. "/source.lua"
129+
vim.fn.writefile({ "target.lua:2" }, source)
130+
131+
local bufnr = vim.fn.bufadd(source)
132+
vim.fn.bufload(bufnr)
133+
vim.api.nvim_set_current_buf(bufnr)
134+
vim.api.nvim_win_set_cursor(0, { 1, 0 })
135+
136+
local result = nil
137+
file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations)
138+
result = locations
139+
end)
140+
141+
assert.is_table(result)
142+
assert.equals(1, #result)
143+
assert.equals(1, result[1].range.start.line)
144+
assert.equals(0, result[1].range.start.character)
145+
146+
vim.api.nvim_buf_delete(bufnr, { force = true })
147+
vim.fn.delete(tmpdir, "rf")
148+
end)
95149
end)

tests/grep_provider_spec.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ describe("peekstack.providers.grep", function()
4747
assert.is_true(items[1].uri:find("sample.lua", 1, true) ~= nil)
4848
end)
4949

50+
it("prefers the actual file path when text also contains colon-separated numbers", function()
51+
local tmpdir = vim.fn.tempname()
52+
vim.fn.mkdir(tmpdir, "p")
53+
local target = tmpdir .. "/sample:12:34.lua"
54+
vim.fn.writefile({ "first", "second", "third" }, target)
55+
56+
local output = target .. ":2:6:match:9:8:payload"
57+
local items = grep._parse_output(output)
58+
59+
assert.equals(1, #items)
60+
assert.equals("grep.search", items[1].provider)
61+
assert.equals(1, items[1].range.start.line)
62+
assert.equals(5, items[1].range.start.character)
63+
assert.equals("match:9:8:payload", items[1].text)
64+
65+
vim.fn.delete(tmpdir, "rf")
66+
end)
67+
5068
it("formats ignore-file failures with a targeted hint", function()
5169
local message = grep._format_failure_message("error reading .gitignore: invalid UTF-8")
5270

0 commit comments

Comments
 (0)