import { existsSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; import { dirname } from "path"; import { tool } from "ai"; import z from "zod"; export const editFile = tool({ description: `This is a tool for making edits to existing files. For moving or renaming files, use the terminal tool with the 'mv' command. For creating new files use 'write-file' tool. This tool replaces text within a file by finding an exact match for the old_string and replacing it with new_string. For precise targeting, include sufficient context around the change. Requirements: 1. old_string MUST be the exact literal text to replace (including all whitespace, indentation, newlines) 2. new_string MUST be the exact literal text to replace old_string with 3. For single replacements, include at least 3 lines of context BEFORE and AFTER the target text 4. Match whitespace and indentation precisely 5. Use empty old_string to create a new file Before using this tool, use the read_file tool to examine the file's current content.`, inputSchema: z.object({ path: z.string().describe( `The relative path of the file to edit in the project. WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. backend/src/main.rs frontend/db.js ` ), old_string: z.string().describe( `The exact literal text to replace. Must match exactly including whitespace and indentation. For new files, use an empty string. For edits, include sufficient context (3+ lines before and after) to uniquely identify the location.` ), new_string: z.string().describe( `The exact literal text to replace old_string with. Ensure the resulting code is correct and properly indented.` ), expected_occurrences: z .number() .optional() .describe( `Number of occurrences expected to be replaced. Defaults to 1. Use when you want to replace multiple occurrences of the same text.` ), }), execute: async ({ path, old_string, new_string, expected_occurrences = 1, }) => { try { const isNewFile = old_string === ""; let currentContent: string | null = null; let fileExists = false; // Check if file exists and read content try { if (existsSync(path)) { currentContent = await readFile(path, "utf8"); // Normalize line endings to LF for consistent processing currentContent = currentContent.replace(/\r\n/g, "\n"); fileExists = true; } } catch (error) { return { status: "error", message: `Failed to read file: ${ error instanceof Error ? error.message : String(error) }`, operation: "read", path, }; } // Handle new file creation if (isNewFile && !fileExists) { try { // Ensure parent directories exist await mkdir(dirname(path), { recursive: true }); await writeFile(path, new_string, "utf8"); return { status: "success", message: `Successfully created new file: ${path}`, operation: "created", path, diff: `+ ${new_string.split("\n").length} lines added`, }; } catch (error) { return { status: "error", message: `Failed to create file: ${ error instanceof Error ? error.message : String(error) }`, operation: "create", path, }; } } // Handle attempt to create file that already exists if (isNewFile && fileExists) { return { status: "error", message: `Cannot create file: ${path} already exists. Use non-empty old_string to edit existing files.`, operation: "create", path, }; } // Handle editing non-existent file if (!isNewFile && !fileExists) { return { status: "error", message: `Cannot edit file: ${path} does not exist. Use empty old_string to create new files.`, operation: "edit", path, }; } // At this point we're editing an existing file if (currentContent === null) { return { status: "error", message: `Failed to read content of existing file: ${path}`, operation: "edit", path, }; } // Check for exact matches const occurrences = countOccurrences(currentContent, old_string); if (occurrences === 0) { // Try flexible matching with whitespace normalization const flexibleResult = tryFlexibleReplace( currentContent, old_string, new_string ); if (flexibleResult.success) { const newContent = flexibleResult.content; if (newContent === currentContent) { return { status: "success", message: `No changes made to ${path} - content is identical`, operation: "no-change", path, diff: "No changes", }; } await writeFile(path, newContent, "utf8"); const diff = createDiff(currentContent, newContent); return { status: "success", message: `Successfully edited ${path} (flexible matching applied)`, operation: "edited", path, diff: diff || "Changes applied", }; } return { status: "error", message: `Could not find exact match for old_string in ${path}:\n${old_string}\n\nThe text may not exist or may have different whitespace/indentation. Use read_file to verify the current content.`, operation: "edit", path, suggestion: "Include more context lines around the target text and ensure exact whitespace matching.", }; } if (occurrences !== expected_occurrences) { return { status: "error", message: `Expected ${expected_occurrences} occurrence(s) but found ${occurrences} in ${path}`, operation: "edit", path, suggestion: occurrences > expected_occurrences ? "Add more context to old_string to make it more specific" : "Check if the text exists or adjust expected_occurrences", }; } // Check if old_string and new_string are identical if (old_string === new_string) { return { status: "success", message: `No changes made to ${path} - old_string and new_string are identical`, operation: "no-change", path, diff: "No changes", }; } // Apply the replacement const newContent = currentContent.replaceAll(old_string, new_string); if (newContent === currentContent) { return { status: "success", message: `No changes made to ${path} - content is identical after replacement`, operation: "no-change", path, diff: "No changes", }; } // Write the modified content await writeFile(path, newContent, "utf8"); const diff = createDiff(currentContent, newContent); return { status: "success", message: `Successfully edited ${path} (${occurrences} replacement(s))`, operation: "edited", path, diff: diff || "Changes applied", }; } catch (error) { return { status: "error", message: `Failed to edit file: ${ error instanceof Error ? error.message : String(error) }`, operation: "edit", path, }; } }, }); function countOccurrences(content: string, searchString: string): number { if (searchString === "") return 0; return content.split(searchString).length - 1; } function tryFlexibleReplace( content: string, oldText: string, newText: string ): { success: boolean; content: string } { if (oldText === "") return { success: false, content }; const contentLines = content.split("\n"); const oldLines = oldText.split("\n"); for (let i = 0; i <= contentLines.length - oldLines.length; i++) { const potentialMatch = contentLines.slice(i, i + oldLines.length); // Compare lines with normalized whitespace const isMatch = oldLines.every((oldLine, j) => { const contentLine = potentialMatch[j]; if (contentLine === undefined) return false; // Normalize whitespace for comparison const normalizedOld = oldLine.trim(); const normalizedContent = contentLine.trim(); return normalizedOld === normalizedContent; }); if (isMatch) { // Preserve original indentation of the first line const firstContentLine = contentLines[i]; if (firstContentLine === undefined) continue; const originalIndent = firstContentLine.match(/^\s*/)?.[0] || ""; const newLines = newText.split("\n"); // Apply original indentation to new lines const indentedNewLines = newLines.map((line, index) => { if (index === 0) { return originalIndent + line.trimStart(); } // For subsequent lines, preserve relative indentation from original context const oldLineIndent = oldLines[index]?.match(/^\s*/)?.[0] || ""; const newLineIndent = line.match(/^\s*/)?.[0] || ""; if (oldLines[index] !== undefined) { // Calculate the relative indentation difference const baseIndentLevel = oldLines[0]?.match(/^\s*/)?.[0]?.length || 0; const currentOldIndentLevel = oldLineIndent.length; const relativeIndent = currentOldIndentLevel - baseIndentLevel; // Apply the same relative indentation to the new line const targetIndent = originalIndent.length + relativeIndent; return " ".repeat(Math.max(0, targetIndent)) + line.trimStart(); } // For new lines beyond the original pattern, maintain the same indent as the replacement context return originalIndent + line.trimStart(); }); // Replace the matched lines contentLines.splice(i, oldLines.length, ...indentedNewLines); return { success: true, content: contentLines.join("\n") }; } } return { success: false, content }; } function createDiff(original: string, modified: string): string { const originalLines = original.split("\n"); const modifiedLines = modified.split("\n"); const maxLines = Math.max(originalLines.length, modifiedLines.length); const diffLines: string[] = []; let addedLines = 0; let removedLines = 0; for (let i = 0; i < maxLines; i++) { const origLine = originalLines[i]; const modLine = modifiedLines[i]; if (origLine !== modLine) { if (origLine !== undefined) { diffLines.push(`- ${origLine}`); removedLines++; } if (modLine !== undefined) { diffLines.push(`+ ${modLine}`); addedLines++; } } } if (diffLines.length === 0) { return "No changes"; } const summary = `Changes: +${addedLines} -${removedLines} lines\n`; return summary + diffLines.join("\n"); }