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-10-26 19:50:41 +00:00
import z from "zod"
2025-05-31 18:41:00 +00:00
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../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-03-20 20:55:46 +00:00
import { File } from "../file/service"
2026-01-26 22:46:01 +00:00
import { FileWatcher } from "../file/watcher"
2025-06-27 15:29:20 +00:00
import { Bus } from "../bus"
import { FileTime } from "../file/time"
2025-07-31 00:57:52 +00:00
import { Filesystem } from "../util/filesystem"
2025-09-01 21:15:49 +00:00
import { Instance } from "../project/instance"
2026-03-20 20:55:46 +00:00
import { Snapshot } from "@/snapshot/service"
2026-01-10 23:49:36 +00:00
import { assertExternalDirectory } from "./external-directory"
2025-05-19 23:29:38 +00:00
2025-12-14 01:56:26 +00:00
const MAX_DIAGNOSTICS_PER_FILE = 20
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" )
}
2025-07-25 17:29:29 +00:00
export const EditTool = Tool . define ( "edit" , {
2025-05-19 23:29:38 +00:00
description : DESCRIPTION ,
parameters : z.object ( {
2025-05-27 02:08:50 +00:00
filePath : z.string ( ) . describe ( "The absolute path to the file to modify" ) ,
oldString : z.string ( ) . describe ( "The text to replace" ) ,
2025-11-08 01:59:02 +00:00
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)" ) ,
2025-05-19 23:29:38 +00:00
} ) ,
2025-06-02 23:51:26 +00:00
async execute ( params , ctx ) {
2025-05-27 02:08:50 +00:00
if ( ! params . filePath ) {
2025-05-31 18:41:00 +00:00
throw new Error ( "filePath is required" )
2025-05-19 23:29:38 +00:00
}
2025-06-25 23:22:54 +00:00
if ( params . oldString === params . 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
}
2025-11-08 01:59:02 +00:00
const filePath = path . isAbsolute ( params . filePath ) ? params.filePath : path.join ( Instance . directory , params . filePath )
2026-01-10 23:49:36 +00:00
await assertExternalDirectory ( ctx , filePath )
2025-05-19 23:29:38 +00:00
2025-07-31 14:34:43 +00:00
let diff = ""
2025-05-31 22:42:43 +00:00
let contentOld = ""
let contentNew = ""
2025-12-15 05:01:50 +00:00
await FileTime . withLock ( filePath , async ( ) = > {
2025-05-27 02:08:50 +00:00
if ( params . oldString === "" ) {
2026-02-19 16:32:32 +00:00
const existed = await Filesystem . exists ( filePath )
2025-05-31 22:42:43 +00:00
contentNew = params . newString
2025-07-31 14:34:43 +00:00
diff = trimDiff ( createTwoFilesPatch ( filePath , filePath , contentOld , contentNew ) )
2026-01-01 22:54:11 +00:00
await ctx . ask ( {
permission : "edit" ,
patterns : [ path . relative ( Instance . worktree , filePath ) ] ,
always : [ "*" ] ,
metadata : {
filepath : filePath ,
diff ,
} ,
} )
2026-02-19 16:32:32 +00:00
await Filesystem . write ( filePath , params . newString )
2025-06-27 15:29:20 +00:00
await Bus . publish ( File . Event . Edited , {
2025-07-31 14:34:43 +00:00
file : filePath ,
2025-06-27 15:29:20 +00:00
} )
2026-01-26 22:46:01 +00:00
await Bus . publish ( FileWatcher . Event . Updated , {
file : filePath ,
event : existed ? "change" : "add" ,
} )
2026-03-16 18:23:13 +00:00
await FileTime . read ( ctx . sessionID , filePath )
2025-05-31 18:41:00 +00:00
return
2025-05-21 02:00:00 +00:00
}
2025-05-19 23:29:38 +00:00
2026-02-19 16:32:32 +00:00
const stats = Filesystem . stat ( filePath )
2025-07-31 14:34:43 +00:00
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 )
2026-02-19 16:32:32 +00:00
contentOld = await Filesystem . readText ( filePath )
2026-03-07 07:42:54 +00:00
const ending = detectLineEnding ( contentOld )
const old = convertToLineEnding ( normalizeLineEndings ( params . oldString ) , ending )
const next = convertToLineEnding ( normalizeLineEndings ( params . newString ) , ending )
contentNew = replace ( contentOld , old , next , params . replaceAll )
2025-07-31 14:34:43 +00:00
2025-11-16 03:18:39 +00:00
diff = trimDiff (
createTwoFilesPatch ( filePath , filePath , normalizeLineEndings ( contentOld ) , normalizeLineEndings ( contentNew ) ) ,
)
2026-01-01 22:54:11 +00:00
await ctx . ask ( {
permission : "edit" ,
patterns : [ path . relative ( Instance . worktree , filePath ) ] ,
always : [ "*" ] ,
metadata : {
filepath : filePath ,
diff ,
} ,
} )
2025-07-31 14:34:43 +00:00
2026-02-19 16:32:32 +00:00
await Filesystem . write ( filePath , contentNew )
2025-06-27 15:29:20 +00:00
await Bus . publish ( File . Event . Edited , {
2025-07-31 14:34:43 +00:00
file : filePath ,
2025-06-27 15:29:20 +00:00
} )
2026-01-26 22:46:01 +00:00
await Bus . publish ( FileWatcher . Event . Updated , {
file : filePath ,
event : "change" ,
} )
2026-02-19 16:32:32 +00:00
contentNew = await Filesystem . readText ( filePath )
2025-11-16 03:18:39 +00:00
diff = trimDiff (
createTwoFilesPatch ( filePath , filePath , normalizeLineEndings ( contentOld ) , normalizeLineEndings ( contentNew ) ) ,
)
2026-03-16 18:23:13 +00:00
await FileTime . read ( ctx . sessionID , filePath )
2025-12-15 05:01:50 +00:00
} )
2025-05-21 02:00:00 +00:00
2026-01-01 22:54:11 +00:00
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 : { } ,
} ,
} )
2026-01-13 02:39:57 +00:00
let output = "Edit applied successfully."
2025-07-31 14:34:43 +00:00
await LSP . touchFile ( filePath , true )
2025-05-31 18:41:00 +00:00
const diagnostics = await LSP . diagnostics ( )
2025-12-16 00:01:03 +00:00
const normalizedFilePath = Filesystem . normalizePath ( filePath )
const issues = diagnostics [ normalizedFilePath ] ? ? [ ]
2025-12-26 04:24:48 +00:00
const errors = issues . filter ( ( item ) = > item . severity === 1 )
if ( errors . length > 0 ) {
2025-12-16 00:01:03 +00:00
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 ` : ""
2026-01-14 21:44:44 +00:00
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> `
2025-05-19 23:29:38 +00:00
}
2025-05-20 15:11:06 +00:00
return {
2025-05-21 20:35:33 +00:00
metadata : {
diagnostics ,
2025-06-03 17:44:46 +00:00
diff ,
2025-10-28 18:08:10 +00:00
filediff ,
2025-05-21 20:35:33 +00:00
} ,
2025-09-01 21:15:49 +00:00
title : ` ${ path . relative ( Instance . worktree , filePath ) } ` ,
2025-05-21 02:00:00 +00:00
output ,
2025-05-31 18:41:00 +00:00
}
2025-05-19 23:29:38 +00:00
} ,
2025-05-31 18:41:00 +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
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
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
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 ) {
2025-07-24 20:18:04 +00:00
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
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
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
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 ]
}
} catch ( e ) {
// 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
}
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
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
}