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
21 changes: 19 additions & 2 deletions lua/jumpy/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,25 @@ M.config = {
endpoint = nil,
model = nil,
api_key = nil,
system_prompt = [[You are a code editor. The user will give you a file and an instruction. ]]
.. [[Return ONLY the complete modified file contents. Do not wrap in markdown code fences. Do not explain.]],
system_prompt = table.concat({
"You are a code editor. The user will give you a file and an instruction.",
"Return ONLY the changed sections as SEARCH/REPLACE blocks.",
"",
"Format for each change:",
"<<<< SEARCH",
"exact existing lines from the file",
"====",
"replacement lines",
">>>> REPLACE",
"",
"Rules:",
"- SEARCH content must match the file EXACTLY (whitespace, indentation, etc.)",
"- Include 1-2 lines of surrounding context so the match is unique",
"- For deletions, leave the section between ==== and >>>> REPLACE empty",
"- Output NOTHING outside of SEARCH/REPLACE blocks",
"- Do NOT wrap in markdown code fences",
"- Do NOT explain",
}, "\n"),
keymaps = {
prompt = "<leader>j",
next_hunk = "]h",
Expand Down
108 changes: 108 additions & 0 deletions lua/jumpy/patch.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
local M = {}

local function split_lines(text)
local lines = {}
local start = 1
while true do
local pos = text:find("\n", start, true)
if pos then
table.insert(lines, text:sub(start, pos - 1))
start = pos + 1
else
table.insert(lines, text:sub(start))
break
end
end
return lines
end

local function find_lines(haystack, needle)
if #needle == 0 then
return nil
end
for i = 1, #haystack - #needle + 1 do
local match = true
for j = 1, #needle do
if haystack[i + j - 1] ~= needle[j] then
match = false
break
end
end
if match then
return i
end
end
return nil
end

function M.parse(text)
local blocks = {}
local lines = split_lines(text)
local i = 1

while i <= #lines do
if lines[i]:match("^<<<< SEARCH%s*$") then
local search_lines = {}
local replace_lines = {}
i = i + 1

while i <= #lines and not lines[i]:match("^====%s*$") do
table.insert(search_lines, lines[i])
i = i + 1
end

i = i + 1 -- skip ====

while i <= #lines and not lines[i]:match("^>>>> REPLACE%s*$") do
table.insert(replace_lines, lines[i])
i = i + 1
end

table.insert(blocks, {
search = search_lines,
replace = replace_lines,
})
end
i = i + 1
end

return blocks
end

function M.apply(original_lines, response_text)
local blocks = M.parse(response_text)

if #blocks == 0 then
return split_lines(response_text), 0
end

local lines = {}
for _, l in ipairs(original_lines) do
table.insert(lines, l)
end

local unmatched = 0

for _, block in ipairs(blocks) do
local pos = find_lines(lines, block.search)
if pos then
local new = {}
for i = 1, pos - 1 do
table.insert(new, lines[i])
end
for _, l in ipairs(block.replace) do
table.insert(new, l)
end
for i = pos + #block.search, #lines do
table.insert(new, lines[i])
end
lines = new
else
unmatched = unmatched + 1
end
end

return lines, unmatched
end

return M
11 changes: 10 additions & 1 deletion lua/jumpy/prompt.lua
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,19 @@ function M._submit()

llm.request(context, function(response_text)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(source_buf) then
vim.notify("jumpy: buffer closed, discarding response", vim.log.levels.WARN)
return
end

local diff = require("jumpy.diff")
local render = require("jumpy.render")
local patch = require("jumpy.patch")

local proposed_lines = vim.split(response_text, "\n", { trimempty = false })
local proposed_lines, unmatched = patch.apply(source_lines, response_text)
if unmatched > 0 then
vim.notify(string.format("jumpy: %d block(s) could not be matched", unmatched), vim.log.levels.WARN)
end
local hunks = diff.compute(source_lines, proposed_lines)

if #hunks == 0 then
Expand Down
205 changes: 205 additions & 0 deletions tests/patch_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package.path = package.path .. ";lua/?.lua;lua/?/init.lua"

local patch = require("jumpy.patch")

describe("patch.parse", function()
it("parses a single search/replace block", function()
local text = table.concat({
"<<<< SEARCH",
"old line",
"====",
"new line",
">>>> REPLACE",
}, "\n")
local blocks = patch.parse(text)

assert.are.equal(1, #blocks)
assert.are.same({ "old line" }, blocks[1].search)
assert.are.same({ "new line" }, blocks[1].replace)
end)

it("parses multiple blocks", function()
local text = table.concat({
"<<<< SEARCH",
"aaa",
"====",
"bbb",
">>>> REPLACE",
"<<<< SEARCH",
"ccc",
"====",
"ddd",
">>>> REPLACE",
}, "\n")
local blocks = patch.parse(text)

assert.are.equal(2, #blocks)
assert.are.same({ "aaa" }, blocks[1].search)
assert.are.same({ "bbb" }, blocks[1].replace)
assert.are.same({ "ccc" }, blocks[2].search)
assert.are.same({ "ddd" }, blocks[2].replace)
end)

it("parses multi-line search and replace", function()
local text = table.concat({
"<<<< SEARCH",
"line 1",
"line 2",
"line 3",
"====",
"new 1",
"new 2",
">>>> REPLACE",
}, "\n")
local blocks = patch.parse(text)

assert.are.equal(1, #blocks)
assert.are.same({ "line 1", "line 2", "line 3" }, blocks[1].search)
assert.are.same({ "new 1", "new 2" }, blocks[1].replace)
end)

it("parses deletion (empty replace)", function()
local text = table.concat({
"<<<< SEARCH",
"delete me",
"====",
">>>> REPLACE",
}, "\n")
local blocks = patch.parse(text)

assert.are.equal(1, #blocks)
assert.are.same({ "delete me" }, blocks[1].search)
assert.are.same({}, blocks[1].replace)
end)

it("returns empty for text with no blocks", function()
local blocks = patch.parse("just some random text\nno blocks here")
assert.are.equal(0, #blocks)
end)
end)

describe("patch.apply", function()
it("applies a single replacement", function()
local original = { "a", "b", "c" }
local response = table.concat({
"<<<< SEARCH",
"b",
"====",
"X",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ "a", "X", "c" }, result)
end)

it("applies multiple replacements", function()
local original = { "a", "b", "c", "d", "e" }
local response = table.concat({
"<<<< SEARCH",
"b",
"====",
"X",
">>>> REPLACE",
"<<<< SEARCH",
"d",
"====",
"Y",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ "a", "X", "c", "Y", "e" }, result)
end)

it("applies a deletion", function()
local original = { "a", "b", "c" }
local response = table.concat({
"<<<< SEARCH",
"b",
"====",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ "a", "c" }, result)
end)

it("applies an insertion via context lines", function()
local original = { "a", "c" }
local response = table.concat({
"<<<< SEARCH",
"a",
"c",
"====",
"a",
"b",
"c",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ "a", "b", "c" }, result)
end)

it("reports unmatched blocks", function()
local original = { "a", "b", "c" }
local response = table.concat({
"<<<< SEARCH",
"zzz",
"====",
"yyy",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(1, unmatched)
assert.are.same({ "a", "b", "c" }, result)
end)

it("falls back to full-file when no blocks found", function()
local original = { "a", "b" }
local response = "x\ny\nz"

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ "x", "y", "z" }, result)
end)

it("handles multi-line search with context", function()
local original = { "header", " old1", " old2", "footer" }
local response = table.concat({
"<<<< SEARCH",
"header",
" old1",
" old2",
"====",
"header",
" new1",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ "header", " new1", "footer" }, result)
end)

it("preserves indentation exactly", function()
local original = { " if true then", " print('hi')", " end" }
local response = table.concat({
"<<<< SEARCH",
" print('hi')",
"====",
" print('hello')",
">>>> REPLACE",
}, "\n")

local result, unmatched = patch.apply(original, response)
assert.are.equal(0, unmatched)
assert.are.same({ " if true then", " print('hello')", " end" }, result)
end)
end)
Loading