diff --git a/apply_patch_plan.md b/apply_patch_plan.md new file mode 100644 index 000000000..20fc13a17 --- /dev/null +++ b/apply_patch_plan.md @@ -0,0 +1,140 @@ +# 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 | + + +### 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/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/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..d2aeaf98b --- /dev/null +++ b/lua/codecompanion/interactions/chat/tools/builtin/apply_patch.lua @@ -0,0 +1,469 @@ +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[] +---@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 + +---@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*$" + 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 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 = {} + 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 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 + +---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 function handle_add(path, hunk) + write_file(path, hunk.contents or "") + return "A " .. path +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 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 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 + + -- 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]) + 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 + 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 + + 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", + ["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 = PROMPT, + 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/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 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..f3c6007cf --- /dev/null +++ b/tests/interactions/chat/tools/test_apply_patch.lua @@ -0,0 +1,327 @@ +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_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_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("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"]["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') + 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 +@@ line 1 +-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("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 line +-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("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') + 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 +@@ line 1 +-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("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 line +-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("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') + 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