diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index 2cdf7f0..2b9a5b3 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -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 = "j", next_hunk = "]h", diff --git a/lua/jumpy/patch.lua b/lua/jumpy/patch.lua new file mode 100644 index 0000000..5c55ecf --- /dev/null +++ b/lua/jumpy/patch.lua @@ -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 diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index aba0946..c1c8366 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -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 diff --git a/tests/patch_spec.lua b/tests/patch_spec.lua new file mode 100644 index 0000000..a3bb24c --- /dev/null +++ b/tests/patch_spec.lua @@ -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)