// the approaches in this edit tool are sourced from // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts import { z } from "zod" import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import { Permission } from "../permission" import DESCRIPTION from "./edit.txt" import { App } from "../app/app" import { File } from "../file" import { Bus } from "../bus" import { FileTime } from "../file/time" export const EditTool = Tool.define({ id: "edit", description: DESCRIPTION, parameters: z.object({ filePath: z.string().describe("The absolute path to the file to modify"), oldString: z.string().describe("The text to replace"), newString: z.string().describe("The text to replace it with (must be different from oldString)"), replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), }), async execute(params, ctx) { if (!params.filePath) { throw new Error("filePath is required") } if (params.oldString === params.newString) { throw new Error("oldString and newString must be different") } const app = App.info() const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath) await Permission.ask({ id: "edit", sessionID: ctx.sessionID, title: "Edit this file: " + filepath, metadata: { filePath: filepath, oldString: params.oldString, newString: params.newString, }, }) let contentOld = "" let contentNew = "" await (async () => { if (params.oldString === "") { contentNew = params.newString await Bun.write(filepath, params.newString) await Bus.publish(File.Event.Edited, { file: filepath, }) return } const file = Bun.file(filepath) const stats = await file.stat().catch(() => {}) if (!stats) throw new Error(`File ${filepath} not found`) if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`) await FileTime.assert(ctx.sessionID, filepath) contentOld = await file.text() contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) await file.write(contentNew) await Bus.publish(File.Event.Edited, { file: filepath, }) contentNew = await file.text() })() const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) FileTime.read(ctx.sessionID, filepath) let output = "" await LSP.touchFile(filepath, true) const diagnostics = await LSP.diagnostics() for (const [file, issues] of Object.entries(diagnostics)) { if (issues.length === 0) continue if (file === filepath) { output += `\nThis file has errors, please fix\n\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` continue } output += `\n\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` } return { metadata: { diagnostics, diff, }, title: `${path.relative(app.path.root, filepath)}`, output, } }, }) export type Replacer = (content: string, find: string) => Generator export const SimpleReplacer: Replacer = function* (_content, find) { yield find } export const LineTrimmedReplacer: Replacer = function* (content, find) { const originalLines = content.split("\n") const searchLines = find.split("\n") if (searchLines[searchLines.length - 1] === "") { searchLines.pop() } for (let i = 0; i <= originalLines.length - searchLines.length; i++) { let matches = true for (let j = 0; j < searchLines.length; j++) { const originalTrimmed = originalLines[i + j].trim() const searchTrimmed = searchLines[j].trim() if (originalTrimmed !== searchTrimmed) { matches = false break } } if (matches) { let matchStartIndex = 0 for (let k = 0; k < i; k++) { matchStartIndex += originalLines[k].length + 1 } let matchEndIndex = matchStartIndex for (let k = 0; k < searchLines.length; k++) { matchEndIndex += originalLines[i + k].length + 1 } yield content.substring(matchStartIndex, matchEndIndex) } } } export const BlockAnchorReplacer: Replacer = function* (content, find) { const originalLines = content.split("\n") const searchLines = find.split("\n") if (searchLines.length < 3) { return } if (searchLines[searchLines.length - 1] === "") { searchLines.pop() } const firstLineSearch = searchLines[0].trim() const lastLineSearch = searchLines[searchLines.length - 1].trim() // Find blocks where first line matches the search first line for (let i = 0; i < originalLines.length; i++) { if (originalLines[i].trim() !== firstLineSearch) { continue } // Look for the matching last line after this first line for (let j = i + 2; j < originalLines.length; j++) { if (originalLines[j].trim() === lastLineSearch) { // Found a potential block from i to j let matchStartIndex = 0 for (let k = 0; k < i; k++) { matchStartIndex += originalLines[k].length + 1 } let matchEndIndex = matchStartIndex for (let k = 0; k <= j - i; k++) { matchEndIndex += originalLines[i + k].length if (k < j - i) { matchEndIndex += 1 // Add newline character except for the last line } } yield content.substring(matchStartIndex, matchEndIndex) break // Only match the first occurrence of the last line } } } } export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) { const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim() const normalizedFind = normalizeWhitespace(find) // Handle single line matches const lines = content.split("\n") for (let i = 0; i < lines.length; i++) { const line = lines[i] if (normalizeWhitespace(line) === normalizedFind) { yield line } // Also check for substring matches within lines const normalizedLine = normalizeWhitespace(line) if (normalizedLine.includes(normalizedFind)) { // Find the actual substring in the original line that matches const words = find.trim().split(/\s+/) if (words.length > 0) { const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") try { const regex = new RegExp(pattern) const match = line.match(regex) if (match) { yield match[0] } } catch (e) { // Invalid regex pattern, skip } } } } // Handle multi-line matches const findLines = find.split("\n") if (findLines.length > 1) { for (let i = 0; i <= lines.length - findLines.length; i++) { const block = lines.slice(i, i + findLines.length) if (normalizeWhitespace(block.join("\n")) === normalizedFind) { yield block.join("\n") } } } } export const IndentationFlexibleReplacer: Replacer = function* (content, find) { const removeIndentation = (text: string) => { const lines = text.split("\n") const nonEmptyLines = lines.filter((line) => line.trim().length > 0) if (nonEmptyLines.length === 0) return text const minIndent = Math.min( ...nonEmptyLines.map((line) => { const match = line.match(/^(\s*)/) return match ? match[1].length : 0 }), ) return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n") } const normalizedFind = removeIndentation(find) const contentLines = content.split("\n") const findLines = find.split("\n") for (let i = 0; i <= contentLines.length - findLines.length; i++) { const block = contentLines.slice(i, i + findLines.length).join("\n") if (removeIndentation(block) === normalizedFind) { yield block } } } export const EscapeNormalizedReplacer: Replacer = function* (content, find) { const unescapeString = (str: string): string => { return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => { 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" case "$": return "$" default: return match } }) } const unescapedFind = unescapeString(find) // Try direct match with unescaped find string if (content.includes(unescapedFind)) { yield unescapedFind } // Also try finding escaped versions in content that match unescaped find const lines = content.split("\n") const findLines = unescapedFind.split("\n") for (let i = 0; i <= lines.length - findLines.length; i++) { const block = lines.slice(i, i + findLines.length).join("\n") const unescapedBlock = unescapeString(block) if (unescapedBlock === unescapedFind) { yield block } } } export const MultiOccurrenceReplacer: Replacer = function* (content, find) { // This replacer yields all exact matches, allowing the replace function // to handle multiple occurrences based on replaceAll parameter let startIndex = 0 while (true) { const index = content.indexOf(find, startIndex) if (index === -1) break yield find startIndex = index + find.length } } export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { const trimmedFind = find.trim() if (trimmedFind === find) { // Already trimmed, no point in trying return } // Try to find the trimmed version if (content.includes(trimmedFind)) { yield trimmedFind } // Also try finding blocks where trimmed content matches const lines = content.split("\n") const findLines = find.split("\n") for (let i = 0; i <= lines.length - findLines.length; i++) { const block = lines.slice(i, i + findLines.length).join("\n") if (block.trim() === trimmedFind) { yield block } } } export const ContextAwareReplacer: Replacer = function* (content, find) { const findLines = find.split("\n") if (findLines.length < 3) { // Need at least 3 lines to have meaningful context return } // Remove trailing empty line if present if (findLines[findLines.length - 1] === "") { findLines.pop() } const contentLines = content.split("\n") // Extract first and last lines as context anchors const firstLine = findLines[0].trim() const lastLine = findLines[findLines.length - 1].trim() // Find blocks that start and end with the context anchors for (let i = 0; i < contentLines.length; i++) { if (contentLines[i].trim() !== firstLine) continue // Look for the matching last line for (let j = i + 2; j < contentLines.length; j++) { if (contentLines[j].trim() === lastLine) { // Found a potential context block const blockLines = contentLines.slice(i, j + 1) const block = blockLines.join("\n") // Check if the middle content has reasonable similarity // (simple heuristic: at least 50% of non-empty lines should match when trimmed) if (blockLines.length === findLines.length) { let matchingLines = 0 let totalNonEmptyLines = 0 for (let k = 1; k < blockLines.length - 1; k++) { const blockLine = blockLines[k].trim() const findLine = findLines[k].trim() if (blockLine.length > 0 || findLine.length > 0) { totalNonEmptyLines++ if (blockLine === findLine) { matchingLines++ } } } if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) { yield block break // Only match the first occurrence } } break } } } } function trimDiff(diff: string): string { const lines = diff.split("\n") const contentLines = lines.filter( (line) => (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && !line.startsWith("---") && !line.startsWith("+++"), ) if (contentLines.length === 0) return diff let min = Infinity for (const line of contentLines) { const content = line.slice(1) if (content.trim().length > 0) { const match = content.match(/^(\s*)/) if (match) min = Math.min(min, match[1].length) } } if (min === Infinity || min === 0) return diff const trimmedLines = lines.map((line) => { if ( (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && !line.startsWith("---") && !line.startsWith("+++") ) { const prefix = line[0] const content = line.slice(1) return prefix + content.slice(min) } return line }) return trimmedLines.join("\n") } export function replace(content: string, oldString: string, newString: string, replaceAll = false): string { if (oldString === newString) { throw new Error("oldString and newString must be different") } for (const replacer of [ SimpleReplacer, LineTrimmedReplacer, BlockAnchorReplacer, WhitespaceNormalizedReplacer, IndentationFlexibleReplacer, // EscapeNormalizedReplacer, // TrimmedBoundaryReplacer, // ContextAwareReplacer, // MultiOccurrenceReplacer, ]) { for (const search of replacer(content, oldString)) { const index = content.indexOf(search) if (index === -1) continue if (replaceAll) { return content.replaceAll(search, newString) } const lastIndex = content.lastIndexOf(search) if (index !== lastIndex) continue return content.substring(0, index) + newString + content.substring(index + search.length) } } throw new Error("oldString not found in content or was found multiple times") }