pi-openai-codex-compat 0.0.6 → 0.0.7

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.
@@ -0,0 +1,1535 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { extname } from "node:path";
3
+ import { wasmURL, type GrammarName } from "@2h2d/tree-sitter-wasms";
4
+ import { Language, type Node as SyntaxNode, Parser } from "web-tree-sitter";
5
+
6
+ export type UpdateHunkLine = {
7
+ kind: "add" | "context" | "delete";
8
+ text: string;
9
+ };
10
+
11
+ export type UpdateChunk = {
12
+ context?: string;
13
+ oldLines: string[];
14
+ newLines: string[];
15
+ lines: UpdateHunkLine[];
16
+ endOfFile: boolean;
17
+ };
18
+
19
+ type MatchMode = "exact" | "trim-end" | "trim" | "unicode";
20
+
21
+ type ByteEdit = {
22
+ start: number;
23
+ end: number;
24
+ replacement: Buffer;
25
+ };
26
+
27
+ type EditCandidate = {
28
+ start: number;
29
+ end: number;
30
+ startLine: number;
31
+ endLine: number;
32
+ edits: ByteEdit[];
33
+ };
34
+
35
+ type EditGroup = {
36
+ chunk: UpdateChunk;
37
+ chunkIndex: number;
38
+ chunkCount: number;
39
+ oldLines: string[];
40
+ newLines: string[];
41
+ beforeContext: string[];
42
+ afterContext: string[];
43
+ endsChunk: boolean;
44
+ };
45
+
46
+ export type FormatterMatchCandidateRange = {
47
+ startLine: number;
48
+ endLine: number;
49
+ };
50
+
51
+ export type FormatterMatchFailureReason =
52
+ | "no-candidate"
53
+ | "no-ordered-mapping"
54
+ | "too-many-candidates"
55
+ | "ambiguous-output"
56
+ | "mapping-limit"
57
+ | "overlapping-edits";
58
+
59
+ export type FormatterMatchFailureDetails = {
60
+ reason: FormatterMatchFailureReason;
61
+ path: string;
62
+ groupCount: number;
63
+ groupIndex?: number;
64
+ chunkCount?: number;
65
+ chunkIndex?: number;
66
+ candidateCount: number;
67
+ candidates: FormatterMatchCandidateRange[];
68
+ previousGroupIndex?: number;
69
+ previousCandidates?: FormatterMatchCandidateRange[];
70
+ reverseOrdered?: boolean;
71
+ overlapping?: boolean;
72
+ replacementCandidateCount?: number;
73
+ replacementCandidates?: FormatterMatchCandidateRange[];
74
+ oldExcerpt?: string;
75
+ };
76
+
77
+ type SyntaxPathEntry = {
78
+ id: number;
79
+ type: string;
80
+ };
81
+
82
+ type SyntaxToken = {
83
+ type: string;
84
+ text: string;
85
+ start: number;
86
+ end: number;
87
+ path: SyntaxPathEntry[];
88
+ unsafe: boolean;
89
+ };
90
+
91
+ type StructuralDocument = {
92
+ grammar: GrammarName;
93
+ tokens: SyntaxToken[];
94
+ };
95
+
96
+ type WrappedFragment = {
97
+ source: string;
98
+ start: number;
99
+ end: number;
100
+ };
101
+
102
+ const MATCH_MODES: readonly MatchMode[] = ["exact", "trim-end", "trim", "unicode"];
103
+ const MAX_CANDIDATES_PER_GROUP = 64;
104
+ const MAX_COMPLETE_MAPPINGS = 256;
105
+
106
+ const GRAMMAR_BY_EXTENSION = new Map<string, GrammarName>([
107
+ [".js", "javascript"],
108
+ [".mjs", "javascript"],
109
+ [".cjs", "javascript"],
110
+ [".jsx", "jsx"],
111
+ [".ts", "typescript"],
112
+ [".mts", "typescript"],
113
+ [".cts", "typescript"],
114
+ [".tsx", "tsx"],
115
+ [".py", "python"],
116
+ [".pyi", "python"],
117
+ [".go", "go"],
118
+ [".java", "java"],
119
+ [".scala", "scala"],
120
+ [".sc", "scala"],
121
+ [".sbt", "scala"],
122
+ ]);
123
+
124
+ const GRAMMAR_BY_FENCE_INFO = new Map<string, GrammarName>([
125
+ ["js", "javascript"],
126
+ ["javascript", "javascript"],
127
+ ["jsx", "jsx"],
128
+ ["ts", "typescript"],
129
+ ["typescript", "typescript"],
130
+ ["tsx", "tsx"],
131
+ ["py", "python"],
132
+ ["python", "python"],
133
+ ["go", "go"],
134
+ ["java", "java"],
135
+ ["scala", "scala"],
136
+ ]);
137
+
138
+ const languagePromises = new Map<GrammarName, Promise<Language>>();
139
+ let parserInitialization: Promise<void> | undefined;
140
+
141
+ export class FormatterMatchError extends Error {
142
+ readonly details: FormatterMatchFailureDetails;
143
+
144
+ constructor(message: string, details: FormatterMatchFailureDetails) {
145
+ super(message);
146
+ this.details = details;
147
+ }
148
+ }
149
+
150
+ export class FormatterMatchAmbiguityError extends FormatterMatchError {}
151
+
152
+ class OverlappingFormatterEditsError extends Error {}
153
+
154
+ function throwIfAborted(signal: AbortSignal | undefined): void {
155
+ if (signal?.aborted) throw new Error("apply_patch was cancelled.");
156
+ }
157
+
158
+ function isRustWhitespace(codePoint: number): boolean {
159
+ return (
160
+ (codePoint >= 0x0009 && codePoint <= 0x000d) ||
161
+ codePoint === 0x0020 ||
162
+ codePoint === 0x0085 ||
163
+ codePoint === 0x00a0 ||
164
+ codePoint === 0x1680 ||
165
+ (codePoint >= 0x2000 && codePoint <= 0x200a) ||
166
+ codePoint === 0x2028 ||
167
+ codePoint === 0x2029 ||
168
+ codePoint === 0x202f ||
169
+ codePoint === 0x205f ||
170
+ codePoint === 0x3000
171
+ );
172
+ }
173
+
174
+ function rustTrimStart(value: string): string {
175
+ let index = 0;
176
+ while (index < value.length && isRustWhitespace(value.charCodeAt(index))) index += 1;
177
+ return value.slice(index);
178
+ }
179
+
180
+ function rustTrimEnd(value: string): string {
181
+ let index = value.length;
182
+ while (index > 0 && isRustWhitespace(value.charCodeAt(index - 1))) index -= 1;
183
+ return value.slice(0, index);
184
+ }
185
+
186
+ function rustTrim(value: string): string {
187
+ return rustTrimEnd(rustTrimStart(value));
188
+ }
189
+
190
+ function normalizeFuzzyText(value: string): string {
191
+ const replacements: Record<string, string> = {
192
+ "\u2010": "-",
193
+ "\u2011": "-",
194
+ "\u2012": "-",
195
+ "\u2013": "-",
196
+ "\u2014": "-",
197
+ "\u2015": "-",
198
+ "\u2212": "-",
199
+ "\u2018": "'",
200
+ "\u2019": "'",
201
+ "\u201a": "'",
202
+ "\u201b": "'",
203
+ "\u201c": '"',
204
+ "\u201d": '"',
205
+ "\u201e": '"',
206
+ "\u201f": '"',
207
+ "\u00a0": " ",
208
+ "\u2002": " ",
209
+ "\u2003": " ",
210
+ "\u2004": " ",
211
+ "\u2005": " ",
212
+ "\u2006": " ",
213
+ "\u2007": " ",
214
+ "\u2008": " ",
215
+ "\u2009": " ",
216
+ "\u200a": " ",
217
+ "\u202f": " ",
218
+ "\u205f": " ",
219
+ "\u3000": " ",
220
+ };
221
+ return Array.from(rustTrim(value))
222
+ .map((character) => replacements[character] ?? character)
223
+ .join("");
224
+ }
225
+
226
+ function linesMatch(actual: string, expected: string, mode: MatchMode): boolean {
227
+ switch (mode) {
228
+ case "exact":
229
+ return actual === expected;
230
+ case "trim-end":
231
+ return rustTrimEnd(actual) === rustTrimEnd(expected);
232
+ case "trim":
233
+ return rustTrim(actual) === rustTrim(expected);
234
+ case "unicode":
235
+ return normalizeFuzzyText(actual) === normalizeFuzzyText(expected);
236
+ }
237
+ }
238
+
239
+ function sequenceMatches(
240
+ lines: readonly string[],
241
+ pattern: readonly string[],
242
+ index: number,
243
+ mode: MatchMode,
244
+ ): boolean {
245
+ if (index + pattern.length > lines.length) return false;
246
+ return pattern.every((expected, offset) => linesMatch(lines[index + offset]!, expected, mode));
247
+ }
248
+
249
+ function findSequences(
250
+ lines: readonly string[],
251
+ pattern: readonly string[],
252
+ start: number,
253
+ endOfFile: boolean,
254
+ ): number[] {
255
+ if (pattern.length === 0) return [start];
256
+ if (pattern.length > lines.length) return [];
257
+ const last = lines.length - pattern.length;
258
+ const searchStart = endOfFile ? last : start;
259
+ for (const mode of MATCH_MODES) {
260
+ const matches: number[] = [];
261
+ for (let index = searchStart; index <= last; index++) {
262
+ if (sequenceMatches(lines, pattern, index, mode)) matches.push(index);
263
+ }
264
+ if (matches.length > 0) return matches;
265
+ }
266
+ return [];
267
+ }
268
+
269
+ function findSequence(
270
+ lines: readonly string[],
271
+ pattern: readonly string[],
272
+ start: number,
273
+ endOfFile: boolean,
274
+ ): number | undefined {
275
+ return findSequences(lines, pattern, start, endOfFile)[0];
276
+ }
277
+
278
+ function isMarkdownPath(path: string): boolean {
279
+ return [".md", ".markdown"].includes(extname(path).toLowerCase());
280
+ }
281
+
282
+ function markdownTableCells(line: string): string[] | undefined {
283
+ const trimmed = rustTrim(line);
284
+ if (
285
+ !trimmed.startsWith("|") ||
286
+ !trimmed.endsWith("|") ||
287
+ trimmed.includes("\\|") ||
288
+ trimmed.includes("`")
289
+ ) {
290
+ return undefined;
291
+ }
292
+ const cells = trimmed
293
+ .slice(1, -1)
294
+ .split("|")
295
+ .map((cell) => rustTrim(cell));
296
+ return cells.length >= 2 ? cells : undefined;
297
+ }
298
+
299
+ function markdownTableLinesMatch(actual: string, expected: string): boolean {
300
+ const actualCells = markdownTableCells(actual);
301
+ const expectedCells = markdownTableCells(expected);
302
+ return (
303
+ actualCells !== undefined &&
304
+ expectedCells !== undefined &&
305
+ actualCells.length === expectedCells.length &&
306
+ actualCells.every((cell, index) => cell === expectedCells[index])
307
+ );
308
+ }
309
+
310
+ function withoutCarriageReturn(value: string): string {
311
+ return value.endsWith("\r") ? value.slice(0, -1) : value;
312
+ }
313
+
314
+ function markdownTolerantLineIsSafe(actual: string, expected: string): boolean {
315
+ if (actual === expected) return true;
316
+ return (
317
+ !/[\t ]{2}$|\\$/u.test(withoutCarriageReturn(actual)) &&
318
+ !/[\t ]{2}$|\\$/u.test(withoutCarriageReturn(expected))
319
+ );
320
+ }
321
+
322
+ function findTolerantSequences(
323
+ lines: readonly string[],
324
+ pattern: readonly string[],
325
+ start: number,
326
+ endOfFile: boolean,
327
+ path: string,
328
+ ): number[] {
329
+ const ordinary = findSequences(lines, pattern, start, endOfFile).filter(
330
+ (index) =>
331
+ !isMarkdownPath(path) ||
332
+ pattern.every((expected, offset) =>
333
+ markdownTolerantLineIsSafe(lines[index + offset]!, expected),
334
+ ),
335
+ );
336
+ if (ordinary.length > 0 || !isMarkdownPath(path) || pattern.length === 0) return ordinary;
337
+ if (!pattern.every((line) => markdownTableCells(line) !== undefined)) return [];
338
+
339
+ const last = lines.length - pattern.length;
340
+ const searchStart = endOfFile ? last : start;
341
+ const fencedLines = markdownFencedLines(lines);
342
+ const matches: number[] = [];
343
+ for (let index = searchStart; index <= last; index++) {
344
+ if (
345
+ !pattern.some((_, offset) => fencedLines.has(index + offset)) &&
346
+ pattern.every((expected, offset) => markdownTableLinesMatch(lines[index + offset]!, expected))
347
+ ) {
348
+ matches.push(index);
349
+ }
350
+ }
351
+ return matches;
352
+ }
353
+
354
+ function deriveStrictContent(
355
+ content: string,
356
+ chunks: readonly UpdateChunk[],
357
+ path: string,
358
+ ): string {
359
+ const lines = content.split("\n");
360
+ if (lines.at(-1) === "") lines.pop();
361
+ const replacements: Array<{ index: number; oldLength: number; newLines: string[] }> = [];
362
+ let cursor = 0;
363
+
364
+ for (const chunk of chunks) {
365
+ if (chunk.context) {
366
+ const contextIndex = findSequence(lines, [chunk.context], cursor, false);
367
+ if (contextIndex === undefined) {
368
+ throw new Error(`Failed to find context '${chunk.context}' in ${path}`);
369
+ }
370
+ cursor = contextIndex + 1;
371
+ }
372
+
373
+ if (chunk.oldLines.length === 0) {
374
+ const insertionIndex = lines.at(-1) === "" ? lines.length - 1 : lines.length;
375
+ replacements.push({
376
+ index: insertionIndex,
377
+ oldLength: 0,
378
+ newLines: strictReplacementLines(lines, insertionIndex, 0, chunk, chunk.newLines),
379
+ });
380
+ continue;
381
+ }
382
+
383
+ let oldLines = chunk.oldLines;
384
+ let newLines = chunk.newLines;
385
+ let found = findSequence(lines, oldLines, cursor, chunk.endOfFile);
386
+ if (found === undefined && oldLines.at(-1) === "") {
387
+ oldLines = oldLines.slice(0, -1);
388
+ if (newLines.at(-1) === "") newLines = newLines.slice(0, -1);
389
+ found = findSequence(lines, oldLines, cursor, chunk.endOfFile);
390
+ }
391
+ if (found === undefined) {
392
+ throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`);
393
+ }
394
+ replacements.push({
395
+ index: found,
396
+ oldLength: oldLines.length,
397
+ newLines: strictReplacementLines(lines, found, oldLines.length, chunk, newLines),
398
+ });
399
+ cursor = found + oldLines.length;
400
+ }
401
+
402
+ replacements.sort((left, right) => left.index - right.index);
403
+ for (const replacement of replacements.toReversed()) {
404
+ lines.splice(replacement.index, replacement.oldLength, ...replacement.newLines);
405
+ }
406
+ if (lines.at(-1) !== "") lines.push("");
407
+ return lines.join("\n");
408
+ }
409
+
410
+ function withLineEnding(line: string, lineEnding: "\n" | "\r\n"): string {
411
+ return lineEnding === "\r\n" && !line.endsWith("\r") ? `${line}\r` : line;
412
+ }
413
+
414
+ function strictReplacementLines(
415
+ sourceLines: readonly string[],
416
+ startLine: number,
417
+ oldLength: number,
418
+ chunk: UpdateChunk,
419
+ newLines: readonly string[],
420
+ ): string[] {
421
+ const roleAware: string[] = [];
422
+ let sourceOffset = 0;
423
+ for (const line of chunk.lines) {
424
+ if (line.kind === "delete") {
425
+ sourceOffset += 1;
426
+ continue;
427
+ }
428
+ const lineEnding =
429
+ line.kind === "context"
430
+ ? lineEndingForLine(sourceLines, startLine + sourceOffset)
431
+ : lineEndingAtBoundary(sourceLines, startLine + sourceOffset);
432
+ roleAware.push(withLineEnding(line.text, lineEnding));
433
+ if (line.kind === "context") sourceOffset += 1;
434
+ }
435
+ if (
436
+ roleAware.length === newLines.length &&
437
+ roleAware.every((line, index) => withoutCarriageReturn(line) === newLines[index])
438
+ ) {
439
+ return roleAware;
440
+ }
441
+
442
+ return newLines.map((line, index) => {
443
+ const lineEnding =
444
+ oldLength === 0
445
+ ? lineEndingAtBoundary(sourceLines, startLine)
446
+ : lineEndingForLine(sourceLines, startLine + Math.min(index, oldLength - 1));
447
+ return withLineEnding(line, lineEnding);
448
+ });
449
+ }
450
+
451
+ function editGroups(chunks: readonly UpdateChunk[]): EditGroup[] {
452
+ const groups: EditGroup[] = [];
453
+ for (const [chunkIndex, chunk] of chunks.entries()) {
454
+ const chunkGroups: EditGroup[] = [];
455
+ for (let index = 0; index < chunk.lines.length;) {
456
+ if (chunk.lines[index]!.kind === "context") {
457
+ index += 1;
458
+ continue;
459
+ }
460
+ const start = index;
461
+ while (index < chunk.lines.length && chunk.lines[index]!.kind !== "context") index += 1;
462
+ const segment = chunk.lines.slice(start, index);
463
+ let beforeStart = start;
464
+ while (beforeStart > 0 && chunk.lines[beforeStart - 1]!.kind === "context") {
465
+ beforeStart -= 1;
466
+ }
467
+ let afterEnd = index;
468
+ while (afterEnd < chunk.lines.length && chunk.lines[afterEnd]!.kind === "context") {
469
+ afterEnd += 1;
470
+ }
471
+ chunkGroups.push({
472
+ chunk,
473
+ chunkIndex: chunkIndex + 1,
474
+ chunkCount: chunks.length,
475
+ oldLines: segment.filter((line) => line.kind === "delete").map((line) => line.text),
476
+ newLines: segment.filter((line) => line.kind === "add").map((line) => line.text),
477
+ beforeContext: chunk.lines.slice(beforeStart, start).map((line) => line.text),
478
+ afterContext: chunk.lines.slice(index, afterEnd).map((line) => line.text),
479
+ endsChunk: false,
480
+ });
481
+ }
482
+ const finalGroup = chunkGroups.at(-1);
483
+ if (finalGroup) finalGroup.endsChunk = true;
484
+ groups.push(...chunkGroups);
485
+ }
486
+ return groups;
487
+ }
488
+
489
+ function normalizedSource(content: string): {
490
+ source: string;
491
+ lines: string[];
492
+ lineStarts: number[];
493
+ } {
494
+ const lines = content.split("\n");
495
+ if (lines.at(-1) === "") lines.pop();
496
+ const source = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
497
+ const lineStarts = [0];
498
+ let offset = 0;
499
+ for (const line of lines) {
500
+ offset += Buffer.byteLength(line, "utf8") + 1;
501
+ lineStarts.push(offset);
502
+ }
503
+ return { source, lines, lineStarts };
504
+ }
505
+
506
+ function lineForByte(lineStarts: readonly number[], byte: number): number {
507
+ let low = 0;
508
+ let high = lineStarts.length - 1;
509
+ while (low < high) {
510
+ const middle = Math.ceil((low + high) / 2);
511
+ if (lineStarts[middle]! <= byte) low = middle;
512
+ else high = middle - 1;
513
+ }
514
+ return low;
515
+ }
516
+
517
+ function contextMatches(actual: string, expected: string, path: string): boolean {
518
+ return (
519
+ MATCH_MODES.some((mode) => linesMatch(actual, expected, mode)) ||
520
+ (isMarkdownPath(path) && markdownTableLinesMatch(actual, expected))
521
+ );
522
+ }
523
+
524
+ function anchorLines(group: EditGroup, sourceLines: readonly string[], path: string): number[] {
525
+ if (!group.chunk.context) return [];
526
+ return sourceLines.flatMap((line, index) =>
527
+ contextMatches(line, group.chunk.context!, path) ? [index] : [],
528
+ );
529
+ }
530
+
531
+ function candidateFollowsAnchor(
532
+ group: EditGroup,
533
+ sourceLines: readonly string[],
534
+ startLine: number,
535
+ path: string,
536
+ ): boolean {
537
+ const anchors = anchorLines(group, sourceLines, path);
538
+ return anchors.length === 0 || anchors.some((line) => line < startLine);
539
+ }
540
+
541
+ function candidateSatisfiesEndOfFile(
542
+ group: EditGroup,
543
+ sourceLineCount: number,
544
+ endLine: number,
545
+ ): boolean {
546
+ return (
547
+ !group.chunk.endOfFile ||
548
+ !group.endsChunk ||
549
+ endLine + group.afterContext.length === sourceLineCount
550
+ );
551
+ }
552
+
553
+ function candidateRange(candidate: EditCandidate): FormatterMatchCandidateRange {
554
+ return {
555
+ startLine: candidate.startLine + 1,
556
+ endLine: Math.max(candidate.startLine + 1, candidate.endLine),
557
+ };
558
+ }
559
+
560
+ function candidateRanges(candidates: readonly EditCandidate[]): FormatterMatchCandidateRange[] {
561
+ return candidates.slice(0, 3).map(candidateRange);
562
+ }
563
+
564
+ function lineRangeLabel(range: FormatterMatchCandidateRange): string {
565
+ return range.startLine === range.endLine
566
+ ? `line ${range.startLine}`
567
+ : `lines ${range.startLine}-${range.endLine}`;
568
+ }
569
+
570
+ function rangeList(ranges: readonly FormatterMatchCandidateRange[]): string {
571
+ return ranges.map(lineRangeLabel).join(", ");
572
+ }
573
+
574
+ export function formatFormatterMatchFailure(details: FormatterMatchFailureDetails): string {
575
+ const group =
576
+ details.groupIndex === undefined
577
+ ? ""
578
+ : `edit group ${details.groupIndex} of ${details.groupCount}`;
579
+ const chunk =
580
+ details.chunkIndex === undefined
581
+ ? ""
582
+ : ` (chunk ${details.chunkIndex} of ${details.chunkCount})`;
583
+ switch (details.reason) {
584
+ case "no-candidate": {
585
+ const replacement =
586
+ details.replacementCandidateCount && details.replacementCandidates?.length
587
+ ? ` The requested replacement already appears at ${rangeList(details.replacementCandidates)}.`
588
+ : "";
589
+ return `No formatter-tolerant candidate for ${group}${chunk} in ${details.path}.${replacement}`;
590
+ }
591
+ case "no-ordered-mapping": {
592
+ const current = rangeList(details.candidates);
593
+ const previous = rangeList(details.previousCandidates ?? []);
594
+ const relation = details.reverseOrdered
595
+ ? `${current} precedes edit group ${details.previousGroupIndex}, matched at ${previous}`
596
+ : details.overlapping
597
+ ? `${current} overlaps edit group ${details.previousGroupIndex}, matched at ${previous}`
598
+ : `no candidate at ${current} can follow edit group ${details.previousGroupIndex}, matched at ${previous}`;
599
+ return `No ordered formatter-tolerant mapping for ${group}${chunk} in ${details.path}: ${relation}. The hunks may be in reverse source order or overlap.`;
600
+ }
601
+ case "too-many-candidates":
602
+ return `Formatter-tolerant match is ambiguous for ${group}${chunk} in ${details.path}: ${details.candidateCount} eligible locations exceed the ${MAX_CANDIDATES_PER_GROUP}-candidate limit.`;
603
+ case "ambiguous-output":
604
+ return `Formatter-tolerant match is ambiguous in ${details.path}: candidate mappings produce different files.`;
605
+ case "mapping-limit":
606
+ return `Formatter-tolerant match is ambiguous in ${details.path}: more than ${MAX_COMPLETE_MAPPINGS} candidate mappings require evaluation.`;
607
+ case "overlapping-edits":
608
+ return `Formatter-tolerant candidate edits overlap in ${details.path}.`;
609
+ }
610
+ }
611
+
612
+ function oldExcerpt(group: EditGroup): string | undefined {
613
+ if (group.oldLines.length === 0) return undefined;
614
+ const lines = group.oldLines.slice(0, 3);
615
+ let excerpt = lines.join("\n");
616
+ if (group.oldLines.length > lines.length) excerpt = `${excerpt}\n…`;
617
+ return excerpt.length > 240 ? `${excerpt.slice(0, 239)}…` : excerpt;
618
+ }
619
+
620
+ function enforceCandidateLimit(
621
+ candidates: EditCandidate[],
622
+ path: string,
623
+ group: EditGroup,
624
+ groupIndex: number,
625
+ groupCount: number,
626
+ ): EditCandidate[] {
627
+ if (candidates.length > MAX_CANDIDATES_PER_GROUP) {
628
+ const details: FormatterMatchFailureDetails = {
629
+ reason: "too-many-candidates",
630
+ path,
631
+ groupCount,
632
+ groupIndex: groupIndex + 1,
633
+ chunkCount: group.chunkCount,
634
+ chunkIndex: group.chunkIndex,
635
+ candidateCount: candidates.length,
636
+ candidates: candidateRanges(candidates),
637
+ };
638
+ throw new FormatterMatchAmbiguityError(formatFormatterMatchFailure(details), details);
639
+ }
640
+ return candidates;
641
+ }
642
+
643
+ function lineEndingForLine(sourceLines: readonly string[], line: number): "\n" | "\r\n" {
644
+ return sourceLines[line]?.endsWith("\r") ? "\r\n" : "\n";
645
+ }
646
+
647
+ function lineEndingAtBoundary(sourceLines: readonly string[], line: number): "\n" | "\r\n" {
648
+ const adjacent = sourceLines[line - 1] ?? sourceLines[line];
649
+ return adjacent?.endsWith("\r") ? "\r\n" : "\n";
650
+ }
651
+
652
+ function replacementLines(
653
+ lines: readonly string[],
654
+ lineEnding: "\n" | "\r\n",
655
+ trailingNewline = true,
656
+ ): Buffer {
657
+ return lines.length === 0
658
+ ? Buffer.alloc(0)
659
+ : Buffer.from(`${lines.join(lineEnding)}${trailingNewline ? lineEnding : ""}`, "utf8");
660
+ }
661
+
662
+ function lineCandidates(
663
+ group: EditGroup,
664
+ sourceLines: readonly string[],
665
+ lineStarts: readonly number[],
666
+ path: string,
667
+ ): EditCandidate[] {
668
+ const starts = findTolerantSequences(sourceLines, group.oldLines, 0, false, path);
669
+ const ordinary = starts
670
+ .map((startLine) => {
671
+ const endLine = startLine + group.oldLines.length;
672
+ const start = lineStarts[startLine]!;
673
+ const end = lineStarts[endLine]!;
674
+ const replacement = replacementLines(
675
+ group.newLines,
676
+ lineEndingForLine(sourceLines, startLine),
677
+ );
678
+ return {
679
+ start,
680
+ end,
681
+ startLine,
682
+ endLine,
683
+ edits: [{ start, end, replacement }],
684
+ };
685
+ })
686
+ .filter(
687
+ (candidate) =>
688
+ candidateFollowsAnchor(group, sourceLines, candidate.startLine, path) &&
689
+ candidateSatisfiesEndOfFile(group, sourceLines.length, candidate.endLine),
690
+ );
691
+ return ordinary;
692
+ }
693
+
694
+ function insertionCandidates(
695
+ group: EditGroup,
696
+ sourceLines: readonly string[],
697
+ lineStarts: readonly number[],
698
+ path: string,
699
+ ): EditCandidate[] {
700
+ const boundaries = new Set<number>();
701
+ const nearestBefore = group.beforeContext.at(-1);
702
+ if (nearestBefore !== undefined) {
703
+ const expected = nearestBefore;
704
+ const matches = findTolerantSequences(sourceLines, [expected], 0, false, path);
705
+ for (const match of matches) boundaries.add(match + 1);
706
+ }
707
+ const nearestAfter = group.afterContext[0];
708
+ if (nearestAfter !== undefined) {
709
+ const expected = nearestAfter;
710
+ const matches = findTolerantSequences(sourceLines, [expected], 0, false, path);
711
+ for (const match of matches) boundaries.add(match);
712
+ }
713
+ if (group.chunk.context) {
714
+ for (const match of findTolerantSequences(sourceLines, [group.chunk.context], 0, false, path)) {
715
+ boundaries.add(match + 1);
716
+ }
717
+ }
718
+ if (
719
+ group.chunk.endOfFile &&
720
+ group.endsChunk &&
721
+ group.beforeContext.length === 0 &&
722
+ group.afterContext.length === 0
723
+ ) {
724
+ boundaries.add(sourceLines.length);
725
+ }
726
+
727
+ return [...boundaries]
728
+ .filter(
729
+ (line) =>
730
+ candidateFollowsAnchor(group, sourceLines, line, path) &&
731
+ candidateSatisfiesEndOfFile(group, sourceLines.length, line),
732
+ )
733
+ .map((line) => {
734
+ const byte = lineStarts[line]!;
735
+ const replacement = replacementLines(group.newLines, lineEndingAtBoundary(sourceLines, line));
736
+ return {
737
+ start: byte,
738
+ end: byte,
739
+ startLine: line,
740
+ endLine: line,
741
+ edits: [{ start: byte, end: byte, replacement }],
742
+ };
743
+ });
744
+ }
745
+
746
+ function deduplicateCandidates(candidates: readonly EditCandidate[]): EditCandidate[] {
747
+ const unique = new Map<string, EditCandidate>();
748
+ for (const candidate of candidates) {
749
+ const key = JSON.stringify([
750
+ candidate.start,
751
+ candidate.end,
752
+ candidate.edits.map((edit) => [edit.start, edit.end, edit.replacement.toString("base64")]),
753
+ ]);
754
+ if (!unique.has(key)) unique.set(key, candidate);
755
+ }
756
+ return [...unique.values()];
757
+ }
758
+
759
+ function parserInitializationPromise(): Promise<void> {
760
+ if (!parserInitialization) {
761
+ const promise = structuralRuntime.initializeParser();
762
+ parserInitialization = promise;
763
+ void promise.catch(() => {
764
+ if (parserInitialization === promise) parserInitialization = undefined;
765
+ });
766
+ }
767
+ return parserInitialization;
768
+ }
769
+
770
+ async function loadLanguage(grammar: GrammarName): Promise<Language> {
771
+ let promise = languagePromises.get(grammar);
772
+ if (!promise) {
773
+ promise = (async () => {
774
+ await parserInitializationPromise();
775
+ return structuralRuntime.loadLanguage(fileURLToPath(wasmURL(grammar)));
776
+ })();
777
+ languagePromises.set(grammar, promise);
778
+ void promise.catch(() => {
779
+ if (languagePromises.get(grammar) === promise) languagePromises.delete(grammar);
780
+ });
781
+ }
782
+ return promise;
783
+ }
784
+
785
+ type StructuralRuntime = {
786
+ initializeParser: () => Promise<void>;
787
+ loadLanguage: (path: string) => Promise<Language>;
788
+ };
789
+
790
+ const DEFAULT_STRUCTURAL_RUNTIME: StructuralRuntime = {
791
+ initializeParser: () => Parser.init(),
792
+ loadLanguage: (path) => Language.load(path),
793
+ };
794
+ let structuralRuntime = DEFAULT_STRUCTURAL_RUNTIME;
795
+
796
+ export function setApplyPatchStructuralRuntimeForTesting(
797
+ overrides: Partial<StructuralRuntime>,
798
+ ): () => void {
799
+ parserInitialization = undefined;
800
+ languagePromises.clear();
801
+ structuralRuntime = { ...DEFAULT_STRUCTURAL_RUNTIME, ...overrides };
802
+ return () => {
803
+ parserInitialization = undefined;
804
+ languagePromises.clear();
805
+ structuralRuntime = DEFAULT_STRUCTURAL_RUNTIME;
806
+ };
807
+ }
808
+
809
+ function grammarForPath(path: string): GrammarName | undefined {
810
+ return GRAMMAR_BY_EXTENSION.get(extname(path).toLowerCase());
811
+ }
812
+
813
+ function fenceGrammar(group: EditGroup): GrammarName | undefined {
814
+ const context = [...(group.chunk.context ? [group.chunk.context] : []), ...group.beforeContext];
815
+ for (const line of context.toReversed()) {
816
+ if (/^ {0,3}(?:`{3,}|~{3,})[\t ]*$/u.test(line)) return undefined;
817
+ const match = rustTrim(line).match(/^(?:`{3,}|~{3,})[\t ]*([A-Za-z0-9_+-]+)/u);
818
+ if (match) return GRAMMAR_BY_FENCE_INFO.get(match[1]!.toLowerCase());
819
+ }
820
+ return undefined;
821
+ }
822
+
823
+ function utf16ByteOffsets(source: string): Uint32Array {
824
+ const offsets = new Uint32Array(source.length + 1);
825
+ let byteOffset = 0;
826
+ for (let index = 0; index < source.length;) {
827
+ offsets[index] = byteOffset;
828
+ const codePoint = source.codePointAt(index)!;
829
+ const width = codePoint > 0xffff ? 2 : 1;
830
+ if (width === 2) offsets[index + 1] = byteOffset;
831
+ byteOffset += Buffer.byteLength(String.fromCodePoint(codePoint), "utf8");
832
+ index += width;
833
+ offsets[index] = byteOffset;
834
+ }
835
+ return offsets;
836
+ }
837
+
838
+ function syntaxTokens(root: SyntaxNode, source: string): SyntaxToken[] {
839
+ const tokens: SyntaxToken[] = [];
840
+ const byteOffsets = utf16ByteOffsets(source);
841
+ const visit = (node: SyntaxNode, path: SyntaxPathEntry[], unsafe: boolean): void => {
842
+ const nextPath = [...path, { id: node.id, type: node.type }];
843
+ const nextUnsafe = unsafe || node.isError || node.isMissing || node.type === "ERROR";
844
+ if (node.childCount === 0) {
845
+ if (!node.isMissing && node.endIndex > node.startIndex) {
846
+ tokens.push({
847
+ type: node.type,
848
+ text: node.text,
849
+ start: byteOffsets[node.startIndex]!,
850
+ end: byteOffsets[node.endIndex]!,
851
+ path: nextPath,
852
+ unsafe: nextUnsafe,
853
+ });
854
+ }
855
+ return;
856
+ }
857
+ for (const child of node.children) visit(child, nextPath, nextUnsafe);
858
+ };
859
+ visit(root, [], false);
860
+ return tokens;
861
+ }
862
+
863
+ async function parseStructuralDocument(
864
+ grammar: GrammarName,
865
+ source: string,
866
+ byteOffset = 0,
867
+ signal?: AbortSignal,
868
+ ): Promise<StructuralDocument | null> {
869
+ try {
870
+ throwIfAborted(signal);
871
+ const language = await loadLanguage(grammar);
872
+ const parser = new Parser();
873
+ try {
874
+ parser.setLanguage(language);
875
+ const tree = parser.parse(source, null, {
876
+ progressCallback: () => signal?.aborted ?? false,
877
+ });
878
+ throwIfAborted(signal);
879
+ if (!tree) return null;
880
+ try {
881
+ if (tree.rootNode.hasError) return null;
882
+ const tokens = syntaxTokens(tree.rootNode, source).map((token) => ({
883
+ ...token,
884
+ start: token.start + byteOffset,
885
+ end: token.end + byteOffset,
886
+ }));
887
+ if (tokens.some((token) => token.unsafe)) return null;
888
+ return { grammar, tokens };
889
+ } finally {
890
+ tree.delete();
891
+ }
892
+ } finally {
893
+ parser.delete();
894
+ }
895
+ } catch {
896
+ if (signal?.aborted) throw new Error("apply_patch was cancelled.");
897
+ return null;
898
+ }
899
+ }
900
+
901
+ function fenceOpening(
902
+ line: string,
903
+ ): { marker: "`" | "~"; length: number; grammar?: GrammarName } | undefined {
904
+ const match = line.match(/^ {0,3}(`{3,}|~{3,})[\t ]*([A-Za-z0-9_+-]+)?[^\r\n]*$/u);
905
+ if (!match) return undefined;
906
+ const delimiter = match[1]!;
907
+ const grammar = match[2] ? GRAMMAR_BY_FENCE_INFO.get(match[2].toLowerCase()) : undefined;
908
+ return {
909
+ marker: delimiter[0] as "`" | "~",
910
+ length: delimiter.length,
911
+ ...(grammar ? { grammar } : {}),
912
+ };
913
+ }
914
+
915
+ function fenceClosing(line: string, opening: { marker: "`" | "~"; length: number }): boolean {
916
+ const marker = opening.marker === "`" ? "`" : "~";
917
+ const match = line.match(new RegExp(`^ {0,3}(${marker}{${opening.length},})[\\t ]*$`, "u"));
918
+ return match !== null;
919
+ }
920
+
921
+ function markdownFencedLines(sourceLines: readonly string[]): Set<number> {
922
+ const fenced = new Set<number>();
923
+ for (let index = 0; index < sourceLines.length; index++) {
924
+ const opening = fenceOpening(sourceLines[index]!);
925
+ if (!opening) continue;
926
+ fenced.add(index);
927
+ index += 1;
928
+ while (index < sourceLines.length) {
929
+ fenced.add(index);
930
+ if (fenceClosing(sourceLines[index]!, opening)) break;
931
+ index += 1;
932
+ }
933
+ }
934
+ return fenced;
935
+ }
936
+
937
+ async function embeddedStructuralDocuments(
938
+ grammar: GrammarName,
939
+ source: string,
940
+ sourceLines: readonly string[],
941
+ lineStarts: readonly number[],
942
+ signal?: AbortSignal,
943
+ ): Promise<StructuralDocument[]> {
944
+ const documents: StructuralDocument[] = [];
945
+ const sourceBytes = Buffer.from(source, "utf8");
946
+ for (let index = 0; index < sourceLines.length; index++) {
947
+ const opening = fenceOpening(sourceLines[index]!);
948
+ if (!opening) continue;
949
+ const contentStart = index + 1;
950
+ let closing = contentStart;
951
+ while (closing < sourceLines.length && !fenceClosing(sourceLines[closing]!, opening)) {
952
+ closing += 1;
953
+ }
954
+ if (closing >= sourceLines.length) break;
955
+ if (opening.grammar === grammar) {
956
+ const start = lineStarts[contentStart]!;
957
+ const end = lineStarts[closing]!;
958
+ const document = await parseStructuralDocument(
959
+ grammar,
960
+ sourceBytes.subarray(start, end).toString("utf8"),
961
+ start,
962
+ signal,
963
+ );
964
+ if (document) documents.push(document);
965
+ }
966
+ index = closing;
967
+ }
968
+ return documents;
969
+ }
970
+
971
+ async function structuralDocuments(
972
+ path: string,
973
+ group: EditGroup,
974
+ source: string,
975
+ sourceLines: readonly string[],
976
+ lineStarts: readonly number[],
977
+ signal?: AbortSignal,
978
+ ): Promise<StructuralDocument[]> {
979
+ const grammar = grammarForPath(path);
980
+ if (grammar) {
981
+ const document = await parseStructuralDocument(grammar, source, 0, signal);
982
+ return document ? [document] : [];
983
+ }
984
+ if (!isMarkdownPath(path)) return [];
985
+ const embeddedGrammar = fenceGrammar(group);
986
+ return embeddedGrammar
987
+ ? embeddedStructuralDocuments(embeddedGrammar, source, sourceLines, lineStarts, signal)
988
+ : [];
989
+ }
990
+
991
+ function commonIndent(lines: readonly string[]): string {
992
+ const indents = lines
993
+ .filter((line) => line.trim().length > 0)
994
+ .map((line) => line.match(/^[\t ]*/u)?.[0] ?? "");
995
+ if (indents.length === 0) return "";
996
+ let prefix = indents[0]!;
997
+ for (const indent of indents.slice(1)) {
998
+ while (prefix && !indent.startsWith(prefix)) prefix = prefix.slice(0, -1);
999
+ }
1000
+ return prefix;
1001
+ }
1002
+
1003
+ function dedent(value: string): string {
1004
+ const lines = value.split("\n");
1005
+ const indent = commonIndent(lines);
1006
+ return lines.map((line) => (line.trim() ? line.slice(indent.length) : "")).join("\n");
1007
+ }
1008
+
1009
+ function indented(value: string, indent: string): string {
1010
+ return value
1011
+ .split("\n")
1012
+ .map((line) => `${indent}${line}`)
1013
+ .join("\n");
1014
+ }
1015
+
1016
+ function wrapped(prefix: string, fragment: string, suffix: string): WrappedFragment {
1017
+ return {
1018
+ source: `${prefix}${fragment}${suffix}`,
1019
+ start: Buffer.byteLength(prefix, "utf8"),
1020
+ end: Buffer.byteLength(`${prefix}${fragment}`, "utf8"),
1021
+ };
1022
+ }
1023
+
1024
+ function fragmentWrappers(grammar: GrammarName, value: string): WrappedFragment[] {
1025
+ const fragment = dedent(value);
1026
+ const body = indented(fragment, " ");
1027
+ const pythonBody = indented(fragment, " ");
1028
+ switch (grammar) {
1029
+ case "javascript":
1030
+ case "jsx":
1031
+ case "typescript":
1032
+ case "tsx":
1033
+ return [
1034
+ wrapped("", fragment, "\n"),
1035
+ wrapped("function __patch__() {\n", body, "\n}\n"),
1036
+ wrapped("class __Patch__ {\n", body, "\n}\n"),
1037
+ wrapped("const __patch__ = (\n", body, "\n);\n"),
1038
+ ];
1039
+ case "python":
1040
+ return [
1041
+ wrapped("", fragment, "\n"),
1042
+ wrapped("def __patch__():\n", pythonBody, "\n"),
1043
+ wrapped("class __Patch__:\n", pythonBody, "\n"),
1044
+ wrapped("__patch__ = (\n", pythonBody, "\n)\n"),
1045
+ ];
1046
+ case "go":
1047
+ return [
1048
+ wrapped("package patch\n", fragment, "\n"),
1049
+ wrapped("package patch\nfunc __patch__() {\n", body, "\n}\n"),
1050
+ wrapped("package patch\nvar __patch__ = (\n", body, "\n)\n"),
1051
+ ];
1052
+ case "java":
1053
+ return [
1054
+ wrapped("", fragment, "\n"),
1055
+ wrapped("class __Patch__ {\n", body, "\n}\n"),
1056
+ wrapped("class __Patch__ { void __patch__() {\n", body, "\n}}\n"),
1057
+ wrapped("class __Patch__ { Object __patch__ = (\n", body, "\n); }\n"),
1058
+ ];
1059
+ case "scala":
1060
+ return [
1061
+ wrapped("", fragment, "\n"),
1062
+ wrapped("object __Patch__ {\n", body, "\n}\n"),
1063
+ wrapped("object __Patch__ { def __patch__ = {\n", body, "\n}}\n"),
1064
+ wrapped("object __Patch__ { val __patch__ = (\n", body, "\n) }\n"),
1065
+ ];
1066
+ default:
1067
+ return [];
1068
+ }
1069
+ }
1070
+
1071
+ function fragmentCovered(fragment: WrappedFragment, tokens: readonly SyntaxToken[]): boolean {
1072
+ const bytes = Buffer.from(fragment.source, "utf8");
1073
+ const local = Buffer.from(bytes.subarray(fragment.start, fragment.end));
1074
+ for (const token of tokens) {
1075
+ const start = Math.max(token.start, fragment.start) - fragment.start;
1076
+ const end = Math.min(token.end, fragment.end) - fragment.start;
1077
+ if (start >= end) continue;
1078
+ local.fill(0x20, start, end);
1079
+ }
1080
+ return local.toString("utf8").trim().length === 0;
1081
+ }
1082
+
1083
+ async function parseFragment(grammar: GrammarName, value: string): Promise<SyntaxToken[] | null> {
1084
+ const language = await loadLanguage(grammar);
1085
+ for (const fragment of fragmentWrappers(grammar, value)) {
1086
+ const parser = new Parser();
1087
+ try {
1088
+ parser.setLanguage(language);
1089
+ const tree = parser.parse(fragment.source);
1090
+ if (!tree) continue;
1091
+ try {
1092
+ const allTokens = syntaxTokens(tree.rootNode, fragment.source);
1093
+ const tokens = allTokens.filter(
1094
+ (token) => token.start >= fragment.start && token.end <= fragment.end,
1095
+ );
1096
+ const valid =
1097
+ !tree.rootNode.hasError &&
1098
+ tokens.length > 0 &&
1099
+ tokens.every((token) => !token.unsafe) &&
1100
+ fragmentCovered(fragment, tokens);
1101
+ if (valid) return tokens;
1102
+ } finally {
1103
+ tree.delete();
1104
+ }
1105
+ } finally {
1106
+ parser.delete();
1107
+ }
1108
+ }
1109
+ return null;
1110
+ }
1111
+
1112
+ function tokenSignatureMatches(
1113
+ actual: readonly SyntaxToken[],
1114
+ expected: readonly SyntaxToken[],
1115
+ ): boolean {
1116
+ return expected.every(
1117
+ (token, index) =>
1118
+ actual[index]?.type === token.type &&
1119
+ actual[index]?.text === token.text &&
1120
+ !actual[index]?.unsafe,
1121
+ );
1122
+ }
1123
+
1124
+ function relativeShape(tokens: readonly SyntaxToken[]): string {
1125
+ if (tokens.length === 0) return "";
1126
+ let common = tokens[0]!.path.length;
1127
+ for (const token of tokens.slice(1)) {
1128
+ let index = 0;
1129
+ while (
1130
+ index < common &&
1131
+ index < token.path.length &&
1132
+ tokens[0]!.path[index]!.id === token.path[index]!.id
1133
+ ) {
1134
+ index += 1;
1135
+ }
1136
+ common = index;
1137
+ }
1138
+ const shapeStart = Math.max(0, common - 1);
1139
+ return tokens
1140
+ .map((token) =>
1141
+ token.path
1142
+ .slice(shapeStart)
1143
+ .map((entry) => entry.type)
1144
+ .join(">"),
1145
+ )
1146
+ .join("\u0000");
1147
+ }
1148
+
1149
+ function lineBounds(
1150
+ source: Buffer,
1151
+ start: number,
1152
+ end: number,
1153
+ ): {
1154
+ lineStart: number;
1155
+ lineEnd: number;
1156
+ afterLine: number;
1157
+ fullLines: boolean;
1158
+ } {
1159
+ const previousNewline = source.lastIndexOf(0x0a, Math.max(0, start - 1));
1160
+ const lineStart = previousNewline < 0 ? 0 : previousNewline + 1;
1161
+ const nextNewline = source.indexOf(0x0a, end);
1162
+ const lineEnd = nextNewline < 0 ? source.length : nextNewline;
1163
+ const afterLine = nextNewline < 0 ? source.length : nextNewline + 1;
1164
+ const prefix = source.subarray(lineStart, start).toString("utf8");
1165
+ const contentLineEnd =
1166
+ lineEnd > lineStart && source[lineEnd - 1] === 0x0d ? lineEnd - 1 : lineEnd;
1167
+ const suffix = source.subarray(end, contentLineEnd).toString("utf8");
1168
+ return {
1169
+ lineStart,
1170
+ lineEnd,
1171
+ afterLine,
1172
+ fullLines: /^[\t ]*$/u.test(prefix) && /^[\t ]*$/u.test(suffix),
1173
+ };
1174
+ }
1175
+
1176
+ async function tokenCandidates(
1177
+ group: EditGroup,
1178
+ document: StructuralDocument,
1179
+ source: string,
1180
+ sourceLines: readonly string[],
1181
+ lineStarts: readonly number[],
1182
+ path: string,
1183
+ signal?: AbortSignal,
1184
+ ): Promise<EditCandidate[]> {
1185
+ throwIfAborted(signal);
1186
+ const oldTokens = await parseFragment(document.grammar, group.oldLines.join("\n"));
1187
+ if (!oldTokens || oldTokens.length < 2 || oldTokens.length > document.tokens.length) return [];
1188
+ const expectedShape = relativeShape(oldTokens);
1189
+ const sourceBytes = Buffer.from(source, "utf8");
1190
+ const candidates: EditCandidate[] = [];
1191
+
1192
+ for (let index = 0; index <= document.tokens.length - oldTokens.length; index++) {
1193
+ throwIfAborted(signal);
1194
+ const window = document.tokens.slice(index, index + oldTokens.length);
1195
+ if (!tokenSignatureMatches(window, oldTokens) || relativeShape(window) !== expectedShape) {
1196
+ continue;
1197
+ }
1198
+ const first = window[0]!;
1199
+ const last = window.at(-1)!;
1200
+ const bounds = lineBounds(sourceBytes, first.start, last.end);
1201
+ if (!bounds.fullLines) continue;
1202
+ const lineEnding: "\n" | "\r\n" =
1203
+ bounds.lineEnd > bounds.lineStart && sourceBytes[bounds.lineEnd - 1] === 0x0d ? "\r\n" : "\n";
1204
+ const start = bounds.lineStart;
1205
+ const end = bounds.afterLine;
1206
+ const edits: ByteEdit[] = [
1207
+ {
1208
+ start,
1209
+ end,
1210
+ replacement: replacementLines(group.newLines, lineEnding, end > bounds.lineEnd),
1211
+ },
1212
+ ];
1213
+ const startLine = lineForByte(lineStarts, first.start);
1214
+ const endLine = Math.min(sourceLines.length, lineForByte(lineStarts, last.end - 1) + 1);
1215
+ const candidate = {
1216
+ start,
1217
+ end,
1218
+ startLine,
1219
+ endLine,
1220
+ edits,
1221
+ };
1222
+ if (
1223
+ candidateFollowsAnchor(group, sourceLines, candidate.startLine, path) &&
1224
+ candidateSatisfiesEndOfFile(group, sourceLines.length, candidate.endLine)
1225
+ ) {
1226
+ candidates.push(candidate);
1227
+ }
1228
+ }
1229
+ return candidates;
1230
+ }
1231
+
1232
+ function applyEdits(source: Buffer, edits: readonly ByteEdit[]): Buffer {
1233
+ const ordered = edits
1234
+ .map((edit, index) => ({ ...edit, index }))
1235
+ .sort((left, right) => left.start - right.start || left.index - right.index);
1236
+ for (let index = 1; index < ordered.length; index++) {
1237
+ if (ordered[index]!.start < ordered[index - 1]!.end) {
1238
+ throw new OverlappingFormatterEditsError("formatter-tolerant edits overlap");
1239
+ }
1240
+ }
1241
+ let result = source;
1242
+ for (const edit of ordered.toReversed()) {
1243
+ result = Buffer.concat([
1244
+ result.subarray(0, edit.start),
1245
+ edit.replacement,
1246
+ result.subarray(edit.end),
1247
+ ]);
1248
+ }
1249
+ return result;
1250
+ }
1251
+
1252
+ function distinctMappedOutputs(
1253
+ source: string,
1254
+ candidateSets: readonly EditCandidate[][],
1255
+ signal?: AbortSignal,
1256
+ ): { outputs: Map<string, Buffer>; exhaustive: boolean } {
1257
+ const outputs = new Map<string, Buffer>();
1258
+ const sourceBytes = Buffer.from(source, "utf8");
1259
+ let mappings = 0;
1260
+ let exhaustive = true;
1261
+
1262
+ const visit = (groupIndex: number, previousEnd: number, edits: ByteEdit[]): void => {
1263
+ throwIfAborted(signal);
1264
+ if (outputs.size > 1) return;
1265
+ if (mappings >= MAX_COMPLETE_MAPPINGS) {
1266
+ exhaustive = false;
1267
+ return;
1268
+ }
1269
+ if (groupIndex === candidateSets.length) {
1270
+ mappings += 1;
1271
+ const output = applyEdits(sourceBytes, edits);
1272
+ outputs.set(output.toString("base64"), output);
1273
+ return;
1274
+ }
1275
+ for (const candidate of candidateSets[groupIndex]!) {
1276
+ if (candidate.start < previousEnd) continue;
1277
+ visit(groupIndex + 1, candidate.end, [...edits, ...candidate.edits]);
1278
+ }
1279
+ };
1280
+
1281
+ visit(0, 0, []);
1282
+ return { outputs, exhaustive };
1283
+ }
1284
+
1285
+ function candidateFollows(
1286
+ candidate: EditCandidate,
1287
+ previousCandidates: readonly EditCandidate[],
1288
+ ): boolean {
1289
+ return previousCandidates.some((previous) => candidate.start >= previous.end);
1290
+ }
1291
+
1292
+ function noOrderedMappingDetails(
1293
+ path: string,
1294
+ groups: readonly EditGroup[],
1295
+ candidateSets: readonly EditCandidate[][],
1296
+ ): FormatterMatchError {
1297
+ let reachable = [...candidateSets[0]!];
1298
+ for (let groupIndex = 1; groupIndex < candidateSets.length; groupIndex++) {
1299
+ const groupCandidates = candidateSets[groupIndex]!;
1300
+ const nextReachable = groupCandidates.filter((candidate) =>
1301
+ candidateFollows(candidate, reachable),
1302
+ );
1303
+ if (nextReachable.length > 0) {
1304
+ reachable = nextReachable;
1305
+ continue;
1306
+ }
1307
+ const reverseOrdered = groupCandidates.some((candidate) =>
1308
+ reachable.some((previous) => candidate.end <= previous.start),
1309
+ );
1310
+ const overlapping = groupCandidates.some((candidate) =>
1311
+ reachable.some(
1312
+ (previous) => candidate.start < previous.end && candidate.end > previous.start,
1313
+ ),
1314
+ );
1315
+ const excerpt = oldExcerpt(groups[groupIndex]!);
1316
+ const details: FormatterMatchFailureDetails = {
1317
+ reason: "no-ordered-mapping",
1318
+ path,
1319
+ groupCount: groups.length,
1320
+ groupIndex: groupIndex + 1,
1321
+ chunkCount: groups[groupIndex]!.chunkCount,
1322
+ chunkIndex: groups[groupIndex]!.chunkIndex,
1323
+ candidateCount: groupCandidates.length,
1324
+ candidates: candidateRanges(groupCandidates),
1325
+ previousGroupIndex: groupIndex,
1326
+ previousCandidates: candidateRanges(reachable),
1327
+ ...(reverseOrdered ? { reverseOrdered: true } : {}),
1328
+ ...(overlapping ? { overlapping: true } : {}),
1329
+ ...(excerpt ? { oldExcerpt: excerpt } : {}),
1330
+ };
1331
+ return new FormatterMatchError(formatFormatterMatchFailure(details), details);
1332
+ }
1333
+ const details: FormatterMatchFailureDetails = {
1334
+ reason: "no-ordered-mapping",
1335
+ path,
1336
+ groupCount: groups.length,
1337
+ candidateCount: 0,
1338
+ candidates: [],
1339
+ };
1340
+ return new FormatterMatchError(formatFormatterMatchFailure(details), details);
1341
+ }
1342
+
1343
+ async function formatterCandidates(
1344
+ group: EditGroup,
1345
+ normalized: ReturnType<typeof normalizedSource>,
1346
+ path: string,
1347
+ documentCache: Map<string, Promise<StructuralDocument[]>>,
1348
+ signal?: AbortSignal,
1349
+ ): Promise<EditCandidate[]> {
1350
+ const lineLevelCandidates =
1351
+ group.oldLines.length === 0
1352
+ ? insertionCandidates(group, normalized.lines, normalized.lineStarts, path)
1353
+ : lineCandidates(group, normalized.lines, normalized.lineStarts, path);
1354
+ if (group.oldLines.length === 0) return lineLevelCandidates;
1355
+
1356
+ const grammar = grammarForPath(path) ?? (isMarkdownPath(path) ? fenceGrammar(group) : undefined);
1357
+ if (!grammar) return lineLevelCandidates;
1358
+ const cacheKey = `${isMarkdownPath(path) ? "embedded:" : "file:"}${grammar}`;
1359
+ let documentsPromise = documentCache.get(cacheKey);
1360
+ if (!documentsPromise) {
1361
+ documentsPromise = structuralDocuments(
1362
+ path,
1363
+ group,
1364
+ normalized.source,
1365
+ normalized.lines,
1366
+ normalized.lineStarts,
1367
+ signal,
1368
+ );
1369
+ documentCache.set(cacheKey, documentsPromise);
1370
+ }
1371
+ const documents = await documentsPromise;
1372
+ const structuralCandidates = (
1373
+ await Promise.all(
1374
+ documents.map((document) =>
1375
+ tokenCandidates(
1376
+ group,
1377
+ document,
1378
+ normalized.source,
1379
+ normalized.lines,
1380
+ normalized.lineStarts,
1381
+ path,
1382
+ signal,
1383
+ ),
1384
+ ),
1385
+ )
1386
+ ).flat();
1387
+ return deduplicateCandidates([...lineLevelCandidates, ...structuralCandidates]);
1388
+ }
1389
+
1390
+ async function requestedReplacementCandidates(
1391
+ group: EditGroup,
1392
+ normalized: ReturnType<typeof normalizedSource>,
1393
+ path: string,
1394
+ documentCache: Map<string, Promise<StructuralDocument[]>>,
1395
+ signal?: AbortSignal,
1396
+ ): Promise<EditCandidate[]> {
1397
+ if (group.oldLines.length === 0 || group.newLines.length === 0) return [];
1398
+ return formatterCandidates(
1399
+ {
1400
+ ...group,
1401
+ oldLines: group.newLines,
1402
+ newLines: group.newLines,
1403
+ },
1404
+ normalized,
1405
+ path,
1406
+ documentCache,
1407
+ signal,
1408
+ );
1409
+ }
1410
+
1411
+ async function deriveFormatterTolerantContent(
1412
+ content: string,
1413
+ chunks: readonly UpdateChunk[],
1414
+ path: string,
1415
+ signal?: AbortSignal,
1416
+ ): Promise<string | undefined> {
1417
+ const groups = editGroups(chunks);
1418
+ if (groups.length === 0) return undefined;
1419
+ const normalized = normalizedSource(content);
1420
+ const documentCache = new Map<string, Promise<StructuralDocument[]>>();
1421
+ const candidates: EditCandidate[][] = [];
1422
+
1423
+ for (const [groupIndex, group] of groups.entries()) {
1424
+ throwIfAborted(signal);
1425
+ const groupCandidates = await formatterCandidates(
1426
+ group,
1427
+ normalized,
1428
+ path,
1429
+ documentCache,
1430
+ signal,
1431
+ );
1432
+ const eligible = enforceCandidateLimit(groupCandidates, path, group, groupIndex, groups.length);
1433
+ if (eligible.length === 0) {
1434
+ const replacements = await requestedReplacementCandidates(
1435
+ group,
1436
+ normalized,
1437
+ path,
1438
+ documentCache,
1439
+ signal,
1440
+ );
1441
+ const excerpt = oldExcerpt(group);
1442
+ const details: FormatterMatchFailureDetails = {
1443
+ reason: "no-candidate",
1444
+ path,
1445
+ groupCount: groups.length,
1446
+ groupIndex: groupIndex + 1,
1447
+ chunkCount: group.chunkCount,
1448
+ chunkIndex: group.chunkIndex,
1449
+ candidateCount: 0,
1450
+ candidates: [],
1451
+ ...(replacements.length > 0
1452
+ ? {
1453
+ replacementCandidateCount: replacements.length,
1454
+ replacementCandidates: candidateRanges(replacements),
1455
+ }
1456
+ : {}),
1457
+ ...(excerpt ? { oldExcerpt: excerpt } : {}),
1458
+ };
1459
+ throw new FormatterMatchError(formatFormatterMatchFailure(details), details);
1460
+ }
1461
+ candidates.push(eligible);
1462
+ }
1463
+
1464
+ let outputs: Map<string, Buffer>;
1465
+ let exhaustive: boolean;
1466
+ try {
1467
+ ({ outputs, exhaustive } = distinctMappedOutputs(normalized.source, candidates, signal));
1468
+ } catch (error) {
1469
+ if (!(error instanceof OverlappingFormatterEditsError)) throw error;
1470
+ const details: FormatterMatchFailureDetails = {
1471
+ reason: "overlapping-edits",
1472
+ path,
1473
+ groupCount: groups.length,
1474
+ candidateCount: candidates.reduce((count, group) => count + group.length, 0),
1475
+ candidates: candidateRanges(candidates.flat()),
1476
+ overlapping: true,
1477
+ };
1478
+ throw new FormatterMatchAmbiguityError(formatFormatterMatchFailure(details), details);
1479
+ }
1480
+ if (outputs.size === 0) throw noOrderedMappingDetails(path, groups, candidates);
1481
+ if (outputs.size > 1) {
1482
+ const details: FormatterMatchFailureDetails = {
1483
+ reason: "ambiguous-output",
1484
+ path,
1485
+ groupCount: groups.length,
1486
+ candidateCount: candidates.reduce((count, group) => count + group.length, 0),
1487
+ candidates: candidateRanges(candidates.flat()),
1488
+ };
1489
+ throw new FormatterMatchAmbiguityError(formatFormatterMatchFailure(details), details);
1490
+ }
1491
+ if (!exhaustive) {
1492
+ const details: FormatterMatchFailureDetails = {
1493
+ reason: "mapping-limit",
1494
+ path,
1495
+ groupCount: groups.length,
1496
+ candidateCount: candidates.reduce((count, group) => count + group.length, 0),
1497
+ candidates: candidateRanges(candidates.flat()),
1498
+ };
1499
+ throw new FormatterMatchAmbiguityError(formatFormatterMatchFailure(details), details);
1500
+ }
1501
+ return outputs.values().next().value!.toString("utf8");
1502
+ }
1503
+
1504
+ function isContextMismatch(error: unknown): boolean {
1505
+ return (
1506
+ error instanceof Error &&
1507
+ (error.message.startsWith("Failed to find context") ||
1508
+ error.message.startsWith("Failed to find expected lines"))
1509
+ );
1510
+ }
1511
+
1512
+ export async function deriveNewContent(
1513
+ content: string,
1514
+ chunks: readonly UpdateChunk[],
1515
+ path: string,
1516
+ signal?: AbortSignal,
1517
+ ): Promise<string> {
1518
+ throwIfAborted(signal);
1519
+ try {
1520
+ return deriveStrictContent(content, chunks, path);
1521
+ } catch (error) {
1522
+ if (!isContextMismatch(error)) throw error;
1523
+ try {
1524
+ const tolerant = await deriveFormatterTolerantContent(content, chunks, path, signal);
1525
+ if (tolerant !== undefined) return tolerant;
1526
+ } catch (matcherError) {
1527
+ if (!(matcherError instanceof FormatterMatchError)) throw matcherError;
1528
+ throw new FormatterMatchError(
1529
+ `${error instanceof Error ? error.message : String(error)}\nMatcher diagnostics: ${matcherError.message}`,
1530
+ matcherError.details,
1531
+ );
1532
+ }
1533
+ throw error;
1534
+ }
1535
+ }