655 lines · 20.2 KB · TypeScript
RawDownload
// 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// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts import z from "zod"import * as path from "path"import { Tool } from "./tool"import { LSP } from "../lsp"import { createTwoFilesPatch, diffLines } from "diff"import DESCRIPTION from "./edit.txt"import { File } from "../file"import { FileWatcher } from "../file/watcher"import { Bus } from "../bus"import { FileTime } from "../file/time"import { Filesystem } from "../util/filesystem"import { Instance } from "../project/instance"import { Snapshot } from "@/snapshot"import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 function normalizeLineEndings(text: string): string {  return text.replaceAll("\r\n", "\n")} export const EditTool = Tool.define("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 filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)    await assertExternalDirectory(ctx, filePath)     let diff = ""    let contentOld = ""    let contentNew = ""    await FileTime.withLock(filePath, async () => {      if (params.oldString === "") {        const existed = await Bun.file(filePath).exists()        contentNew = params.newString        diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))        await ctx.ask({          permission: "edit",          patterns: [path.relative(Instance.worktree, filePath)],          always: ["*"],          metadata: {            filepath: filePath,            diff,          },        })        await Bun.write(filePath, params.newString)        await Bus.publish(File.Event.Edited, {          file: filePath,        })        await Bus.publish(FileWatcher.Event.Updated, {          file: filePath,          event: existed ? "change" : "add",        })        FileTime.read(ctx.sessionID, 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)       diff = trimDiff(        createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),      )      await ctx.ask({        permission: "edit",        patterns: [path.relative(Instance.worktree, filePath)],        always: ["*"],        metadata: {          filepath: filePath,          diff,        },      })       await file.write(contentNew)      await Bus.publish(File.Event.Edited, {        file: filePath,      })      await Bus.publish(FileWatcher.Event.Updated, {        file: filePath,        event: "change",      })      contentNew = await file.text()      diff = trimDiff(        createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),      )      FileTime.read(ctx.sessionID, filePath)    })     const filediff: Snapshot.FileDiff = {      file: filePath,      before: contentOld,      after: contentNew,      additions: 0,      deletions: 0,    }    for (const change of diffLines(contentOld, contentNew)) {      if (change.added) filediff.additions += change.count || 0      if (change.removed) filediff.deletions += change.count || 0    }     ctx.metadata({      metadata: {        diff,        filediff,        diagnostics: {},      },    })     let output = "Edit applied successfully."    await LSP.touchFile(filePath, true)    const diagnostics = await LSP.diagnostics()    const normalizedFilePath = Filesystem.normalizePath(filePath)    const issues = diagnostics[normalizedFilePath] ?? []    const errors = issues.filter((item) => item.severity === 1)    if (errors.length > 0) {      const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)      const suffix =        errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""      output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`    }     return {      metadata: {        diagnostics,        diff,        filediff,      },      title: `${path.relative(Instance.worktree, filePath)}`,      output,    }  },}) export type Replacer = (content: string, find: string) => Generator<string, void, unknown> // Similarity thresholds for block anchor fallback matchingconst SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3 /** * Levenshtein distance algorithm implementation */function levenshtein(a: string, b: string): number {  // Handle empty strings  if (a === "" || b === "") {    return Math.max(a.length, b.length)  }  const matrix = Array.from({ length: a.length + 1 }, (_, i) =>    Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),  )   for (let i = 1; i <= a.length; i++) {    for (let j = 1; j <= b.length; j++) {      const cost = a[i - 1] === b[j - 1] ? 0 : 1      matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)    }  }  return matrix[a.length][b.length]} 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        if (k < searchLines.length - 1) {          matchEndIndex += 1 // Add newline character except for the last line        }      }       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()  const searchBlockSize = searchLines.length   // Collect all candidate positions where both anchors match  const candidates: Array<{ startLine: number; endLine: number }> = []  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) {        candidates.push({ startLine: i, endLine: j })        break // Only match the first occurrence of the last line      }    }  }   // Return immediately if no candidates  if (candidates.length === 0) {    return  }   // Handle single candidate scenario (using relaxed threshold)  if (candidates.length === 1) {    const { startLine, endLine } = candidates[0]    const actualBlockSize = endLine - startLine + 1     let similarity = 0    let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only     if (linesToCheck > 0) {      for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {        const originalLine = originalLines[startLine + j].trim()        const searchLine = searchLines[j].trim()        const maxLen = Math.max(originalLine.length, searchLine.length)        if (maxLen === 0) {          continue        }        const distance = levenshtein(originalLine, searchLine)        similarity += (1 - distance / maxLen) / linesToCheck         // Exit early when threshold is reached        if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {          break        }      }    } else {      // No middle lines to compare, just accept based on anchors      similarity = 1.0    }     if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {      let matchStartIndex = 0      for (let k = 0; k < startLine; k++) {        matchStartIndex += originalLines[k].length + 1      }      let matchEndIndex = matchStartIndex      for (let k = startLine; k <= endLine; k++) {        matchEndIndex += originalLines[k].length        if (k < endLine) {          matchEndIndex += 1 // Add newline character except for the last line        }      }      yield content.substring(matchStartIndex, matchEndIndex)    }    return  }   // Calculate similarity for multiple candidates  let bestMatch: { startLine: number; endLine: number } | null = null  let maxSimilarity = -1   for (const candidate of candidates) {    const { startLine, endLine } = candidate    const actualBlockSize = endLine - startLine + 1     let similarity = 0    let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only     if (linesToCheck > 0) {      for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {        const originalLine = originalLines[startLine + j].trim()        const searchLine = searchLines[j].trim()        const maxLen = Math.max(originalLine.length, searchLine.length)        if (maxLen === 0) {          continue        }        const distance = levenshtein(originalLine, searchLine)        similarity += 1 - distance / maxLen      }      similarity /= linesToCheck // Average similarity    } else {      // No middle lines to compare, just accept based on anchors      similarity = 1.0    }     if (similarity > maxSimilarity) {      maxSimilarity = similarity      bestMatch = candidate    }  }   // Threshold judgment  if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {    const { startLine, endLine } = bestMatch    let matchStartIndex = 0    for (let k = 0; k < startLine; k++) {      matchStartIndex += originalLines[k].length + 1    }    let matchEndIndex = matchStartIndex    for (let k = startLine; k <= endLine; k++) {      matchEndIndex += originalLines[k].length      if (k < endLine) {        matchEndIndex += 1      }    }    yield content.substring(matchStartIndex, matchEndIndex)  }} 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    } else {      // Only check for substring matches if the full line doesn't match      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() 

Lines 1–500 of 655.