2025-06-25 21:10:48 +00:00
// 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
2025-06-25 21:54:54 +00:00
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
2025-07-24 20:18:04 +00:00
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
2025-07-31 00:57:52 +00:00
2025-05-31 18:41:00 +00:00
import * as path from "path"
2026-04-23 20:09:34 +00:00
import { Effect , Schema , Semaphore } from "effect"
2026-04-16 03:56:54 +00:00
import * as Tool from "./tool"
2026-04-27 18:33:33 +00:00
import { LSP } from "@/lsp/lsp"
2025-10-28 18:08:10 +00:00
import { createTwoFilesPatch , diffLines } from "diff"
2025-06-04 17:12:13 +00:00
import DESCRIPTION from "./edit.txt"
2026-06-02 20:09:26 +00:00
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
2026-05-31 01:08:38 +00:00
import { EventV2Bridge } from "@/event-v2-bridge"
2026-03-26 00:19:24 +00:00
import { Format } from "../format"
2026-05-01 01:01:06 +00:00
import { InstanceState } from "@/effect/instance-state"
2026-03-21 04:51:35 +00:00
import { Snapshot } from "@/snapshot"
2026-04-10 21:10:28 +00:00
import { assertExternalDirectoryEffect } from "./external-directory"
2026-06-02 20:09:26 +00:00
import { FSUtil } from "@opencode-ai/core/fs-util"
2026-04-22 08:03:34 +00:00
import * as Bom from "@/util/bom"
2025-05-19 23:29:38 +00:00
2025-11-16 03:18:39 +00:00
function normalizeLineEndings ( text : string ) : string {
return text . replaceAll ( "\r\n" , "\n" )
}
2026-03-07 07:42:54 +00:00
function detectLineEnding ( text : string ) : "\n" | "\r\n" {
return text . includes ( "\r\n" ) ? "\r\n" : "\n"
}
function convertToLineEnding ( text : string , ending : "\n" | "\r\n" ) : string {
if ( ending === "\n" ) return text
return text . replaceAll ( "\n" , "\r\n" )
}
2026-04-20 05:14:21 +00:00
const locks = new Map < string , Semaphore.Semaphore > ( )
function lock ( filePath : string ) {
2026-06-02 20:09:26 +00:00
const resolvedFilePath = FSUtil . resolve ( filePath )
2026-04-20 05:14:21 +00:00
const hit = locks . get ( resolvedFilePath )
if ( hit ) return hit
const next = Semaphore . makeUnsafe ( 1 )
locks . set ( resolvedFilePath , next )
return next
}
2026-04-23 20:09:34 +00:00
export const Parameters = Schema . Struct ( {
filePath : Schema.String.annotate ( { description : "The absolute path to the file to modify" } ) ,
oldString : Schema.String.annotate ( { description : "The text to replace" } ) ,
newString : Schema.String.annotate ( {
description : "The text to replace it with (must be different from oldString)" ,
} ) ,
replaceAll : Schema.optional ( Schema . Boolean ) . annotate ( {
description : "Replace all occurrences of oldString (default false)" ,
} ) ,
2026-04-10 21:10:28 +00:00
} )
2026-01-01 22:54:11 +00:00
2026-04-11 02:36:02 +00:00
export const EditTool = Tool . define (
2026-04-10 21:10:28 +00:00
"edit" ,
Effect . gen ( function * ( ) {
const lsp = yield * LSP . Service
2026-06-02 20:09:26 +00:00
const afs = yield * FSUtil . Service
2026-04-11 02:36:02 +00:00
const format = yield * Format . Service
2026-05-31 01:08:38 +00:00
const events = yield * EventV2Bridge . Service
2025-05-19 23:29:38 +00:00
2025-05-20 15:11:06 +00:00
return {
2026-04-10 21:10:28 +00:00
description : DESCRIPTION ,
parameters : Parameters ,
2026-04-23 20:09:34 +00:00
execute : ( params : Schema.Schema.Type < typeof Parameters > , ctx : Tool.Context ) = >
2026-04-10 21:10:28 +00:00
Effect . gen ( function * ( ) {
if ( ! params . filePath ) {
throw new Error ( "filePath is required" )
}
if ( params . oldString === params . newString ) {
throw new Error ( "No changes to apply: oldString and newString are identical." )
}
2026-05-01 01:01:06 +00:00
const instance = yield * InstanceState . context
2026-04-10 21:10:28 +00:00
const filePath = path . isAbsolute ( params . filePath )
? params . filePath
2026-05-01 01:01:06 +00:00
: path . join ( instance . directory , params . filePath )
2026-04-10 21:10:28 +00:00
yield * assertExternalDirectoryEffect ( ctx , filePath )
let diff = ""
let contentOld = ""
let contentNew = ""
2026-04-20 05:14:21 +00:00
yield * lock ( filePath ) . withPermits ( 1 ) (
Effect . gen ( function * ( ) {
if ( params . oldString === "" ) {
const existed = yield * afs . existsSafe ( filePath )
2026-06-05 14:38:05 +00:00
if ( existed ) {
throw new Error (
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement." ,
)
}
2026-04-22 08:03:34 +00:00
const next = Bom . split ( params . newString )
2026-06-05 14:38:05 +00:00
const desiredBom = next . bom
contentOld = ""
2026-04-22 08:03:34 +00:00
contentNew = next . text
2026-04-20 05:14:21 +00:00
diff = trimDiff ( createTwoFilesPatch ( filePath , filePath , contentOld , contentNew ) )
yield * ctx . ask ( {
permission : "edit" ,
2026-05-01 01:01:06 +00:00
patterns : [ path . relative ( instance . worktree , filePath ) ] ,
2026-04-20 05:14:21 +00:00
always : [ "*" ] ,
metadata : {
filepath : filePath ,
diff ,
} ,
} )
2026-04-22 08:03:34 +00:00
yield * afs . writeWithDirs ( filePath , Bom . join ( contentNew , desiredBom ) )
if ( yield * format . file ( filePath ) ) {
contentNew = yield * Bom . syncFile ( afs , filePath , desiredBom )
}
2026-06-02 20:09:26 +00:00
yield * events . publish ( FileSystem . Event . Edited , { file : filePath } )
yield * events . publish ( Watcher . Event . Updated , {
2026-04-20 05:14:21 +00:00
file : filePath ,
2026-06-05 14:38:05 +00:00
event : "add" ,
2026-04-20 05:14:21 +00:00
} )
return
}
const info = yield * afs . stat ( filePath ) . pipe ( Effect . catch ( ( ) = > Effect . succeed ( undefined ) ) )
if ( ! info ) throw new Error ( ` File ${ filePath } not found ` )
if ( info . type === "Directory" ) throw new Error ( ` Path is a directory, not a file: ${ filePath } ` )
2026-04-22 08:03:34 +00:00
const source = yield * Bom . readFile ( afs , filePath )
contentOld = source . text
2026-04-20 05:14:21 +00:00
const ending = detectLineEnding ( contentOld )
const old = convertToLineEnding ( normalizeLineEndings ( params . oldString ) , ending )
2026-04-22 08:03:34 +00:00
const replacement = convertToLineEnding ( normalizeLineEndings ( params . newString ) , ending )
2026-04-20 05:14:21 +00:00
2026-04-22 08:03:34 +00:00
const next = Bom . split ( replace ( contentOld , old , replacement , params . replaceAll ) )
const desiredBom = source . bom || next . bom
contentNew = next . text
2026-04-20 05:14:21 +00:00
diff = trimDiff (
createTwoFilesPatch (
filePath ,
filePath ,
normalizeLineEndings ( contentOld ) ,
normalizeLineEndings ( contentNew ) ,
) ,
)
2026-04-11 02:36:02 +00:00
yield * ctx . ask ( {
2026-04-10 21:10:28 +00:00
permission : "edit" ,
2026-05-01 01:01:06 +00:00
patterns : [ path . relative ( instance . worktree , filePath ) ] ,
2026-04-10 21:10:28 +00:00
always : [ "*" ] ,
metadata : {
filepath : filePath ,
diff ,
} ,
} )
2026-04-20 05:14:21 +00:00
2026-04-22 08:03:34 +00:00
yield * afs . writeWithDirs ( filePath , Bom . join ( contentNew , desiredBom ) )
if ( yield * format . file ( filePath ) ) {
contentNew = yield * Bom . syncFile ( afs , filePath , desiredBom )
}
2026-06-02 20:09:26 +00:00
yield * events . publish ( FileSystem . Event . Edited , { file : filePath } )
yield * events . publish ( Watcher . Event . Updated , {
2026-04-10 21:10:28 +00:00
file : filePath ,
2026-04-20 05:14:21 +00:00
event : "change" ,
2026-04-10 21:10:28 +00:00
} )
2026-04-20 05:14:21 +00:00
diff = trimDiff (
createTwoFilesPatch (
filePath ,
filePath ,
normalizeLineEndings ( contentOld ) ,
normalizeLineEndings ( contentNew ) ,
) ,
)
} ) . pipe ( Effect . orDie ) ,
)
2026-04-10 21:10:28 +00:00
2026-04-21 21:37:27 +00:00
let additions = 0
let deletions = 0
for ( const change of diffLines ( contentOld , contentNew ) ) {
if ( change . added ) additions += change . count || 0
if ( change . removed ) deletions += change . count || 0
}
2026-04-10 21:10:28 +00:00
const filediff : Snapshot.FileDiff = {
file : filePath ,
patch : diff ,
2026-04-21 21:37:27 +00:00
additions ,
deletions ,
2026-04-10 21:10:28 +00:00
}
2026-04-11 03:12:04 +00:00
yield * ctx . metadata ( {
2026-04-10 21:10:28 +00:00
metadata : {
diff ,
filediff ,
diagnostics : { } ,
} ,
} )
let output = "Edit applied successfully."
2026-04-22 23:24:11 +00:00
yield * lsp . touchFile ( filePath , "document" )
2026-04-10 21:10:28 +00:00
const diagnostics = yield * lsp . diagnostics ( )
2026-06-02 20:09:26 +00:00
const normalizedFilePath = FSUtil . normalizePath ( filePath )
2026-04-11 02:00:56 +00:00
const block = LSP . Diagnostic . report ( filePath , diagnostics [ normalizedFilePath ] ? ? [ ] )
if ( block ) output += ` \ n \ nLSP errors detected in this file, please fix: \ n ${ block } `
2026-04-10 21:10:28 +00:00
return {
metadata : {
diagnostics ,
diff ,
filediff ,
} ,
2026-05-01 01:01:06 +00:00
title : ` ${ path . relative ( instance . worktree , filePath ) } ` ,
2026-04-10 21:10:28 +00:00
output ,
}
2026-04-11 02:36:02 +00:00
} ) ,
2025-05-31 18:41:00 +00:00
}
2026-04-10 21:10:28 +00:00
} ) ,
)
2025-06-18 15:20:40 +00:00
2025-07-07 19:53:43 +00:00
export type Replacer = ( content : string , find : string ) = > Generator < string , void , unknown >
2025-06-25 21:10:48 +00:00
2025-07-24 20:18:04 +00:00
// Similarity thresholds for block anchor fallback matching
2026-06-05 14:38:05 +00:00
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65
2025-07-24 20:18:04 +00:00
/ * *
* 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
2025-11-08 01:59:02 +00:00
matrix [ i ] [ j ] = Math . min ( matrix [ i - 1 ] [ j ] + 1 , matrix [ i ] [ j - 1 ] + 1 , matrix [ i - 1 ] [ j - 1 ] + cost )
2025-07-24 20:18:04 +00:00
}
}
return matrix [ a . length ] [ b . length ]
}
2025-06-25 21:10:48 +00:00
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 ++ ) {
2025-08-11 11:55:45 +00:00
matchEndIndex += originalLines [ i + k ] . length
if ( k < searchLines . length - 1 ) {
matchEndIndex += 1 // Add newline character except for the last line
}
2025-06-25 21:10:48 +00:00
}
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 ( )
2025-07-24 20:18:04 +00:00
const searchBlockSize = searchLines . length
2026-06-05 14:38:05 +00:00
const maxLineDelta = Math . max ( 1 , Math . floor ( searchBlockSize * 0.25 ) )
2025-06-25 21:10:48 +00:00
2025-07-24 20:18:04 +00:00
// Collect all candidate positions where both anchors match
const candidates : Array < { startLine : number ; endLine : number } > = [ ]
2025-06-25 21:10:48 +00:00
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 ) {
2026-06-05 14:38:05 +00:00
const actualBlockSize = j - i + 1
if ( Math . abs ( actualBlockSize - searchBlockSize ) <= maxLineDelta ) {
candidates . push ( { startLine : i , endLine : j } )
}
2025-07-24 20:18:04 +00:00
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
2026-06-05 14:38:05 +00:00
const linesToCheck = Math . min ( searchBlockSize - 2 , actualBlockSize - 2 ) // Middle lines only
2025-07-24 20:18:04 +00:00
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
2025-06-25 21:10:48 +00:00
}
2025-07-24 20:18:04 +00:00
const distance = levenshtein ( originalLine , searchLine )
similarity += ( 1 - distance / maxLen ) / linesToCheck
2025-06-25 21:10:48 +00:00
2025-07-24 20:18:04 +00:00
// Exit early when threshold is reached
if ( similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD ) {
break
2025-06-25 21:10:48 +00:00
}
2025-07-24 20:18:04 +00:00
}
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
2025-06-25 21:10:48 +00:00
2025-07-24 20:18:04 +00:00
if ( similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD ) {
let matchStartIndex = 0
for ( let k = 0 ; k < startLine ; k ++ ) {
matchStartIndex += originalLines [ k ] . length + 1
2025-06-25 21:10:48 +00:00
}
2025-07-24 20:18:04 +00:00
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
2026-06-05 14:38:05 +00:00
const linesToCheck = Math . min ( searchBlockSize - 2 , actualBlockSize - 2 ) // Middle lines only
2025-07-24 20:18:04 +00:00
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
2025-06-25 21:10:48 +00:00
}
}
2025-07-24 20:18:04 +00:00
// 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 )
}
2025-06-25 21:10:48 +00:00
}
2025-07-07 19:53:43 +00:00
export const WhitespaceNormalizedReplacer : Replacer = function * ( content , find ) {
2025-06-25 21:10:48 +00:00
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
2025-07-24 20:18:04 +00:00
} 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 ) {
2025-11-08 01:59:02 +00:00
const pattern = words . map ( ( word ) = > word . replace ( /[.*+?^${}()|[\]\\]/g , "\\$&" ) ) . join ( "\\s+" )
2025-07-24 20:18:04 +00:00
try {
const regex = new RegExp ( pattern )
const match = line . match ( regex )
if ( match ) {
yield match [ 0 ]
}
2026-04-16 01:33:54 +00:00
} catch {
2025-07-24 20:18:04 +00:00
// Invalid regex pattern, skip
2025-06-25 23:22:54 +00:00
}
2025-06-25 21:10:48 +00:00
}
}
}
}
// 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
} ) ,
)
2025-07-07 19:53:43 +00:00
return lines . map ( ( line ) = > ( line . trim ( ) . length === 0 ? line : line.slice ( minIndent ) ) ) . join ( "\n" )
2025-06-25 21:10:48 +00:00
}
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
}
}
}
2025-06-25 21:54:54 +00:00
export const EscapeNormalizedReplacer : Replacer = function * ( content , find ) {
const unescapeString = ( str : string ) : string = > {
2025-06-25 23:22:54 +00:00
return str . replace ( /\\(n|t|r|'|"|`|\\|\n|\$)/g , ( match , capturedChar ) = > {
2025-06-25 21:54:54 +00:00
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
}
2025-06-25 23:22:54 +00:00
// Remove trailing empty line if present
if ( findLines [ findLines . length - 1 ] === "" ) {
findLines . pop ( )
}
2025-06-25 21:54:54 +00:00
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 ++
}
}
}
2025-07-07 19:53:43 +00:00
if ( totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5 ) {
2025-06-25 21:54:54 +00:00
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
2025-10-14 06:55:02 +00:00
export function trimDiff ( diff : string ) : string {
2025-06-18 15:20:40 +00:00
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" )
}
2025-06-25 21:10:48 +00:00
2025-11-08 01:59:02 +00:00
export function replace ( content : string , oldString : string , newString : string , replaceAll = false ) : string {
2025-06-25 23:22:54 +00:00
if ( oldString === newString ) {
2026-02-12 06:33:18 +00:00
throw new Error ( "No changes to apply: oldString and newString are identical." )
2025-06-25 23:22:54 +00:00
}
2026-06-05 14:38:05 +00:00
if ( oldString === "" ) {
throw new Error (
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement." ,
)
}
2025-06-27 02:12:23 +00:00
2025-09-05 16:36:13 +00:00
let notFound = true
2025-06-25 21:10:48 +00:00
for ( const replacer of [
SimpleReplacer ,
LineTrimmedReplacer ,
2025-09-27 07:04:42 +00:00
BlockAnchorReplacer ,
2025-06-25 21:10:48 +00:00
WhitespaceNormalizedReplacer ,
IndentationFlexibleReplacer ,
2025-07-24 20:18:04 +00:00
EscapeNormalizedReplacer ,
2025-09-27 07:04:42 +00:00
TrimmedBoundaryReplacer ,
ContextAwareReplacer ,
MultiOccurrenceReplacer ,
2025-06-25 21:10:48 +00:00
] ) {
for ( const search of replacer ( content , oldString ) ) {
const index = content . indexOf ( search )
if ( index === - 1 ) continue
2025-09-05 16:36:13 +00:00
notFound = false
2026-06-05 14:38:05 +00:00
if ( isDisproportionateMatch ( search , oldString ) ) {
throw new Error (
"Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement." ,
)
}
2025-06-25 21:10:48 +00:00
if ( replaceAll ) {
return content . replaceAll ( search , newString )
}
const lastIndex = content . lastIndexOf ( search )
if ( index !== lastIndex ) continue
2025-07-07 19:53:43 +00:00
return content . substring ( 0 , index ) + newString + content . substring ( index + search . length )
2025-06-25 21:10:48 +00:00
}
}
2025-09-05 16:36:13 +00:00
if ( notFound ) {
2026-02-12 06:33:18 +00:00
throw new Error (
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." ,
)
2025-09-05 16:36:13 +00:00
}
2026-02-12 06:33:18 +00:00
throw new Error ( "Found multiple matches for oldString. Provide more surrounding context to make the match unique." )
2025-06-25 21:10:48 +00:00
}
2026-06-05 14:38:05 +00:00
function isDisproportionateMatch ( search : string , oldString : string ) {
const oldLines = oldString . split ( "\n" ) . length
const searchLines = search . split ( "\n" ) . length
if ( searchLines >= Math . max ( oldLines + 3 , oldLines * 2 ) ) return true
if ( oldLines === 1 ) return false
return search . trim ( ) . length > Math . max ( oldString . trim ( ) . length + 500 , oldString . trim ( ) . length * 4 )
}