@@ -256,47 +256,155 @@ function countOccurrences(content: string, oldText: string): number {
256256 return fuzzyContent . split ( fuzzyOldText ) . length - 1 ;
257257}
258258
259+ const EDIT_CANDIDATE_LIMIT = 3 ;
260+ const EDIT_CANDIDATE_MAX_LINES = 1000 ;
261+ const EDIT_CANDIDATE_MAX_SCAN_CHARS = 128 * 1024 ;
262+ const EDIT_CANDIDATE_MAX_LINE_CHARS = 120 ;
263+ const EDIT_CANDIDATE_MIN_SCORE = 0.45 ;
264+
265+ interface EditCandidate {
266+ lineNumber : number ;
267+ line : string ;
268+ score : number ;
269+ }
270+
271+ function truncateCandidateText ( text : string , maxChars : number ) : string {
272+ if ( text . length <= maxChars ) {
273+ return text ;
274+ }
275+ const cut =
276+ maxChars > 0 &&
277+ / [ \uD800 - \uDBFF ] / . test ( text [ maxChars - 1 ] ) &&
278+ / [ \uDC00 - \uDFFF ] / . test ( text [ maxChars ] )
279+ ? maxChars - 1
280+ : maxChars ;
281+ return text . slice ( 0 , cut ) ;
282+ }
283+
284+ function getBoundedLines ( text : string , maxLines : number , maxScanChars : number ) : string [ ] {
285+ return truncateCandidateText ( text , maxScanChars )
286+ . split ( "\n" , maxLines )
287+ . map ( ( line ) => truncateCandidateText ( line , EDIT_CANDIDATE_MAX_LINE_CHARS ) ) ;
288+ }
289+
290+ function scoreCandidate ( expected : string , candidate : string ) : number {
291+ const normalizedExpected = expected . trim ( ) ;
292+ const normalizedCandidate = candidate . trim ( ) ;
293+ const maxLength = Math . max ( normalizedExpected . length , normalizedCandidate . length ) ;
294+ if ( maxLength === 0 ) {
295+ return 0 ;
296+ }
297+
298+ // Length alone sets an upper bound on the possible similarity score.
299+ if (
300+ Math . min ( normalizedExpected . length , normalizedCandidate . length ) / maxLength <
301+ EDIT_CANDIDATE_MIN_SCORE
302+ ) {
303+ return 0 ;
304+ }
305+
306+ return 1 - levenshteinDistance ( normalizedExpected , normalizedCandidate ) / maxLength ;
307+ }
308+
309+ function describeIndentation ( line : string ) : string {
310+ const indentation = line . match ( / ^ [ \t ] * / ) ?. [ 0 ] ?? "" ;
311+ if ( ! indentation ) {
312+ return "none" ;
313+ }
314+ const tabs = indentation . match ( / \t / g) ?. length ?? 0 ;
315+ const spaces = indentation . length - tabs ;
316+ return tabs === 0 ? `${ spaces } spaces` : `${ spaces } spaces and ${ tabs } tabs` ;
317+ }
318+
319+ function firstDifferenceIndex ( left : string , right : string ) : number {
320+ const sharedLength = Math . min ( left . length , right . length ) ;
321+ for ( let index = 0 ; index < sharedLength ; index ++ ) {
322+ if ( left [ index ] !== right [ index ] ) {
323+ return index ;
324+ }
325+ }
326+ return left . length === right . length ? - 1 : sharedLength ;
327+ }
328+
329+ function describeCandidateDifference ( expected : string , found : string ) : string {
330+ const expectedIndentation = expected . match ( / ^ [ \t ] * / ) ?. [ 0 ] ?? "" ;
331+ const foundIndentation = found . match ( / ^ [ \t ] * / ) ?. [ 0 ] ?? "" ;
332+ if ( expectedIndentation !== foundIndentation ) {
333+ return `indentation differs (expected ${ describeIndentation ( expected ) } , found ${ describeIndentation ( found ) } )` ;
334+ }
335+
336+ const expectedBackslashes = expected . match ( / \\ / g) ?. length ?? 0 ;
337+ const foundBackslashes = found . match ( / \\ / g) ?. length ?? 0 ;
338+ if ( expectedBackslashes !== foundBackslashes ) {
339+ return `escaping differs (expected ${ expectedBackslashes } backslashes, found ${ foundBackslashes } )` ;
340+ }
341+
342+ const differenceIndex = firstDifferenceIndex ( expected , found ) ;
343+ return differenceIndex === - 1
344+ ? "this line matches; surrounding lines differ"
345+ : `first difference at column ${ differenceIndex + 1 } ` ;
346+ }
347+
348+ function getCandidateHint ( content : string , oldText : string ) : string {
349+ const expected = getBoundedLines ( oldText , 32 , 4096 ) . reduce (
350+ ( best , line ) => ( line . trim ( ) . length > best . trim ( ) . length ? line : best ) ,
351+ "" ,
352+ ) ;
353+ if ( ! expected . trim ( ) ) {
354+ return "" ;
355+ }
356+ const candidates = getBoundedLines (
357+ content ,
358+ EDIT_CANDIDATE_MAX_LINES ,
359+ EDIT_CANDIDATE_MAX_SCAN_CHARS ,
360+ )
361+ . map ( ( line , index ) : EditCandidate | undefined => {
362+ const score = scoreCandidate ( expected , line ) ;
363+ return score >= EDIT_CANDIDATE_MIN_SCORE ? { lineNumber : index + 1 , line, score } : undefined ;
364+ } )
365+ . filter ( ( candidate ) : candidate is EditCandidate => candidate !== undefined )
366+ . toSorted ( ( left , right ) => right . score - left . score || left . lineNumber - right . lineNumber )
367+ . slice ( 0 , EDIT_CANDIDATE_LIMIT ) ;
368+ if ( candidates . length === 0 ) {
369+ return "" ;
370+ }
371+ const expectedDisplay = JSON . stringify ( expected ) ;
372+ return (
373+ "\nClosest matching lines:\n" +
374+ candidates
375+ . map ( ( candidate ) => {
376+ const foundDisplay = JSON . stringify ( candidate . line ) ;
377+ const differenceIndex = firstDifferenceIndex ( expectedDisplay , foundDisplay ) ;
378+ const markerIndex =
379+ differenceIndex === - 1
380+ ? Math . min ( expectedDisplay . length , foundDisplay . length )
381+ : differenceIndex ;
382+ const markerWidth = Math . max (
383+ 1 ,
384+ Math . min ( 12 , Math . max ( expectedDisplay . length , foundDisplay . length ) - markerIndex ) ,
385+ ) ;
386+ return [
387+ ` near line ${ candidate . lineNumber } (${ Math . round ( candidate . score * 100 ) } % match):` ,
388+ ` expected: ${ expectedDisplay } ` ,
389+ ` found: ${ foundDisplay } ` ,
390+ ` ${ " " . repeat ( markerIndex ) } ${ "^" . repeat ( markerWidth ) } ` ,
391+ ` hint: ${ describeCandidateDifference ( expected , candidate . line ) } ` ,
392+ ] . join ( "\n" ) ;
393+ } )
394+ . join ( "\n" )
395+ ) ;
396+ }
397+
259398function getNotFoundError (
260399 path : string ,
261400 editIndex : number ,
262401 totalEdits : number ,
263- content ? : string ,
264- oldText ? : string ,
402+ content : string ,
403+ oldText : string ,
265404) : Error {
266405 const prefix =
267406 totalEdits === 1 ? "Could not find the exact text" : `Could not find edits[${ editIndex } ]` ;
268- let hint = "" ;
269- if ( content && oldText ) {
270- // Score each line by similarity to the first line of oldText.
271- // Cap inputs to avoid unbounded CPU on large / minified files (P1).
272- const oldTextFirstLine = oldText . split ( "\n" ) [ 0 ] ?. trim ( ) . slice ( 0 , 200 ) ?? "" ;
273- if ( oldTextFirstLine . length > 0 ) {
274- const MAX_LINES = 3000 ;
275- const MAX_LINE_LEN = 200 ;
276- const lines = content . split ( "\n" ) ;
277- const scored = lines
278- . slice ( 0 , MAX_LINES )
279- . map ( ( line , idx ) => {
280- const trimmed = line . trim ( ) . slice ( 0 , MAX_LINE_LEN ) ;
281- const dist = levenshteinDistance ( trimmed , oldTextFirstLine ) ;
282- const maxLen = Math . max ( trimmed . length , oldTextFirstLine . length ) ;
283- return { lineNum : idx + 1 , line : trimmed , score : maxLen > 0 ? 1 - dist / maxLen : 0 } ;
284- } )
285- . filter ( ( s ) => s . score > 0.3 )
286- . toSorted ( ( a , b ) => b . score - a . score )
287- . slice ( 0 , 3 ) ;
288- if ( scored . length > 0 ) {
289- hint =
290- "\n" +
291- scored
292- . map (
293- ( s ) =>
294- ` near line ${ s . lineNum } : "${ s . line } " (${ Math . round ( s . score * 100 ) } % match, check whitespace/indentation)` ,
295- )
296- . join ( "\n" ) ;
297- }
298- }
299- }
407+ const hint = getCandidateHint ( content , oldText ) ;
300408 return new Error (
301409 `${ prefix } in ${ path } . The old text must match exactly including all whitespace and newlines.${ hint } ` ,
302410 ) ;
@@ -372,7 +480,7 @@ export function applyEditsToNormalizedContent(
372480 const edit = normalizedEdits [ i ] ;
373481 const matchResult = fuzzyFindText ( replacementBaseContent , edit . oldText ) ;
374482 if ( ! matchResult . found ) {
375- throw getNotFoundError ( path , i , normalizedEdits . length , baseContent , edit . oldText ) ;
483+ throw getNotFoundError ( path , i , normalizedEdits . length , normalizedContent , edit . oldText ) ;
376484 }
377485
378486 const occurrences = countOccurrences ( replacementBaseContent , edit . oldText ) ;
0 commit comments