| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675 |
- // 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 { Permission } from "../permission"
- import DESCRIPTION from "./edit.txt"
- import { File } from "../file"
- import { Bus } from "../bus"
- import { FileTime } from "../file/time"
- import { Filesystem } from "../util/filesystem"
- import { Instance } from "../project/instance"
- import { Agent } from "../agent/agent"
- import { Snapshot } from "@/snapshot"
- 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 agent = await Agent.get(ctx.agent)
- const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
- if (!Filesystem.contains(Instance.directory, filePath)) {
- const parentDir = path.dirname(filePath)
- if (agent.permission.external_directory === "ask") {
- await Permission.ask({
- type: "external_directory",
- pattern: [parentDir, path.join(parentDir, "*")],
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: `Edit file outside working directory: ${filePath}`,
- metadata: {
- filepath: filePath,
- parentDir,
- },
- })
- } else if (agent.permission.external_directory === "deny") {
- throw new Permission.RejectedError(
- ctx.sessionID,
- "external_directory",
- ctx.callID,
- {
- filepath: filePath,
- parentDir,
- },
- `File ${filePath} is not in the current working directory`,
- )
- }
- }
- let diff = ""
- let contentOld = ""
- let contentNew = ""
- await FileTime.withLock(filePath, async () => {
- if (params.oldString === "") {
- contentNew = params.newString
- diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
- if (agent.permission.edit === "ask") {
- await Permission.ask({
- type: "edit",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Edit this file: " + filePath,
- metadata: {
- filePath,
- diff,
- },
- })
- }
- await Bun.write(filePath, params.newString)
- await Bus.publish(File.Event.Edited, {
- file: filePath,
- })
- 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)),
- )
- if (agent.permission.edit === "ask") {
- await Permission.ask({
- type: "edit",
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- callID: ctx.callID,
- title: "Edit this file: " + filePath,
- metadata: {
- filePath,
- diff,
- },
- })
- }
- await file.write(contentNew)
- await Bus.publish(File.Event.Edited, {
- file: filePath,
- })
- contentNew = await file.text()
- diff = trimDiff(
- createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
- )
- FileTime.read(ctx.sessionID, filePath)
- })
- let output = ""
- 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 += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
- }
- 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
- }
- 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 matching
- const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
- const 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()
- 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
- }
- }
- }
- }
- export 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")
- }
- let notFound = true
- 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
- notFound = false
- 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)
- }
- }
- if (notFound) {
- throw new Error("oldString not found in content")
- }
- throw new Error(
- "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.",
- )
- }
|