@oh-my-pi/hashline 17.2.14 → 17.3.0

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.
package/src/apply.ts CHANGED
@@ -3,23 +3,26 @@
3
3
  * post-edit lines plus any diagnostic warnings. Pure function: no FS, no
4
4
  * mutation of the input.
5
5
  *
6
- * Replacement groups are first normalized by {@link repairReplacementBoundaries},
7
- * which absorbs common model mistakes where a payload restates unchanged range
8
- * boundaries or duplicates/drops structural closers.
6
+ * Mis-set replacement range boundaries are repaired by bounded candidate
7
+ * search. Exact line equality, indentation, tree-sitter structure, and a
8
+ * narrow pure-closer shape gate constrain candidates; tree-sitter validates
9
+ * the selected result.
9
10
  */
10
11
 
11
12
  import { resolveClipboardEdits } from "./clipboard";
12
13
  import {
13
14
  afterInsertLandingShiftWarning,
14
15
  ambiguousBoundaryEchoMessage,
15
- ambiguousCloserSpareMessage,
16
- ambiguousLeadingCloserSpareMessage,
16
+ ambiguousBoundaryPlacementMessage,
17
17
  blockInsertLandingShiftWarning,
18
- midBlockRangeWarning,
18
+ boundaryVariantRepairWarning,
19
+ editBrokeParseWarning,
19
20
  REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
21
+ textualBoundaryEchoWarning,
20
22
  UNRESOLVED_BLOCK_INTERNAL,
23
+ UNRESOLVED_CLIPBOARD_INTERNAL,
21
24
  } from "./messages";
22
- import { parsesCleanly } from "./syntax";
25
+ import { enclosingBoundaries, parsesCleanly } from "./syntax";
23
26
  import { cloneCursor } from "./tokenizer";
24
27
  import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
25
28
 
@@ -29,6 +32,14 @@ type InsertEdit = Extract<Edit, { kind: "insert" }>;
29
32
  type DeleteEdit = Extract<Edit, { kind: "delete" }>;
30
33
  type AppliedEdit = InsertEdit | DeleteEdit;
31
34
 
35
+ function insertEditAt(edits: readonly AppliedEdit[], index: number): InsertEdit {
36
+ const edit = edits[index];
37
+ if (edit?.kind !== "insert") {
38
+ throw new Error("internal error: after-insert group contains a non-insert edit");
39
+ }
40
+ return edit;
41
+ }
42
+
32
43
  interface IndexedEdit {
33
44
  edit: AppliedEdit;
34
45
  idx: number;
@@ -123,237 +134,25 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
123
134
  }
124
135
  return byLine;
125
136
  }
126
- /**
127
- * A closer-spare repair could not tell which side of a spared delimiter the
128
- * payload belongs on. Distinct from the evidence-complete textual rejections
129
- * (a one-sided boundary echo) so {@link applyEdits} can withhold *only* this
130
- * delimiter-semantics verdict on a file the parser cannot vouch for, while
131
- * every other rejection propagates unconditionally.
132
- */
133
- class CloserSpareAmbiguityError extends Error {
134
- constructor(message: string) {
135
- super(message);
136
- this.name = "CloserSpareAmbiguityError";
137
- }
138
- }
139
-
140
137
  // ═══════════════════════════════════════════════════════════════════════════
141
138
  // Replacement-boundary repair
142
139
  //
143
- // Models routinely miscount a replacement range's edges. Sometimes the payload
144
- // re-states unchanged lines that still live on both sides of the range
145
- // (duplicating a function header and final statement); sometimes it only
146
- // re-states or omits a structural closer, which leaves delimiter balance broken.
140
+ // Models routinely miscount replacement edges: the range swallows an unchanged
141
+ // boundary row, or the payload restates rows that survive just outside it.
142
+ // Exact outside echoes are normalized from line equality alone. If the authored
143
+ // result still does not parse, a bounded whole-patch search may retain the
144
+ // selected range's first or effective-last row and may combine that retention
145
+ // with exact echo removal.
147
146
  //
148
- // A balance-neutral boundary-echo repair fires only when both the leading and
149
- // trailing payload edges are exact copies of the surviving lines outside the
150
- // range. One-sided content echoes are left alone unless delimiter-balance repair
151
- // proves they are duplicated structural boundaries. This preserves intended
152
- // duplicate statements while absorbing the common "body includes the unchanged
153
- // wrapper" mistake.
147
+ // Retention never follows parse success alone. On a valid baseline, deleting
148
+ // the row must itself break syntax; every candidate also requires source-range
149
+ // structure and indentation evidence. Distinct candidate texts tied at the
150
+ // minimum repair cost are rejected rather than guessed. Pure structural-closer
151
+ // rows are recognized only to verify sibling-depth placement.
154
152
 
155
153
  /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
156
154
  export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
157
155
 
158
- /** A JSX/XML closing boundary that carries structure but no bracket tokens. */
159
- const JSX_CLOSER_RE = /^\s*(?:<\/>|<\/[A-Za-z][\w.:-]*>|\/>)\s*[;,]?\s*$/;
160
- const JSX_NAMED_CLOSER_RE = /^\s*<\/([A-Za-z][\w.:-]*)>\s*[;,]?\s*$/;
161
- const JSX_FRAGMENT_CLOSER_RE = /^\s*<\/>\s*[;,]?\s*$/;
162
-
163
- function isStructuralCloserLine(text: string): boolean {
164
- return STRUCTURAL_CLOSER_RE.test(text) || JSX_CLOSER_RE.test(text);
165
- }
166
-
167
- function jsxCloserName(text: string): string | undefined {
168
- if (JSX_FRAGMENT_CLOSER_RE.test(text)) return "";
169
- const match = JSX_NAMED_CLOSER_RE.exec(text);
170
- return match?.[1];
171
- }
172
-
173
- interface JsxPayloadTag {
174
- readonly name: string;
175
- readonly closing: boolean;
176
- readonly selfClosing: boolean;
177
- }
178
-
179
- function isJsxTagStart(text: string, index: number): boolean {
180
- const next = text[index + 1];
181
- return next === ">" || next === "/" || (next >= "A" && next <= "Z") || (next >= "a" && next <= "z");
182
- }
183
-
184
- function findJsxTagEnd(text: string, start: number): number {
185
- let quote: string | undefined;
186
- let braces = 0;
187
- for (let i = start + 1; i < text.length; i++) {
188
- const ch = text[i];
189
- if (quote) {
190
- if (ch === "\\" && i + 1 < text.length) {
191
- i++;
192
- } else if (ch === quote) {
193
- quote = undefined;
194
- }
195
- continue;
196
- }
197
- if (ch === '"' || ch === "'" || ch === "`") {
198
- quote = ch;
199
- } else if (ch === "{") {
200
- braces++;
201
- } else if (ch === "}" && braces > 0) {
202
- braces--;
203
- } else if (ch === ">" && braces === 0) {
204
- return i;
205
- }
206
- }
207
- return -1;
208
- }
209
-
210
- function parseJsxPayloadTag(raw: string): JsxPayloadTag | undefined {
211
- if (raw === "<>") return { name: "", closing: false, selfClosing: false };
212
- if (raw === "</>") return { name: "", closing: true, selfClosing: false };
213
- const closing = raw.startsWith("</");
214
- const nameStart = closing ? 2 : 1;
215
- let nameEnd = nameStart;
216
- while (nameEnd < raw.length && /[\w.:-]/.test(raw[nameEnd])) nameEnd++;
217
- if (nameEnd === nameStart) return undefined;
218
- return {
219
- name: raw.slice(nameStart, nameEnd),
220
- closing,
221
- selfClosing: !closing && /\/>\s*$/.test(raw),
222
- };
223
- }
224
-
225
- function readJsxPayloadTags(text: string): JsxPayloadTag[] {
226
- const tags: JsxPayloadTag[] = [];
227
- for (let start = text.indexOf("<"); start >= 0; start = text.indexOf("<", start + 1)) {
228
- if (!isJsxTagStart(text, start)) continue;
229
- const end = findJsxTagEnd(text, start);
230
- if (end < 0) break;
231
- const tag = parseJsxPayloadTag(text.slice(start, end + 1));
232
- if (tag) tags.push(tag);
233
- start = end;
234
- }
235
- return tags;
236
- }
237
-
238
- function payloadHasJsxOpenerForEcho(payloadPrefix: readonly string[], echoLines: readonly string[]): boolean {
239
- const openTags: string[] = [];
240
- for (const tag of readJsxPayloadTags(payloadPrefix.join("\n"))) {
241
- if (tag.closing) {
242
- if (openTags[openTags.length - 1] === tag.name) openTags.pop();
243
- } else if (!tag.selfClosing) {
244
- openTags.push(tag.name);
245
- }
246
- }
247
- for (const line of echoLines) {
248
- const name = jsxCloserName(line);
249
- if (name !== undefined && openTags.includes(name)) return true;
250
- }
251
- return false;
252
- }
253
-
254
- interface DelimiterBalance {
255
- paren: number;
256
- bracket: number;
257
- brace: number;
258
- }
259
-
260
- /**
261
- * Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
262
- * comments (`//`), block comments, and string/template literals. Block-comment
263
- * and backtick-template state carry across lines; `"` / `'` reset at EOL since
264
- * they cannot span lines. Deliberately language-light: constructs it cannot
265
- * classify (e.g. regex literals) are counted naively, which can only suppress a
266
- * repair (the safe direction), never force one.
267
- */
268
- function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
269
- const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
270
- let inBlockComment = false;
271
- let quote = "";
272
- for (const line of lines) {
273
- for (let i = 0; i < line.length; i++) {
274
- const ch = line[i];
275
- if (inBlockComment) {
276
- if (ch === "*" && line[i + 1] === "/") {
277
- inBlockComment = false;
278
- i++;
279
- }
280
- continue;
281
- }
282
- if (quote) {
283
- if (ch === "\\") i++;
284
- else if (ch === quote) quote = "";
285
- continue;
286
- }
287
- if (ch === '"' || ch === "'" || ch === "`") {
288
- quote = ch;
289
- continue;
290
- }
291
- if (ch === "/" && line[i + 1] === "/") break;
292
- if (ch === "/" && line[i + 1] === "*") {
293
- inBlockComment = true;
294
- i++;
295
- continue;
296
- }
297
- switch (ch) {
298
- case "(":
299
- balance.paren++;
300
- break;
301
- case ")":
302
- balance.paren--;
303
- break;
304
- case "[":
305
- balance.bracket++;
306
- break;
307
- case "]":
308
- balance.bracket--;
309
- break;
310
- case "{":
311
- balance.brace++;
312
- break;
313
- case "}":
314
- balance.brace--;
315
- break;
316
- }
317
- }
318
- // `"` / `'` cannot span lines; only backtick templates and block comments do.
319
- if (quote === '"' || quote === "'") quote = "";
320
- }
321
- return balance;
322
- }
323
-
324
- function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
325
- return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace };
326
- }
327
-
328
- function balanceNegate(a: DelimiterBalance): DelimiterBalance {
329
- return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace };
330
- }
331
-
332
- function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
333
- return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
334
- }
335
-
336
- function balanceIsZero(a: DelimiterBalance): boolean {
337
- return a.paren === 0 && a.bracket === 0 && a.brace === 0;
338
- }
339
-
340
- function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
341
- return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace };
342
- }
343
-
344
- function balanceComponentCovers(candidate: number, target: number): boolean {
345
- if (target === 0) return true;
346
- return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target);
347
- }
348
-
349
- function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean {
350
- return (
351
- balanceComponentCovers(candidate.paren, target.paren) &&
352
- balanceComponentCovers(candidate.bracket, target.bracket) &&
353
- balanceComponentCovers(candidate.brace, target.brace)
354
- );
355
- }
356
-
357
156
  interface ReplacementGroup {
358
157
  /** Positions in the edit array of the payload inserts, in payload order. */
359
158
  insertIndices: number[];
@@ -467,797 +266,575 @@ function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly
467
266
  return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : [];
468
267
  }
