bingocode 1.1.178 → 1.1.180

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