Skip to content

Commit 982df15

Browse files
committed
feat: add @path mentions and multi-file patch parsing
Foundation for cross-file prompts #18: parse @path from prompt text, load tagged file contents with size limits, and route SEARCH/REPLACE blocks to files by path.
1 parent 2cb29c2 commit 982df15

7 files changed

Lines changed: 535 additions & 12 deletions

File tree

lua/jumpy/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ M.config = {
55
endpoint = nil,
66
model = nil,
77
api_key = nil,
8+
-- TODO: tell the llm to use "<<<< SEARCH path/to/file" for tagged files
89
system_prompt = table.concat({
910
"You are a code editor. The user will give you a file and an instruction.",
1011
"Return ONLY the changed sections as SEARCH/REPLACE blocks.",

lua/jumpy/llm.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ local function get_config()
55
end
66

77
local function build_messages(context)
8+
-- TODO: multi-file user message (--- FILE: path --- blocks) when tagged files present
89
local config = get_config()
910

1011
local user_content = string.format(

lua/jumpy/patch.lua

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,26 @@ local function find_lines(haystack, needle)
3535
return nil
3636
end
3737

38+
local function parse_search_marker(line)
39+
local rest = line:match("^<<<< SEARCH%s*(.*)$")
40+
if rest == nil then
41+
return nil
42+
end
43+
rest = rest:match("^%s*(.-)%s*$")
44+
if rest == "" then
45+
return nil
46+
end
47+
return rest
48+
end
49+
3850
function M.parse(text)
3951
local blocks = {}
4052
local lines = split_lines(text)
4153
local i = 1
4254

4355
while i <= #lines do
44-
if lines[i]:match("^<<<< SEARCH%s*$") then
56+
local path = parse_search_marker(lines[i])
57+
if path ~= nil or lines[i]:match("^<<<< SEARCH%s*$") then
4558
local search_lines = {}
4659
local replace_lines = {}
4760
i = i + 1
@@ -59,6 +72,7 @@ function M.parse(text)
5972
end
6073

6174
table.insert(blocks, {
75+
path = path,
6276
search = search_lines,
6377
replace = replace_lines,
6478
})
@@ -69,13 +83,7 @@ function M.parse(text)
6983
return blocks
7084
end
7185

72-
function M.apply(original_lines, response_text)
73-
local blocks = M.parse(response_text)
74-
75-
if #blocks == 0 then
76-
return split_lines(response_text), 0
77-
end
78-
86+
local function apply_blocks(original_lines, blocks)
7987
local lines = {}
8088
for _, l in ipairs(original_lines) do
8189
table.insert(lines, l)
@@ -87,14 +95,14 @@ function M.apply(original_lines, response_text)
8795
local pos = find_lines(lines, block.search)
8896
if pos then
8997
local new = {}
90-
for i = 1, pos - 1 do
91-
table.insert(new, lines[i])
98+
for j = 1, pos - 1 do
99+
table.insert(new, lines[j])
92100
end
93101
for _, l in ipairs(block.replace) do
94102
table.insert(new, l)
95103
end
96-
for i = pos + #block.search, #lines do
97-
table.insert(new, lines[i])
104+
for j = pos + #block.search, #lines do
105+
table.insert(new, lines[j])
98106
end
99107
lines = new
100108
else
@@ -105,4 +113,52 @@ function M.apply(original_lines, response_text)
105113
return lines, unmatched
106114
end
107115

116+
function M.apply(original_lines, response_text)
117+
local blocks = M.parse(response_text)
118+
119+
if #blocks == 0 then
120+
return split_lines(response_text), 0
121+
end
122+
123+
return apply_blocks(original_lines, blocks)
124+
end
125+
126+
function M.apply_by_file(files_by_path, response_text, primary_path)
127+
-- TODO: call this from prompt once llm responds; paths should match tags.parse keys
128+
local blocks = M.parse(response_text)
129+
130+
if #blocks == 0 then
131+
if primary_path and files_by_path[primary_path] then
132+
local lines, unmatched = M.apply(files_by_path[primary_path], response_text)
133+
return { [primary_path] = { lines = lines, unmatched = unmatched } }, unmatched
134+
end
135+
return {}, 0
136+
end
137+
138+
local grouped = {}
139+
for _, block in ipairs(blocks) do
140+
local key = block.path or primary_path
141+
if key then
142+
grouped[key] = grouped[key] or {}
143+
table.insert(grouped[key], block)
144+
end
145+
end
146+
147+
local results = {}
148+
local total_unmatched = 0
149+
150+
for path, file_blocks in pairs(grouped) do
151+
local original = files_by_path[path]
152+
if not original then
153+
total_unmatched = total_unmatched + #file_blocks
154+
else
155+
local lines, unmatched = apply_blocks(original, file_blocks)
156+
results[path] = { lines = lines, unmatched = unmatched }
157+
total_unmatched = total_unmatched + unmatched
158+
end
159+
end
160+
161+
return results, total_unmatched
162+
end
163+
108164
return M

lua/jumpy/prompt.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ local state = {
88
buf = nil,
99
source_buf = nil,
1010
reprompt_hunk_idx = nil,
11+
-- TODO: tagged_files = { path, bufnr, lines } from tags.parse
1112
}
1213

1314
local mention_ns = vim.api.nvim_create_namespace("jumpy_mentions")
@@ -172,6 +173,7 @@ function M._set_submit_keymap()
172173
end
173174

174175
function M._submit()
176+
-- TODO: tags.parse -> llm with multi-file context -> apply_by_file -> render.show per file
175177
local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false)
176178
local prompt_text = table.concat(lines, "\n")
177179

lua/jumpy/tags.lua

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
local M = {}
2+
3+
-- TODO: hook into prompt._submit, stash result on state.tagged_files
4+
5+
M.MAX_BYTES = 256 * 1024
6+
M.MAX_LINES = 2000
7+
8+
local RESERVED = {
9+
lsp = true,
10+
}
11+
12+
local function word_boundary_before(text, pos)
13+
if pos <= 1 then
14+
return true
15+
end
16+
return not text:sub(pos - 1, pos - 1):match("[%w@]")
17+
end
18+
19+
local function word_boundary_after(text, pos)
20+
if pos >= #text then
21+
return true
22+
end
23+
return not text:sub(pos + 1, pos + 1):match("[%w]")
24+
end
25+
26+
function M.find_mentions(text)
27+
local mentions = {}
28+
local seen = {}
29+
local search_from = 1
30+
31+
while search_from <= #text do
32+
local at = text:find("@", search_from, true)
33+
if not at then
34+
break
35+
end
36+
37+
if word_boundary_before(text, at) then
38+
local rest = text:sub(at + 1)
39+
local path = rest:match("^([%.%w%-_/]+)")
40+
if path and path ~= "" and not RESERVED[path] and word_boundary_after(text, at + #path) then
41+
if not seen[path] then
42+
seen[path] = true
43+
table.insert(mentions, path)
44+
end
45+
search_from = at + #path + 1
46+
else
47+
search_from = at + 1
48+
end
49+
else
50+
search_from = at + 1
51+
end
52+
end
53+
54+
return mentions
55+
end
56+
57+
local function trim(text)
58+
return (text:gsub("^%s+", ""):gsub("%s+$", ""))
59+
end
60+
61+
function M.strip_mentions(text)
62+
local stripped = text
63+
for _, path in ipairs(M.find_mentions(text)) do
64+
stripped = stripped:gsub("%f[%w@]@" .. path:gsub("([%-%.%+%[%]%(%)%$%^%%%?%*])", "%%%1") .. "%f[%W]", "")
65+
end
66+
return trim((stripped:gsub("%s+", " ")))
67+
end
68+
69+
function M.normalize_abs(path)
70+
if vim and vim.fn and vim.fn.fnamemodify then
71+
path = vim.fn.fnamemodify(path, ":p")
72+
end
73+
if path:sub(-1) == "/" then
74+
path = path:sub(1, -2)
75+
end
76+
return path
77+
end
78+
79+
function M.resolve_path(raw_path, root)
80+
root = M.normalize_abs(root or (vim and vim.fn and vim.fn.getcwd() or "."))
81+
if raw_path:sub(1, 1) == "/" then
82+
return M.normalize_abs(raw_path)
83+
end
84+
return M.normalize_abs(root .. "/" .. raw_path)
85+
end
86+
87+
function M.rel_path(abs_path, root)
88+
abs_path = M.normalize_abs(abs_path)
89+
root = M.normalize_abs(root)
90+
local prefix = root .. "/"
91+
if abs_path:sub(1, #prefix) == prefix then
92+
return abs_path:sub(#prefix + 1)
93+
end
94+
return abs_path
95+
end
96+
97+
local function slice_lines(lines, count)
98+
local out = {}
99+
for i = 1, math.min(count, #lines) do
100+
out[i] = lines[i]
101+
end
102+
return out
103+
end
104+
105+
function M.truncate_lines(lines)
106+
local truncated = false
107+
if #lines > M.MAX_LINES then
108+
lines = slice_lines(lines, M.MAX_LINES)
109+
truncated = true
110+
end
111+
return lines, truncated
112+
end
113+
114+
function M.project_root()
115+
local cwd = vim.fn.getcwd()
116+
if vim.system then
117+
local result = vim.system({ "git", "rev-parse", "--show-toplevel" }, { cwd = cwd }):wait()
118+
if result.code == 0 then
119+
local root = vim.trim(result.stdout or "")
120+
if root ~= "" then
121+
return M.normalize_abs(root)
122+
end
123+
end
124+
end
125+
return M.normalize_abs(cwd)
126+
end
127+
128+
function M.find_bufnr(abs_path)
129+
-- TODO: open the file if no buffer exists yet (probably at apply time)
130+
abs_path = M.normalize_abs(abs_path)
131+
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
132+
if vim.api.nvim_buf_is_loaded(bufnr) then
133+
local name = vim.api.nvim_buf_get_name(bufnr)
134+
if name ~= "" and M.normalize_abs(name) == abs_path then
135+
return bufnr
136+
end
137+
end
138+
end
139+
return nil
140+
end
141+
142+
function M.read_lines(abs_path, opts)
143+
opts = opts or {}
144+
145+
if opts.read_file then
146+
return opts.read_file(abs_path)
147+
end
148+
149+
local bufnr = M.find_bufnr(abs_path)
150+
if bufnr then
151+
local lines, truncated = M.truncate_lines(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
152+
local err = truncated and string.format("file exceeds %d line limit: %s", M.MAX_LINES, abs_path) or nil
153+
return lines, err, bufnr
154+
end
155+
156+
local fd = vim.uv and vim.uv.fs_open(abs_path, "r", 438) or nil
157+
if not fd then
158+
return nil, "file not found: " .. abs_path
159+
end
160+
161+
local stat = vim.uv.fs_fstat(fd)
162+
if stat and stat.size > M.MAX_BYTES then
163+
vim.uv.fs_close(fd)
164+
return nil, string.format("file exceeds %d byte limit: %s", M.MAX_BYTES, abs_path)
165+
end
166+
167+
local data = vim.uv.fs_read(fd, M.MAX_BYTES)
168+
vim.uv.fs_close(fd)
169+
170+
if not data then
171+
return nil, "could not read file: " .. abs_path
172+
end
173+
174+
if data:sub(-1) == "\n" then
175+
data = data:sub(1, -2)
176+
end
177+
178+
local lines = data == "" and {} or vim.split(data, "\n", { plain = true })
179+
local truncated
180+
lines, truncated = M.truncate_lines(lines)
181+
if truncated then
182+
return lines, string.format("file exceeds %d line limit: %s", M.MAX_LINES, abs_path)
183+
end
184+
185+
return lines, nil, nil
186+
end
187+
188+
function M.parse(prompt_text, opts)
189+
-- TODO: should probably always include the source buffer too, even without @
190+
opts = opts or {}
191+
local root = opts.root or M.project_root()
192+
local mentions = M.find_mentions(prompt_text)
193+
local tagged = {}
194+
local errors = {}
195+
196+
for _, raw_path in ipairs(mentions) do
197+
local abs_path = M.resolve_path(raw_path, root)
198+
local lines, err, bufnr = M.read_lines(abs_path, opts)
199+
200+
if not lines then
201+
table.insert(errors, err or ("could not read: " .. raw_path))
202+
else
203+
table.insert(tagged, {
204+
path = M.rel_path(abs_path, root),
205+
abs_path = abs_path,
206+
lines = lines,
207+
bufnr = bufnr,
208+
})
209+
if err then
210+
table.insert(errors, err)
211+
end
212+
end
213+
end
214+
215+
return {
216+
tagged = tagged,
217+
cleaned_prompt = M.strip_mentions(prompt_text),
218+
errors = errors,
219+
}
220+
end
221+
222+
return M

0 commit comments

Comments
 (0)