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