From bfbdbcdb30b750092ea8a88d5e481e39a1fbff5a Mon Sep 17 00:00:00 2001 From: Elliott Date: Fri, 17 Apr 2026 06:35:28 +1000 Subject: [PATCH 1/6] feat(tools): implement `apply_patch` tool Implement a structured patch application tool that allows the LLM to add, delete, and update files in the codebase. - Implement `apply_patch` tool logic with support for `*** Add File`, `*** Delete File`, and `*** Update File` (including `*** Move to`) operations. - Add a robust seeking mechanism for updates using a fallback hierarchy: Exact Match $\rightarrow$ RStrip $\rightarrow$ Trim $\rightarrow$ Normalized Unicode. - Register the tool in `config.lua` and provide a comprehensive system prompt. - Add unit tests covering parsing, file creation, deletion, updates, and renames. - Include `apply_patch_plan.md` detailing the tool specification. --- apply_patch_plan.md | 111 +++++ lua/codecompanion/config.lua | 9 + .../chat/tools/builtin/apply_patch.lua | 421 ++++++++++++++++++ .../chat/tools/test_apply_patch.lua | 300 +++++++++++++ 4 files changed, 841 insertions(+) create mode 100644 apply_patch_plan.md create mode 100644 lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua create mode 100644 tests/interactions/chat/tools/test_apply_patch.lua diff --git a/apply_patch_plan.md b/apply_patch_plan.md new file mode 100644 index 000000000..66f669a97 --- /dev/null +++ b/apply_patch_plan.md @@ -0,0 +1,111 @@ +# Apply Patch Tool Specification + +## Purpose +Implement the `apply_patch` tool for the `codecompanion` Neovim plugin (Lua) based on the `opencode` TypeScript implementation. + +## Original Source Files +- `~/workspace/patch_tool/opencode/packages/opencode/src/tool/apply_patch.txt` - this is the prompt file that describes the tool and how to use it. +- `~/workspace/patch_tool/opencode/packages/opencode/src/tool/apply_patch.ts` - this is the logic we need to duplicate +- `~/workspace/patch_tool/opencode/packages/opencode/src/patch/index.ts` + +## codecompanion tool documentation +- https://codecompanion.olimorris.dev/extending/tools - documentation for implementing tools in codecompanion. + +## Specification + +### 1. Patch Format +The patch is enclosed in a high-level envelope: +``` +*** Begin Patch +[ one or more file sections ] +*** End Patch +``` + +**File Section Headers**: +- `*** Add File: `: Create a new file. Subsequent lines starting with `+` are the content. +- `*** Delete File: `: Remove the specified file. +- `*** Update File: `: Patch an existing file. + - Optional: `*** Move to: ` immediately following the update header to rename the file. + - Content updates are defined by "chunks" starting with `@@ `. + - Lines in chunks: + - ` ` (space): Context line (must match original). + - `-`: Line to remove. + - `+`: Line to add. + - Optional: `*** End of File` marker. + +### 2. Core Logic & Behavior + +**A. Parsing Phase** +- Strip heredoc wrappers if present. +- Identify `*** Begin Patch` and `*** End Patch` markers. +- Parse sections into "hunks" (Add, Delete, Update). +- For updates, capture the `change_context` and the sequences of `old_lines` and `new_lines`. + +**B. Application Phase** +- **Add**: Create parent directories recursively and write the `+` prefixed content. +- **Delete**: Remove the file from the filesystem. +- **Update**: + - **Seeking**: Locate the replacement point. + 1. Use `change_context` if provided to find the starting line. + 2. Match `old_lines` exactly. + 3. Fallback: Match after trimming trailing whitespace. + 4. Fallback: Match after trimming both ends. + 5. Fallback: Match after normalizing Unicode punctuation to ASCII. + - **Replacement**: Replace the matched `old_lines` with `new_lines`. + - **Rename**: If `*** Move to` is present, write the new content to the destination and delete the original. + +**C. Verification & Constraints** +- Ensure paths are resolved relative to the project root. +- Validations: + - Fail if `*** Begin/End Patch` markers are missing. + - Fail if an `Update` hunk cannot find the expected `old_lines` or `context` in the target file. + - Fail if a file to be updated or deleted does not exist. + +### 3. Expected Input/Output +- **Input**: A string containing the full patch text. +- **Output**: A summary of changes (e.g., `A path/to/file`, `M path/to/file`, `D path/to/file`). + +## Implementation Plan for CodeCompanion + +### 1. Tool Structure +Define the tool in `lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua` using the `CodeCompanion.Tools.Tool` structure: +- **Name**: `apply_patch` +- **Description**: "Apply a structured patch to the codebase to add, delete, or update files." +- **Schema**: Full OpenAI compatible function schema. + - `parameters`: Object with required `patchText` (string). +- **Opts**: `{ require_approval_before = true }` to ensure user safety during filesystem mutations. + +### 2. Execution Logic (`cmds`) +Implement the core logic within a function in the `cmds` table. This function will receive `(self, args, opts)` and must return `{ status = "success"|"error", data = any }`. + +**Internal Implementation Phases:** +- **Phase 1: The Parser (The "Frontend")** + - Validate `*** Begin Patch` and `*** End Patch` markers. + - Decompose `patchText` into a list of **Hunks** (Add, Delete, Update). + - Parse chunks for updates (@@ markers, context, and +/- changes). +- **Phase 2: The Seeking Logic (The "Engine")** + - Match `old_lines` using a fallback hierarchy: + 1. Exact Match $\rightarrow$ 2. RStrip Match $\rightarrow$ 3. Trim Match $\rightarrow$ 4. Normalized Match (Unicode $\rightarrow$ ASCII). +- **Phase 3: The Application Logic (The "Backend")** + - Use `vim.fs.mkdir` (recursive) and `io.write` for **Add/Update**. + - Use `vim.fs.remove` for **Delete**. + - Handle **Rename** by writing to the new path and deleting the old one. +- **Phase 4: Summary Generation** + - Return a success summary listing affected files with prefixes: `A` (Added), `M` (Modified/Moved), `D` (Deleted). + +### 3. Output Handling (`output`) +- **`success`**: Use `meta.tools.chat:add_tool_output(self, stdout[1])` to share the summary with the LLM and user. +- **`error`**: Report the failure message back to the chat buffer. + +### 4. Summary of Mapping + +| TypeScript (`opencode`) | Lua (`codecompanion`) | +| :--- | :--- | +| `z.object({ patchText: ... })` | `schema.parameters.properties.patchText` | +| `Patch.parsePatch` | `cmds` function $\rightarrow$ internal `parse_patch` | +| `Patch.seekSequence` | `cmds` function $\rightarrow$ internal `seek_sequence` | +| `afs.writeWithDirs` | `vim.fs.mkdir` + `io.write` | +| `afs.remove` | `vim.fs.remove` | +| `Effect.fail` | `return { status = "error", data = "..." }` | +| `LSP.Diagnostic.report` | (Simplified) Summary of A/M/D files | + diff --git a/lua/codecompanion/config.lua b/lua/codecompanion/config.lua index e3f660fd5..f406d1a6c 100644 --- a/lua/codecompanion/config.lua +++ b/lua/codecompanion/config.lua @@ -164,6 +164,7 @@ The user is working on a %s machine. Please respond with system specific command "insert_edit_into_file", "read_file", "run_command", + "apply_patch", }, opts = { collapse_tools = true, @@ -182,6 +183,7 @@ The user is working on a %s machine. Please respond with system specific command "grep_search", "insert_edit_into_file", "read_file", + "apply_patch", }, opts = { collapse_tools = true, @@ -267,6 +269,13 @@ The user is working on a %s machine. Please respond with system specific command whitelist = {}, -- e.g. { { path = "/absolute/path", as = "/alias" } } }, }, + ["apply_patch"] = { + path = "interactions.chat.tools.builtin.apply_patch", + description = "Apply a structured patch to the codebase to add, delete, or update files.", + opts = { + require_approval_before = true, + }, + }, ["read_file"] = { path = "interactions.chat.tools.builtin.read_file", description = "Read a file in the current working directory", diff --git a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua new file mode 100644 index 000000000..762d1f9a1 --- /dev/null +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua @@ -0,0 +1,421 @@ +---@class UpdateFileChunk +---@field old_lines string[] +---@field new_lines string[] +---@field change_context string|nil +---@field is_end_of_file boolean|nil + +---@class Hunk +---@field type "add"|"delete"|"update" +---@field path string +---@field contents string|nil +---@field move_path string|nil +---@field chunks UpdateFileChunk[]|nil + +local M = {} + +local function strip_heredoc(input) + local heredoc_pattern = "^(?:cat%s+)?<<['\"]?(%w+)['\"]?%s*\n([%s%S]*?)\n%1%s*$" + local match = input:match(heredoc_pattern) + if match then + return match + end + return input +end + +local function normalize_unicode(str) + if not str then + return "" + end + local s = str:gsub("[\u{2018}\u{2019}\u{201A}\u{201B}]", "'") + s = s:gsub("[\u{201C}\u{201D}\u{201E}\u{201F}]", '"') + s = s:gsub("[\u{2010}\u{2011}\u{2012}\u{2013}\u{2014}\u{2015}]", "-") + s = s:gsub("\u{2026}", "...") + s = s:gsub("\u{00A0}", " ") + return s +end + +local function try_match(lines, pattern, start_index, compare_fn, eof) + if eof then + local match_idx = #lines - #pattern + 1 + if match_idx >= start_index then + local matches = true + for j = 1, #pattern do + if not compare_fn(lines[match_idx + j - 1], pattern[j]) then + matches = false + break + end + end + if matches then + return match_idx + end + end + return -1 + end + + for i = start_index, #lines - #pattern + 1 do + local matches = true + for j = 1, #pattern do + if not compare_fn(lines[i + j - 1], pattern[j]) then + matches = false + break + end + end + if matches then + return i + end + end + + return -1 +end + +local function seek_sequence(lines, pattern, start_index, eof) + if #pattern == 0 then + if eof then + return #lines + 1 + end + return -1 + end + + -- Pass 1: exact match + local exact = try_match(lines, pattern, start_index, function(a, b) + return a == b + end, eof) + if exact ~= -1 then + return exact + end + + -- Pass 2: rstrip + local rstrip = try_match(lines, pattern, start_index, function(a, b) + return (a or ""):gsub("%s*$", "") == (b or ""):gsub("%s*$", "") + end, eof) + if rstrip ~= -1 then + return rstrip + end + + -- Pass 3: trim + local trim = try_match(lines, pattern, start_index, function(a, b) + return (a or ""):gsub("^%s*(.-)%s*$", "%1") == (b or ""):gsub("^%s*(.-)%s*$", "%1") + end, eof) + if trim ~= -1 then + return trim + end + + -- Pass 4: normalized + local normalized = try_match(lines, pattern, start_index, function(a, b) + return normalize_unicode((a or ""):gsub("^%s*(.-)%s*$", "%1")) + == normalize_unicode((b or ""):gsub("^%s*(.-)%s*$", "%1")) + end, eof) + + return normalized +end + +local function parse_patch(patch_text) + local cleaned = strip_heredoc(patch_text:gsub("^%s*(.-)%s*$", "%1")) + local lines = {} + for line in cleaned:gmatch("([^\n]*)\n?") do + table.insert(lines, line) + end + + local begin_marker = "*** Begin Patch" + local end_marker = "*** End Patch" + local begin_idx, end_idx = -1, -1 + + for i, line in ipairs(lines) do + if line:match("^%s*" .. begin_marker .. "%s*$") then + begin_idx = i + end + if line:match("^%s*" .. end_marker .. "%s*$") then + end_idx = i + end + end + + if begin_idx == -1 or end_idx == -1 or begin_idx >= end_idx then + error("Invalid patch format: missing Begin/End markers") + end + + local hunks = {} + local i = begin_idx + 1 + while i < end_idx do + local line = lines[i] + if line:match("^*** Add File: (.+)") then + local path = line:match("^*** Add File: (.+)") + local contents = "" + i = i + 1 + while i < end_idx and not lines[i]:match("^***") do + if lines[i]:match("^%+") then + contents = contents .. lines[i]:sub(2) .. "\n" + end + i = i + 1 + end + if contents:match("\n$") then + contents = contents:sub(1, -2) + end + table.insert(hunks, { type = "add", path = path, contents = contents }) + elseif line:match("^*** Delete File: (.+)") then + local path = line:match("^*** Delete File: (.+)") + table.insert(hunks, { type = "delete", path = path }) + i = i + 1 + elseif line:match("^*** Update File: (.+)") then + local path = line:match("^*** Update File: (.+)") + local move_path = nil + i = i + 1 + if i < end_idx and lines[i]:match("^*** Move to: (.+)") then + move_path = lines[i]:match("^*** Move to: (.+)") + i = i + 1 + end + local chunks = {} + while i < end_idx and not lines[i]:match("^***") do + if lines[i]:match("^@@ (.+)") then + local context = lines[i]:match("^@@ (.+)") + i = i + 1 + local old_lines, new_lines = {}, {} + local is_end_of_file = false + while i < end_idx and not lines[i]:match("^@@") and not lines[i]:match("^***") do + if lines[i] == "*** End of File" then + is_end_of_file = true + i = i + 1 + break + elseif lines[i]:match("^ ") then + local content = lines[i]:sub(2) + table.insert(old_lines, content) + table.insert(new_lines, content) + elseif lines[i]:match("^-") then + table.insert(old_lines, lines[i]:sub(2)) + elseif lines[i]:match("^%+") then + table.insert(new_lines, lines[i]:sub(2)) + end + i = i + 1 + end + table.insert( + chunks, + { old_lines = old_lines, new_lines = new_lines, change_context = context, is_end_of_file = is_end_of_file } + ) + else + i = i + 1 + end + end + table.insert(hunks, { type = "update", path = path, move_path = move_path, chunks = chunks }) + else + i = i + 1 + end + end + + return { hunks = hunks } +end + +local file_utils = require("codecompanion.utils.files") +local log = require("codecompanion.utils.log") +local tool_helpers = require("codecompanion.interactions.chat.tools.builtin.helpers") + +local function write_file(path, content) + local dir = vim.fn.fnamemodify(path, ":h") + vim.fn.mkdir(dir, "p") + local f = io.open(path, "w") + if not f then + error("Could not open file for writing: " .. path) + end + f:write(content) + f:close() +end + +local tool = { + name = "apply_patch", + cmds = { + ---Execute the apply patch commands + ---@param self CodeCompanion.Tool.ApplyPatch + ---@param args table The arguments from the LLM's tool call + ---@param input? any The output from the previous function call + ---@return { status: "success"|"error", data: string } + function(self, args, input) + if not args.patchText then + return { status = "error", data = "patchText is required" } + end + + local success, result = pcall(parse_patch, args.patchText) + if not success then + return { status = "error", data = "Patch parsing failed: " .. result } + end + + local hunks = result.hunks + if #hunks == 0 then + return { status = "error", data = "No hunks found in patch" } + end + + local summary = {} + + for _, hunk in ipairs(hunks) do + local path = file_utils.validate_and_normalize_path(hunk.path) + + if hunk.type == "add" then + write_file(path, hunk.contents or "") + table.insert(summary, "A " .. path) + elseif hunk.type == "delete" then + if vim.fn.getfsize(path) == -1 then + return { status = "error", data = "File to delete does not exist: " .. path } + end + vim.fn.delete(path, "rf") + table.insert(summary, "D " .. path) + elseif hunk.type == "update" then + if vim.fn.getfsize(path) == -1 then + return { status = "error", data = "File to update does not exist: " .. path } + end + + local lines = {} + local f = io.open(path, "r") + if f then + for line in f:lines() do + table.insert(lines, line) + end + f:close() + end + + local current_idx = 1 + for _, chunk in ipairs(hunk.chunks) do + local search_start = current_idx + + if chunk.change_context then + local ctx_match = seek_sequence(lines, { chunk.change_context }, search_start, chunk.is_end_of_file) + if ctx_match == -1 then + return { status = "error", data = fmt("Could not find context '%s' in %s", chunk.change_context, path) } + end + search_start = ctx_match + 1 + end + + local match_idx = seek_sequence(lines, chunk.old_lines, search_start, chunk.is_end_of_file) + + if match_idx == -1 and #chunk.old_lines == 0 then + match_idx = search_start + end + + if match_idx == -1 then + return { status = "error", data = "Could not find match for hunk in " .. path } + end + + local before = {} + for i = 1, match_idx - 1 do + table.insert(before, lines[i]) + end + + local after = {} + for i = match_idx + #chunk.old_lines, #lines do + table.insert(after, lines[i]) + end + + local new_lines = {} + for i = 1, #before do + table.insert(new_lines, before[i]) + end + for i = 1, #chunk.new_lines do + table.insert(new_lines, chunk.new_lines[i]) + end + for i = 1, #after do + table.insert(new_lines, after[i]) + end + + lines = new_lines + current_idx = match_idx + #chunk.new_lines + end + + local final_content = table.concat(lines, "\n") + if #lines > 0 and lines[#lines] ~= "" then + final_content = final_content .. "\n" + end + + local target_path = path + if hunk.move_path then + target_path = file_utils.validate_and_normalize_path(hunk.move_path) + end + + write_file(target_path, final_content) + if hunk.move_path then + vim.fn.delete(path, "rf") + end + table.insert(summary, "M " .. target_path) + end + end + + return { + status = "success", + data = "Success. Updated the following files:\n" .. table.concat(summary, "\n"), + } + end, + }, + schema = { + type = "function", + ["function"] = { + name = "apply_patch", + description = "Apply a structured patch to the codebase to add, delete, or update files.", + parameters = { + type = "object", + properties = { + patchText = { + type = "string", + description = "The full patch text that describes all changes to be made", + }, + }, + required = { "patchText" }, + }, + }, + }, + system_prompt = function() + return [[Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: + +*** Begin Patch +[ one or more file sections ] +*** End Patch + +Within that envelope, you get a sequence of file operations. +You MUST include a header to specify the action you are taking. +Each operation starts with one of three headers: + +*** Add File: - create a new file. Every following line is a + line (the initial contents). +*** Delete File: - remove an existing file. Nothing follows. +*** Update File: - patch an existing file in place (optionally with a rename). + +Example patch: + +``` +*** Begin Patch +*** Add File: hello.txt ++Hello world +*** Update File: src/app.py +*** Move to: src/main.py +@@ def greet(): +-print("Hi") ++print("Hello, world!") +*** Delete File: obsolete.txt +*** End Patch +``` + +It is important to remember: + +- You must include a header with your intended action (Add/Delete/Update) +- You must prefix new lines with `+` even when creating a new file]] + end, + output = { + cmd_string = function(self, opts) + return "apply_patch" + end, + prompt = function(self, meta) + return "Apply patch to codebase?" + end, + success = function(self, stdout, meta) + local chat = meta.tools.chat + chat:add_tool_output(self, stdout[1]) + end, + error = function(self, stderr, meta) + local chat = meta.tools.chat + local errors = vim.iter(stderr):flatten():join("\n") + chat:add_tool_output(self, errors) + end, + rejected = function(self, meta) + tool_helpers.rejected(self, meta) + end, + }, + opts = { + require_approval_before = true, + }, +} + +tool.parse_patch = parse_patch +return tool diff --git a/tests/interactions/chat/tools/test_apply_patch.lua b/tests/interactions/chat/tools/test_apply_patch.lua new file mode 100644 index 000000000..0d64c6925 --- /dev/null +++ b/tests/interactions/chat/tools/test_apply_patch.lua @@ -0,0 +1,300 @@ +local h = require("tests.helpers") + +local new_set = MiniTest.new_set + +local child = MiniTest.new_child_neovim() +T = new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_case = function() + -- Cleanup test files + child.lua([[ + if vim.fn.isdirectory('test_patch_dir') == 1 then + vim.fn.delete('test_patch_dir', 'rf') + end + ]]) + end, + post_once = child.stop, + }, +}) + +T["Apply Patch"] = new_set() +T["Apply Patch"]["can parse patch text"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + local patch_text = [[ +*** Begin Patch +*** Add File: test_patch.txt ++hello world +*** End Patch + ]] + local result = apply_patch.parse_patch(patch_text) + _G.result = result + ]=]) + + h.eq(1, child.lua_get("#_G.result.hunks")) + h.eq("add", child.lua_get("_G.result.hunks[1].type")) + h.eq("test_patch.txt", child.lua_get("_G.result.hunks[1].path")) + h.eq("hello world", child.lua_get("_G.result.hunks[1].contents")) +end + +T["Apply Patch"]["can parse update patch text"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + local patch_text = [[ +*** Begin Patch +*** Update File: test_update.txt +@@ context +-old line ++new line +*** End Patch + ]] + local result = apply_patch.parse_patch(patch_text) + _G.result = result + ]=]) + + h.eq(1, child.lua_get("#_G.result.hunks")) + h.eq("update", child.lua_get("_G.result.hunks[1].type")) + h.eq("test_update.txt", child.lua_get("_G.result.hunks[1].path")) + h.eq(1, child.lua_get("#_G.result.hunks[1].chunks")) + h.eq("context", child.lua_get("_G.result.hunks[1].chunks[1].change_context")) + h.eq("old line", child.lua_get("_G.result.hunks[1].chunks[1].old_lines[1]")) + h.eq("new line", child.lua_get("_G.result.hunks[1].chunks[1].new_lines[1]")) +end + +T["Apply Patch"]["can add a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + local patch_text = [[ +*** Begin Patch +*** Add File: test_patch_dir/new_file.txt ++hello world +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + _G.file_exists = vim.fn.getfsize('test_patch_dir/new_file.txt') ~= -1 + + local f = io.open('test_patch_dir/new_file.txt', 'r') + _G.file_content = f:read('*a') + f:close() + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq(true, child.lua_get("_G.file_exists")) + h.eq("hello world", child.lua_get("_G.file_content")) +end + +T["Apply Patch"]["can delete a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file to delete + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/delete_me.txt', 'w') + f:write('bye') + f:close() + + local patch_text = [[ +*** Begin Patch +*** Delete File: test_patch_dir/delete_me.txt +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + _G.file_exists = vim.fn.getfsize('test_patch_dir/delete_me.txt') ~= -1 + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq(false, child.lua_get("_G.file_exists")) +end + +T["Apply Patch"]["can update a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file to update + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/update_me.txt', 'w') + f:write("line 1\nline 2\nline 3\n") + f:close() + + local patch_text = [[ +*** Begin Patch +*** Update File: test_patch_dir/update_me.txt +@@ context +-line 2 ++updated line 2 +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + + local f = io.open('test_patch_dir/update_me.txt', 'r') + _G.file_content = f:read('*a') + f:close() + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq("line 1\nupdated line 2\nline 3\n", child.lua_get("_G.file_content")) +end + +T["Apply Patch"]["can update and move a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file to update and move + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/old_name.txt', 'w') + f:write("old content\n") + f:close() + + local patch_text = [[ +*** Begin Patch +*** Update File: test_patch_dir/old_name.txt +*** Move to: test_patch_dir/new_name.txt +@@ context +-old content ++new content +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + _G.old_exists = vim.fn.getfsize('test_patch_dir/old_name.txt') ~= -1 + _G.new_exists = vim.fn.getfsize('test_patch_dir/new_name.txt') ~= -1 + + local f = io.open('test_patch_dir/new_name.txt', 'r') + _G.new_content = f:read('*a') + f:close() + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq(false, child.lua_get("_G.old_exists")) + h.eq(true, child.lua_get("_G.new_exists")) + h.eq("new content\n", child.lua_get("_G.new_content")) +end + +T["Apply Patch"]["fails if file to update does not exist"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + local patch_text = [[ +*** Begin Patch +*** Update File: non_existent.txt +@@ context +-old ++new +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + ]=]) + + h.eq("error", child.lua_get("_G.result.status")) + h.expect_starts_with("File to update does not exist", child.lua_get("_G.result.data")) +end + +T["Apply Patch"]["can delete a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file to delete + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/delete_me.txt', 'w') + f:write('bye') + f:close() + + local patch_text = [[ +*** Begin Patch +*** Delete File: test_patch_dir/delete_me.txt +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + _G.file_exists = vim.fn.getfsize('test_patch_dir/delete_me.txt') ~= -1 + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq(false, child.lua_get("_G.file_exists")) +end + +T["Apply Patch"]["can update a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file to update + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/update_me.txt', 'w') + f:write("line 1\nline 2\nline 3\n") + f:close() + + local patch_text = [[ +*** Begin Patch +*** Update File: test_patch_dir/update_me.txt +@@ context +-line 2 ++updated line 2 +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + + local f = io.open('test_patch_dir/update_me.txt', 'r') + _G.file_content = f:read('*a') + f:close() + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq("line 1\nupdated line 2\nline 3\n", child.lua_get("_G.file_content")) +end + +T["Apply Patch"]["can update and move a file"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file to update and move + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/old_name.txt', 'w') + f:write("old content\n") + f:close() + + local patch_text = [[ +*** Begin Patch +*** Update File: test_patch_dir/old_name.txt +*** Move to: test_patch_dir/new_name.txt +@@ context +-old content ++new content +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + _G.old_exists = vim.fn.getfsize('test_patch_dir/old_name.txt') ~= -1 + _G.new_exists = vim.fn.getfsize('test_patch_dir/new_name.txt') ~= -1 + + local f = io.open('test_patch_dir/new_name.txt', 'r') + _G.new_content = f:read('*a') + f:close() + ]=]) + + h.eq("success", child.lua_get("_G.result.status")) + h.eq(false, child.lua_get("_G.old_exists")) + h.eq(true, child.lua_get("_G.new_exists")) + h.eq("new content\n", child.lua_get("_G.new_content")) +end + +T["Apply Patch"]["fails if file to update does not exist"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + local patch_text = [[ +*** Begin Patch +*** Update File: non_existent.txt +@@ context +-old ++new +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + ]=]) + + h.eq("error", child.lua_get("_G.result.status")) + h.expect_starts_with("File to update does not exist", child.lua_get("_G.result.data")) +end + +return T From 8dbfc2af97a4e843c9a7a54b3eb583ca6bfa0cfb Mon Sep 17 00:00:00 2001 From: Elliott Date: Fri, 17 Apr 2026 06:57:51 +1000 Subject: [PATCH 2/6] refactor(apply_patch): move system prompt to external markdown file --- .../chat/tools/builtin/apply_patch.lua | 50 ++++++------------- .../chat/tools/builtin/apply_patch.md | 33 ++++++++++++ 2 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 lua/codecompanion/interactions/chat/tools/builtin/apply_patch.md diff --git a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua index 762d1f9a1..7a1a8aed1 100644 --- a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua @@ -218,6 +218,20 @@ local function write_file(path, content) f:close() end +---Load prompt from markdown file +---@return string The prompt content +local function load_prompt() + local source_path = debug.getinfo(1, "S").source:sub(2) + local dir = vim.fn.fnamemodify(source_path, ":h") + local prompt_path = dir .. "/apply_patch.md" + if vim.fn.filereadable(prompt_path) == 0 then + error("Prompt file not found: " .. prompt_path) + end + return table.concat(vim.fn.readfile(prompt_path), "\n") +end + +local PROMPT = load_prompt() + local tool = { name = "apply_patch", cmds = { @@ -357,41 +371,7 @@ local tool = { }, }, }, - system_prompt = function() - return [[Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: - -*** Begin Patch -[ one or more file sections ] -*** End Patch - -Within that envelope, you get a sequence of file operations. -You MUST include a header to specify the action you are taking. -Each operation starts with one of three headers: - -*** Add File: - create a new file. Every following line is a + line (the initial contents). -*** Delete File: - remove an existing file. Nothing follows. -*** Update File: - patch an existing file in place (optionally with a rename). - -Example patch: - -``` -*** Begin Patch -*** Add File: hello.txt -+Hello world -*** Update File: src/app.py -*** Move to: src/main.py -@@ def greet(): --print("Hi") -+print("Hello, world!") -*** Delete File: obsolete.txt -*** End Patch -``` - -It is important to remember: - -- You must include a header with your intended action (Add/Delete/Update) -- You must prefix new lines with `+` even when creating a new file]] - end, + system_prompt = PROMPT, output = { cmd_string = function(self, opts) return "apply_patch" diff --git a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.md b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.md new file mode 100644 index 000000000..5b2d95608 --- /dev/null +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.md @@ -0,0 +1,33 @@ +Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: + +*** Begin Patch +[ one or more file sections ] +*** End Patch + +Within that envelope, you get a sequence of file operations. +You MUST include a header to specify the action you are taking. +Each operation starts with one of three headers: + +*** Add File: - create a new file. Every following line is a + line (the initial contents). +*** Delete File: - remove an existing file. Nothing follows. +*** Update File: - patch an existing file in place (optionally with a rename). + +Example patch: + +``` +*** Begin Patch +*** Add File: hello.txt ++Hello world +*** Update File: src/app.py +*** Move to: src/main.py +@@ def greet(): +-print("Hi") ++print("Hello, world!") +*** Delete File: obsolete.txt +*** End Patch +``` + +It is important to remember: + +- You must include a header with your intended action (Add/Delete/Update) +- You must prefix new lines with `+` even when creating a new file From 4173d504cdc2eab7dd87bbea3edfb426791ddb62 Mon Sep 17 00:00:00 2001 From: Elliott Date: Fri, 17 Apr 2026 07:24:48 +1000 Subject: [PATCH 3/6] refactor(tools): improve apply_patch tool robustness and typing - Add type annotations for the `ApplyPatch` tool. - Validate normalized paths and move paths to prevent invalid file operations. - Reorganize imports to the top of the file for better consistency. - Enhance error messages when patch context is not found. --- .../chat/tools/builtin/apply_patch.lua | 165 ++++++++++-------- 1 file changed, 91 insertions(+), 74 deletions(-) diff --git a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua index 7a1a8aed1..ccf3e474d 100644 --- a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua @@ -1,3 +1,7 @@ +local file_utils = require("codecompanion.utils.files") +local fmt = string.format +local tool_helpers = require("codecompanion.interactions.chat.tools.builtin.helpers") + ---@class UpdateFileChunk ---@field old_lines string[] ---@field new_lines string[] @@ -11,7 +15,13 @@ ---@field move_path string|nil ---@field chunks UpdateFileChunk[]|nil -local M = {} +---@class CodeCompanion.Tool.ApplyPatch +---@field name string +---@field cmds table +---@field schema table +---@field system_prompt string +---@field output table +---@field opts table local function strip_heredoc(input) local heredoc_pattern = "^(?:cat%s+)?<<['\"]?(%w+)['\"]?%s*\n([%s%S]*?)\n%1%s*$" @@ -203,10 +213,6 @@ local function parse_patch(patch_text) return { hunks = hunks } end -local file_utils = require("codecompanion.utils.files") -local log = require("codecompanion.utils.log") -local tool_helpers = require("codecompanion.interactions.chat.tools.builtin.helpers") - local function write_file(path, content) local dir = vim.fn.fnamemodify(path, ":h") vim.fn.mkdir(dir, "p") @@ -260,91 +266,102 @@ local tool = { for _, hunk in ipairs(hunks) do local path = file_utils.validate_and_normalize_path(hunk.path) - if hunk.type == "add" then - write_file(path, hunk.contents or "") - table.insert(summary, "A " .. path) - elseif hunk.type == "delete" then - if vim.fn.getfsize(path) == -1 then - return { status = "error", data = "File to delete does not exist: " .. path } - end - vim.fn.delete(path, "rf") - table.insert(summary, "D " .. path) - elseif hunk.type == "update" then - if vim.fn.getfsize(path) == -1 then - return { status = "error", data = "File to update does not exist: " .. path } - end + if not path then + table.insert(summary, "Skipped (invalid path): " .. tostring(hunk.path)) + else + if hunk.type == "add" then + write_file(path, hunk.contents or "") + table.insert(summary, "A " .. path) + elseif hunk.type == "delete" then + if vim.fn.getfsize(path) == -1 then + return { status = "error", data = "File to delete does not exist: " .. path } + end + vim.fn.delete(path, "rf") + table.insert(summary, "D " .. path) + elseif hunk.type == "update" then + if vim.fn.getfsize(path) == -1 then + return { status = "error", data = "File to update does not exist: " .. path } + end - local lines = {} - local f = io.open(path, "r") - if f then - for line in f:lines() do - table.insert(lines, line) + local lines = {} + local f = io.open(path, "r") + if f then + for line in f:lines() do + table.insert(lines, line) + end + f:close() end - f:close() - end - local current_idx = 1 - for _, chunk in ipairs(hunk.chunks) do - local search_start = current_idx + local current_idx = 1 + for _, chunk in ipairs(hunk.chunks) do + local search_start = current_idx + + if chunk.change_context then + local ctx_match = seek_sequence(lines, { chunk.change_context }, search_start, chunk.is_end_of_file) + if ctx_match == -1 then + return { + status = "error", + data = fmt("Could not find context '%s' in %s", chunk.change_context, path), + } + end + search_start = ctx_match + 1 + end - if chunk.change_context then - local ctx_match = seek_sequence(lines, { chunk.change_context }, search_start, chunk.is_end_of_file) - if ctx_match == -1 then - return { status = "error", data = fmt("Could not find context '%s' in %s", chunk.change_context, path) } + local match_idx = seek_sequence(lines, chunk.old_lines, search_start, chunk.is_end_of_file) + + if match_idx == -1 and #chunk.old_lines == 0 then + match_idx = search_start end - search_start = ctx_match + 1 - end - local match_idx = seek_sequence(lines, chunk.old_lines, search_start, chunk.is_end_of_file) + if match_idx == -1 then + return { status = "error", data = "Could not find match for hunk in " .. path } + end - if match_idx == -1 and #chunk.old_lines == 0 then - match_idx = search_start - end + local before = {} + for i = 1, match_idx - 1 do + table.insert(before, lines[i]) + end - if match_idx == -1 then - return { status = "error", data = "Could not find match for hunk in " .. path } - end + local after = {} + for i = match_idx + #chunk.old_lines, #lines do + table.insert(after, lines[i]) + end - local before = {} - for i = 1, match_idx - 1 do - table.insert(before, lines[i]) - end + local new_lines = {} + for i = 1, #before do + table.insert(new_lines, before[i]) + end + for i = 1, #chunk.new_lines do + table.insert(new_lines, chunk.new_lines[i]) + end + for i = 1, #after do + table.insert(new_lines, after[i]) + end - local after = {} - for i = match_idx + #chunk.old_lines, #lines do - table.insert(after, lines[i]) + lines = new_lines + current_idx = match_idx + #chunk.new_lines end - local new_lines = {} - for i = 1, #before do - table.insert(new_lines, before[i]) - end - for i = 1, #chunk.new_lines do - table.insert(new_lines, chunk.new_lines[i]) - end - for i = 1, #after do - table.insert(new_lines, after[i]) + local final_content = table.concat(lines, "\n") + if #lines > 0 and lines[#lines] ~= "" then + final_content = final_content .. "\n" end - lines = new_lines - current_idx = match_idx + #chunk.new_lines - end - - local final_content = table.concat(lines, "\n") - if #lines > 0 and lines[#lines] ~= "" then - final_content = final_content .. "\n" - end - - local target_path = path - if hunk.move_path then - target_path = file_utils.validate_and_normalize_path(hunk.move_path) - end + local target_path = path + if hunk.move_path then + local normalized_move_path = file_utils.validate_and_normalize_path(hunk.move_path) + if not normalized_move_path then + return { status = "error", data = "Invalid move path: " .. hunk.move_path } + end + target_path = normalized_move_path + end - write_file(target_path, final_content) - if hunk.move_path then - vim.fn.delete(path, "rf") + write_file(target_path, final_content) + if hunk.move_path then + vim.fn.delete(path, "rf") + end + table.insert(summary, "M " .. target_path) end - table.insert(summary, "M " .. target_path) end end From 4bb80ba2c13f5bad652ab92dccbe1a8f79632761 Mon Sep 17 00:00:00 2001 From: Elliott Date: Fri, 17 Apr 2026 07:51:25 +1000 Subject: [PATCH 4/6] refactor(tools): refactor `apply_patch` tool implementation Extract hunk handling logic into dedicated helper functions (`handle_add`, `handle_delete`, and `handle_update`) to improve readability and maintainability. Update associated tests to align with the refactored structure and use more representative test cases. --- .../chat/tools/builtin/apply_patch.lua | 260 ++++++++++-------- .../chat/tools/test_apply_patch.lua | 57 ++-- 2 files changed, 172 insertions(+), 145 deletions(-) diff --git a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua index ccf3e474d..b4e5f3dc3 100644 --- a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua @@ -236,140 +236,160 @@ local function load_prompt() return table.concat(vim.fn.readfile(prompt_path), "\n") end -local PROMPT = load_prompt() +local function handle_add(path, hunk) + write_file(path, hunk.contents or "") + return "A " .. path +end -local tool = { - name = "apply_patch", - cmds = { - ---Execute the apply patch commands - ---@param self CodeCompanion.Tool.ApplyPatch - ---@param args table The arguments from the LLM's tool call - ---@param input? any The output from the previous function call - ---@return { status: "success"|"error", data: string } - function(self, args, input) - if not args.patchText then - return { status = "error", data = "patchText is required" } - end +local function handle_delete(path, hunk) + if vim.fn.getfsize(path) == -1 then + return { status = "error", data = "File to delete does not exist: " .. path } + end + vim.fn.delete(path, "rf") + return "D " .. path +end - local success, result = pcall(parse_patch, args.patchText) - if not success then - return { status = "error", data = "Patch parsing failed: " .. result } - end +local function handle_update(path, hunk) + if vim.fn.getfsize(path) == -1 then + return { status = "error", data = "File to update does not exist: " .. path } + end + + local lines = {} + local f = io.open(path, "r") + if f then + for line in f:lines() do + table.insert(lines, line) + end + f:close() + end - local hunks = result.hunks - if #hunks == 0 then - return { status = "error", data = "No hunks found in patch" } + local current_idx = 1 + for _, chunk in ipairs(hunk.chunks) do + local search_start = current_idx + + if chunk.change_context then + local ctx_match = seek_sequence(lines, { chunk.change_context }, search_start, chunk.is_end_of_file) + if ctx_match == -1 then + return { + status = "error", + data = fmt("Could not find context '%s' in %s", chunk.change_context, path), + } end + search_start = ctx_match + 1 + end - local summary = {} + local match_idx = seek_sequence(lines, chunk.old_lines, search_start, chunk.is_end_of_file) - for _, hunk in ipairs(hunks) do - local path = file_utils.validate_and_normalize_path(hunk.path) + if match_idx == -1 and #chunk.old_lines == 0 then + match_idx = search_start + end - if not path then - table.insert(summary, "Skipped (invalid path): " .. tostring(hunk.path)) - else - if hunk.type == "add" then - write_file(path, hunk.contents or "") - table.insert(summary, "A " .. path) - elseif hunk.type == "delete" then - if vim.fn.getfsize(path) == -1 then - return { status = "error", data = "File to delete does not exist: " .. path } - end - vim.fn.delete(path, "rf") - table.insert(summary, "D " .. path) - elseif hunk.type == "update" then - if vim.fn.getfsize(path) == -1 then - return { status = "error", data = "File to update does not exist: " .. path } - end + if match_idx == -1 then + return { status = "error", data = "Could not find match for hunk in " .. path } + end - local lines = {} - local f = io.open(path, "r") - if f then - for line in f:lines() do - table.insert(lines, line) - end - f:close() - end + local before = {} + for i = 1, match_idx - 1 do + table.insert(before, lines[i]) + end - local current_idx = 1 - for _, chunk in ipairs(hunk.chunks) do - local search_start = current_idx - - if chunk.change_context then - local ctx_match = seek_sequence(lines, { chunk.change_context }, search_start, chunk.is_end_of_file) - if ctx_match == -1 then - return { - status = "error", - data = fmt("Could not find context '%s' in %s", chunk.change_context, path), - } - end - search_start = ctx_match + 1 - end - - local match_idx = seek_sequence(lines, chunk.old_lines, search_start, chunk.is_end_of_file) - - if match_idx == -1 and #chunk.old_lines == 0 then - match_idx = search_start - end - - if match_idx == -1 then - return { status = "error", data = "Could not find match for hunk in " .. path } - end - - local before = {} - for i = 1, match_idx - 1 do - table.insert(before, lines[i]) - end - - local after = {} - for i = match_idx + #chunk.old_lines, #lines do - table.insert(after, lines[i]) - end - - local new_lines = {} - for i = 1, #before do - table.insert(new_lines, before[i]) - end - for i = 1, #chunk.new_lines do - table.insert(new_lines, chunk.new_lines[i]) - end - for i = 1, #after do - table.insert(new_lines, after[i]) - end - - lines = new_lines - current_idx = match_idx + #chunk.new_lines - end + local after = {} + for i = match_idx + #chunk.old_lines, #lines do + table.insert(after, lines[i]) + end - local final_content = table.concat(lines, "\n") - if #lines > 0 and lines[#lines] ~= "" then - final_content = final_content .. "\n" - end + local new_lines = {} + for i = 1, #before do + table.insert(new_lines, before[i]) + end + for i = 1, #chunk.new_lines do + table.insert(new_lines, chunk.new_lines[i]) + end + for i = 1, #after do + table.insert(new_lines, after[i]) + end - local target_path = path - if hunk.move_path then - local normalized_move_path = file_utils.validate_and_normalize_path(hunk.move_path) - if not normalized_move_path then - return { status = "error", data = "Invalid move path: " .. hunk.move_path } - end - target_path = normalized_move_path - end + lines = new_lines + current_idx = match_idx + #chunk.new_lines + end - write_file(target_path, final_content) - if hunk.move_path then - vim.fn.delete(path, "rf") - end - table.insert(summary, "M " .. target_path) - end - end + local final_content = table.concat(lines, "\n") + if #lines > 0 and lines[#lines] ~= "" then + final_content = final_content .. "\n" + end + + local target_path = path + if hunk.move_path then + local normalized_move_path = file_utils.validate_and_normalize_path(hunk.move_path) + if not normalized_move_path then + return { status = "error", data = "Invalid move path: " .. hunk.move_path } + end + target_path = normalized_move_path + end + + write_file(target_path, final_content) + if hunk.move_path then + vim.fn.delete(path, "rf") + end + return "M " .. target_path +end + +local function execute_apply_patch(self, args, input) + if not args.patchText then + return { status = "error", data = "patchText is required" } + end + + local success, result = pcall(parse_patch, args.patchText) + if not success then + return { status = "error", data = "Patch parsing failed: " .. result } + end + + local hunks = result.hunks + if #hunks == 0 then + return { status = "error", data = "No hunks found in patch" } + end + + local summary = {} + + for _, hunk in ipairs(hunks) do + local path = file_utils.validate_and_normalize_path(hunk.path) + + if not path then + table.insert(summary, "Skipped (invalid path): " .. tostring(hunk.path)) + else + local result + if hunk.type == "add" then + result = handle_add(path, hunk) + elseif hunk.type == "delete" then + result = handle_delete(path, hunk) + elseif hunk.type == "update" then + result = handle_update(path, hunk) end - return { - status = "success", - data = "Success. Updated the following files:\n" .. table.concat(summary, "\n"), - } - end, + if type(result) == "table" and result.status == "error" then + return result + end + table.insert(summary, result) + end + end + + return { + status = "success", + data = "Success. Updated the following files:\n" .. table.concat(summary, "\n"), + } +end + +local PROMPT = load_prompt() + +local tool = { + name = "apply_patch", + cmds = { + ---Execute the apply patch commands + ---@param self CodeCompanion.Tool.ApplyPatch + ---@param args table The arguments from the LLM's tool call + ---@param input? any The output from the previous function call + ---@return { status: "success"|"error", data: string } + execute_apply_patch, }, schema = { type = "function", diff --git a/tests/interactions/chat/tools/test_apply_patch.lua b/tests/interactions/chat/tools/test_apply_patch.lua index 0d64c6925..19c7663b7 100644 --- a/tests/interactions/chat/tools/test_apply_patch.lua +++ b/tests/interactions/chat/tools/test_apply_patch.lua @@ -45,25 +45,28 @@ T["Apply Patch"]["can parse update patch text"] = function() local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') local patch_text = [[ *** Begin Patch -*** Update File: test_update.txt -@@ context --old line -+new line +*** Update File: test_patch_dir/update_me.txt +@@ line 1 +-line 2 ++updated line 2 *** End Patch ]] + + local result = apply_patch.parse_patch(patch_text) _G.result = result ]=]) - + h.eq(1, child.lua_get("#_G.result.hunks")) h.eq("update", child.lua_get("_G.result.hunks[1].type")) - h.eq("test_update.txt", child.lua_get("_G.result.hunks[1].path")) + h.eq("test_patch_dir/update_me.txt", child.lua_get("_G.result.hunks[1].path")) h.eq(1, child.lua_get("#_G.result.hunks[1].chunks")) - h.eq("context", child.lua_get("_G.result.hunks[1].chunks[1].change_context")) - h.eq("old line", child.lua_get("_G.result.hunks[1].chunks[1].old_lines[1]")) - h.eq("new line", child.lua_get("_G.result.hunks[1].chunks[1].new_lines[1]")) + h.eq("line 1", child.lua_get("_G.result.hunks[1].chunks[1].change_context")) + h.eq("line 2", child.lua_get("_G.result.hunks[1].chunks[1].old_lines[1]")) + h.eq("updated line 2", child.lua_get("_G.result.hunks[1].chunks[1].new_lines[1]")) end + T["Apply Patch"]["can add a file"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') @@ -118,11 +121,11 @@ T["Apply Patch"]["can update a file"] = function() local f = io.open('test_patch_dir/update_me.txt', 'w') f:write("line 1\nline 2\nline 3\n") f:close() - + local patch_text = [[ *** Begin Patch *** Update File: test_patch_dir/update_me.txt -@@ context +@@ line 1 -line 2 +updated line 2 *** End Patch @@ -134,25 +137,26 @@ T["Apply Patch"]["can update a file"] = function() _G.file_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq("line 1\nupdated line 2\nline 3\n", child.lua_get("_G.file_content")) end + T["Apply Patch"]["can update and move a file"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') -- Setup: create file to update and move vim.fn.mkdir('test_patch_dir', 'p') local f = io.open('test_patch_dir/old_name.txt', 'w') - f:write("old content\n") + f:write("context line\nold content\n") f:close() - + local patch_text = [[ *** Begin Patch *** Update File: test_patch_dir/old_name.txt *** Move to: test_patch_dir/new_name.txt -@@ context +@@ context line -old content +new content *** End Patch @@ -166,13 +170,14 @@ T["Apply Patch"]["can update and move a file"] = function() _G.new_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq(false, child.lua_get("_G.old_exists")) h.eq(true, child.lua_get("_G.new_exists")) - h.eq("new content\n", child.lua_get("_G.new_content")) + h.eq("context line\nnew content\n", child.lua_get("_G.new_content")) end + T["Apply Patch"]["fails if file to update does not exist"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') @@ -223,11 +228,11 @@ T["Apply Patch"]["can update a file"] = function() local f = io.open('test_patch_dir/update_me.txt', 'w') f:write("line 1\nline 2\nline 3\n") f:close() - + local patch_text = [[ *** Begin Patch *** Update File: test_patch_dir/update_me.txt -@@ context +@@ line 1 -line 2 +updated line 2 *** End Patch @@ -239,25 +244,26 @@ T["Apply Patch"]["can update a file"] = function() _G.file_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq("line 1\nupdated line 2\nline 3\n", child.lua_get("_G.file_content")) end + T["Apply Patch"]["can update and move a file"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') -- Setup: create file to update and move vim.fn.mkdir('test_patch_dir', 'p') local f = io.open('test_patch_dir/old_name.txt', 'w') - f:write("old content\n") + f:write("context line\nold content\n") f:close() - + local patch_text = [[ *** Begin Patch *** Update File: test_patch_dir/old_name.txt *** Move to: test_patch_dir/new_name.txt -@@ context +@@ context line -old content +new content *** End Patch @@ -271,13 +277,14 @@ T["Apply Patch"]["can update and move a file"] = function() _G.new_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq(false, child.lua_get("_G.old_exists")) h.eq(true, child.lua_get("_G.new_exists")) - h.eq("new content\n", child.lua_get("_G.new_content")) + h.eq("context line\nnew content\n", child.lua_get("_G.new_content")) end + T["Apply Patch"]["fails if file to update does not exist"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') From 44f057b8d7c16365bdd4f16b97b518d3eca6877b Mon Sep 17 00:00:00 2001 From: Elliott Date: Fri, 17 Apr 2026 09:17:56 +1000 Subject: [PATCH 5/6] fix(apply-patch): prevent applying patches with ambiguous matches Implement a check to ensure that patch hunks match exactly one location in the target file. If multiple matches are found for the old lines, an error is returned requesting more context to resolve the ambiguity. - Add `count_sequences` helper to detect multiple pattern occurrences. - Update `handle_update` to validate match uniqueness. - Add test case to verify failure on ambiguous matches. --- apply_patch_plan.md | 29 +++++++++++++++++ .../chat/tools/builtin/apply_patch.lua | 31 +++++++++++++++++++ .../chat/tools/test_apply_patch.lua | 25 +++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/apply_patch_plan.md b/apply_patch_plan.md index 66f669a97..20fc13a17 100644 --- a/apply_patch_plan.md +++ b/apply_patch_plan.md @@ -109,3 +109,32 @@ Implement the core logic within a function in the `cmds` table. This function wi | `Effect.fail` | `return { status = "error", data = "..." }` | | `LSP.Diagnostic.report` | (Simplified) Summary of A/M/D files | + +### 4. Advanced UX Extensions (Buffer Awareness & Visual Diff) + +To align `apply_patch` with the user experience of `insert_edit_into_file`, the tool will be extended to support buffer-based editing and a visual review cycle. + +#### A. Buffer-Aware Content Sourcing +- Implement a source abstraction similar to `insert_edit_into_file`: + - `make_file_source`: Reads content from disk. + - `make_buffer_source`: Reads content from an active Neovim buffer. +- When processing a patch hunk, check if the target path corresponds to an open buffer. If so, use the buffer as the source to ensure changes are applied to the current editor state. + +#### B. Diff & Review Integration +- **Deferred Application**: Instead of applying changes immediately to disk/buffer, the tool will calculate the "Proposed State" for all affected files. +- **Visual Diff**: Integrate with `codecompanion.interactions.chat.tools.builtin.insert_edit_into_file.diff` to: + - Present a `minidiff` floating view showing the before/after state of the entire patch. + - Use the `review` flow to allow the user to Accept, Reject, or View the changes. +- **Atomic Execution**: Only apply the actual writes (to buffer or disk) after the user approves the patch. + +#### C. Refined Application Flow +1. **Parsing**: Parse `patchText` into hunks. +2. **Staging**: For each hunk: + - Determine source (Buffer vs Disk). + - Calculate new content based on seeking logic. + - Store the `from_lines` and `to_lines`. +3. **Review**: Call `diff.review` with the aggregated changes. +4. **Commit**: On approval, execute the writes using the source's `write` method (updating buffers via `nvim_buf_set_lines` and files via `io.write`). + + + diff --git a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua index b4e5f3dc3..d2aeaf98b 100644 --- a/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua @@ -119,6 +119,23 @@ local function seek_sequence(lines, pattern, start_index, eof) return normalized end +local function count_sequences(lines, pattern, start_index, eof) + local count = 0 + local current_start = start_index + while true do + local match = seek_sequence(lines, pattern, current_start, eof) + if match == -1 then + break + end + count = count + 1 + current_start = match + #pattern + 1 + if eof and match == #lines - #pattern + 1 then + break + end + end + return count +end + local function parse_patch(patch_text) local cleaned = strip_heredoc(patch_text:gsub("^%s*(.-)%s*$", "%1")) local lines = {} @@ -288,6 +305,20 @@ local function handle_update(path, hunk) return { status = "error", data = "Could not find match for hunk in " .. path } end + -- Check for multiple matches to ensure uniqueness + if #chunk.old_lines > 0 then + local matches = count_sequences(lines, chunk.old_lines, search_start, chunk.is_end_of_file) + if matches > 1 then + return { + status = "error", + data = fmt( + "Found multiple matches for oldString in %s. Provide more surrounding context to make the match unique.", + path + ), + } + end + end + local before = {} for i = 1, match_idx - 1 do table.insert(before, lines[i]) diff --git a/tests/interactions/chat/tools/test_apply_patch.lua b/tests/interactions/chat/tools/test_apply_patch.lua index 19c7663b7..8d46144f4 100644 --- a/tests/interactions/chat/tools/test_apply_patch.lua +++ b/tests/interactions/chat/tools/test_apply_patch.lua @@ -67,6 +67,31 @@ T["Apply Patch"]["can parse update patch text"] = function() end +T["Apply Patch"]["fails if multiple matches found for old lines"] = function() + child.lua([=[ + local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') + -- Setup: create file with duplicate lines + vim.fn.mkdir('test_patch_dir', 'p') + local f = io.open('test_patch_dir/duplicate.txt', 'w') + f:write("context\nline 1\nline 2\nline 1\nline 3\n") + f:close() + + local patch_text = [[ +*** Begin Patch +*** Update File: test_patch_dir/duplicate.txt +@@ context +-line 1 ++updated line 1 +*** End Patch + ]] + local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) + _G.result = result + ]=]) + + h.eq("error", child.lua_get("_G.result.status")) + h.expect_starts_with("Found multiple matches for oldString", child.lua_get("_G.result.data")) +end + T["Apply Patch"]["can add a file"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') From 28b204b27b95f4fbd09221f620c2b7e9f2765914 Mon Sep 17 00:00:00 2001 From: Elliott Date: Sat, 18 Apr 2026 08:37:11 +1000 Subject: [PATCH 6/6] changes post make all --- doc/codecompanion.txt | 5 +++-- .../chat/tools/test_apply_patch.lua | 17 ++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/doc/codecompanion.txt b/doc/codecompanion.txt index a9eae9259..85851c090 100644 --- a/doc/codecompanion.txt +++ b/doc/codecompanion.txt @@ -1,4 +1,5 @@ -*codecompanion.txt* For NVIM v0.11 Last change: 2026 April 17 +*codecompanion.txt* + For NVIM v0.11 Last change: 2026 April 18 ============================================================================== Table of Contents *codecompanion-table-of-contents* @@ -888,7 +889,7 @@ CURRENT LIMITATIONS *codecompanion-acp-current-limitations* `terminal/output`, `terminal/release`, etc.) are not implemented. CodeCompanion doesn't advertise terminal capabilities to agents. - **Agent Plan Rendering**: Plan - updates from agents are + updates from agents are received and logged, but they're not currently rendered in the chat buffer UI. - **Audio Content**: Audio can't be sent or received diff --git a/tests/interactions/chat/tools/test_apply_patch.lua b/tests/interactions/chat/tools/test_apply_patch.lua index 8d46144f4..f3c6007cf 100644 --- a/tests/interactions/chat/tools/test_apply_patch.lua +++ b/tests/interactions/chat/tools/test_apply_patch.lua @@ -56,7 +56,7 @@ T["Apply Patch"]["can parse update patch text"] = function() local result = apply_patch.parse_patch(patch_text) _G.result = result ]=]) - + h.eq(1, child.lua_get("#_G.result.hunks")) h.eq("update", child.lua_get("_G.result.hunks[1].type")) h.eq("test_patch_dir/update_me.txt", child.lua_get("_G.result.hunks[1].path")) @@ -66,7 +66,6 @@ T["Apply Patch"]["can parse update patch text"] = function() h.eq("updated line 2", child.lua_get("_G.result.hunks[1].chunks[1].new_lines[1]")) end - T["Apply Patch"]["fails if multiple matches found for old lines"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') @@ -87,7 +86,7 @@ T["Apply Patch"]["fails if multiple matches found for old lines"] = function() local result = apply_patch.cmds[1](apply_patch, { patchText = patch_text }, nil) _G.result = result ]=]) - + h.eq("error", child.lua_get("_G.result.status")) h.expect_starts_with("Found multiple matches for oldString", child.lua_get("_G.result.data")) end @@ -162,12 +161,11 @@ T["Apply Patch"]["can update a file"] = function() _G.file_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq("line 1\nupdated line 2\nline 3\n", child.lua_get("_G.file_content")) end - T["Apply Patch"]["can update and move a file"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') @@ -195,14 +193,13 @@ T["Apply Patch"]["can update and move a file"] = function() _G.new_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq(false, child.lua_get("_G.old_exists")) h.eq(true, child.lua_get("_G.new_exists")) h.eq("context line\nnew content\n", child.lua_get("_G.new_content")) end - T["Apply Patch"]["fails if file to update does not exist"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') @@ -269,12 +266,11 @@ T["Apply Patch"]["can update a file"] = function() _G.file_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq("line 1\nupdated line 2\nline 3\n", child.lua_get("_G.file_content")) end - T["Apply Patch"]["can update and move a file"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch') @@ -302,14 +298,13 @@ T["Apply Patch"]["can update and move a file"] = function() _G.new_content = f:read('*a') f:close() ]=]) - + h.eq("success", child.lua_get("_G.result.status")) h.eq(false, child.lua_get("_G.old_exists")) h.eq(true, child.lua_get("_G.new_exists")) h.eq("context line\nnew content\n", child.lua_get("_G.new_content")) end - T["Apply Patch"]["fails if file to update does not exist"] = function() child.lua([=[ local apply_patch = require('codecompanion.interactions.chat.tools.builtin.apply_patch')