| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- // 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<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
- continue
- }
- output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
- }
- return {
- metadata: {
- diagnostics,
- diff,
- },
- title: `${path.relative(app.path.root, filepath)}`,
- output,
- }
- },
- })
- export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
- 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")
- }
|