bingocode 1.1.177 → 1.1.179

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,898 +1,1004 @@
1
- import { type StructuredPatchHunk, structuredPatch } from 'diff'
2
- import { logError } from 'src/utils/log.js'
3
- import { expandPath } from 'src/utils/path.js'
4
- import { countCharInString } from 'src/utils/stringUtils.js'
5
- import {
6
- DIFF_TIMEOUT_MS,
7
- getPatchForDisplay,
8
- getPatchFromContents,
9
- } from '../../utils/diff.js'
10
- import { errorMessage, isENOENT } from '../../utils/errors.js'
11
- import {
12
- addLineNumbers,
13
- convertLeadingTabsToSpaces,
14
- readFileSyncCached,
15
- } from '../../utils/file.js'
16
- import type { EditInput, FileEdit } from './types.js'
17
-
18
- // Claude can't output curly quotes, so we define them as constants here for Claude to use
19
- // in the code. We do this because we normalize curly quotes to straight quotes
20
- // when applying edits.
21
- export const LEFT_SINGLE_CURLY_QUOTE = '‘'
22
- export const RIGHT_SINGLE_CURLY_QUOTE = '’'
23
- export const LEFT_DOUBLE_CURLY_QUOTE = '“'
24
- export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
25
-
26
- /**
27
- * Normalizes quotes in a string by converting curly quotes to straight quotes
28
- * @param str The string to normalize
29
- * @returns The string with all curly quotes replaced by straight quotes
30
- */
31
- export function normalizeQuotes(str: string): string {
32
- return str
33
- .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
34
- .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
35
- .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
36
- .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
37
- }
38
-
39
- /**
40
- * Strips trailing whitespace from each line in a string while preserving line endings
41
- * @param str The string to process
42
- * @returns The string with trailing whitespace removed from each line
43
- */
44
- export function stripTrailingWhitespace(str: string): string {
45
- // Handle different line endings: CRLF, LF, CR
46
- // Use a regex that matches line endings and captures them
47
- const lines = str.split(/(\r\n|\n|\r)/)
48
-
49
- let result = ''
50
- for (let i = 0; i < lines.length; i++) {
51
- const part = lines[i]
52
- if (part !== undefined) {
53
- if (i % 2 === 0) {
54
- // Even indices are line content
55
- result += part.replace(/\s+$/, '')
56
- } else {
57
- // Odd indices are line endings
58
- result += part
59
- }
60
- }
61
- }
62
-
63
- return result
64
- }
65
-
66
- /**
67
- * Finds the actual string in the file content that matches the search string,
68
- * accounting for quote normalization
69
- * @param fileContent The file content to search in
70
- * @param searchString The string to search for
71
- * @returns The actual string found in the file, or null if not found
72
- */
73
-
74
- /** Normalizes Unicode dashes to ASCII, indent whitespace to spaces.
75
- * Fills gaps where models emit ASCII dashes instead of Unicode dashes,
76
- * or provide different tab/space indentation than the file has. */
77
- export function normalizeDashes(str: string): string {
78
- return str.replaceAll('\u2014', '-').replaceAll('\u2013', '-').replaceAll('\u2015', '-')
79
- }
80
- export function normalizeIndentation(str: string): string {
81
- return str.split('\n').map(line => line.trim()).join('\n')
82
- }
83
- export function findActualString(
84
- fileContent: string,
85
- searchString: string,
86
- ): string | null {
87
- // First try exact match
88
- if (fileContent.includes(searchString)) {
89
- return searchString
90
- }
91
-
92
- // Try with normalized quotes
93
- const normalizedSearch = normalizeQuotes(searchString)
94
- const normalizedFile = normalizeQuotes(fileContent)
95
- const searchIndex = normalizedFile.indexOf(normalizedSearch)
96
- if (searchIndex !== -1) {
97
- return fileContent.substring(searchIndex, searchIndex + searchString.length)
98
- }
99
-
100
- // Try with normalized dashes (em-dash, en-dash -> ASCII dash)
101
- const dashedSearch = normalizeDashes(searchString)
102
- const dashedFile = normalizeDashes(fileContent)
103
- const dashIndex = dashedFile.indexOf(dashedSearch)
104
- if (dashIndex !== -1) {
105
- return fileContent.substring(dashIndex, dashIndex + searchString.length)
106
- }
107
-
108
- // Try with normalized leading whitespace (tab <-> space)
109
- const indentNormalizedSearch = normalizeIndentation(searchString)
110
- const indentTrimmedFile = normalizeIndentation(fileContent)
111
- const matchPoint = indentTrimmedFile.indexOf(indentNormalizedSearch)
112
- if (matchPoint !== -1) {
113
- // Leading whitespace normalization is NOT length-preserving,
114
- // so compute bounds by matching each trimmed line back to its original.
115
- const origLines = fileContent.split('\n')
116
- const trimmedLines = indentTrimmedFile.split('\n')
117
- const searchLines = indentNormalizedSearch.split('\n')
118
- for (let i = 0; i <= trimmedLines.length - searchLines.length; i++) {
119
- let k = 0
120
- while (k < searchLines.length && trimmedLines[i + k] === searchLines[k]) k++
121
- if (k !== searchLines.length) continue
122
- let start = 0
123
- for (let j = 0; j < i; j++) start += origLines[j].length + 1
124
- let end = start
125
- for (let j = i; j < i + k; j++) end += origLines[j].length + 1
126
- return fileContent.substring(start, Math.max(start, end - 1))
127
- }
128
- return null
129
- }
130
-
131
- return null
132
- }
133
-
134
- /**
135
- * When old_string matched via quote normalization (curly quotes in file,
136
- * straight quotes from model), apply the same curly quote style to new_string
137
- * so the edit preserves the file's typography.
138
- *
139
- * Uses a simple open/close heuristic: a quote character preceded by whitespace,
140
- * start of string, or opening punctuation is treated as an opening quote;
141
- * otherwise it's a closing quote.
142
- */
143
- export function preserveQuoteStyle(
144
- oldString: string,
145
- actualOldString: string,
146
- newString: string,
147
- ): string {
148
- // If they're the same, no normalization happened
149
- if (oldString === actualOldString) {
150
- return newString
151
- }
152
-
153
- // Detect which curly quote types were in the file
154
- const hasDoubleQuotes =
155
- actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
156
- actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
157
- const hasSingleQuotes =
158
- actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
159
- actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
160
-
161
- if (!hasDoubleQuotes && !hasSingleQuotes) {
162
- return newString
163
- }
164
-
165
- let result = newString
166
-
167
- if (hasDoubleQuotes) {
168
- result = applyCurlyDoubleQuotes(result)
169
- }
170
- if (hasSingleQuotes) {
171
- result = applyCurlySingleQuotes(result)
172
- }
173
-
174
- return result
175
- }
176
-
177
- function isOpeningContext(chars: string[], index: number): boolean {
178
- if (index === 0) {
179
- return true
180
- }
181
- const prev = chars[index - 1]
182
- return (
183
- prev === ' ' ||
184
- prev === '\t' ||
185
- prev === '\n' ||
186
- prev === '\r' ||
187
- prev === '(' ||
188
- prev === '[' ||
189
- prev === '{' ||
190
- prev === '\u2014' || // em dash
191
- prev === '\u2013' // en dash
192
- )
193
- }
194
-
195
- function applyCurlyDoubleQuotes(str: string): string {
196
- const chars = [...str]
197
- const result: string[] = []
198
- for (let i = 0; i < chars.length; i++) {
199
- if (chars[i] === '"') {
200
- result.push(
201
- isOpeningContext(chars, i)
202
- ? LEFT_DOUBLE_CURLY_QUOTE
203
- : RIGHT_DOUBLE_CURLY_QUOTE,
204
- )
205
- } else {
206
- result.push(chars[i]!)
207
- }
208
- }
209
- return result.join('')
210
- }
211
-
212
- function applyCurlySingleQuotes(str: string): string {
213
- const chars = [...str]
214
- const result: string[] = []
215
- for (let i = 0; i < chars.length; i++) {
216
- if (chars[i] === "'") {
217
- // Don't convert apostrophes in contractions (e.g., "don't", "it's")
218
- // An apostrophe between two letters is a contraction, not a quote
219
- const prev = i > 0 ? chars[i - 1] : undefined
220
- const next = i < chars.length - 1 ? chars[i + 1] : undefined
221
- const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
222
- const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
223
- if (prevIsLetter && nextIsLetter) {
224
- // Apostrophe in a contraction — use right single curly quote
225
- result.push(RIGHT_SINGLE_CURLY_QUOTE)
226
- } else {
227
- result.push(
228
- isOpeningContext(chars, i)
229
- ? LEFT_SINGLE_CURLY_QUOTE
230
- : RIGHT_SINGLE_CURLY_QUOTE,
231
- )
232
- }
233
- } else {
234
- result.push(chars[i]!)
235
- }
236
- }
237
- return result.join('')
238
- }
239
-
240
- /**
241
- * Error class for when an edit's old_string can't be found in the file.
242
- * Carries diagnostics for better error reporting.
243
- */
244
- export class EditNotFoundError extends Error {
245
- diagnostics: {
246
- searchString: string
247
- visibleSearch: string
248
- closestMatches: {
249
- snippet: string
250
- lineNumber: number
251
- diffType: string
252
- }[]
253
- }
254
- constructor(
255
- message: string,
256
- diagnostics: EditNotFoundError['diagnostics'],
257
- ) {
258
- super(message)
259
- this.name = 'EditNotFoundError'
260
- this.diagnostics = diagnostics
261
- }
262
- }
263
-
264
- /**
265
- * Renders whitespace characters as visible Unicode equivalents:
266
- * tab → '→', space → '·'
267
- */
268
- export function visibleWhitespace(str: string): string {
269
- return str
270
- .replace(/\t/g, '→')
271
- .replace(/ /g, '·')
272
- .replace(/\n/g, '↵')
273
- .replace(/\r/g, '␍')
274
- }
275
-
276
- /**
277
- * Finds up to 3 lines in fileContent whose content (non-whitespace portion)
278
- * matches the content of the first line of searchString.
279
- * Used for diagnostic purposes when findActualString returns null.
280
- *
281
- * Returns matches sorted with whitespace-diff first, then content matches.
282
- */
283
- export function findClosestLines(
284
- fileContent: string,
285
- searchString: string,
286
- ): { snippet: string; lineNumber: number; diffType: string }[] {
287
- const firstContent = searchString.split('\n')[0]!.replace(/^\s+/, '')
288
- if (!firstContent) return []
289
-
290
- const matches: { snippet: string; lineNumber: number; diffType: string }[] = []
291
- const fileLines = fileContent.split('\n')
292
-
293
- for (let i = 0; i < fileLines.length; i++) {
294
- const line = fileLines[i]!
295
- if (line.replace(/^\s+/, '') !== firstContent) continue
296
-
297
- const snippet = line.replace(/\s+$/, '')
298
-
299
- // Avoid duplicates
300
- if (!matches.some(m => m.snippet === snippet)) {
301
- matches.push({
302
- snippet,
303
- lineNumber: i + 1,
304
- diffType: 'content match',
305
- })
306
- if (matches.length >= 3) break
307
- }
308
- }
309
-
310
- return matches
311
- }
312
-
313
- /**
314
- * Transform edits to ensure replace_all always has a boolean value
315
- * @param edits Array of edits with optional replace_all
316
- * @returns Array of edits with replace_all guaranteed to be boolean
317
- */
318
- export function applyEditToFile(
319
- originalContent: string,
320
- oldString: string,
321
- newString: string,
322
- replaceAll: boolean = false,
323
- ): string {
324
- const f = replaceAll
325
- ? (content: string, search: string, replace: string) =>
326
- content.replaceAll(search, () => replace)
327
- : (content: string, search: string, replace: string) =>
328
- content.replace(search, () => replace)
329
-
330
- if (newString !== '') {
331
- return f(originalContent, oldString, newString)
332
- }
333
-
334
- const stripTrailingNewline =
335
- !oldString.endsWith('\n') && originalContent.includes(oldString + '\n')
336
-
337
- return stripTrailingNewline
338
- ? f(originalContent, oldString + '\n', newString)
339
- : f(originalContent, oldString, newString)
340
- }
341
-
342
- /**
343
- * Applies an edit to a file and returns the patch and updated file.
344
- * Does not write the file to disk.
345
- */
346
- export function getPatchForEdit({
347
- filePath,
348
- fileContents,
349
- oldString,
350
- newString,
351
- replaceAll = false,
352
- }: {
353
- filePath: string
354
- fileContents: string
355
- oldString: string
356
- newString: string
357
- replaceAll?: boolean
358
- }): { patch: StructuredPatchHunk[]; updatedFile: string } {
359
- return getPatchForEdits({
360
- filePath,
361
- fileContents,
362
- edits: [
363
- { old_string: oldString, new_string: newString, replace_all: replaceAll },
364
- ],
365
- })
366
- }
367
-
368
- /**
369
- * Applies a list of edits to a file and returns the patch and updated file.
370
- * Does not write the file to disk.
371
- *
372
- * NOTE: The returned patch is to be used for display purposes only - it has spaces instead of tabs
373
- */
374
- export function getPatchForEdits({
375
- filePath,
376
- fileContents,
377
- edits,
378
- }: {
379
- filePath: string
380
- fileContents: string
381
- edits: FileEdit[]
382
- }): { patch: StructuredPatchHunk[]; updatedFile: string } {
383
- let updatedFile = fileContents
384
- const appliedNewStrings: string[] = []
385
-
386
- // Special case for empty files.
387
- if (
388
- !fileContents &&
389
- edits.length === 1 &&
390
- edits[0] &&
391
- edits[0].old_string === '' &&
392
- edits[0].new_string === ''
393
- ) {
394
- const patch = getPatchForDisplay({
395
- filePath,
396
- fileContents,
397
- edits: [
398
- {
399
- old_string: fileContents,
400
- new_string: updatedFile,
401
- replace_all: false,
402
- },
403
- ],
404
- })
405
- return { patch, updatedFile: '' }
406
- }
407
-
408
- // Apply each edit and check if it actually changes the file
409
- for (const edit of edits) {
410
- // Strip trailing newlines from old_string before checking
411
- const oldStringToCheck = edit.old_string.replace(/\n+$/, '')
412
-
413
- // Check if old_string is a substring of any previously applied new_string
414
- for (const previousNewString of appliedNewStrings) {
415
- if (
416
- oldStringToCheck !== '' &&
417
- previousNewString.includes(oldStringToCheck)
418
- ) {
419
- throw new Error(
420
- 'Cannot edit file: old_string is a substring of a new_string from a previous edit.',
421
- )
422
- }
423
- }
424
-
425
- const previousContent = updatedFile
426
- updatedFile =
427
- edit.old_string === ''
428
- ? edit.new_string
429
- : applyEditToFile(
430
- updatedFile,
431
- edit.old_string,
432
- edit.new_string,
433
- edit.replace_all,
434
- )
435
-
436
- // If this edit didn't change anything, throw an error
437
- if (updatedFile === previousContent) {
438
- const closest = findClosestLines(fileContents, edit.old_string)
439
- throw new EditNotFoundError(
440
- closest.length
441
- ? `Edit failed — closest match:
442
- ${closest.map(m => ` line ${m.lineNumber}: ${visibleWhitespace(m.snippet)} (${m.diffType})`).join('\n')}`
443
- : 'Edit failed — string not found in file.',
444
- {
445
- searchString: edit.old_string,
446
- visibleSearch: visibleWhitespace(edit.old_string),
447
- closestMatches: closest,
448
- },
449
- )
450
- }
451
-
452
- // Track the new string that was applied
453
- appliedNewStrings.push(edit.new_string)
454
- }
455
-
456
- if (updatedFile === fileContents) {
457
- throw new Error(
458
- 'Original and edited file match exactly. Failed to apply edit.',
459
- )
460
- }
461
-
462
- // We already have before/after content, so call getPatchFromContents directly.
463
- // Previously this went through getPatchForDisplay with edits=[{old:fileContents,new:updatedFile}],
464
- // which transforms fileContents twice (once as preparedFileContents, again as escapedOldString
465
- // inside the reduce) and runs a no-op full-content .replace(). This saves ~20% on large files.
466
- const patch = getPatchFromContents({
467
- filePath,
468
- oldContent: convertLeadingTabsToSpaces(fileContents),
469
- newContent: convertLeadingTabsToSpaces(updatedFile),
470
- })
471
-
472
- return { patch, updatedFile }
473
- }
474
-
475
- // Cap on edited_text_file attachment snippets. Format-on-save of a large file
476
- // previously injected the entire file per turn (observed max 16.1KB, ~14K
477
- // tokens/session). 8KB preserves meaningful context while bounding worst case.
478
- const DIFF_SNIPPET_MAX_BYTES = 8192
479
-
480
- /**
481
- * Used for attachments, to show snippets when files change.
482
- *
483
- * TODO: Unify this with the other snippet logic.
484
- */
485
- export function getSnippetForTwoFileDiff(
486
- fileAContents: string,
487
- fileBContents: string,
488
- ): string {
489
- const patch = structuredPatch(
490
- 'file.txt',
491
- 'file.txt',
492
- fileAContents,
493
- fileBContents,
494
- undefined,
495
- undefined,
496
- {
497
- context: 8,
498
- timeout: DIFF_TIMEOUT_MS,
499
- },
500
- )
501
-
502
- if (!patch) {
503
- return ''
504
- }
505
-
506
- const full = patch.hunks
507
- .map(_ => ({
508
- startLine: _.oldStart,
509
- content: _.lines
510
- // Filter out deleted lines AND diff metadata lines
511
- .filter(_ => !_.startsWith('-') && !_.startsWith('\\'))
512
- .map(_ => _.slice(1))
513
- .join('\n'),
514
- }))
515
- .map(addLineNumbers)
516
- .join('\n...\n')
517
-
518
- if (full.length <= DIFF_SNIPPET_MAX_BYTES) {
519
- return full
520
- }
521
-
522
- // Truncate at the last line boundary that fits within the cap.
523
- // Marker format matches BashTool/utils.ts.
524
- const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES)
525
- const kept =
526
- cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES)
527
- const remaining = countCharInString(full, '\n', kept.length) + 1
528
- return `${kept}\n\n... [${remaining} lines truncated] ...`
529
- }
530
-
531
- const CONTEXT_LINES = 4
532
-
533
- /**
534
- * Gets a snippet from a file showing the context around a patch with line numbers.
535
- * @param originalFile The original file content before applying the patch
536
- * @param patch The diff hunks to use for determining snippet location
537
- * @param newFile The file content after applying the patch
538
- * @returns The snippet text with line numbers and the starting line number
539
- */
540
- export function getSnippetForPatch(
541
- patch: StructuredPatchHunk[],
542
- newFile: string,
543
- ): { formattedSnippet: string; startLine: number } {
544
- if (patch.length === 0) {
545
- // No changes, return empty snippet
546
- return { formattedSnippet: '', startLine: 1 }
547
- }
548
-
549
- // Find the first and last changed lines across all hunks
550
- let minLine = Infinity
551
- let maxLine = -Infinity
552
-
553
- for (const hunk of patch) {
554
- if (hunk.oldStart < minLine) {
555
- minLine = hunk.oldStart
556
- }
557
- // For the end line, we need to consider the new lines count since we're showing the new file
558
- const hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1
559
- if (hunkEnd > maxLine) {
560
- maxLine = hunkEnd
561
- }
562
- }
563
-
564
- // Calculate the range with context
565
- const startLine = Math.max(1, minLine - CONTEXT_LINES)
566
- const endLine = maxLine + CONTEXT_LINES
567
-
568
- // Split the new file into lines and get the snippet
569
- const fileLines = newFile.split(/\r?\n/)
570
- const snippetLines = fileLines.slice(startLine - 1, endLine)
571
- const snippet = snippetLines.join('\n')
572
-
573
- // Add line numbers
574
- const formattedSnippet = addLineNumbers({
575
- content: snippet,
576
- startLine,
577
- })
578
-
579
- return { formattedSnippet, startLine }
580
- }
581
-
582
- /**
583
- * Gets a snippet from a file showing the context around a single edit.
584
- * This is a convenience function that uses the original algorithm.
585
- * @param originalFile The original file content
586
- * @param oldString The text to replace
587
- * @param newString The text to replace it with
588
- * @param contextLines The number of lines to show before and after the change
589
- * @returns The snippet and the starting line number
590
- */
591
- export function getSnippet(
592
- originalFile: string,
593
- oldString: string,
594
- newString: string,
595
- contextLines: number = 4,
596
- ): { snippet: string; startLine: number } {
597
- // Use the original algorithm from FileEditTool.tsx
598
- const before = originalFile.split(oldString)[0] ?? ''
599
- const replacementLine = before.split(/\r?\n/).length - 1
600
- const newFileLines = applyEditToFile(
601
- originalFile,
602
- oldString,
603
- newString,
604
- ).split(/\r?\n/)
605
-
606
- // Calculate the start and end line numbers for the snippet
607
- const startLine = Math.max(0, replacementLine - contextLines)
608
- const endLine =
609
- replacementLine + contextLines + newString.split(/\r?\n/).length
610
-
611
- // Get snippet
612
- const snippetLines = newFileLines.slice(startLine, endLine)
613
- const snippet = snippetLines.join('\n')
614
-
615
- return { snippet, startLine: startLine + 1 }
616
- }
617
-
618
- export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] {
619
- return patch.map(hunk => {
620
- // Extract the changes from this hunk
621
- const contextLines: string[] = []
622
- const oldLines: string[] = []
623
- const newLines: string[] = []
624
-
625
- // Parse each line and categorize it
626
- for (const line of hunk.lines) {
627
- if (line.startsWith(' ')) {
628
- // Context line - appears in both versions
629
- contextLines.push(line.slice(1))
630
- oldLines.push(line.slice(1))
631
- newLines.push(line.slice(1))
632
- } else if (line.startsWith('-')) {
633
- // Deleted line - only in old version
634
- oldLines.push(line.slice(1))
635
- } else if (line.startsWith('+')) {
636
- // Added line - only in new version
637
- newLines.push(line.slice(1))
638
- }
639
- }
640
-
641
- return {
642
- old_string: oldLines.join('\n'),
643
- new_string: newLines.join('\n'),
644
- replace_all: false,
645
- }
646
- })
647
- }
648
-
649
- /**
650
- * Contains replacements to de-sanitize strings from Claude
651
- * Since Claude can't see any of these strings (sanitized in the API)
652
- * It'll output the sanitized versions in the edit response
653
- */
654
- const DESANITIZATIONS: Record<string, string> = {
655
- '<fnr>': '<function_results>',
656
- '<n>': '<name>',
657
- '</n>': '</name>',
658
- '<o>': '<output>',
659
- '</o>': '</output>',
660
- '<e>': '<error>',
661
- '</e>': '</error>',
662
- '<s>': '<system>',
663
- '</s>': '</system>',
664
- '<r>': '<result>',
665
- '</r>': '</result>',
666
- '< META_START >': '<META_START>',
667
- '< META_END >': '<META_END>',
668
- '< EOT >': '<EOT>',
669
- '< META >': '<META>',
670
- '< SOS >': '<SOS>',
671
- '\n\nH:': '\n\nHuman:',
672
- '\n\nA:': '\n\nAssistant:',
673
- }
674
-
675
- /**
676
- * Normalizes a match string by applying specific replacements
677
- * This helps handle when exact matches fail due to formatting differences
678
- * @returns The normalized string and which replacements were applied
679
- */
680
- function desanitizeMatchString(matchString: string): {
681
- result: string
682
- appliedReplacements: Array<{ from: string; to: string }>
683
- } {
684
- let result = matchString
685
- const appliedReplacements: Array<{ from: string; to: string }> = []
686
-
687
- for (const [from, to] of Object.entries(DESANITIZATIONS)) {
688
- const beforeReplace = result
689
- result = result.replaceAll(from, to)
690
-
691
- if (beforeReplace !== result) {
692
- appliedReplacements.push({ from, to })
693
- }
694
- }
695
-
696
- return { result, appliedReplacements }
697
- }
698
-
699
- /**
700
- * Normalize the input for the FileEditTool
701
- * If the string to replace is not found in the file, try with a normalized version
702
- * Returns the normalized input if successful, or the original input if not
703
- */
704
- export function normalizeFileEditInput({
705
- file_path,
706
- edits,
707
- }: {
708
- file_path: string
709
- edits: EditInput[]
710
- }): {
711
- file_path: string
712
- edits: EditInput[]
713
- } {
714
- if (edits.length === 0) {
715
- return { file_path, edits }
716
- }
717
-
718
- // Markdown uses two trailing spaces as a hard line break — stripping would
719
- // silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.
720
- const isMarkdown = /\.(md|mdx)$/i.test(file_path)
721
-
722
- try {
723
- const fullPath = expandPath(file_path)
724
-
725
- // Use cached file read to avoid redundant I/O operations.
726
- // If the file doesn't exist, readFileSyncCached throws ENOENT which the
727
- // catch below handles by returning the original input (no TOCTOU pre-check).
728
- const fileContent = readFileSyncCached(fullPath)
729
-
730
- return {
731
- file_path,
732
- edits: edits.map(({ old_string, new_string, replace_all }) => {
733
- const normalizedNewString = isMarkdown
734
- ? new_string
735
- : stripTrailingWhitespace(new_string)
736
-
737
- // If exact string match works, keep it as is
738
- if (fileContent.includes(old_string)) {
739
- return {
740
- old_string,
741
- new_string: normalizedNewString,
742
- replace_all,
743
- }
744
- }
745
-
746
- // Try de-sanitize string if exact match fails
747
- const { result: desanitizedOldString, appliedReplacements } =
748
- desanitizeMatchString(old_string)
749
-
750
- if (fileContent.includes(desanitizedOldString)) {
751
- // Apply the same exact replacements to new_string
752
- let desanitizedNewString = normalizedNewString
753
- for (const { from, to } of appliedReplacements) {
754
- desanitizedNewString = desanitizedNewString.replaceAll(from, to)
755
- }
756
-
757
- return {
758
- old_string: desanitizedOldString,
759
- new_string: desanitizedNewString,
760
- replace_all,
761
- }
762
- }
763
-
764
- return {
765
- old_string,
766
- new_string: normalizedNewString,
767
- replace_all,
768
- }
769
- }),
770
- }
771
- } catch (error) {
772
- // If there's any error reading the file, just return original input.
773
- // ENOENT is expected when the file doesn't exist yet (e.g., new file).
774
- if (!isENOENT(error)) {
775
- logError(error)
776
- }
777
- }
778
-
779
- return { file_path, edits }
780
- }
781
-
782
- /**
783
- * Compare two sets of edits to determine if they are equivalent
784
- * by applying both sets to the original content and comparing results.
785
- * This handles cases where edits might be different but produce the same outcome.
786
- */
787
- export function areFileEditsEquivalent(
788
- edits1: FileEdit[],
789
- edits2: FileEdit[],
790
- originalContent: string,
791
- ): boolean {
792
- // Fast path: check if edits are literally identical
793
- if (
794
- edits1.length === edits2.length &&
795
- edits1.every((edit1, index) => {
796
- const edit2 = edits2[index]
797
- return (
798
- edit2 !== undefined &&
799
- edit1.old_string === edit2.old_string &&
800
- edit1.new_string === edit2.new_string &&
801
- edit1.replace_all === edit2.replace_all
802
- )
803
- })
804
- ) {
805
- return true
806
- }
807
-
808
- // Try applying both sets of edits
809
- let result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
810
- null
811
- let error1: string | null = null
812
- let result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
813
- null
814
- let error2: string | null = null
815
-
816
- try {
817
- result1 = getPatchForEdits({
818
- filePath: 'temp',
819
- fileContents: originalContent,
820
- edits: edits1,
821
- })
822
- } catch (e) {
823
- error1 = errorMessage(e)
824
- }
825
-
826
- try {
827
- result2 = getPatchForEdits({
828
- filePath: 'temp',
829
- fileContents: originalContent,
830
- edits: edits2,
831
- })
832
- } catch (e) {
833
- error2 = errorMessage(e)
834
- }
835
-
836
- // If both threw errors, they're equal only if the errors are the same
837
- if (error1 !== null && error2 !== null) {
838
- // Normalize error messages for comparison
839
- return error1 === error2
840
- }
841
-
842
- // If one threw an error and the other didn't, they're not equal
843
- if (error1 !== null || error2 !== null) {
844
- return false
845
- }
846
-
847
- // Both succeeded - compare the results
848
- return result1!.updatedFile === result2!.updatedFile
849
- }
850
-
851
- /**
852
- * Unified function to check if two file edit inputs are equivalent.
853
- * Handles file edits (FileEditTool).
854
- */
855
- export function areFileEditsInputsEquivalent(
856
- input1: {
857
- file_path: string
858
- edits: FileEdit[]
859
- },
860
- input2: {
861
- file_path: string
862
- edits: FileEdit[]
863
- },
864
- ): boolean {
865
- // Fast path: different files
866
- if (input1.file_path !== input2.file_path) {
867
- return false
868
- }
869
-
870
- // Fast path: literal equality
871
- if (
872
- input1.edits.length === input2.edits.length &&
873
- input1.edits.every((edit1, index) => {
874
- const edit2 = input2.edits[index]
875
- return (
876
- edit2 !== undefined &&
877
- edit1.old_string === edit2.old_string &&
878
- edit1.new_string === edit2.new_string &&
879
- edit1.replace_all === edit2.replace_all
880
- )
881
- })
882
- ) {
883
- return true
884
- }
885
-
886
- // Semantic comparison (requires file read). If the file doesn't exist,
887
- // compare against empty content (no TOCTOU pre-check).
888
- let fileContent = ''
889
- try {
890
- fileContent = readFileSyncCached(input1.file_path)
891
- } catch (error) {
892
- if (!isENOENT(error)) {
893
- throw error
894
- }
895
- }
896
-
897
- return areFileEditsEquivalent(input1.edits, input2.edits, fileContent)
898
- }
1
+ import { type StructuredPatchHunk, structuredPatch } from 'diff'
2
+ import { logError } from 'src/utils/log.js'
3
+ import { expandPath } from 'src/utils/path.js'
4
+ import { countCharInString } from 'src/utils/stringUtils.js'
5
+ import {
6
+ DIFF_TIMEOUT_MS,
7
+ getPatchForDisplay,
8
+ getPatchFromContents,
9
+ } from '../../utils/diff.js'
10
+ import { errorMessage, isENOENT } from '../../utils/errors.js'
11
+ import {
12
+ addLineNumbers,
13
+ convertLeadingTabsToSpaces,
14
+ readFileSyncCached,
15
+ } from '../../utils/file.js'
16
+ import type { EditInput, FileEdit } from './types.js'
17
+
18
+ /**
19
+ * Computes Levenshtein distance between two strings using Wagner-Fischer DP.
20
+ * O(n*m) time, O(min(n,m)) space (two-row optimization).
21
+ */
22
+ export function levenshteinDistance(a: string, b: string): number {
23
+ // Ensure a is the shorter string for memory efficiency
24
+ if (a.length > b.length) {
25
+ ;[a, b] = [b, a]
26
+ }
27
+ const m = a.length
28
+ const n = b.length
29
+
30
+ let prevRow = new Uint16Array(m + 1)
31
+ let currRow = new Uint16Array(m + 1)
32
+
33
+ for (let i = 0; i <= m; i++) prevRow[i] = i
34
+ for (let j = 1; j <= n; j++) {
35
+ currRow[0] = j
36
+ for (let i = 1; i <= m; i++) {
37
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1
38
+ // d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost)
39
+ // deletion: remove char from s → d[i-1][j] + 1 → currRow[i - 1] + 1
40
+ // insertion: add char from t d[i][j-1] + 1 prevRow[i] + 1
41
+ // substitution: swap or match d[i-1][j-1] + cost → prevRow[i - 1] + cost
42
+ currRow[i] = Math.min(
43
+ currRow[i - 1]! + 1,
44
+ prevRow[i]! + 1,
45
+ prevRow[i - 1]! + cost,
46
+ )
47
+ ;[prevRow, currRow] = [currRow, prevRow]
48
+ }
49
+ return prevRow[m]!
50
+ }
51
+
52
+ /**
53
+ * Fuzzy matching finds the best match in fileContent for searchString
54
+ * when the edit distance is within threshold. Used as a secondary pass
55
+ * after exact/normalized matching fails, to suggest "Did you mean: X"
56
+ * when the model's old_string is only a few characters off.
57
+ *
58
+ * For multi-line search strings: compares each contiguous block of lines
59
+ * whose total length is within tolerance. Returns null when no match is
60
+ * within threshold.
61
+ */
62
+ export function findFuzzySuggestion(
63
+ fileContent: string,
64
+ searchString: string,
65
+ threshold: number = 2,
66
+ ): { suggestion: string; lineNumber: number; distance: number } | null {
67
+ const searchLines = searchString.split('\n')
68
+ const fileLines = fileContent.split('\n')
69
+ const searchLen = searchString.length
70
+
71
+ // For single-line searches, scan all lines with similar length
72
+ if (searchLines.length === 1) {
73
+ const lenTolerance = searchLen + threshold * 2
74
+ let bestLine: string | null = null
75
+ let bestDist = Infinity
76
+ let bestIdx = -1
77
+
78
+ for (let i = 0; i < fileLines.length; i++) {
79
+ const line = fileLines[i]!
80
+ if (Math.abs(line.length - searchLen) > lenTolerance) continue
81
+ const dist = levenshteinDistance(line, searchString)
82
+ if (dist < bestDist) {
83
+ bestDist = dist
84
+ bestLine = line
85
+ bestIdx = i
86
+ }
87
+ }
88
+
89
+ if (bestLine && bestDist > 0 && bestDist <= threshold) {
90
+ return {
91
+ suggestion: bestLine.replace(/\s+$/, ''),
92
+ lineNumber: bestIdx + 1,
93
+ distance: bestDist,
94
+ }
95
+ }
96
+ // No match within threshold (or distance 0 = exact match already present)
97
+ return null
98
+ }
99
+
100
+ // Multi-line: compare contiguous blocks of lines
101
+ const maxBlockLen = searchLen + threshold * searchLines.length
102
+ let best: { suggestion: string; lineNumber: number; distance: number } | null = null
103
+
104
+ for (let i = 0; i <= fileLines.length - searchLines.length; i++) {
105
+ const block = fileLines.slice(i, i + searchLines.length).join('\n')
106
+ if (Math.abs(block.length - searchLen) > maxBlockLen) continue
107
+ const dist = levenshteinDistance(block.replace(/\s+$/, ''), searchString.replace(/\s+$/, ''))
108
+ if (dist > 0 && dist <= threshold && dist < (best?.distance ?? Infinity)) {
109
+ best = {
110
+ suggestion: block.replace(/\s+$/, ''),
111
+ lineNumber: i + 1,
112
+ distance: dist,
113
+ }
114
+ }
115
+ }
116
+
117
+ return best
118
+ }
119
+
120
+ // Claude can't output curly quotes, so we define them as constants here for Claude to use
121
+ // in the code. We do this because we normalize curly quotes to straight quotes
122
+ // when applying edits.
123
+ export const LEFT_SINGLE_CURLY_QUOTE = '‘'
124
+ export const RIGHT_SINGLE_CURLY_QUOTE = '’'
125
+ export const LEFT_DOUBLE_CURLY_QUOTE = '“'
126
+ export const RIGHT_DOUBLE_CURLY_QUOTE = '”'
127
+
128
+ /**
129
+ * Normalizes quotes in a string by converting curly quotes to straight quotes
130
+ * @param str The string to normalize
131
+ * @returns The string with all curly quotes replaced by straight quotes
132
+ */
133
+ export function normalizeQuotes(str: string): string {
134
+ return str
135
+ .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
136
+ .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
137
+ .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
138
+ .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
139
+ }
140
+
141
+ /**
142
+ * Strips trailing whitespace from each line in a string while preserving line endings
143
+ * @param str The string to process
144
+ * @returns The string with trailing whitespace removed from each line
145
+ */
146
+ export function stripTrailingWhitespace(str: string): string {
147
+ // Handle different line endings: CRLF, LF, CR
148
+ // Use a regex that matches line endings and captures them
149
+ const lines = str.split(/(\r\n|\n|\r)/)
150
+
151
+ let result = ''
152
+ for (let i = 0; i < lines.length; i++) {
153
+ const part = lines[i]
154
+ if (part !== undefined) {
155
+ if (i % 2 === 0) {
156
+ // Even indices are line content
157
+ result += part.replace(/\s+$/, '')
158
+ } else {
159
+ // Odd indices are line endings
160
+ result += part
161
+ }
162
+ }
163
+ }
164
+
165
+ return result
166
+ }
167
+
168
+ /**
169
+ * Finds the actual string in the file content that matches the search string,
170
+ * accounting for quote normalization
171
+ * @param fileContent The file content to search in
172
+ * @param searchString The string to search for
173
+ * @returns The actual string found in the file, or null if not found
174
+ */
175
+
176
+ /** Normalizes Unicode dashes to ASCII, indent whitespace to spaces.
177
+ * Fills gaps where models emit ASCII dashes instead of Unicode dashes,
178
+ * or provide different tab/space indentation than the file has. */
179
+ export function normalizeDashes(str: string): string {
180
+ return str.replaceAll('\u2014', '-').replaceAll('\u2013', '-').replaceAll('\u2015', '-')
181
+ }
182
+ export function normalizeIndentation(str: string): string {
183
+ return str.split('\n').map(line => line.trim()).join('\n')
184
+ }
185
+ export function findActualString(
186
+ fileContent: string,
187
+ searchString: string,
188
+ ): string | null {
189
+ // First try exact match
190
+ if (fileContent.includes(searchString)) {
191
+ return searchString
192
+ }
193
+
194
+ // Try with normalized quotes
195
+ const normalizedSearch = normalizeQuotes(searchString)
196
+ const normalizedFile = normalizeQuotes(fileContent)
197
+ const searchIndex = normalizedFile.indexOf(normalizedSearch)
198
+ if (searchIndex !== -1) {
199
+ return fileContent.substring(searchIndex, searchIndex + searchString.length)
200
+ }
201
+
202
+ // Try with normalized dashes (em-dash, en-dash -> ASCII dash)
203
+ const dashedSearch = normalizeDashes(searchString)
204
+ const dashedFile = normalizeDashes(fileContent)
205
+ const dashIndex = dashedFile.indexOf(dashedSearch)
206
+ if (dashIndex !== -1) {
207
+ return fileContent.substring(dashIndex, dashIndex + searchString.length)
208
+ }
209
+
210
+ // Try with normalized leading whitespace (tab <-> space)
211
+ const indentNormalizedSearch = normalizeIndentation(searchString)
212
+ const indentTrimmedFile = normalizeIndentation(fileContent)
213
+ const matchPoint = indentTrimmedFile.indexOf(indentNormalizedSearch)
214
+ if (matchPoint !== -1) {
215
+ // Leading whitespace normalization is NOT length-preserving,
216
+ // so compute bounds by matching each trimmed line back to its original.
217
+ const origLines = fileContent.split('\n')
218
+ const trimmedLines = indentTrimmedFile.split('\n')
219
+ const searchLines = indentNormalizedSearch.split('\n')
220
+ for (let i = 0; i <= trimmedLines.length - searchLines.length; i++) {
221
+ let k = 0
222
+ while (k < searchLines.length && trimmedLines[i + k] === searchLines[k]) k++
223
+ if (k !== searchLines.length) continue
224
+ let start = 0
225
+ for (let j = 0; j < i; j++) start += origLines[j].length + 1
226
+ let end = start
227
+ for (let j = i; j < i + k; j++) end += origLines[j].length + 1
228
+ return fileContent.substring(start, Math.max(start, end - 1))
229
+ }
230
+ return null
231
+ }
232
+
233
+ return null
234
+ }
235
+
236
+ /**
237
+ * When old_string matched via quote normalization (curly quotes in file,
238
+ * straight quotes from model), apply the same curly quote style to new_string
239
+ * so the edit preserves the file's typography.
240
+ *
241
+ * Uses a simple open/close heuristic: a quote character preceded by whitespace,
242
+ * start of string, or opening punctuation is treated as an opening quote;
243
+ * otherwise it's a closing quote.
244
+ */
245
+ export function preserveQuoteStyle(
246
+ oldString: string,
247
+ actualOldString: string,
248
+ newString: string,
249
+ ): string {
250
+ // If they're the same, no normalization happened
251
+ if (oldString === actualOldString) {
252
+ return newString
253
+ }
254
+
255
+ // Detect which curly quote types were in the file
256
+ const hasDoubleQuotes =
257
+ actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
258
+ actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)
259
+ const hasSingleQuotes =
260
+ actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||
261
+ actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)
262
+
263
+ if (!hasDoubleQuotes && !hasSingleQuotes) {
264
+ return newString
265
+ }
266
+
267
+ let result = newString
268
+
269
+ if (hasDoubleQuotes) {
270
+ result = applyCurlyDoubleQuotes(result)
271
+ }
272
+ if (hasSingleQuotes) {
273
+ result = applyCurlySingleQuotes(result)
274
+ }
275
+
276
+ return result
277
+ }
278
+
279
+ function isOpeningContext(chars: string[], index: number): boolean {
280
+ if (index === 0) {
281
+ return true
282
+ }
283
+ const prev = chars[index - 1]
284
+ return (
285
+ prev === ' ' ||
286
+ prev === '\t' ||
287
+ prev === '\n' ||
288
+ prev === '\r' ||
289
+ prev === '(' ||
290
+ prev === '[' ||
291
+ prev === '{' ||
292
+ prev === '\u2014' || // em dash
293
+ prev === '\u2013' // en dash
294
+ )
295
+ }
296
+
297
+ function applyCurlyDoubleQuotes(str: string): string {
298
+ const chars = [...str]
299
+ const result: string[] = []
300
+ for (let i = 0; i < chars.length; i++) {
301
+ if (chars[i] === '"') {
302
+ result.push(
303
+ isOpeningContext(chars, i)
304
+ ? LEFT_DOUBLE_CURLY_QUOTE
305
+ : RIGHT_DOUBLE_CURLY_QUOTE,
306
+ )
307
+ } else {
308
+ result.push(chars[i]!)
309
+ }
310
+ }
311
+ return result.join('')
312
+ }
313
+
314
+ function applyCurlySingleQuotes(str: string): string {
315
+ const chars = [...str]
316
+ const result: string[] = []
317
+ for (let i = 0; i < chars.length; i++) {
318
+ if (chars[i] === "'") {
319
+ // Don't convert apostrophes in contractions (e.g., "don't", "it's")
320
+ // An apostrophe between two letters is a contraction, not a quote
321
+ const prev = i > 0 ? chars[i - 1] : undefined
322
+ const next = i < chars.length - 1 ? chars[i + 1] : undefined
323
+ const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)
324
+ const nextIsLetter = next !== undefined && /\p{L}/u.test(next)
325
+ if (prevIsLetter && nextIsLetter) {
326
+ // Apostrophe in a contraction — use right single curly quote
327
+ result.push(RIGHT_SINGLE_CURLY_QUOTE)
328
+ } else {
329
+ result.push(
330
+ isOpeningContext(chars, i)
331
+ ? LEFT_SINGLE_CURLY_QUOTE
332
+ : RIGHT_SINGLE_CURLY_QUOTE,
333
+ )
334
+ }
335
+ } else {
336
+ result.push(chars[i]!)
337
+ }
338
+ }
339
+ return result.join('')
340
+ }
341
+
342
+ /**
343
+ * Error class for when an edit's old_string can't be found in the file.
344
+ * Carries diagnostics for better error reporting.
345
+ */
346
+ export class EditNotFoundError extends Error {
347
+ diagnostics: {
348
+ searchString: string
349
+ visibleSearch: string
350
+ closestMatches: {
351
+ snippet: string
352
+ lineNumber: number
353
+ diffType: string
354
+ }[]
355
+ }
356
+ constructor(
357
+ message: string,
358
+ diagnostics: EditNotFoundError['diagnostics'],
359
+ ) {
360
+ super(message)
361
+ this.name = 'EditNotFoundError'
362
+ this.diagnostics = diagnostics
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Renders whitespace characters as visible Unicode equivalents:
368
+ * tab → '→', space → '·'
369
+ */
370
+ export function visibleWhitespace(str: string): string {
371
+ return str
372
+ .replace(/\t/g, '→')
373
+ .replace(/ /g, '·')
374
+ .replace(/\n/g, '↵')
375
+ .replace(/\r/g, '␍')
376
+ }
377
+
378
+ /**
379
+ * Finds up to 3 lines in fileContent whose content (non-whitespace portion)
380
+ * matches the content of the first line of searchString.
381
+ * Used for diagnostic purposes when findActualString returns null.
382
+ *
383
+ * Returns matches sorted with whitespace-diff first, then content matches.
384
+ */
385
+ export function findClosestLines(
386
+ fileContent: string,
387
+ searchString: string,
388
+ ): { snippet: string; lineNumber: number; diffType: string }[] {
389
+ const firstContent = searchString.split('\n')[0]!.replace(/^\s+/, '')
390
+ if (!firstContent) return []
391
+
392
+ const matches: { snippet: string; lineNumber: number; diffType: string }[] = []
393
+ const fileLines = fileContent.split('\n')
394
+
395
+ for (let i = 0; i < fileLines.length; i++) {
396
+ const line = fileLines[i]!
397
+ if (line.replace(/^\s+/, '') !== firstContent) continue
398
+
399
+ const snippet = line.replace(/\s+$/, '')
400
+
401
+ // Avoid duplicates
402
+ if (!matches.some(m => m.snippet === snippet)) {
403
+ matches.push({
404
+ snippet,
405
+ lineNumber: i + 1,
406
+ diffType: 'content match',
407
+ })
408
+ if (matches.length >= 3) break
409
+ }
410
+ }
411
+
412
+ return matches
413
+ }
414
+
415
+ /**
416
+ * Transform edits to ensure replace_all always has a boolean value
417
+ * @param edits Array of edits with optional replace_all
418
+ * @returns Array of edits with replace_all guaranteed to be boolean
419
+ */
420
+ export function applyEditToFile(
421
+ originalContent: string,
422
+ oldString: string,
423
+ newString: string,
424
+ replaceAll: boolean = false,
425
+ ): string {
426
+ const f = replaceAll
427
+ ? (content: string, search: string, replace: string) =>
428
+ content.replaceAll(search, () => replace)
429
+ : (content: string, search: string, replace: string) =>
430
+ content.replace(search, () => replace)
431
+
432
+ if (newString !== '') {
433
+ return f(originalContent, oldString, newString)
434
+ }
435
+
436
+ const stripTrailingNewline =
437
+ !oldString.endsWith('\n') && originalContent.includes(oldString + '\n')
438
+
439
+ return stripTrailingNewline
440
+ ? f(originalContent, oldString + '\n', newString)
441
+ : f(originalContent, oldString, newString)
442
+ }
443
+
444
+ /**
445
+ * Applies an edit to a file and returns the patch and updated file.
446
+ * Does not write the file to disk.
447
+ */
448
+ export function getPatchForEdit({
449
+ filePath,
450
+ fileContents,
451
+ oldString,
452
+ newString,
453
+ replaceAll = false,
454
+ }: {
455
+ filePath: string
456
+ fileContents: string
457
+ oldString: string
458
+ newString: string
459
+ replaceAll?: boolean
460
+ }): { patch: StructuredPatchHunk[]; updatedFile: string } {
461
+ return getPatchForEdits({
462
+ filePath,
463
+ fileContents,
464
+ edits: [
465
+ { old_string: oldString, new_string: newString, replace_all: replaceAll },
466
+ ],
467
+ })
468
+ }
469
+
470
+ /**
471
+ * Applies a list of edits to a file and returns the patch and updated file.
472
+ * Does not write the file to disk.
473
+ *
474
+ * NOTE: The returned patch is to be used for display purposes only - it has spaces instead of tabs
475
+ */
476
+ export function getPatchForEdits({
477
+ filePath,
478
+ fileContents,
479
+ edits,
480
+ }: {
481
+ filePath: string
482
+ fileContents: string
483
+ edits: FileEdit[]
484
+ }): { patch: StructuredPatchHunk[]; updatedFile: string } {
485
+ let updatedFile = fileContents
486
+ const appliedNewStrings: string[] = []
487
+
488
+ // Special case for empty files.
489
+ if (
490
+ !fileContents &&
491
+ edits.length === 1 &&
492
+ edits[0] &&
493
+ edits[0].old_string === '' &&
494
+ edits[0].new_string === ''
495
+ ) {
496
+ const patch = getPatchForDisplay({
497
+ filePath,
498
+ fileContents,
499
+ edits: [
500
+ {
501
+ old_string: fileContents,
502
+ new_string: updatedFile,
503
+ replace_all: false,
504
+ },
505
+ ],
506
+ })
507
+ return { patch, updatedFile: '' }
508
+ }
509
+
510
+ // Apply each edit and check if it actually changes the file
511
+ for (const edit of edits) {
512
+ // Strip trailing newlines from old_string before checking
513
+ const oldStringToCheck = edit.old_string.replace(/\n+$/, '')
514
+
515
+ // Check if old_string is a substring of any previously applied new_string
516
+ for (const previousNewString of appliedNewStrings) {
517
+ if (
518
+ oldStringToCheck !== '' &&
519
+ previousNewString.includes(oldStringToCheck)
520
+ ) {
521
+ throw new Error(
522
+ 'Cannot edit file: old_string is a substring of a new_string from a previous edit.',
523
+ )
524
+ }
525
+ }
526
+
527
+ const previousContent = updatedFile
528
+ updatedFile =
529
+ edit.old_string === ''
530
+ ? edit.new_string
531
+ : applyEditToFile(
532
+ updatedFile,
533
+ edit.old_string,
534
+ edit.new_string,
535
+ edit.replace_all,
536
+ )
537
+
538
+ // If this edit didn't change anything, throw an error
539
+ if (updatedFile === previousContent) {
540
+ const closest = findClosestLines(fileContents, edit.old_string)
541
+ const fuzzy = findFuzzySuggestion(fileContents, edit.old_string)
542
+ const fuzzyBlock = fuzzy
543
+ ? `\n\nDid you mean:\n line ${fuzzy.lineNumber}: ${visibleWhitespace(fuzzy.suggestion)}\n (edit distance ${fuzzy.distance})`
544
+ : ''
545
+ throw new EditNotFoundError(
546
+ closest.length
547
+ ? `Edit failed — closest match:
548
+ ${closest.map(m => ` line ${m.lineNumber}: ${visibleWhitespace(m.snippet)} (${m.diffType})`).join('\n')}${fuzzyBlock}`
549
+ : `Edit failed string not found in file.${fuzzyBlock}`,
550
+ {
551
+ searchString: edit.old_string,
552
+ visibleSearch: visibleWhitespace(edit.old_string),
553
+ closestMatches: closest,
554
+ },
555
+ )
556
+ }
557
+
558
+ // Track the new string that was applied
559
+ appliedNewStrings.push(edit.new_string)
560
+ }
561
+
562
+ if (updatedFile === fileContents) {
563
+ throw new Error(
564
+ 'Original and edited file match exactly. Failed to apply edit.',
565
+ )
566
+ }
567
+
568
+ // We already have before/after content, so call getPatchFromContents directly.
569
+ // Previously this went through getPatchForDisplay with edits=[{old:fileContents,new:updatedFile}],
570
+ // which transforms fileContents twice (once as preparedFileContents, again as escapedOldString
571
+ // inside the reduce) and runs a no-op full-content .replace(). This saves ~20% on large files.
572
+ const patch = getPatchFromContents({
573
+ filePath,
574
+ oldContent: convertLeadingTabsToSpaces(fileContents),
575
+ newContent: convertLeadingTabsToSpaces(updatedFile),
576
+ })
577
+
578
+ return { patch, updatedFile }
579
+ }
580
+
581
+ // Cap on edited_text_file attachment snippets. Format-on-save of a large file
582
+ // previously injected the entire file per turn (observed max 16.1KB, ~14K
583
+ // tokens/session). 8KB preserves meaningful context while bounding worst case.
584
+ const DIFF_SNIPPET_MAX_BYTES = 8192
585
+
586
+ /**
587
+ * Used for attachments, to show snippets when files change.
588
+ *
589
+ * TODO: Unify this with the other snippet logic.
590
+ */
591
+ export function getSnippetForTwoFileDiff(
592
+ fileAContents: string,
593
+ fileBContents: string,
594
+ ): string {
595
+ const patch = structuredPatch(
596
+ 'file.txt',
597
+ 'file.txt',
598
+ fileAContents,
599
+ fileBContents,
600
+ undefined,
601
+ undefined,
602
+ {
603
+ context: 8,
604
+ timeout: DIFF_TIMEOUT_MS,
605
+ },
606
+ )
607
+
608
+ if (!patch) {
609
+ return ''
610
+ }
611
+
612
+ const full = patch.hunks
613
+ .map(_ => ({
614
+ startLine: _.oldStart,
615
+ content: _.lines
616
+ // Filter out deleted lines AND diff metadata lines
617
+ .filter(_ => !_.startsWith('-') && !_.startsWith('\\'))
618
+ .map(_ => _.slice(1))
619
+ .join('\n'),
620
+ }))
621
+ .map(addLineNumbers)
622
+ .join('\n...\n')
623
+
624
+ if (full.length <= DIFF_SNIPPET_MAX_BYTES) {
625
+ return full
626
+ }
627
+
628
+ // Truncate at the last line boundary that fits within the cap.
629
+ // Marker format matches BashTool/utils.ts.
630
+ const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES)
631
+ const kept =
632
+ cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES)
633
+ const remaining = countCharInString(full, '\n', kept.length) + 1
634
+ return `${kept}\n\n... [${remaining} lines truncated] ...`
635
+ }
636
+
637
+ const CONTEXT_LINES = 4
638
+
639
+ /**
640
+ * Gets a snippet from a file showing the context around a patch with line numbers.
641
+ * @param originalFile The original file content before applying the patch
642
+ * @param patch The diff hunks to use for determining snippet location
643
+ * @param newFile The file content after applying the patch
644
+ * @returns The snippet text with line numbers and the starting line number
645
+ */
646
+ export function getSnippetForPatch(
647
+ patch: StructuredPatchHunk[],
648
+ newFile: string,
649
+ ): { formattedSnippet: string; startLine: number } {
650
+ if (patch.length === 0) {
651
+ // No changes, return empty snippet
652
+ return { formattedSnippet: '', startLine: 1 }
653
+ }
654
+
655
+ // Find the first and last changed lines across all hunks
656
+ let minLine = Infinity
657
+ let maxLine = -Infinity
658
+
659
+ for (const hunk of patch) {
660
+ if (hunk.oldStart < minLine) {
661
+ minLine = hunk.oldStart
662
+ }
663
+ // For the end line, we need to consider the new lines count since we're showing the new file
664
+ const hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1
665
+ if (hunkEnd > maxLine) {
666
+ maxLine = hunkEnd
667
+ }
668
+ }
669
+
670
+ // Calculate the range with context
671
+ const startLine = Math.max(1, minLine - CONTEXT_LINES)
672
+ const endLine = maxLine + CONTEXT_LINES
673
+
674
+ // Split the new file into lines and get the snippet
675
+ const fileLines = newFile.split(/\r?\n/)
676
+ const snippetLines = fileLines.slice(startLine - 1, endLine)
677
+ const snippet = snippetLines.join('\n')
678
+
679
+ // Add line numbers
680
+ const formattedSnippet = addLineNumbers({
681
+ content: snippet,
682
+ startLine,
683
+ })
684
+
685
+ return { formattedSnippet, startLine }
686
+ }
687
+
688
+ /**
689
+ * Gets a snippet from a file showing the context around a single edit.
690
+ * This is a convenience function that uses the original algorithm.
691
+ * @param originalFile The original file content
692
+ * @param oldString The text to replace
693
+ * @param newString The text to replace it with
694
+ * @param contextLines The number of lines to show before and after the change
695
+ * @returns The snippet and the starting line number
696
+ */
697
+ export function getSnippet(
698
+ originalFile: string,
699
+ oldString: string,
700
+ newString: string,
701
+ contextLines: number = 4,
702
+ ): { snippet: string; startLine: number } {
703
+ // Use the original algorithm from FileEditTool.tsx
704
+ const before = originalFile.split(oldString)[0] ?? ''
705
+ const replacementLine = before.split(/\r?\n/).length - 1
706
+ const newFileLines = applyEditToFile(
707
+ originalFile,
708
+ oldString,
709
+ newString,
710
+ ).split(/\r?\n/)
711
+
712
+ // Calculate the start and end line numbers for the snippet
713
+ const startLine = Math.max(0, replacementLine - contextLines)
714
+ const endLine =
715
+ replacementLine + contextLines + newString.split(/\r?\n/).length
716
+
717
+ // Get snippet
718
+ const snippetLines = newFileLines.slice(startLine, endLine)
719
+ const snippet = snippetLines.join('\n')
720
+
721
+ return { snippet, startLine: startLine + 1 }
722
+ }
723
+
724
+ export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] {
725
+ return patch.map(hunk => {
726
+ // Extract the changes from this hunk
727
+ const contextLines: string[] = []
728
+ const oldLines: string[] = []
729
+ const newLines: string[] = []
730
+
731
+ // Parse each line and categorize it
732
+ for (const line of hunk.lines) {
733
+ if (line.startsWith(' ')) {
734
+ // Context line - appears in both versions
735
+ contextLines.push(line.slice(1))
736
+ oldLines.push(line.slice(1))
737
+ newLines.push(line.slice(1))
738
+ } else if (line.startsWith('-')) {
739
+ // Deleted line - only in old version
740
+ oldLines.push(line.slice(1))
741
+ } else if (line.startsWith('+')) {
742
+ // Added line - only in new version
743
+ newLines.push(line.slice(1))
744
+ }
745
+ }
746
+
747
+ return {
748
+ old_string: oldLines.join('\n'),
749
+ new_string: newLines.join('\n'),
750
+ replace_all: false,
751
+ }
752
+ })
753
+ }
754
+
755
+ /**
756
+ * Contains replacements to de-sanitize strings from Claude
757
+ * Since Claude can't see any of these strings (sanitized in the API)
758
+ * It'll output the sanitized versions in the edit response
759
+ */
760
+ const DESANITIZATIONS: Record<string, string> = {
761
+ '<fnr>': '<function_results>',
762
+ '<n>': '<name>',
763
+ '</n>': '</name>',
764
+ '<o>': '<output>',
765
+ '</o>': '</output>',
766
+ '<e>': '<error>',
767
+ '</e>': '</error>',
768
+ '<s>': '<system>',
769
+ '</s>': '</system>',
770
+ '<r>': '<result>',
771
+ '</r>': '</result>',
772
+ '< META_START >': '<META_START>',
773
+ '< META_END >': '<META_END>',
774
+ '< EOT >': '<EOT>',
775
+ '< META >': '<META>',
776
+ '< SOS >': '<SOS>',
777
+ '\n\nH:': '\n\nHuman:',
778
+ '\n\nA:': '\n\nAssistant:',
779
+ }
780
+
781
+ /**
782
+ * Normalizes a match string by applying specific replacements
783
+ * This helps handle when exact matches fail due to formatting differences
784
+ * @returns The normalized string and which replacements were applied
785
+ */
786
+ function desanitizeMatchString(matchString: string): {
787
+ result: string
788
+ appliedReplacements: Array<{ from: string; to: string }>
789
+ } {
790
+ let result = matchString
791
+ const appliedReplacements: Array<{ from: string; to: string }> = []
792
+
793
+ for (const [from, to] of Object.entries(DESANITIZATIONS)) {
794
+ const beforeReplace = result
795
+ result = result.replaceAll(from, to)
796
+
797
+ if (beforeReplace !== result) {
798
+ appliedReplacements.push({ from, to })
799
+ }
800
+ }
801
+
802
+ return { result, appliedReplacements }
803
+ }
804
+
805
+ /**
806
+ * Normalize the input for the FileEditTool
807
+ * If the string to replace is not found in the file, try with a normalized version
808
+ * Returns the normalized input if successful, or the original input if not
809
+ */
810
+ export function normalizeFileEditInput({
811
+ file_path,
812
+ edits,
813
+ }: {
814
+ file_path: string
815
+ edits: EditInput[]
816
+ }): {
817
+ file_path: string
818
+ edits: EditInput[]
819
+ } {
820
+ if (edits.length === 0) {
821
+ return { file_path, edits }
822
+ }
823
+
824
+ // Markdown uses two trailing spaces as a hard line break — stripping would
825
+ // silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.
826
+ const isMarkdown = /\.(md|mdx)$/i.test(file_path)
827
+
828
+ try {
829
+ const fullPath = expandPath(file_path)
830
+
831
+ // Use cached file read to avoid redundant I/O operations.
832
+ // If the file doesn't exist, readFileSyncCached throws ENOENT which the
833
+ // catch below handles by returning the original input (no TOCTOU pre-check).
834
+ const fileContent = readFileSyncCached(fullPath)
835
+
836
+ return {
837
+ file_path,
838
+ edits: edits.map(({ old_string, new_string, replace_all }) => {
839
+ const normalizedNewString = isMarkdown
840
+ ? new_string
841
+ : stripTrailingWhitespace(new_string)
842
+
843
+ // If exact string match works, keep it as is
844
+ if (fileContent.includes(old_string)) {
845
+ return {
846
+ old_string,
847
+ new_string: normalizedNewString,
848
+ replace_all,
849
+ }
850
+ }
851
+
852
+ // Try de-sanitize string if exact match fails
853
+ const { result: desanitizedOldString, appliedReplacements } =
854
+ desanitizeMatchString(old_string)
855
+
856
+ if (fileContent.includes(desanitizedOldString)) {
857
+ // Apply the same exact replacements to new_string
858
+ let desanitizedNewString = normalizedNewString
859
+ for (const { from, to } of appliedReplacements) {
860
+ desanitizedNewString = desanitizedNewString.replaceAll(from, to)
861
+ }
862
+
863
+ return {
864
+ old_string: desanitizedOldString,
865
+ new_string: desanitizedNewString,
866
+ replace_all,
867
+ }
868
+ }
869
+
870
+ return {
871
+ old_string,
872
+ new_string: normalizedNewString,
873
+ replace_all,
874
+ }
875
+ }),
876
+ }
877
+ } catch (error) {
878
+ // If there's any error reading the file, just return original input.
879
+ // ENOENT is expected when the file doesn't exist yet (e.g., new file).
880
+ if (!isENOENT(error)) {
881
+ logError(error)
882
+ }
883
+ }
884
+
885
+ return { file_path, edits }
886
+ }
887
+
888
+ /**
889
+ * Compare two sets of edits to determine if they are equivalent
890
+ * by applying both sets to the original content and comparing results.
891
+ * This handles cases where edits might be different but produce the same outcome.
892
+ */
893
+ export function areFileEditsEquivalent(
894
+ edits1: FileEdit[],
895
+ edits2: FileEdit[],
896
+ originalContent: string,
897
+ ): boolean {
898
+ // Fast path: check if edits are literally identical
899
+ if (
900
+ edits1.length === edits2.length &&
901
+ edits1.every((edit1, index) => {
902
+ const edit2 = edits2[index]
903
+ return (
904
+ edit2 !== undefined &&
905
+ edit1.old_string === edit2.old_string &&
906
+ edit1.new_string === edit2.new_string &&
907
+ edit1.replace_all === edit2.replace_all
908
+ )
909
+ })
910
+ ) {
911
+ return true
912
+ }
913
+
914
+ // Try applying both sets of edits
915
+ let result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
916
+ null
917
+ let error1: string | null = null
918
+ let result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null =
919
+ null
920
+ let error2: string | null = null
921
+
922
+ try {
923
+ result1 = getPatchForEdits({
924
+ filePath: 'temp',
925
+ fileContents: originalContent,
926
+ edits: edits1,
927
+ })
928
+ } catch (e) {
929
+ error1 = errorMessage(e)
930
+ }
931
+
932
+ try {
933
+ result2 = getPatchForEdits({
934
+ filePath: 'temp',
935
+ fileContents: originalContent,
936
+ edits: edits2,
937
+ })
938
+ } catch (e) {
939
+ error2 = errorMessage(e)
940
+ }
941
+
942
+ // If both threw errors, they're equal only if the errors are the same
943
+ if (error1 !== null && error2 !== null) {
944
+ // Normalize error messages for comparison
945
+ return error1 === error2
946
+ }
947
+
948
+ // If one threw an error and the other didn't, they're not equal
949
+ if (error1 !== null || error2 !== null) {
950
+ return false
951
+ }
952
+
953
+ // Both succeeded - compare the results
954
+ return result1!.updatedFile === result2!.updatedFile
955
+ }
956
+
957
+ /**
958
+ * Unified function to check if two file edit inputs are equivalent.
959
+ * Handles file edits (FileEditTool).
960
+ */
961
+ export function areFileEditsInputsEquivalent(
962
+ input1: {
963
+ file_path: string
964
+ edits: FileEdit[]
965
+ },
966
+ input2: {
967
+ file_path: string
968
+ edits: FileEdit[]
969
+ },
970
+ ): boolean {
971
+ // Fast path: different files
972
+ if (input1.file_path !== input2.file_path) {
973
+ return false
974
+ }
975
+
976
+ // Fast path: literal equality
977
+ if (
978
+ input1.edits.length === input2.edits.length &&
979
+ input1.edits.every((edit1, index) => {
980
+ const edit2 = input2.edits[index]
981
+ return (
982
+ edit2 !== undefined &&
983
+ edit1.old_string === edit2.old_string &&
984
+ edit1.new_string === edit2.new_string &&
985
+ edit1.replace_all === edit2.replace_all
986
+ )
987
+ })
988
+ ) {
989
+ return true
990
+ }
991
+
992
+ // Semantic comparison (requires file read). If the file doesn't exist,
993
+ // compare against empty content (no TOCTOU pre-check).
994
+ let fileContent = ''
995
+ try {
996
+ fileContent = readFileSyncCached(input1.file_path)
997
+ } catch (error) {
998
+ if (!isENOENT(error)) {
999
+ throw error
1000
+ }
1001
+ }
1002
+
1003
+ return areFileEditsEquivalent(input1.edits, input2.edits, fileContent)
1004
+ }