469
268
 
470
- /**
471
- * Largest `k` such that the payload's last `k` lines exactly equal the `k`
472
- * surviving file lines just below the range AND dropping them zeroes `delta`.
473
- * Requires a non-zero `delta`: a zero-balance candidate can never account for
474
- * the imbalance, so intentional duplicates of ordinary statements stay intact,
475
- * while duplicated structural lines (closers like `});`, openers like `foo(`)
476
- * are dropped when they exactly explain the imbalance.
477
- */
478
- function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
479
- if (balanceIsZero(delta)) return 0;
480
- const { payload, endLine } = group;
481
- const maxK = Math.min(payload.length, fileLines.length - endLine);
482
- for (let k = maxK; k >= 1; k--) {
483
- let matches = true;
484
- for (let t = 0; t < k; t++) {
485
- if (payload[payload.length - k + t] !== fileLines[endLine + t]) {
486
- matches = false;
487
- break;
488
- }
489
- }
490
- if (!matches) continue;
491
- if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k;
269
+ function hasNonWhitespace(text: string): boolean {
270
+ for (let i = 0; i < text.length; i++) {
271
+ const code = text.charCodeAt(i);
272
+ if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
492
273
  }
493
- return 0;
274
+ return false;
494
275
  }
495
276
 
496
- /**
497
- * Largest `j` such that the payload's first `j` lines exactly equal the `j`
498
- * surviving file lines just above the range AND dropping them zeroes `delta`.
499
- * Requires a non-zero `delta`; see {@link findDuplicateSuffix}.
500
- */
501
- function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
502
- if (balanceIsZero(delta)) return 0;
277
+ function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
503
278
  const { payload, startLine } = group;
504
- const maxJ = Math.min(payload.length, startLine - 1);
505
- for (let j = maxJ; j >= 1; j--) {
279
+ const max = Math.min(payload.length, startLine - 1);
280
+ for (let count = max; count >= 1; count--) {
506
281
  let matches = true;
507
- for (let t = 0; t < j; t++) {
508
- if (payload[t] !== fileLines[startLine - 1 - j + t]) {
282
+ let hasContent = false;
283
+ for (let offset = 0; offset < count; offset++) {
284
+ const line = payload[offset];
285
+ if (line !== fileLines[startLine - 1 - count + offset]) {
509
286
  matches = false;
510
287
  break;
511
288
  }
289
+ hasContent ||= hasNonWhitespace(line);
512
290
  }
513
- if (!matches) continue;
514
- if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
291
+ if (matches && hasContent) return count;
515
292
  }
516
293
  return 0;
517
294
  }
518
- interface DroppedSuffixClosers {
519
- readonly startLine: number;
520
- readonly count: number;
521
- readonly balance: DelimiterBalance;
522
- }
523
295
 
524
- function countPayloadRestatedSuffixHead(payload: readonly string[], suffixLines: readonly string[]): number {
525
- const maxCount = Math.min(payload.length, suffixLines.length);
526
- for (let count = maxCount; count >= 1; count--) {
296
+ function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
297
+ const { payload, endLine } = group;
298
+ const max = Math.min(payload.length, fileLines.length - endLine);
299
+ for (let count = max; count >= 1; count--) {
527
300
  let matches = true;
301
+ let hasContent = false;
528
302
  for (let offset = 0; offset < count; offset++) {
529
- if (payload[payload.length - count + offset] !== suffixLines[offset]) {
303
+ const line = payload[payload.length - count + offset];
304
+ if (line !== fileLines[endLine + offset]) {
530
305
  matches = false;
531
306
  break;
532
307
  }
308
+ hasContent ||= hasNonWhitespace(line);
533
309
  }
534
- if (matches) return count;
310
+ if (matches && hasContent) return count;
535
311
  }
536
312
  return 0;
537
313
  }
314
+ interface TextualBoundaryAmbiguity {
315
+ readonly startLine: number;
316
+ readonly endLine: number;
317
+ readonly side: "leading" | "trailing";
318
+ readonly count: number;
319
+ }
538
320
 
539
- function countProjectedBelowSuffixTail(
540
- group: ReplacementGroup,
321
+ interface TextualBoundaryNormalization {
322
+ readonly edits: AppliedEdit[];
323
+ readonly warnings: string[];
324
+ readonly ambiguities: TextualBoundaryAmbiguity[];
325
+ }
326
+
327
+ /**
328
+ * Normalize exact boundary echoes without interpreting language tokens.
329
+ *
330
+ * Two-sided echoes are removed when stripping both copies leaves one payload
331
+ * row per deleted range line. One-sided echoes on multi-line ranges are
332
+ * removed when the remaining payload still covers the full range; an
333
+ * under-filled one-sided echo is recorded as ambiguous so the syntax-probe
334
+ * search gets first chance to resolve it, then rejected rather than silently
335
+ * dropping unique range content.
336
+ */
337
+ function normalizeTextualBoundaryEchoes(
338
+ edits: readonly AppliedEdit[],
541
339
  fileLines: readonly string[],
542
- deletedLines: ReadonlySet<number>,
543
- insertedLineMaps: InsertedLineMaps,
544
- suffixLines: readonly string[],
545
- ): number {
546
- const below: string[] = [];
547
- const appendCloserLines = (lines: readonly string[] | undefined): boolean => {
548
- if (!lines) return true;
549
- for (const text of lines) {
550
- if (!STRUCTURAL_CLOSER_RE.test(text)) return false;
551
- below.push(text);
552
- }
553
- return true;
554
- };
555
- if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0;
556
- for (let line = group.endLine + 1; line <= fileLines.length; line++) {
557
- if (!appendCloserLines(insertedLineMaps.before.get(line))) break;
558
- if (!deletedLines.has(line)) {
559
- const text = fileLines[line - 1] ?? "";
560
- if (!STRUCTURAL_CLOSER_RE.test(text)) break;
561
- below.push(text);
340
+ ): TextualBoundaryNormalization {
341
+ const out: AppliedEdit[] = [];
342
+ const warnings: string[] = [];
343
+ const ambiguities: TextualBoundaryAmbiguity[] = [];
344
+ let i = 0;
345
+ while (i < edits.length) {
346
+ const group = findReplacementGroup(edits, i);
347
+ if (!group) {
348
+ out.push(cloneAppliedEdit(edits[i], i));
349
+ i++;
350
+ continue;
562
351
  }
563
- if (!appendCloserLines(insertedLineMaps.after.get(line))) break;
564
- }
565
- const maxCount = Math.min(below.length, suffixLines.length);
566
- for (let count = maxCount; count >= 1; count--) {
567
- let matches = true;
568
- for (let offset = 0; offset < count; offset++) {
569
- if (below[offset] !== suffixLines[suffixLines.length - count + offset]) {
570
- matches = false;
571
- break;
352
+ const inserts = replacementInserts(group, edits);
353
+ const deletes = replacementDeletes(group, edits);
354
+ const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
355
+ const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
356
+ const rangeLength = group.deleteIndices.length;
357
+ let dropLeading = 0;
358
+ let dropTrailing = 0;
359
+ if (leading > 0 && trailing > 0) {
360
+ if (group.payload.length - leading - trailing === rangeLength) {
361
+ dropLeading = leading;
362
+ dropTrailing = trailing;
363
+ }
364
+ } else if (leading > 0 && rangeLength > 1) {
365
+ if (group.payload.length - leading >= rangeLength) {
366
+ dropLeading = leading;
367
+ } else {
368
+ ambiguities.push({
369
+ startLine: group.startLine,
370
+ endLine: group.endLine,
371
+ side: "leading",
372
+ count: leading,
373
+ });
374
+ }
375
+ } else if (trailing > 0 && rangeLength > 1) {
376
+ if (group.payload.length - trailing >= rangeLength) {
377
+ dropTrailing = trailing;
378
+ } else {
379
+ ambiguities.push({
380
+ startLine: group.startLine,
381
+ endLine: group.endLine,
382
+ side: "trailing",
383
+ count: trailing,
384
+ });
572
385
  }
573
386
  }
574
- if (matches) return count;
387
+ if (dropLeading > 0 || dropTrailing > 0) {
388
+ out.push(...inserts.slice(dropLeading, inserts.length - dropTrailing), ...deletes);
389
+ warnings.push(textualBoundaryEchoWarning(group.startLine, dropLeading, dropTrailing));
390
+ } else {
391
+ for (const idx of group.insertIndices) out.push(cloneAppliedEdit(edits[idx], idx));
392
+ for (const idx of group.deleteIndices) out.push(cloneAppliedEdit(edits[idx], idx));
393
+ }
394
+ i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
575
395
  }
576
- return 0;
396
+ return { edits: out, warnings, ambiguities };
577
397
  }
578
398
 
579
- interface InsertedLineMaps {
580
- readonly before: ReadonlyMap<number, readonly string[]>;
581
- readonly after: ReadonlyMap<number, readonly string[]>;
399
+ interface KeepPlan {
400
+ readonly beforeLine?: number;
401
+ readonly afterLine?: number;
402
+ readonly kept: number;
582
403
  }
583
404
 
584
- function computeProjectedPrefixBalance(
585
- group: ReplacementGroup,
586
- fileLines: readonly string[],
587
- deletedLines: ReadonlySet<number>,
588
- insertedByLine: ReadonlyMap<number, readonly string[]>,
589
- insertedLineMaps: InsertedLineMaps,
590
- ): DelimiterBalance {
591
- const prefix: string[] = [];
592
- for (let line = 1; line < group.startLine; line++) {
593
- const inserted = insertedByLine.get(line);
594
- if (inserted) prefix.push(...inserted);
595
- if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? "");
596
- }
597
- const insertedAtStart = insertedLineMaps.before.get(group.startLine);
598
- if (insertedAtStart) prefix.push(...insertedAtStart);
599
- prefix.push(...group.payload);
600
- return computeDelimiterBalance(prefix);
405
+ interface GroupVariant {
406
+ readonly edits: AppliedEdit[];
407
+ /** Original boundary rows retained from the selected range. */
408
+ readonly kept: number;
409
+ /** Exact payload echoes removed from outside the selected range. */
410
+ readonly dropped: number;
601
411
  }
602
412
 
603
- function prefixCanCoverSuffixClosers(
604
- group: ReplacementGroup,
605
- fileLines: readonly string[],
606
- suffixBalance: DelimiterBalance,
607
- coveredBelowBalance: DelimiterBalance,
608
- deletedLines: ReadonlySet<number>,
609
- insertedByLine: ReadonlyMap<number, readonly string[]>,
610
- insertedLineMaps: InsertedLineMaps,
611
- ): boolean {
612
- const neededOpeners = balanceNegate(suffixBalance);
613
- const prefixBalance = computeProjectedPrefixBalance(
614
- group,
615
- fileLines,
616
- deletedLines,
617
- insertedByLine,
618
- insertedLineMaps,
619
- );
620
- const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance);
621
- return balanceCovers(uncoveredPrefixBalance, neededOpeners);
413
+ interface GroupVariants {
414
+ readonly variants: GroupVariant[];
415
+ readonly ambiguous: boolean;
622
416
  }
623
417
 
624
- /**
625
- * Missing segment of the range's deleted structural-closer suffix that should
626
- * be spared. Payload lines that already restate the suffix head are not kept
627
- * again, and projected closers immediately below the range satisfy the suffix
628
- * tail. The remaining middle segment is kept only when backed by unmatched
629
- * openers plus the whole-patch residual.
630
- */
631
- function findDroppedSuffixClosers(
632
- group: ReplacementGroup,
633
- fileLines: readonly string[],
634
- delta: DelimiterBalance,
635
- remainingDelta: DelimiterBalance,
636
- deletedPrefixBalance: DelimiterBalance,
637
- deletedLines: ReadonlySet<number>,
638
- insertedByLine: ReadonlyMap<number, readonly string[]>,
639
- insertedLineMaps: InsertedLineMaps,
640
- ): DroppedSuffixClosers | undefined {
641
- let suffixLength = 0;
642
- while (
643
- suffixLength < group.deleteIndices.length &&
644
- STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "")
645
- ) {
646
- suffixLength++;
647
- }
648
- if (suffixLength === 0) return undefined;
649
-
650
- const suffixStartLine = group.endLine - suffixLength + 1;
651
- const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine);
652
- const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines);
653
- const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines);
654
- const keepStart = restatedHead;
655
- const keepEnd = suffixLength - coveredTail;
656
- if (keepStart >= keepEnd) return undefined;
657
-
658
- const keptLines = suffixLines.slice(keepStart, keepEnd);
659
- const keptBalance = computeDelimiterBalance(keptLines);
660
- const neededOpeners = balanceNegate(keptBalance);
661
- const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd));
662
- if (!balanceCovers(delta, neededOpeners)) return undefined;
663
- if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
664
- if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
665
- if (
666
- !prefixCanCoverSuffixClosers(
667
- group,
668
- fileLines,
669
- keptBalance,
670
- coveredBelowBalance,
671
- deletedLines,
672
- insertedByLine,
673
- insertedLineMaps,
674
- )
675
- ) {
676
- return undefined;
418
+ const INDENT_TAB_WIDTH = 4;
419
+
420
+ function indentColumns(line: string): number {
421
+ let column = 0;
422
+ for (let i = 0; i < line.length; i++) {
423
+ const code = line.charCodeAt(i);
424
+ if (code === 32) {
425
+ column++;
426
+ } else if (code === 9) {
427
+ column += INDENT_TAB_WIDTH - (column % INDENT_TAB_WIDTH);
428
+ } else {
429
+ break;
430
+ }
677
431
  }
678
- return { startLine: suffixStartLine + keepStart, count: keepEnd - keepStart, balance: keptBalance };
432
+ return column;
679
433
  }
680
- interface DroppedPrefixClosers {
681
- readonly count: number;
682
- readonly balance: DelimiterBalance;
434
+
435
+ function nearestContentLine(fileLines: readonly string[], start: number, step: 1 | -1): string | undefined {
436
+ for (let index = start; index >= 0 && index < fileLines.length; index += step) {
437
+ const line = fileLines[index];
438
+ if (line !== undefined && hasNonWhitespace(line)) return line;
439
+ }
440
+ return undefined;
683
441
  }
684
442
 
685
- /**
686
- * Leading run of the range's deleted structural-closer line(s) that the
687
- * payload never restates — the mirror of {@link findDroppedSuffixClosers} for
688
- * the "range started one line early, on the `}` that ends the construct
689
- * above" mistake. Fires only when the group's own delta and the whole-patch
690
- * residual are both missing exactly those closers, no deleted lines above the
691
- * range account for their opener, and dangling opener(s) actually survive
692
- * above the range in the projected file.
693
- */
694
- function findDroppedPrefixClosers(
695
- group: ReplacementGroup,
696
- fileLines: readonly string[],
697
- delta: DelimiterBalance,
698
- remainingDelta: DelimiterBalance,
699
- deletedPrefixBalance: DelimiterBalance,
700
- deletedLines: ReadonlySet<number>,
701
- insertedByLine: ReadonlyMap<number, readonly string[]>,
702
- ): DroppedPrefixClosers | undefined {
703
- let prefixLength = 0;
704
- while (
705
- prefixLength < group.deleteIndices.length &&
706
- STRUCTURAL_CLOSER_RE.test(fileLines[group.startLine + prefixLength - 1] ?? "")
707
- ) {
708
- prefixLength++;
443
+ function payloadEdge(payload: readonly string[], side: "leading" | "trailing"): string | undefined {
444
+ if (side === "leading") {
445
+ for (const line of payload) {
446
+ if (hasNonWhitespace(line)) return line;
447
+ }
448
+ return undefined;
709
449
  }
710
- if (prefixLength === 0 || prefixLength >= group.deleteIndices.length) return undefined;
711
- // A payload that opens with a closer restates the boundary itself; that is
712
- // an echo/duplicate mistake with a different reading — leave it alone.
713
- if (group.payload.length === 0 || isStructuralCloserLine(group.payload[0])) return undefined;
714
- const prefixLines = fileLines.slice(group.startLine - 1, group.startLine - 1 + prefixLength);
715
- const balance = computeDelimiterBalance(prefixLines);
716
- if (balanceIsZero(balance)) return undefined;
717
- const neededOpeners = balanceNegate(balance);
718
- if (!balanceCovers(delta, neededOpeners)) return undefined;
719
- if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
720
- if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
721
- // The spared closers need dangling opener(s) above the range in the
722
- // projected file; the payload cannot supply them — it lands below the
723
- // closers either way.
724
- const above: string[] = [];
725
- for (let line = 1; line < group.startLine; line++) {
726
- const inserted = insertedByLine.get(line);
727
- if (inserted) above.push(...inserted);
728
- if (!deletedLines.has(line)) above.push(fileLines[line - 1] ?? "");
450
+ for (let index = payload.length - 1; index >= 0; index--) {
451
+ const line = payload[index];
452
+ if (line !== undefined && hasNonWhitespace(line)) return line;
729
453
  }
730
- if (!balanceCovers(computeDelimiterBalance(above), neededOpeners)) return undefined;
731
- return { count: prefixLength, balance };
454
+ return undefined;
732
455
  }
733
456
 
734
- /**
735
- * Total opening delimiters the range deletes without the payload reopening
736
- * them while their matching closer(s) survive below — the "payload is a
737
- * complete construct but the range ends mid-block" mistake, which orphans the
738
- * surviving closers. A balance-only signal, so it is advisory input rather
739
- * than proof: {@link applyEdits} surfaces it only once the tree-sitter probe
740
- * confirms the authored edit broke the file, which is what separates a real
741
- * mid-block range from a `}` living in prose or a regex literal. Zero when the
742
- * payload is itself net-closing (deliberate rebalancing of a broken file) or
743
- * when another hunk removes the surplus (whole-patch residual clean).
744
- */
745
- function countOrphanedOpeners(
746
- group: ReplacementGroup,
747
- delta: DelimiterBalance,
748
- remainingDelta: DelimiterBalance,
749
- fileLines: readonly string[],
750
- ): number {
751
- const deletedBalance = computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine));
752
- const payloadBalance = computeDelimiterBalance(group.payload);
753
- let orphaned = 0;
754
- for (const key of ["paren", "bracket", "brace"] as const) {
755
- if (payloadBalance[key] < 0) return 0;
756
- if (delta[key] >= 0 || deletedBalance[key] <= 0 || remainingDelta[key] >= 0) continue;
757
- orphaned += Math.min(-delta[key], deletedBalance[key], -remainingDelta[key]);
457
+ function replacementInserts(group: ReplacementGroup, edits: readonly AppliedEdit[]): InsertEdit[] {
458
+ const inserts: InsertEdit[] = [];
459
+ for (const index of group.insertIndices) {
460
+ const edit = edits[index];
461
+ if (edit?.kind === "insert") inserts.push(edit);
758
462
  }
759
- return orphaned;
760
- }
761
- interface BoundaryEcho {
762
- leading: number;
763
- trailing: number;
463
+ return inserts;
764
464
  }
765
- function hasNonWhitespace(text: string): boolean {
766
- for (let i = 0; i < text.length; i++) {
767
- const code = text.charCodeAt(i);
768
- if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
465
+
466
+ function replacementDeletes(group: ReplacementGroup, edits: readonly AppliedEdit[]): DeleteEdit[] {
467
+ const deletes: DeleteEdit[] = [];
468
+ for (const index of group.deleteIndices) {
469
+ const edit = edits[index];
470
+ if (edit?.kind === "delete") deletes.push(edit);
769
471
  }
770
- return false;
472
+ return deletes;
771
473
  }
772
474
 
773
- function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
774
- const { payload, startLine } = group;
775
- const max = Math.min(payload.length, startLine - 1);
776
- for (let count = max; count >= 1; count--) {
777
- let matches = true;
778
- let hasContent = false;
779
- for (let offset = 0; offset < count; offset++) {
780
- const line = payload[offset];
781
- if (line !== fileLines[startLine - 1 - count + offset]) {
782
- matches = false;
783
- break;
784
- }
785
- hasContent ||= hasNonWhitespace(line);
786
- }
787
- if (matches && hasContent) return count;
788
- }
789
- return 0;
475
+ function isSourceLineDeleted(edits: readonly AppliedEdit[], line: number): boolean {
476
+ return edits.some(edit => edit.kind === "delete" && edit.anchor.line === line);
790
477
  }
791
478
 
792
- function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
793
- const { payload, endLine } = group;
794
- const max = Math.min(payload.length, fileLines.length - endLine);
795
- for (let count = max; count >= 1; count--) {
796
- let matches = true;
797
- let hasContent = false;
798
- for (let offset = 0; offset < count; offset++) {
799
- const line = payload[payload.length - count + offset];
800
- if (line !== fileLines[endLine + offset]) {
801
- matches = false;
802
- break;
803
- }
804
- hasContent ||= hasNonWhitespace(line);
805
- }
806
- if (matches && hasContent) return count;
479
+ /**
480
+ * Ignore a deleted trailing row only when the identical next source row
481
+ * survives every hunk. The preceding deleted row then becomes the effective
482
+ * range edge without resurrecting arbitrary interior content.
483
+ */
484
+ function effectiveTrailingBoundary(
485
+ group: ReplacementGroup,
486
+ edits: readonly AppliedEdit[],
487
+ fileLines: readonly string[],
488
+ ): number {
489
+ let line = group.endLine;
490
+ let survivor = group.endLine + 1;
491
+ while (
492
+ line > group.startLine &&
493
+ survivor <= fileLines.length &&
494
+ !isSourceLineDeleted(edits, survivor) &&
495
+ fileLines[line - 1] === fileLines[survivor - 1]
496
+ ) {
497
+ line--;
498
+ survivor++;
807
499
  }
808
- return 0;
500
+ return line;
809
501
  }
810
502
 
811
- function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined {
812
- const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines);
813
- if (leadingMax === 0) return undefined;
814
- const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines);
815
- if (trailingMax === 0) return undefined;
816
- // Bail when every payload line could be claimed by a boundary echo: any
817
- // repair would strip explicit replacement content with no signal that the
818
- // payload was a mistake rather than an intentional duplication.
819
- if (leadingMax + trailingMax >= group.payload.length) return undefined;
820
- // Balance-neutrality guard (see header comment): the dropped echo lines must
821
- // either be delimiter-neutral on their own or exactly cancel the payload/range
822
- // balance delta. In brace-heavy code where bare closer lines repeat, an
823
- // "echo" that shifts delimiter balance is structural content the payload
824
- // placed intentionally — stripping it would corrupt the result.
825
- const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
826
- const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
827
- const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
828
- if (!balanceIsZero(droppedBalance)) {
829
- const delta = balanceDelta(
830
- computeDelimiterBalance(group.payload),
831
- computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
832
- );
833
- if (!balanceEqual(droppedBalance, delta)) return undefined;
834
- }
835
- return { leading: leadingMax, trailing: trailingMax };
503
+ /** Whether deleting one source row from an otherwise valid file breaks it. */
504
+ function isSyntaxEssentialRow(
505
+ fileLines: readonly string[],
506
+ path: string,
507
+ line: number,
508
+ baselineParses: boolean,
509
+ ): boolean {
510
+ if (!baselineParses) return true;
511
+ const without = [...fileLines.slice(0, line - 1), ...fileLines.slice(line)].join("\n");
512
+ return !parsesCleanly(path, without);
836
513
  }
837
514
 
838
- function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string {
839
- return (
840
- `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
841
- `dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` +
842
- `Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
843
- );
515
+ interface EdgeEvidence {
516
+ readonly first: boolean;
517
+ readonly last: boolean;
518
+ readonly leadingStructure: boolean;
844
519
  }
845
520
 
846
- function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
847
- return (
848
- `Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
849
- `Issue the payload as the final desired content only — never restate or omit a closing bracket bordering the range.`
850
- );
521
+ function edgeEvidence(
522
+ fileLines: readonly string[],
523
+ path: string,
524
+ group: ReplacementGroup,
525
+ trailingLine: number,
526
+ baselineParses: boolean,
527
+ ): EdgeEvidence {
528
+ if (!baselineParses) {
529
+ return { first: true, last: true, leadingStructure: false };
530
+ }
531
+ const first = isSyntaxEssentialRow(fileLines, path, group.startLine, true);
532
+ const last = trailingLine === group.startLine ? first : isSyntaxEssentialRow(fileLines, path, trailingLine, true);
533
+ const innerStart = group.startLine + 1;
534
+ const leadingStructure =
535
+ innerStart <= trailingLine &&
536
+ enclosingBoundaries(fileLines, path, innerStart, trailingLine).includes(group.startLine);
537
+ return { first, last, leadingStructure };
851
538
  }
852
539
 
853
540
  /**
854
- * A single-sided boundary echo in an otherwise delimiter-balanced *multi-line*
855
- * replacement: the payload's leading XOR trailing edge exactly restates the
856
- * surviving line(s) just outside the range the off-by-one "range one line
857
- * short of the keeper I retyped" mistake (e.g. att: payload ends with
858
- * `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
859
- * are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
860
- * by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
861
- *
862
- * Scoped broadly for multi-line ranges (a construct rewrite) because retouched
863
- * neutral keepers are usually boundary mistakes there. Single-line expansions
864
- * are riskier — ordinary duplicated statements may be intentional — so they are
865
- * only repaired when the duplicated edge is a structural closer line that
866
- * carries no delimiter-balance signal itself, such as a JSX `</section>` close.
867
- * The dropped lines must keep the already-balanced result balanced, and must
868
- * not consume the whole payload.
869
- *
870
- * A detected echo is only *repairable* when the payload is long enough to be
871
- * the widened range's full content (`payload ≥ range + echo`). Shorter
872
- * payloads are ambiguous — the echo may instead mean the range itself was
873
- * shifted by the echo, which keeps the far boundary line(s) the repair would
874
- * delete — and the caller rejects the edit instead of guessing.
541
+ * Retention is limited to the selected range's first and effective-last rows.
542
+ * On a valid baseline, deleting the row must break syntax; every candidate
543
+ * must also satisfy source-range structure or indentation evidence.
875
544
  */
876
- function findOneSidedBoundaryEcho(
545
+ function buildKeepPlans(
877
546
  group: ReplacementGroup,
547
+ trailingLine: number,
548
+ payload: readonly string[],
878
549
  fileLines: readonly string[],
879
- ): { side: "leading" | "trailing"; count: number } | undefined {
880
- const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
881
- const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
882
- if (leading > 0 === trailing > 0) return undefined;
883
- const side = leading > 0 ? "leading" : "trailing";
884
- const count = leading > 0 ? leading : trailing;
885
- if (count >= group.payload.length) return undefined;
886
- const echoLines =
887
- side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
888
- if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
889
- if (group.deleteIndices.length <= 1) {
890
- if (side !== "trailing" || !echoLines.every(isStructuralCloserLine)) return undefined;
891
- const payloadPrefix = group.payload.slice(0, group.payload.length - count);
892
- if (payloadHasJsxOpenerForEcho(payloadPrefix, echoLines)) return undefined;
550
+ evidence: EdgeEvidence,
551
+ path: string,
552
+ baselineParses: boolean,
553
+ ): { plans: KeepPlan[]; ambiguous: boolean } {
554
+ const leadingPayload = payloadEdge(payload, "leading");
555
+ const trailingPayload = payloadEdge(payload, "trailing");
556
+ const plans: KeepPlan[] = [{ kept: 0 }];
557
+ if (leadingPayload === undefined || trailingPayload === undefined) return { plans, ambiguous: false };
558
+
559
+ const first = fileLines[group.startLine - 1] ?? "";
560
+ const last = fileLines[trailingLine - 1] ?? "";
561
+ const leadingIndent = indentColumns(leadingPayload);
562
+ const trailingIndent = indentColumns(trailingPayload);
563
+ const firstIndent = indentColumns(first);
564
+ const lastIndent = indentColumns(last);
565
+ let ambiguous = false;
566
+
567
+ if (group.startLine === trailingLine) {
568
+ const previous = nearestContentLine(fileLines, group.startLine - 2, -1);
569
+ const fitsBefore = previous === undefined || indentColumns(previous) === trailingIndent;
570
+ if (evidence.first && fitsBefore && trailingIndent > firstIndent) {
571
+ plans.push({ afterLine: group.startLine, kept: 1 });
572
+ } else if (baselineParses && evidence.first && trailingIndent === firstIndent) {
573
+ ambiguous = true;
574
+ }
575
+ return { plans, ambiguous };
893
576
  }
894
- return { side, count };
895
- }
896
577
 
897
- function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string {
898
- const where = side === "leading" ? "above" : "below";
899
- return (
900
- `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
901
- `dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` +
902
- `The range was one line short of the content you retyped — issue the payload as the final content for the ` +
903
- `selected range only, and widen the range to consume any keeper you restate.`
578
+ const next = nearestContentLine(fileLines, group.startLine, 1);
579
+ const previous = nearestContentLine(fileLines, trailingLine - 2, -1);
580
+ const beforeFirst = nearestContentLine(fileLines, group.startLine - 2, -1);
581
+ const selectedLeadingBoundary = enclosingBoundaries(fileLines, path, group.startLine + 1, group.endLine).includes(
582
+ group.startLine,
904
583
  );
584
+ const firstText = (fileLines[group.startLine - 1] ?? "").trim();
585
+ const selectedStructuralEdge =
586
+ STRUCTURAL_CLOSER_RE.test(firstText) &&
587
+ firstIndent === leadingIndent &&
588
+ firstIndent === indentColumns(fileLines[group.endLine - 1] ?? "");
589
+ const underfilledEffectiveEdge =
590
+ trailingLine < group.endLine && payload.length < group.endLine - group.startLine + 1;
591
+ const keepsLeading =
592
+ evidence.first &&
593
+ (evidence.leadingStructure || selectedLeadingBoundary || selectedStructuralEdge || underfilledEffectiveEdge) &&
594
+ (next === undefined || selectedStructuralEdge
595
+ ? leadingIndent >= firstIndent
596
+ : indentColumns(next) === leadingIndent);
597
+ const keepsTrailing =
598
+ (evidence.last || underfilledEffectiveEdge) &&
599
+ !keepsLeading &&
600
+ trailingIndent > lastIndent &&
601
+ (previous === undefined || indentColumns(previous) === trailingIndent);
602
+ if (keepsLeading) plans.push({ beforeLine: group.startLine, kept: 1 });
603
+ if (keepsTrailing) plans.push({ afterLine: trailingLine, kept: 1 });
604
+ // Retaining both edges can silently resurrect an intentionally removed
605
+ // wrapper or signature; parsing cannot distinguish that from omission.
606
+ if (
607
+ baselineParses &&
608
+ evidence.first &&
609
+ beforeFirst !== undefined &&
610
+ firstIndent < indentColumns(beforeFirst) &&
611
+ leadingIndent > firstIndent
612
+ ) {
613
+ ambiguous = true;
614
+ }
615
+ return { plans, ambiguous };
905
616
  }
906
617
 
907
618
  /**
908
- * One pass-1 outcome per source position: resolved edits (with an optional
909
- * warning) or a deferred missing-closer candidate, resolved against the
910
- * whole-patch residual in pass 2.
619
+ * Enumerate repair hypotheses for one replacement group. Exact outside echoes
620
+ * may be removed; edge retention requires syntax, source structure,
621
+ * indentation, or the narrow pure-closer sibling-depth shape. Tree-sitter
622
+ * validates every candidate result.
911
623
  */
912
- type RepairSlot =
913
- | { kind: "edits"; edits: AppliedEdit[]; warning?: string }
914
- | {
915
- kind: "candidate";
916
- group: ReplacementGroup;
917
- inserts: AppliedEdit[];
918
- deletes: AppliedEdit[];
919
- delta: DelimiterBalance;
920
- };
921
-
922
- /**
923
- * Delimiter balance of the lines immediately above a group's range that are
924
- * themselves deleted by other hunks, netted against any payload inserted at
925
- * those lines. When this covers the group's own delta the matching opener was
926
- * deleted (or replaced by an opener of the same shape) just above — a deliberate
927
- * wrapper removal — so the range's deleted closer must stay deleted, not be
928
- * "kept". Scanned over its own contiguous lines so quote/comment state never
929
- * bleeds in from elsewhere in the patch.
930
- */
931
- function netDeletedPrefixBalance(
624
+ function buildGroupVariants(
932
625
  group: ReplacementGroup,
933
- deletedLines: ReadonlySet<number>,
934
- insertedByLine: ReadonlyMap<number, readonly string[]>,
626
+ edits: readonly AppliedEdit[],
935
627
  fileLines: readonly string[],
936
- ): DelimiterBalance {
937
- const deleted: string[] = [];
938
- const inserted: string[] = [];
939
- for (let line = group.startLine - 1; line >= 1 && deletedLines.has(line); line--) {
940
- deleted.unshift(fileLines[line - 1] ?? "");
941
- const insertedAtLine = insertedByLine.get(line);
942
- if (insertedAtLine) inserted.unshift(...insertedAtLine);
628
+ path: string,
629
+ baselineParses: boolean,
630
+ ): GroupVariants {
631
+ const inserts = replacementInserts(group, edits);
632
+ const deletes = replacementDeletes(group, edits);
633
+ const trailingLine = effectiveTrailingBoundary(group, edits, fileLines);
634
+ const evidence = edgeEvidence(fileLines, path, group, trailingLine, baselineParses);
635
+ const dropJ = countDuplicateLeadingBoundaryLines(group, fileLines);
636
+ const dropK = countDuplicateTrailingBoundaryLines(group, fileLines);
637
+ const leadingDrops = dropJ > 0 ? [0, dropJ] : [0];
638
+ const trailingDrops = dropK > 0 ? [0, dropK] : [0];
639
+ const variants: GroupVariant[] = [];
640
+ let ambiguous = false;
641
+
642
+ for (const leadingDrop of leadingDrops) {
643
+ for (const trailingDrop of trailingDrops) {
644
+ const dropped = leadingDrop + trailingDrop;
645
+ if (dropped >= inserts.length) continue;
646
+ const payload = group.payload.slice(leadingDrop, group.payload.length - trailingDrop);
647
+ const keepResult = buildKeepPlans(group, trailingLine, payload, fileLines, evidence, path, baselineParses);
648
+ ambiguous ||= keepResult.ambiguous;
649
+ for (const keep of keepResult.plans) {
650
+ if (keep.kept === 0 && dropped === 0) continue;
651
+ if (keep.kept > 0 && group.deleteIndices.length > 1 && payload.length > group.deleteIndices.length) {
652
+ continue;
653
+ }
654
+ variants.push({
655
+ kept: keep.kept,
656
+ dropped,
657
+ edits: applyGroupVariant(
658
+ inserts,
659
+ deletes,
660
+ keep.beforeLine,
661
+ keep.afterLine,
662
+ leadingDrop,
663
+ trailingDrop,
664
+ fileLines.length,
665
+ ),
666
+ });
667
+ }
668
+ }
669
+ }
670
+ variants.sort(compareGroupVariant);
671
+ return { variants, ambiguous };
672
+ }
673
+
674
+ function compareGroupVariant(a: GroupVariant, b: GroupVariant): number {
675
+ return a.kept - b.kept || a.dropped - b.dropped;
676
+ }
677
+
678
+ function applyGroupVariant(
679
+ inserts: readonly InsertEdit[],
680
+ deletes: readonly DeleteEdit[],
681
+ beforeLine: number | undefined,
682
+ afterLine: number | undefined,
683
+ dropLeading: number,
684
+ dropTrailing: number,
685
+ fileLineCount: number,
686
+ ): AppliedEdit[] {
687
+ let retainedInserts = inserts.slice(dropLeading, inserts.length - dropTrailing);
688
+ const retainedDeletes = deletes.filter(edit => edit.anchor.line !== beforeLine && edit.anchor.line !== afterLine);
689
+ if (beforeLine !== undefined) {
690
+ const cursor: Cursor =
691
+ beforeLine >= fileLineCount ? { kind: "eof" } : { kind: "before_anchor", anchor: { line: beforeLine + 1 } };
692
+ retainedInserts = retainedInserts.map(edit => ({ ...edit, cursor }));
943
693
  }
944
- return balanceDelta(computeDelimiterBalance(deleted), computeDelimiterBalance(inserted));
694
+ return [...retainedInserts, ...retainedDeletes];
945
695
  }
946
696
 
947
- /**
948
- * Net delimiter balance a slot contributes, computed over the slot's own
949
- * contiguous insert/delete lines only. Summing these per-slot deltas — never one
950
- * concatenated scan across non-adjacent hunks — keeps backtick/block-comment
951
- * state local, so an unterminated quote in one hunk cannot mask a real delimiter
952
- * in another.
953
- */
954
- function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): DelimiterBalance {
955
- if (slot.kind === "candidate") return slot.delta;
956
- const inserted: string[] = [];
957
- const deleted: string[] = [];
958
- for (const edit of slot.edits) {
959
- if (edit.kind === "insert") inserted.push(edit.text);
960
- else deleted.push(fileLines[edit.anchor.line - 1] ?? "");
961
- }
962
- return balanceDelta(computeDelimiterBalance(inserted), computeDelimiterBalance(deleted));
697
+ /** One choice per broken group; `null` keeps the group as authored. */
698
+ interface BoundaryCombo {
699
+ readonly variants: readonly (GroupVariant | null)[];
700
+ readonly touched: number;
701
+ readonly kept: number;
702
+ readonly dropped: number;
703
+ }
704
+
705
+ /** Combinatorial cap after each group is added to the candidate beam. */
706
+ const MAX_BOUNDARY_COMBOS = 512;
707
+
708
+ function compareBoundaryCombo(a: BoundaryCombo, b: BoundaryCombo): number {
709
+ return a.touched - b.touched || a.kept - b.kept || a.dropped - b.dropped;
963
710
  }
964
711
 
965
712
  /**
966
- * Normalize replacement groups so common off-by-one boundaries do not duplicate
967
- * unchanged surrounding lines or wrongly drop/keep structural closers. Local
968
- * repairs run in pass 1; the missing-closer repairs (a closer the range
969
- * deleted at its trailing or leading edge) are deferred to pass 2 and weighed
970
- * against the whole-patch delimiter residual, so a closer is only kept when
971
- * the patch as a whole is missing it — never when another hunk already
972
- * removed the matching opener.
973
- *
974
- * Textual repairs (boundary echoes, payload lines duplicated from just outside
975
- * the range) are evidence-complete on their own and always applied. The
976
- * closer-spare repairs are not: they claim a lone `}` is syntax. They run only
977
- * when `applySpares` is set, which {@link applyEdits} does only after the
978
- * tree-sitter probe shows the authored edits broke a file that previously
979
- * parsed. With `applySpares` false the same detections are reported through
980
- * `suspicious` / `advisories` and nothing is rewritten.
981
- *
982
- * When the spares do run, they fire only if exactly one reading explains the
983
- * mistake; ambiguous evidence — a one-sided echo whose payload is too short
984
- * for the widened range, a spared trailing closer the payload neither opens
985
- * nor indents into, or a spared leading closer whose payload claims the block
986
- * interior — throws instead of guessing, so the author re-issues the edit
987
- * rather than shipping silently corrupted content.
713
+ * Search boundary hypotheses across the whole patch. Parsing is the semantic
714
+ * filter; the deterministic cost prefers fewer touched groups, retained rows,
715
+ * then exact echo drops. Different texts tied at that full cost are not
716
+ * guessed.
988
717
  */
989
- function repairReplacementBoundaries(
718
+ function repairBoundaryVariants(
990
719
  edits: readonly AppliedEdit[],
991
720
  fileLines: readonly string[],
992
- applySpares: boolean,
993
- ): {
994
- edits: AppliedEdit[];
995
- warnings: string[];
996
- /** A delimiter-semantics anomaly was detected: worth a parse to confirm. */
997
- suspicious: boolean;
998
- /** A swallowed block closer was detected and a spare repair is available. */
999
- sparesProposed: boolean;
1000
- /** Diagnostics to surface only if the result is kept unrepaired. */
1001
- advisories: string[];
1002
- } {
1003
- // Pass 1: apply every repair whose correctness is local to one group
1004
- // (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
1005
- // it must weigh a group's imbalance against the whole patch, which is only
1006
- // known once the local repairs above have settled.
1007
- const slots: RepairSlot[] = [];
721
+ path: string | undefined,
722
+ baselineParses: boolean,
723
+ ): { edits: AppliedEdit[]; warnings: string[] } | undefined {
724
+ if (path === undefined) return undefined;
725
+
726
+ const groups: { group: ReplacementGroup; variants: GroupVariant[] }[] = [];
727
+ let ambiguousGroup: ReplacementGroup | undefined;
1008
728
  let i = 0;
1009
729
  while (i < edits.length) {
1010
730
  const group = findReplacementGroup(edits, i);
1011
- if (!group) {
1012
- slots.push({ kind: "edits", edits: [edits[i]] });
731
+ if (group) {
732
+ const built = buildGroupVariants(group, edits, fileLines, path, baselineParses);
733
+ if (built.ambiguous && ambiguousGroup === undefined) ambiguousGroup = group;
734
+ if (built.variants.length > 0) groups.push({ group, variants: built.variants });
735
+ i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
736
+ } else {
1013
737
  i++;
1014
- continue;
1015
738
  }
1016
- const inserts = group.insertIndices.map(idx => edits[idx]);
1017
- const deletes = group.deleteIndices.map(idx => edits[idx]);
1018
- i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
1019
-
1020
- const boundaryEcho = findBoundaryEcho(group, fileLines);
1021
- if (boundaryEcho) {
1022
- slots.push({
1023
- kind: "edits",
1024
- edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes],
1025
- warning: describeBoundaryEchoRepair(group, boundaryEcho),
1026
- });
1027
- continue;
739
+ }
740
+ if (groups.length === 0) {
741
+ if (ambiguousGroup) {
742
+ throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
1028
743
  }
744
+ return undefined;
745
+ }
1029
746
 
1030
- const delta = balanceDelta(
1031
- computeDelimiterBalance(group.payload),
1032
- computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
1033
- );
1034
- if (balanceIsZero(delta)) {
1035
- const oneSided = findOneSidedBoundaryEcho(group, fileLines);
1036
- if (oneSided) {
1037
- // A payload shorter than range+echo cannot be the widened
1038
- // range's full content: the repair would delete range line(s)
1039
- // the payload never restates, while the "shifted range"
1040
- // reading keeps them. Reject rather than guess.
1041
- if (group.payload.length < group.deleteIndices.length + oneSided.count) {
1042
- throw new Error(
1043
- ambiguousBoundaryEchoMessage(group.startLine, group.endLine, oneSided.side, oneSided.count),
1044
- );
1045
- }
1046
- const trimmed =
1047
- oneSided.side === "leading"
1048
- ? inserts.slice(oneSided.count)
1049
- : inserts.slice(0, inserts.length - oneSided.count);
1050
- slots.push({
1051
- kind: "edits",
1052
- edits: [...trimmed, ...deletes],
1053
- warning: describeOneSidedEchoRepair(group, oneSided.side, oneSided.count),
747
+ let combos: BoundaryCombo[] = [{ variants: [], touched: 0, kept: 0, dropped: 0 }];
748
+ for (const { variants } of groups) {
749
+ const next: BoundaryCombo[] = [];
750
+ for (const combo of combos) {
751
+ next.push({ ...combo, variants: [...combo.variants, null] });
752
+ for (const variant of variants) {
753
+ next.push({
754
+ variants: [...combo.variants, variant],
755
+ touched: combo.touched + 1,
756
+ kept: combo.kept + variant.kept,
757
+ dropped: combo.dropped + variant.dropped,
1054
758
  });
1055
- continue;
1056
759
  }
1057
- slots.push({ kind: "edits", edits: [...inserts, ...deletes] });
1058
- continue;
1059
- }
1060
-
1061
- const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
1062
- if (dupSuffix > 0) {
1063
- slots.push({
1064
- kind: "edits",
1065
- edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes],
1066
- warning: describeBoundaryRepair(
1067
- group,
1068
- `dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
1069
- ),
1070
- });
1071
- continue;
1072
- }
1073
- const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
1074
- if (dupPrefix > 0) {
1075
- slots.push({
1076
- kind: "edits",
1077
- edits: [...inserts.slice(dupPrefix), ...deletes],
1078
- warning: describeBoundaryRepair(
1079
- group,
1080
- `dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
1081
- ),
1082
- });
1083
- continue;
1084
760
  }
1085
- slots.push({ kind: "candidate", group, inserts, deletes, delta });
761
+ next.sort(compareBoundaryCombo);
762
+ combos = next.slice(0, MAX_BOUNDARY_COMBOS);
1086
763
  }
1087
764
 
1088
- const projected: AppliedEdit[] = [];
1089
- for (const slot of slots) {
1090
- projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits));
1091
- }
1092
- const deletedLines = new Set<number>();
1093
- for (const edit of projected) {
1094
- if (edit.kind === "delete") deletedLines.add(edit.anchor.line);
1095
- }
1096
- const insertedByLine = new Map<number, string[]>();
1097
- const insertedLineMaps: { before: Map<number, string[]>; after: Map<number, string[]> } = {
1098
- before: new Map(),
1099
- after: new Map(),
1100
- };
1101
- for (const edit of projected) {
1102
- if (edit.kind === "insert" && edit.cursor.kind === "bof") {
1103
- const inserted = insertedByLine.get(1);
1104
- if (inserted) inserted.push(edit.text);
1105
- else insertedByLine.set(1, [edit.text]);
1106
- const before = insertedLineMaps.before.get(1);
1107
- if (before) before.push(edit.text);
1108
- else insertedLineMaps.before.set(1, [edit.text]);
765
+ const authored = materializeEdits(
766
+ fileLines,
767
+ edits.map((edit, index) => cloneAppliedEdit(edit, index)),
768
+ ).text;
769
+ const candidates = combos.filter(combo => combo.touched > 0).sort(compareBoundaryCombo);
770
+
771
+ let bestText: string | undefined;
772
+ let bestCombo: BoundaryCombo | undefined;
773
+ for (const combo of candidates) {
774
+ if (bestCombo !== undefined && compareBoundaryCombo(combo, bestCombo) > 0) break;
775
+ const candidate = spliceBoundaryCombo(edits, groups, combo);
776
+ const text = materializeEdits(fileLines, candidate).text;
777
+ if (text === authored || !parsesCleanly(path, text)) continue;
778
+ if (bestCombo === undefined) {
779
+ bestCombo = combo;
780
+ bestText = text;
1109
781
  continue;
1110
782
  }
1111
- if (edit.kind !== "insert") continue;
1112
- for (const anchor of getCursorAnchors(edit.cursor)) {
1113
- const lines = insertedByLine.get(anchor.line);
1114
- if (lines) lines.push(edit.text);
1115
- else insertedByLine.set(anchor.line, [edit.text]);
783
+ if (text !== bestText) {
784
+ if (ambiguousGroup) {
785
+ throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
786
+ }
787
+ return undefined;
1116
788
  }
1117
- if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
1118
- const bySide = edit.cursor.kind === "before_anchor" ? insertedLineMaps.before : insertedLineMaps.after;
1119
- const lines = bySide.get(edit.cursor.anchor.line);
1120
- if (lines) lines.push(edit.text);
1121
- else bySide.set(edit.cursor.anchor.line, [edit.text]);
789
+ }
790
+ if (bestCombo === undefined) {
791
+ if (ambiguousGroup) {
792
+ throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine));
1122
793
  }
794
+ return undefined;
1123
795
  }
1124
- let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
1125
- for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
1126
796
 
1127
- const out: AppliedEdit[] = [];
1128
797
  const warnings: string[] = [];
1129
- const advisories: string[] = [];
1130
- let suspicious = false;
1131
- let sparesProposed = false;
1132
- for (const slot of slots) {
1133
- if (slot.kind !== "candidate") {
1134
- if (slot.warning !== undefined) warnings.push(slot.warning);
1135
- out.push(...slot.edits);
1136
- continue;
1137
- }
1138
- const deletedPrefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines);
1139
- const droppedClosers = findDroppedSuffixClosers(
1140
- slot.group,
1141
- fileLines,
1142
- slot.delta,
1143
- remainingDelta,
1144
- deletedPrefixBalance,
1145
- deletedLines,
1146
- insertedByLine,
1147
- insertedLineMaps,
1148
- );
1149
- if (droppedClosers) {
1150
- suspicious = true;
1151
- sparesProposed = true;
1152
- if (!applySpares) {
1153
- // A lone `}` is only syntax if the parser says so. Keep the
1154
- // authored edit; `sparesProposed` tells the caller a repair is
1155
- // available should the probe decline to vouch for it.
1156
- out.push(...slot.inserts, ...slot.deletes);
1157
- continue;
1158
- }
1159
- // Sparing a closer re-inserts it *after* the payload, which claims
1160
- // the payload lives inside the block the closer terminates. That
1161
- // claim needs evidence: the payload carries the closer's unmatched
1162
- // opener itself, or its indentation sits deeper than the closer.
1163
- // Without either, "before or after the closer" is a coin flip —
1164
- // reject rather than guess (e.g. a statement swapped onto a lone
1165
- // `}` at the closer's own depth belongs after the block).
1166
- const keptIndent = leadingIndent(fileLines[droppedClosers.startLine - 1] ?? "");
1167
- const payloadIndent = bodyTargetIndent(slot.group.payload);
1168
- const payloadOpens = balanceCovers(
1169
- computeDelimiterBalance(slot.group.payload),
1170
- balanceNegate(droppedClosers.balance),
1171
- );
1172
- if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) {
1173
- throw new CloserSpareAmbiguityError(
1174
- ambiguousCloserSpareMessage(
1175
- slot.group.startLine,
1176
- slot.group.endLine,
1177
- droppedClosers.startLine,
1178
- droppedClosers.count,
1179
- ),
1180
- );
1181
- }
1182
- warnings.push(
1183
- describeBoundaryRepair(
1184
- slot.group,
1185
- `kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`,
1186
- ),
1187
- );
1188
- out.push(
1189
- ...slot.inserts,
1190
- ...slot.deletes.filter(
1191
- edit =>
1192
- edit.kind !== "delete" ||
1193
- edit.anchor.line < droppedClosers.startLine ||
1194
- edit.anchor.line >= droppedClosers.startLine + droppedClosers.count,
1195
- ),
1196
- );
1197
- for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) {
1198
- deletedLines.delete(line);
1199
- }
1200
- remainingDelta = balanceSum(remainingDelta, droppedClosers.balance);
798
+ groups.forEach((entry, index) => {
799
+ const variant = bestCombo.variants[index];
800
+ if (variant) warnings.push(boundaryVariantRepairWarning(entry.group.startLine, variant.kept, variant.dropped));
801
+ });
802
+ return { edits: spliceBoundaryCombo(edits, groups, bestCombo), warnings };
803
+ }
804
+
805
+ /** Replace each group's authored edits with its combo variant (or the authored
806
+ * edits where the combo leaves a group untouched). Groups are keyed by their
807
+ * first insert index `findReplacementGroup` builds fresh objects per scan,
808
+ * so object identity cannot be the key. */
809
+ function spliceBoundaryCombo(
810
+ edits: readonly AppliedEdit[],
811
+ groups: readonly { group: ReplacementGroup; variants: GroupVariant[] }[],
812
+ combo: BoundaryCombo,
813
+ ): AppliedEdit[] {
814
+ const chosen = new Map<number, GroupVariant>();
815
+ groups.forEach((entry, idx) => {
816
+ const variant = combo.variants[idx];
817
+ if (variant) chosen.set(entry.group.insertIndices[0], variant);
818
+ });
819
+ const out: AppliedEdit[] = [];
820
+ let i = 0;
821
+ while (i < edits.length) {
822
+ const group = findReplacementGroup(edits, i);
823
+ if (!group) {
824
+ out.push(cloneAppliedEdit(edits[i], i));
825
+ i++;
1201
826
  continue;
1202
827
  }
1203
- const droppedPrefix = findDroppedPrefixClosers(
1204
- slot.group,
1205
- fileLines,
1206
- slot.delta,
1207
- remainingDelta,
1208
- deletedPrefixBalance,
1209
- deletedLines,
1210
- insertedByLine,
1211
- );
1212
- if (droppedPrefix) {
1213
- suspicious = true;
1214
- sparesProposed = true;
1215
- if (!applySpares) {
1216
- out.push(...slot.inserts, ...slot.deletes);
1217
- continue;
1218
- }
1219
- // Sparing a leading closer re-inserts it *before* the payload,
1220
- // which claims the payload lives outside (after) the block the
1221
- // closer terminates. The payload's indentation makes that call:
1222
- // at-or-above the closer's depth is sibling position; a deeper or
1223
- // incomparable claim would put the payload inside the block the
1224
- // range just closed — reject rather than guess.
1225
- const closerIndent = leadingIndent(fileLines[slot.group.startLine - 1] ?? "");
1226
- const payloadIndent = bodyTargetIndent(slot.group.payload);
1227
- if (payloadIndent !== undefined) {
1228
- if (!closerIndent.startsWith(payloadIndent)) {
1229
- throw new CloserSpareAmbiguityError(
1230
- ambiguousLeadingCloserSpareMessage(slot.group.startLine, slot.group.endLine, droppedPrefix.count),
1231
- );
1232
- }
1233
- const spareEnd = slot.group.startLine + droppedPrefix.count;
1234
- warnings.push(
1235
- describeBoundaryRepair(
1236
- slot.group,
1237
- `kept ${droppedPrefix.count} leading structural closing line(s) the range deleted without restating; the payload lands after them`,
1238
- ),
1239
- );
1240
- out.push(
1241
- ...slot.inserts.map(edit =>
1242
- edit.kind === "insert"
1243
- ? { ...edit, cursor: { kind: "before_anchor" as const, anchor: { line: spareEnd } } }
1244
- : edit,
1245
- ),
1246
- ...slot.deletes.filter(edit => edit.kind !== "delete" || edit.anchor.line >= spareEnd),
1247
- );
1248
- for (let line = slot.group.startLine; line < spareEnd; line++) deletedLines.delete(line);
1249
- remainingDelta = balanceSum(remainingDelta, droppedPrefix.balance);
1250
- continue;
1251
- }
1252
- }
1253
- const orphanedOpeners = countOrphanedOpeners(slot.group, slot.delta, remainingDelta, fileLines);
1254
- if (orphanedOpeners > 0) {
1255
- suspicious = true;
1256
- advisories.push(midBlockRangeWarning(slot.group.startLine, slot.group.endLine, orphanedOpeners));
828
+ const variant = chosen.get(group.insertIndices[0]);
829
+ if (variant) {
830
+ out.push(...variant.edits);
831
+ } else {
832
+ for (const idx of group.insertIndices) out.push(cloneAppliedEdit(edits[idx], idx));
833
+ for (const idx of group.deleteIndices) out.push(cloneAppliedEdit(edits[idx], idx));
1257
834
  }
1258
- out.push(...slot.inserts, ...slot.deletes);
835
+ i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
1259
836
  }
1260
- return { edits: out, warnings, suspicious, sparesProposed, advisories };
837
+ return out;
1261
838
  }
1262
839
 
1263
840
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1449,12 +1026,12 @@ function repairAfterInsertLandings(
1449
1026
  const retarget = (group: AfterInsertGroup, line: number): void => {
1450
1027
  out ??= [...edits];
1451
1028
  for (const idx of group.members) {
1452
- const edit = out[idx] as InsertEdit;
1029
+ const edit = insertEditAt(out, idx);
1453
1030
  out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
1454
1031
  }
1455
1032
  };
1456
1033
  for (const group of groups.values()) {
1457
- const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
1034
+ const target = bodyTargetIndent(group.members.map(idx => insertEditAt(edits, idx).text));
1458
1035
  if (target === undefined) continue;
1459
1036
  const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
1460
1037
  if (outward !== undefined) {
@@ -1482,11 +1059,10 @@ export interface ApplyEditsOptions {
1482
1059
  /** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */
1483
1060
  onEmptyPaste?: "throw" | "drop";
1484
1061
  /**
1485
- * Target file path, used only to infer a language for the tree-sitter
1486
- * syntax probe (see {@link parsesCleanly}). Supplying it lets the applier
1487
- * confirm that the edit as authored still parses, in which case no
1488
- * delimiter-shape repair or advisory may touch it. Omitted, the probe casts
1489
- * no veto and the delimiter heuristics decide alone.
1062
+ * Target path used to infer a language for the tree-sitter syntax probe.
1063
+ * Required for syntax-essential boundary retention and post-apply syntax
1064
+ * advisories. Without it, only exact-text boundary normalization and its
1065
+ * evidence-complete rejections run.
1490
1066
  */
1491
1067
  path?: string;
1492
1068
  }
@@ -1590,13 +1166,11 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
1590
1166
  * Returns the post-edit text and the first changed line number (1-indexed).
1591
1167
  * Throws if an anchor is out of bounds.
1592
1168
  *
1593
- * Repairs that hinge on delimiter *semantics* (a range that swallowed the `}`
1594
- * closing the construct above or below it) are subject to a parser veto when
1595
- * `options.path` is supplied: the authored edits are materialized first, and if
1596
- * that result parses it is returned untouched. A `}` in prose, a string, or a
1597
- * regex literal is therefore never mistaken for a block closer. Only when the
1598
- * authored result does not parse — or the language is unknown to the parser, so
1599
- * balance arithmetic is the sole evidence — do the closer-spare repairs run.
1169
+ * Mis-set replacement boundaries are repaired by {@link repairBoundaryVariants}
1170
+ * when `options.path` lets tree-sitter judge the result. A parsing authored
1171
+ * result is never second-guessed. For a broken result, only syntax-essential
1172
+ * edge retention with matching indentation and exact outside-row echo removal
1173
+ * are considered; every selected candidate must parse.
1600
1174
  */
1601
1175
  export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
1602
1176
  if (edits.length === 0) return { text, firstChangedLine: undefined };
@@ -1614,10 +1188,12 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1614
1188
  // Block edits are deferred until `resolveBlockEdits` expands them into
1615
1189
  // concrete inserts + deletes. Reaching the applier with one still present
1616
1190
  // is an internal wiring bug, not authored-input error.
1191
+ const appliedEdits: AppliedEdit[] = [];
1617
1192
  for (const edit of concrete) {
1618
1193
  if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
1194
+ if (edit.kind === "cut" || edit.kind === "paste") throw new Error(UNRESOLVED_CLIPBOARD_INTERNAL);
1195
+ appliedEdits.push(edit);
1619
1196
  }
1620
- const appliedEdits = concrete as readonly AppliedEdit[];
1621
1197
 
1622
1198
  const targetEdits = dropTrailingPhantomDeletes(
1623
1199
  appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
@@ -1625,50 +1201,49 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1625
1201
  );
1626
1202
  validateLineBounds(targetEdits, fileLines);
1627
1203
  const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
1628
- const leading = [...clipboardWarnings, ...indentationWarnings];
1629
-
1630
- // Pass 1: the authored edits, with every delimiter-semantics repair held
1631
- // back. Textual repairs (boundary echoes, duplicated payload lines) are
1632
- // evidence-complete on their own and already applied here.
1633
- const authored = repairReplacementBoundaries(targetEdits, fileLines, false);
1204
+ const normalized = normalizeTextualBoundaryEchoes(targetEdits, fileLines);
1205
+ const leading = [...clipboardWarnings, ...indentationWarnings, ...normalized.warnings];
1206
+ const authoredResult = materializeEdits(fileLines, normalized.edits);
1207
+ const baselineParses = parsesCleanly(options.path, text);
1208
+ const authoredParses = parsesCleanly(options.path, authoredResult.text);
1634
1209
  const finish = (result: Materialized, warnings: string[]): ApplyResult => {
1635
1210
  const merged = [...warnings, ...result.warnings];
1211
+ // Post-apply syntax advisory: the result stopped parsing while the
1212
+ // pre-edit text parsed, so this patch demonstrably introduced the
1213
+ // error. Catches misplacements no boundary variant can explain.
1214
+ if (!parsesCleanly(options.path, result.text) && baselineParses) {
1215
+ merged.push(editBrokeParseWarning(result.firstChangedLine));
1216
+ }
1636
1217
  return {
1637
1218
  text: result.text,
1638
1219
  firstChangedLine: result.firstChangedLine,
1639
1220
  ...(merged.length > 0 ? { warnings: merged } : {}),
1640
1221
  };
1641
1222
  };
1642
- const authoredWarnings = [...leading, ...authored.warnings];
1643
- if (!authored.suspicious) return finish(materializeEdits(fileLines, authored.edits), authoredWarnings);
1644
- const authoredResult = materializeEdits(fileLines, authored.edits);
1645
- // The authored edit keeps the file parsing, so no delimiter heuristic may
1646
- // second-guess its boundaries. This is what keeps a `}` in prose, in a
1647
- // string, or in a regex literal from ever being mistaken for a block closer.
1648
- if (parsesCleanly(options.path, authoredResult.text)) return finish(authoredResult, authoredWarnings);
1649
-
1650
- // The authored result does not parse — or the parser does not know this
1651
- // language, in which case nothing below can be proven and nothing is
1652
- // rewritten. A repair lands only when it is *shown* to restore a parsing
1653
- // file, never on delimiter arithmetic alone.
1654
- const baselineParses = parsesCleanly(options.path, text);
1655
- if (authored.sparesProposed) {
1656
- try {
1657
- const spared = repairReplacementBoundaries(targetEdits, fileLines, true);
1658
- const sparedResult = materializeEdits(fileLines, spared.edits);
1659
- if (parsesCleanly(options.path, sparedResult.text)) {
1660
- return finish(sparedResult, [...leading, ...spared.warnings, ...spared.advisories]);
1661
- }
1662
- } catch (error) {
1663
- // Only the closer-spare verdict is the parser's business, and only on
1664
- // a file it can vouch for. Every other rejection — notably the
1665
- // evidence-complete one-sided boundary echo, which is proven by exact
1666
- // line equality and would otherwise delete range lines the body never
1667
- // restates — propagates regardless of what the parser knows.
1668
- if (baselineParses || !(error instanceof CloserSpareAmbiguityError)) throw error;
1223
+ const ambiguity = normalized.ambiguities[0];
1224
+ // Exact-text normalization is evidence-complete. If it leaves a parsing
1225
+ // result, no speculative keep/drop variant may second-guess it.
1226
+ if (authoredParses) {
1227
+ if (ambiguity) {
1228
+ throw new Error(
1229
+ ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count),
1230
+ );
1231
+ }
1232
+ return finish(authoredResult, leading);
1233
+ }
1234
+ const repaired = repairBoundaryVariants(normalized.edits, fileLines, options.path, baselineParses);
1235
+ if (repaired) {
1236
+ const repairedResult = materializeEdits(fileLines, repaired.edits);
1237
+ if (parsesCleanly(options.path, repairedResult.text)) {
1238
+ return finish(repairedResult, [...leading, ...repaired.warnings]);
1669
1239
  }
1670
1240
  }
1241
+ if (ambiguity) {
1242
+ throw new Error(
1243
+ ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count),
1244
+ );
1245
+ }
1671
1246
  // Nothing proven: leave the authored edit exactly as written. Report the
1672
- // damage only when the baseline parsed, so this edit demonstrably caused it.
1673
- return finish(authoredResult, baselineParses ? [...authoredWarnings, ...authored.advisories] : authoredWarnings);
1247
+ // damage the baseline parsed, so this edit demonstrably caused it.
1248
+ return finish(authoredResult, leading);
1674
1249
  }