import { promises as fs } from "node:fs"; import * as path from "node:path"; import { validateRequiredParams } from "./validation.js"; interface EditArgs { file_path: string; old_string: string; new_string: string; replace_all?: boolean; expected_replacements?: number; } interface EditResult { message: string; replacements: number; startLine?: number; } function countOccurrences(content: string, needle: string): number { if (needle.length === 0) { return 0; } return content.split(needle).length - 1; } function hasSmartQuoteMismatch(content: string, oldString: string): boolean { const withRightSingle = oldString.replace(/'/g, "\u2019"); const withLeftDouble = oldString.replace(/"/g, "\u201C"); const withRightDouble = oldString.replace(/"/g, "\u201D"); if (withRightSingle !== oldString && content.includes(withRightSingle)) { return true; } if (withLeftDouble !== oldString && content.includes(withLeftDouble)) { return true; } if (withRightDouble !== oldString && content.includes(withRightDouble)) { return true; } return false; } function buildNotFoundError( originalOldString: string, normalizedOldString: string, content: string, ): Error { const hints: string[] = []; const trimmed = normalizedOldString.trim(); if ( trimmed !== normalizedOldString && countOccurrences(content, trimmed) > 0 ) { hints.push("Leading or trailing whitespace differs from the file."); } if (hasSmartQuoteMismatch(content, normalizedOldString)) { hints.push("Quote characters may differ (straight vs smart quotes)."); } const oldCollapsed = normalizedOldString.replace(/\s+/g, " ").trim(); const contentCollapsed = content.replace(/\s+/g, " "); if ( oldCollapsed.length >= 20 && oldCollapsed !== normalizedOldString && contentCollapsed.includes(oldCollapsed) ) { hints.push("Line breaks or indentation may not match exactly."); } if (hints.length === 0) { hints.push( "The snippet may be stale; re-read the file and copy exact text.", ); } return new Error( `String to replace not found in file.\nString: ${originalOldString}\nPossible mismatch reasons:\n- ${hints.join("\n- ")}`, ); } /** * Unescapes a string that might have been overly escaped by an LLM. * Based on Gemini CLI's unescapeStringForGeminiBug function. * * LLMs sometimes generate strings with extra escape characters like: * - \\n instead of \n (newline) * - \\t instead of \t (tab) * - \\\" instead of " (quote) * - \\` instead of ` (backtick) */ export function unescapeOverEscapedString(input: string): string { // Match one or more backslashes followed by an escapable character // and reduce to the proper single escape sequence. // Based on Gemini CLI's unescapeStringForGeminiBug - intentionally conservative // to avoid over-correcting intentional escapes in shell/regex contexts. return input.replace( /\\+(n|t|r|'|"|`|\\|\n)/g, (_match: string, capturedChar: string): string => { switch (capturedChar) { case "n": return "\n"; case "t": return "\t"; case "r": return "\r"; case "'": return "'"; case '"': return '"'; case "`": return "`"; case "\\": return "\\"; case "\n": return "\n"; default: return _match; } }, ); } export async function edit(args: EditArgs): Promise { validateRequiredParams( args, ["file_path", "old_string", "new_string"], "Edit", ); const { file_path, replace_all = false, expected_replacements } = args; // Normalize line endings in old_string and new_string to match file normalization const old_string = args.old_string.replace(/\r\n/g, "\n"); const new_string = args.new_string.replace(/\r\n/g, "\n"); if (old_string.length === 0) { throw new Error( "old_string cannot be empty. Provide the exact text you want to replace.", ); } if ( expected_replacements !== undefined && (!Number.isInteger(expected_replacements) || expected_replacements < 1) ) { throw new Error( "expected_replacements must be a positive integer when provided.", ); } const userCwd = process.env.USER_CWD || process.cwd(); const resolvedPath = path.isAbsolute(file_path) ? file_path : path.resolve(userCwd, file_path); if (old_string === new_string) throw new Error( "No changes to make: old_string and new_string are exactly the same.", ); try { const rawContent = await fs.readFile(resolvedPath, "utf-8"); // Normalize line endings to LF for consistent matching (Windows uses CRLF) const content = rawContent.replace(/\r\n/g, "\n"); let occurrences = countOccurrences(content, old_string); let finalOldString = old_string; const finalNewString = new_string; // If no match found, try unescaping old_string in case LLM over-escaped it if (occurrences === 0) { const unescapedOld = unescapeOverEscapedString(old_string); const unescapedOccurrences = countOccurrences(content, unescapedOld); if (unescapedOccurrences > 0) { // Unescaping old_string worked - use it for matching // NOTE: We intentionally do NOT unescape new_string here. // The user's replacement text should be used as-is. If they want // actual newlines, they should provide actual newlines. finalOldString = unescapedOld; occurrences = unescapedOccurrences; } } if (occurrences === 0) throw buildNotFoundError(old_string, finalOldString, content); if ( expected_replacements !== undefined && occurrences !== expected_replacements ) { throw new Error( `Expected ${expected_replacements} occurrence${expected_replacements === 1 ? "" : "s"} but found ${occurrences}. Update old_string to be more specific, or set replace_all/expected_replacements correctly.`, ); } const effectiveReplaceAll = replace_all || (expected_replacements !== undefined && expected_replacements > 1); let newContent: string; let replacements: number; let startLine: number | undefined; if (effectiveReplaceAll) { newContent = content.split(finalOldString).join(finalNewString); replacements = occurrences; // For replace_all, calculate line number of first occurrence const firstIndex = content.indexOf(finalOldString); if (firstIndex !== -1) { startLine = content.substring(0, firstIndex).split("\n").length; } } else { const index = content.indexOf(finalOldString); if (index === -1) throw new Error(`String not found in file: ${finalOldString}`); // Calculate the line number where old_string starts (1-indexed) startLine = content.substring(0, index).split("\n").length; newContent = content.substring(0, index) + finalNewString + content.substring(index + finalOldString.length); replacements = 1; } await fs.writeFile(resolvedPath, newContent, "utf-8"); return { message: `Successfully replaced ${replacements} occurrence${replacements !== 1 ? "s" : ""} in ${resolvedPath}`, replacements, startLine, }; } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === "ENOENT") { throw new Error( `File does not exist. Attempted path: ${resolvedPath}. Current working directory: ${userCwd}`, ); } else if (err.code === "EACCES") throw new Error(`Permission denied: ${resolvedPath}`); else if (err.code === "EISDIR") throw new Error(`Path is a directory: ${resolvedPath}`); else if (err.message) throw err; else throw new Error(`Failed to edit file: ${err}`); } }