bingocode 1.1.178 → 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,1004 +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
- /**
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
- }
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
+ }