jeopi-hashline 16.2.13

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 ADDED
@@ -0,0 +1,1281 @@
1
+ /**
2
+ * Apply a parsed list of {@link Edit}s to a text body and return the
3
+ * post-edit lines plus any diagnostic warnings. Pure function: no FS, no
4
+ * mutation of the input.
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.
9
+ */
10
+ import { afterInsertLandingShiftWarning, blockInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
11
+ import { cloneCursor } from "./tokenizer";
12
+ import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
13
+
14
+ type LineOrigin = "original" | "insert" | "replacement";
15
+
16
+ type InsertEdit = Extract<Edit, { kind: "insert" }>;
17
+ type DeleteEdit = Extract<Edit, { kind: "delete" }>;
18
+ type AppliedEdit = InsertEdit | DeleteEdit;
19
+
20
+ interface IndexedEdit {
21
+ edit: AppliedEdit;
22
+ idx: number;
23
+ }
24
+
25
+ function isReplacementInsert(edit: Edit): edit is InsertEdit & { mode: "replacement" } {
26
+ return edit.kind === "insert" && edit.mode === "replacement";
27
+ }
28
+
29
+ function getCursorAnchors(cursor: Cursor): Anchor[] {
30
+ return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : [];
31
+ }
32
+
33
+ function getEditAnchors(edit: AppliedEdit): Anchor[] {
34
+ if (edit.kind === "delete") return [edit.anchor];
35
+ return getCursorAnchors(edit.cursor);
36
+ }
37
+
38
+ function trailingPhantomLine(fileLines: readonly string[]): number {
39
+ // `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
40
+ // It is addressable for inserts (append-past-end), but it is not real
41
+ // content. Deleting it only strips the file's final newline, so ignore delete
42
+ // edits that land there; inclusive ranges ending at EOF then do the intended
43
+ // thing and delete through the last concrete line.
44
+ return fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
45
+ }
46
+
47
+ function dropTrailingPhantomDeletes(edits: AppliedEdit[], fileLines: readonly string[]): AppliedEdit[] {
48
+ const phantomLine = trailingPhantomLine(fileLines);
49
+ if (phantomLine === 0) return edits;
50
+ return edits.filter(edit => edit.kind !== "delete" || edit.anchor.line !== phantomLine);
51
+ }
52
+
53
+ /**
54
+ * Verify every anchored edit points at an existing line. File-version binding is
55
+ * checked once per section via the header hash before this function runs.
56
+ */
57
+ function validateLineBounds(edits: readonly AppliedEdit[], fileLines: readonly string[]): void {
58
+ for (const edit of edits) {
59
+ for (const anchor of getEditAnchors(edit)) {
60
+ if (anchor.line < 1 || anchor.line > fileLines.length) {
61
+ throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ function cloneAppliedEdit(edit: AppliedEdit, index: number): AppliedEdit {
68
+ if (edit.kind === "delete") return { ...edit, anchor: { ...edit.anchor }, index };
69
+ return { ...edit, cursor: cloneCursor(edit.cursor), index };
70
+ }
71
+
72
+ function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
73
+ if (lines.length === 0) return;
74
+ const origins = lines.map((): LineOrigin => "insert");
75
+ if (fileLines.length === 1 && fileLines[0] === "") {
76
+ fileLines.splice(0, 1, ...lines);
77
+ lineOrigins.splice(0, 1, ...origins);
78
+ return;
79
+ }
80
+ fileLines.splice(0, 0, ...lines);
81
+ lineOrigins.splice(0, 0, ...origins);
82
+ }
83
+
84
+ function insertAtEnd(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): number | undefined {
85
+ if (lines.length === 0) return undefined;
86
+ const origins = lines.map((): LineOrigin => "insert");
87
+ if (fileLines.length === 1 && fileLines[0] === "") {
88
+ fileLines.splice(0, 1, ...lines);
89
+ lineOrigins.splice(0, 1, ...origins);
90
+ return 1;
91
+ }
92
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
93
+ const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
94
+ fileLines.splice(insertIndex, 0, ...lines);
95
+ lineOrigins.splice(insertIndex, 0, ...origins);
96
+ return insertIndex + 1;
97
+ }
98
+
99
+ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
100
+ const byLine = new Map<number, IndexedEdit[]>();
101
+ for (const entry of edits) {
102
+ const line =
103
+ entry.edit.kind === "delete"
104
+ ? entry.edit.anchor.line
105
+ : entry.edit.cursor.kind === "before_anchor" || entry.edit.cursor.kind === "after_anchor"
106
+ ? entry.edit.cursor.anchor.line
107
+ : 0;
108
+ const bucket = byLine.get(line);
109
+ if (bucket) bucket.push(entry);
110
+ else byLine.set(line, [entry]);
111
+ }
112
+ return byLine;
113
+ }
114
+
115
+ // ═══════════════════════════════════════════════════════════════════════════
116
+ // Replacement-boundary repair
117
+ //
118
+ // Models routinely miscount a replacement range's edges. Sometimes the payload
119
+ // re-states unchanged lines that still live on both sides of the range
120
+ // (duplicating a function header and final statement); sometimes it only
121
+ // re-states or omits a structural closer, which leaves delimiter balance broken.
122
+ //
123
+ // A balance-neutral boundary-echo repair fires only when both the leading and
124
+ // trailing payload edges are exact copies of the surviving lines outside the
125
+ // range. One-sided content echoes are left alone unless delimiter-balance repair
126
+ // proves they are duplicated structural boundaries. This preserves intended
127
+ // duplicate statements while absorbing the common "body includes the unchanged
128
+ // wrapper" mistake.
129
+
130
+ /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
131
+ export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
132
+
133
+ /** A JSX/XML closing boundary that carries structure but no bracket tokens. */
134
+ const JSX_CLOSER_RE = /^\s*(?:<\/>|<\/[A-Za-z][\w.:-]*>|\/>)\s*[;,]?\s*$/;
135
+ const JSX_NAMED_CLOSER_RE = /^\s*<\/([A-Za-z][\w.:-]*)>\s*[;,]?\s*$/;
136
+ const JSX_FRAGMENT_CLOSER_RE = /^\s*<\/>\s*[;,]?\s*$/;
137
+
138
+ function isStructuralCloserLine(text: string): boolean {
139
+ return STRUCTURAL_CLOSER_RE.test(text) || JSX_CLOSER_RE.test(text);
140
+ }
141
+
142
+ function jsxCloserName(text: string): string | undefined {
143
+ if (JSX_FRAGMENT_CLOSER_RE.test(text)) return "";
144
+ const match = JSX_NAMED_CLOSER_RE.exec(text);
145
+ return match?.[1];
146
+ }
147
+
148
+ interface JsxPayloadTag {
149
+ readonly name: string;
150
+ readonly closing: boolean;
151
+ readonly selfClosing: boolean;
152
+ }
153
+
154
+ function isJsxTagStart(text: string, index: number): boolean {
155
+ const next = text[index + 1];
156
+ return next === ">" || next === "/" || (next >= "A" && next <= "Z") || (next >= "a" && next <= "z");
157
+ }
158
+
159
+ function findJsxTagEnd(text: string, start: number): number {
160
+ let quote: string | undefined;
161
+ let braces = 0;
162
+ for (let i = start + 1; i < text.length; i++) {
163
+ const ch = text[i];
164
+ if (quote) {
165
+ if (ch === "\\" && i + 1 < text.length) {
166
+ i++;
167
+ } else if (ch === quote) {
168
+ quote = undefined;
169
+ }
170
+ continue;
171
+ }
172
+ if (ch === '"' || ch === "'" || ch === "`") {
173
+ quote = ch;
174
+ } else if (ch === "{") {
175
+ braces++;
176
+ } else if (ch === "}" && braces > 0) {
177
+ braces--;
178
+ } else if (ch === ">" && braces === 0) {
179
+ return i;
180
+ }
181
+ }
182
+ return -1;
183
+ }
184
+
185
+ function parseJsxPayloadTag(raw: string): JsxPayloadTag | undefined {
186
+ if (raw === "<>") return { name: "", closing: false, selfClosing: false };
187
+ if (raw === "</>") return { name: "", closing: true, selfClosing: false };
188
+ const closing = raw.startsWith("</");
189
+ const nameStart = closing ? 2 : 1;
190
+ let nameEnd = nameStart;
191
+ while (nameEnd < raw.length && /[\w.:-]/.test(raw[nameEnd])) nameEnd++;
192
+ if (nameEnd === nameStart) return undefined;
193
+ return {
194
+ name: raw.slice(nameStart, nameEnd),
195
+ closing,
196
+ selfClosing: !closing && /\/>\s*$/.test(raw),
197
+ };
198
+ }
199
+
200
+ function readJsxPayloadTags(text: string): JsxPayloadTag[] {
201
+ const tags: JsxPayloadTag[] = [];
202
+ for (let start = text.indexOf("<"); start >= 0; start = text.indexOf("<", start + 1)) {
203
+ if (!isJsxTagStart(text, start)) continue;
204
+ const end = findJsxTagEnd(text, start);
205
+ if (end < 0) break;
206
+ const tag = parseJsxPayloadTag(text.slice(start, end + 1));
207
+ if (tag) tags.push(tag);
208
+ start = end;
209
+ }
210
+ return tags;
211
+ }
212
+
213
+ function payloadHasJsxOpenerForEcho(payloadPrefix: readonly string[], echoLines: readonly string[]): boolean {
214
+ const openTags: string[] = [];
215
+ for (const tag of readJsxPayloadTags(payloadPrefix.join("\n"))) {
216
+ if (tag.closing) {
217
+ if (openTags[openTags.length - 1] === tag.name) openTags.pop();
218
+ } else if (!tag.selfClosing) {
219
+ openTags.push(tag.name);
220
+ }
221
+ }
222
+ for (const line of echoLines) {
223
+ const name = jsxCloserName(line);
224
+ if (name !== undefined && openTags.includes(name)) return true;
225
+ }
226
+ return false;
227
+ }
228
+
229
+ interface DelimiterBalance {
230
+ paren: number;
231
+ bracket: number;
232
+ brace: number;
233
+ }
234
+
235
+ /**
236
+ * Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
237
+ * comments (`//`), block comments, and string/template literals. Block-comment
238
+ * and backtick-template state carry across lines; `"` / `'` reset at EOL since
239
+ * they cannot span lines. Deliberately language-light: constructs it cannot
240
+ * classify (e.g. regex literals) are counted naively, which can only suppress a
241
+ * repair (the safe direction), never force one.
242
+ */
243
+ function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
244
+ const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
245
+ let inBlockComment = false;
246
+ let quote = "";
247
+ for (const line of lines) {
248
+ for (let i = 0; i < line.length; i++) {
249
+ const ch = line[i];
250
+ if (inBlockComment) {
251
+ if (ch === "*" && line[i + 1] === "/") {
252
+ inBlockComment = false;
253
+ i++;
254
+ }
255
+ continue;
256
+ }
257
+ if (quote) {
258
+ if (ch === "\\") i++;
259
+ else if (ch === quote) quote = "";
260
+ continue;
261
+ }
262
+ if (ch === '"' || ch === "'" || ch === "`") {
263
+ quote = ch;
264
+ continue;
265
+ }
266
+ if (ch === "/" && line[i + 1] === "/") break;
267
+ if (ch === "/" && line[i + 1] === "*") {
268
+ inBlockComment = true;
269
+ i++;
270
+ continue;
271
+ }
272
+ switch (ch) {
273
+ case "(":
274
+ balance.paren++;
275
+ break;
276
+ case ")":
277
+ balance.paren--;
278
+ break;
279
+ case "[":
280
+ balance.bracket++;
281
+ break;
282
+ case "]":
283
+ balance.bracket--;
284
+ break;
285
+ case "{":
286
+ balance.brace++;
287
+ break;
288
+ case "}":
289
+ balance.brace--;
290
+ break;
291
+ }
292
+ }
293
+ // `"` / `'` cannot span lines; only backtick templates and block comments do.
294
+ if (quote === '"' || quote === "'") quote = "";
295
+ }
296
+ return balance;
297
+ }
298
+
299
+ function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
300
+ return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace };
301
+ }
302
+
303
+ function balanceNegate(a: DelimiterBalance): DelimiterBalance {
304
+ return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace };
305
+ }
306
+
307
+ function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
308
+ return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
309
+ }
310
+
311
+ function balanceIsZero(a: DelimiterBalance): boolean {
312
+ return a.paren === 0 && a.bracket === 0 && a.brace === 0;
313
+ }
314
+
315
+ function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
316
+ return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace };
317
+ }
318
+
319
+ function balanceComponentCovers(candidate: number, target: number): boolean {
320
+ if (target === 0) return true;
321
+ return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target);
322
+ }
323
+
324
+ function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean {
325
+ return (
326
+ balanceComponentCovers(candidate.paren, target.paren) &&
327
+ balanceComponentCovers(candidate.bracket, target.bracket) &&
328
+ balanceComponentCovers(candidate.brace, target.brace)
329
+ );
330
+ }
331
+
332
+ interface ReplacementGroup {
333
+ /** Positions in the edit array of the payload inserts, in payload order. */
334
+ insertIndices: number[];
335
+ /** Positions in the edit array of the range deletes, ascending by line. */
336
+ deleteIndices: number[];
337
+ payload: string[];
338
+ /** First deleted line (1-indexed). */
339
+ startLine: number;
340
+ /** Last deleted line (1-indexed). */
341
+ endLine: number;
342
+ }
343
+
344
+ /**
345
+ * Detect a replacement group starting at `start`: a run of `before_anchor`
346
+ * replacement inserts sharing one source op line, immediately followed by the
347
+ * contiguous range deletes for that same op. Mirrors how the parser lowers an
348
+ * `replace N.=M:` hunk with a body.
349
+ */
350
+ function findReplacementGroup(edits: readonly AppliedEdit[], start: number): ReplacementGroup | undefined {
351
+ const first = edits[start];
352
+ if (first?.kind !== "insert" || first.mode !== "replacement" || first.cursor.kind !== "before_anchor") {
353
+ return undefined;
354
+ }
355
+ const { lineNum } = first;
356
+ const anchorLine = first.cursor.anchor.line;
357
+ const insertIndices: number[] = [];
358
+ const payload: string[] = [];
359
+ let i = start;
360
+ for (; i < edits.length; i++) {
361
+ const edit = edits[i];
362
+ if (edit.kind !== "insert" || edit.mode !== "replacement" || edit.lineNum !== lineNum) break;
363
+ if (edit.cursor.kind !== "before_anchor" || edit.cursor.anchor.line !== anchorLine) break;
364
+ insertIndices.push(i);
365
+ payload.push(edit.text);
366
+ }
367
+ const deleteIndices: number[] = [];
368
+ let expectedLine = anchorLine;
369
+ for (; i < edits.length; i++) {
370
+ const edit = edits[i];
371
+ if (edit.kind !== "delete" || edit.lineNum !== lineNum || edit.anchor.line !== expectedLine) break;
372
+ deleteIndices.push(i);
373
+ expectedLine++;
374
+ }
375
+ if (deleteIndices.length === 0) return undefined;
376
+ return {
377
+ insertIndices,
378
+ deleteIndices,
379
+ payload,
380
+ startLine: anchorLine,
381
+ endLine: anchorLine + deleteIndices.length - 1,
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Largest `k` such that the payload's last `k` lines exactly equal the `k`
387
+ * surviving file lines just below the range AND dropping them zeroes `delta`.
388
+ * Requires a non-zero `delta`: a zero-balance candidate can never account for
389
+ * the imbalance, so intentional duplicates of ordinary statements stay intact,
390
+ * while duplicated structural lines (closers like `});`, openers like `foo(`)
391
+ * are dropped when they exactly explain the imbalance.
392
+ */
393
+ function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
394
+ if (balanceIsZero(delta)) return 0;
395
+ const { payload, endLine } = group;
396
+ const maxK = Math.min(payload.length, fileLines.length - endLine);
397
+ for (let k = maxK; k >= 1; k--) {
398
+ let matches = true;
399
+ for (let t = 0; t < k; t++) {
400
+ if (payload[payload.length - k + t] !== fileLines[endLine + t]) {
401
+ matches = false;
402
+ break;
403
+ }
404
+ }
405
+ if (!matches) continue;
406
+ if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k;
407
+ }
408
+ return 0;
409
+ }
410
+
411
+ /**
412
+ * Largest `j` such that the payload's first `j` lines exactly equal the `j`
413
+ * surviving file lines just above the range AND dropping them zeroes `delta`.
414
+ * Requires a non-zero `delta`; see {@link findDuplicateSuffix}.
415
+ */
416
+ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
417
+ if (balanceIsZero(delta)) return 0;
418
+ const { payload, startLine } = group;
419
+ const maxJ = Math.min(payload.length, startLine - 1);
420
+ for (let j = maxJ; j >= 1; j--) {
421
+ let matches = true;
422
+ for (let t = 0; t < j; t++) {
423
+ if (payload[t] !== fileLines[startLine - 1 - j + t]) {
424
+ matches = false;
425
+ break;
426
+ }
427
+ }
428
+ if (!matches) continue;
429
+ if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
430
+ }
431
+ return 0;
432
+ }
433
+ interface DroppedSuffixClosers {
434
+ readonly startLine: number;
435
+ readonly count: number;
436
+ readonly balance: DelimiterBalance;
437
+ }
438
+
439
+ function countPayloadRestatedSuffixHead(payload: readonly string[], suffixLines: readonly string[]): number {
440
+ const maxCount = Math.min(payload.length, suffixLines.length);
441
+ for (let count = maxCount; count >= 1; count--) {
442
+ let matches = true;
443
+ for (let offset = 0; offset < count; offset++) {
444
+ if (payload[payload.length - count + offset] !== suffixLines[offset]) {
445
+ matches = false;
446
+ break;
447
+ }
448
+ }
449
+ if (matches) return count;
450
+ }
451
+ return 0;
452
+ }
453
+
454
+ function countProjectedBelowSuffixTail(
455
+ group: ReplacementGroup,
456
+ fileLines: readonly string[],
457
+ deletedLines: ReadonlySet<number>,
458
+ insertedLineMaps: InsertedLineMaps,
459
+ suffixLines: readonly string[],
460
+ ): number {
461
+ const below: string[] = [];
462
+ const appendCloserLines = (lines: readonly string[] | undefined): boolean => {
463
+ if (!lines) return true;
464
+ for (const text of lines) {
465
+ if (!STRUCTURAL_CLOSER_RE.test(text)) return false;
466
+ below.push(text);
467
+ }
468
+ return true;
469
+ };
470
+ if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0;
471
+ for (let line = group.endLine + 1; line <= fileLines.length; line++) {
472
+ if (!appendCloserLines(insertedLineMaps.before.get(line))) break;
473
+ if (!deletedLines.has(line)) {
474
+ const text = fileLines[line - 1] ?? "";
475
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break;
476
+ below.push(text);
477
+ }
478
+ if (!appendCloserLines(insertedLineMaps.after.get(line))) break;
479
+ }
480
+ const maxCount = Math.min(below.length, suffixLines.length);
481
+ for (let count = maxCount; count >= 1; count--) {
482
+ let matches = true;
483
+ for (let offset = 0; offset < count; offset++) {
484
+ if (below[offset] !== suffixLines[suffixLines.length - count + offset]) {
485
+ matches = false;
486
+ break;
487
+ }
488
+ }
489
+ if (matches) return count;
490
+ }
491
+ return 0;
492
+ }
493
+
494
+ interface InsertedLineMaps {
495
+ readonly before: ReadonlyMap<number, readonly string[]>;
496
+ readonly after: ReadonlyMap<number, readonly string[]>;
497
+ }
498
+
499
+ function computeProjectedPrefixBalance(
500
+ group: ReplacementGroup,
501
+ fileLines: readonly string[],
502
+ deletedLines: ReadonlySet<number>,
503
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
504
+ insertedLineMaps: InsertedLineMaps,
505
+ ): DelimiterBalance {
506
+ const prefix: string[] = [];
507
+ for (let line = 1; line < group.startLine; line++) {
508
+ const inserted = insertedByLine.get(line);
509
+ if (inserted) prefix.push(...inserted);
510
+ if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? "");
511
+ }
512
+ const insertedAtStart = insertedLineMaps.before.get(group.startLine);
513
+ if (insertedAtStart) prefix.push(...insertedAtStart);
514
+ prefix.push(...group.payload);
515
+ return computeDelimiterBalance(prefix);
516
+ }
517
+
518
+ function prefixCanCoverSuffixClosers(
519
+ group: ReplacementGroup,
520
+ fileLines: readonly string[],
521
+ suffixBalance: DelimiterBalance,
522
+ coveredBelowBalance: DelimiterBalance,
523
+ deletedLines: ReadonlySet<number>,
524
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
525
+ insertedLineMaps: InsertedLineMaps,
526
+ ): boolean {
527
+ const neededOpeners = balanceNegate(suffixBalance);
528
+ const prefixBalance = computeProjectedPrefixBalance(
529
+ group,
530
+ fileLines,
531
+ deletedLines,
532
+ insertedByLine,
533
+ insertedLineMaps,
534
+ );
535
+ const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance);
536
+ return balanceCovers(uncoveredPrefixBalance, neededOpeners);
537
+ }
538
+
539
+ /**
540
+ * Missing segment of the range's deleted structural-closer suffix that should
541
+ * be spared. Payload lines that already restate the suffix head are not kept
542
+ * again, and projected closers immediately below the range satisfy the suffix
543
+ * tail. The remaining middle segment is kept only when backed by unmatched
544
+ * openers plus the whole-patch residual.
545
+ */
546
+ function findDroppedSuffixClosers(
547
+ group: ReplacementGroup,
548
+ fileLines: readonly string[],
549
+ delta: DelimiterBalance,
550
+ remainingDelta: DelimiterBalance,
551
+ deletedPrefixBalance: DelimiterBalance,
552
+ deletedLines: ReadonlySet<number>,
553
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
554
+ insertedLineMaps: InsertedLineMaps,
555
+ ): DroppedSuffixClosers | undefined {
556
+ let suffixLength = 0;
557
+ while (
558
+ suffixLength < group.deleteIndices.length &&
559
+ STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "")
560
+ ) {
561
+ suffixLength++;
562
+ }
563
+ if (suffixLength === 0) return undefined;
564
+
565
+ const suffixStartLine = group.endLine - suffixLength + 1;
566
+ const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine);
567
+ const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines);
568
+ const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines);
569
+ const keepStart = restatedHead;
570
+ const keepEnd = suffixLength - coveredTail;
571
+ if (keepStart >= keepEnd) return undefined;
572
+
573
+ const keptLines = suffixLines.slice(keepStart, keepEnd);
574
+ const keptBalance = computeDelimiterBalance(keptLines);
575
+ const neededOpeners = balanceNegate(keptBalance);
576
+ const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd));
577
+ if (!balanceCovers(delta, neededOpeners)) return undefined;
578
+ if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
579
+ if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
580
+ if (
581
+ !prefixCanCoverSuffixClosers(
582
+ group,
583
+ fileLines,
584
+ keptBalance,
585
+ coveredBelowBalance,
586
+ deletedLines,
587
+ insertedByLine,
588
+ insertedLineMaps,
589
+ )
590
+ ) {
591
+ return undefined;
592
+ }
593
+ return { startLine: suffixStartLine + keepStart, count: keepEnd - keepStart, balance: keptBalance };
594
+ }
595
+
596
+ interface BoundaryEcho {
597
+ leading: number;
598
+ trailing: number;
599
+ }
600
+
601
+ function hasNonWhitespace(text: string): boolean {
602
+ for (let i = 0; i < text.length; i++) {
603
+ const code = text.charCodeAt(i);
604
+ if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
605
+ }
606
+ return false;
607
+ }
608
+
609
+ function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
610
+ const { payload, startLine } = group;
611
+ const max = Math.min(payload.length, startLine - 1);
612
+ for (let count = max; count >= 1; count--) {
613
+ let matches = true;
614
+ let hasContent = false;
615
+ for (let offset = 0; offset < count; offset++) {
616
+ const line = payload[offset];
617
+ if (line !== fileLines[startLine - 1 - count + offset]) {
618
+ matches = false;
619
+ break;
620
+ }
621
+ hasContent ||= hasNonWhitespace(line);
622
+ }
623
+ if (matches && hasContent) return count;
624
+ }
625
+ return 0;
626
+ }
627
+
628
+ function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
629
+ const { payload, endLine } = group;
630
+ const max = Math.min(payload.length, fileLines.length - endLine);
631
+ for (let count = max; count >= 1; count--) {
632
+ let matches = true;
633
+ let hasContent = false;
634
+ for (let offset = 0; offset < count; offset++) {
635
+ const line = payload[payload.length - count + offset];
636
+ if (line !== fileLines[endLine + offset]) {
637
+ matches = false;
638
+ break;
639
+ }
640
+ hasContent ||= hasNonWhitespace(line);
641
+ }
642
+ if (matches && hasContent) return count;
643
+ }
644
+ return 0;
645
+ }
646
+
647
+ function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined {
648
+ const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines);
649
+ if (leadingMax === 0) return undefined;
650
+ const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines);
651
+ if (trailingMax === 0) return undefined;
652
+ // Bail when every payload line could be claimed by a boundary echo: any
653
+ // repair would strip explicit replacement content with no signal that the
654
+ // payload was a mistake rather than an intentional duplication.
655
+ if (leadingMax + trailingMax >= group.payload.length) return undefined;
656
+ // Balance-neutrality guard (see header comment): the dropped echo lines must
657
+ // either be delimiter-neutral on their own or exactly cancel the payload/range
658
+ // balance delta. In brace-heavy code where bare closer lines repeat, an
659
+ // "echo" that shifts delimiter balance is structural content the payload
660
+ // placed intentionally — stripping it would corrupt the result.
661
+ const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
662
+ const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
663
+ const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
664
+ if (!balanceIsZero(droppedBalance)) {
665
+ const delta = balanceDelta(
666
+ computeDelimiterBalance(group.payload),
667
+ computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
668
+ );
669
+ if (!balanceEqual(droppedBalance, delta)) return undefined;
670
+ }
671
+ return { leading: leadingMax, trailing: trailingMax };
672
+ }
673
+
674
+ function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string {
675
+ return (
676
+ `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
677
+ `dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` +
678
+ `Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
679
+ );
680
+ }
681
+
682
+ function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
683
+ return (
684
+ `Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
685
+ `Issue the payload as the final desired content only — never restate or omit a closing bracket bordering the range.`
686
+ );
687
+ }
688
+
689
+ /**
690
+ * A single-sided boundary echo in an otherwise delimiter-balanced *multi-line*
691
+ * replacement: the payload's leading XOR trailing edge exactly restates the
692
+ * surviving line(s) just outside the range — the off-by-one "range one line
693
+ * short of the keeper I retyped" mistake (e.g. att: payload ends with
694
+ * `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
695
+ * are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
696
+ * by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
697
+ *
698
+ * Scoped broadly for multi-line ranges (a construct rewrite) because retouched
699
+ * neutral keepers are usually boundary mistakes there. Single-line expansions
700
+ * are riskier — ordinary duplicated statements may be intentional — so they are
701
+ * only repaired when the duplicated edge is a structural closer line that
702
+ * carries no delimiter-balance signal itself, such as a JSX `</section>` close.
703
+ * The dropped lines must keep the already-balanced result balanced, and must
704
+ * not consume the whole payload.
705
+ */
706
+ function findOneSidedBoundaryEcho(
707
+ group: ReplacementGroup,
708
+ fileLines: readonly string[],
709
+ ): { side: "leading" | "trailing"; count: number } | undefined {
710
+ const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
711
+ const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
712
+ if (leading > 0 === trailing > 0) return undefined;
713
+ const side = leading > 0 ? "leading" : "trailing";
714
+ const count = leading > 0 ? leading : trailing;
715
+ if (count >= group.payload.length) return undefined;
716
+ const echoLines =
717
+ side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
718
+ if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
719
+ if (group.deleteIndices.length <= 1) {
720
+ if (side !== "trailing" || !echoLines.every(isStructuralCloserLine)) return undefined;
721
+ const payloadPrefix = group.payload.slice(0, group.payload.length - count);
722
+ if (payloadHasJsxOpenerForEcho(payloadPrefix, echoLines)) return undefined;
723
+ }
724
+ return { side, count };
725
+ }
726
+
727
+ function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string {
728
+ const where = side === "leading" ? "above" : "below";
729
+ return (
730
+ `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
731
+ `dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` +
732
+ `The range was one line short of the content you retyped — issue the payload as the final content for the ` +
733
+ `selected range only, and widen the range to consume any keeper you restate.`
734
+ );
735
+ }
736
+
737
+ /**
738
+ * One pass-1 outcome per source position: resolved edits (with an optional
739
+ * warning) or a deferred missing-closer candidate, resolved against the
740
+ * whole-patch residual in pass 2.
741
+ */
742
+ type RepairSlot =
743
+ | { kind: "edits"; edits: AppliedEdit[]; warning?: string }
744
+ | {
745
+ kind: "candidate";
746
+ group: ReplacementGroup;
747
+ inserts: AppliedEdit[];
748
+ deletes: AppliedEdit[];
749
+ delta: DelimiterBalance;
750
+ };
751
+
752
+ /**
753
+ * Delimiter balance of the lines immediately above a group's range that are
754
+ * themselves deleted by other hunks, netted against any payload inserted at
755
+ * those lines. When this covers the group's own delta the matching opener was
756
+ * deleted (or replaced by an opener of the same shape) just above — a deliberate
757
+ * wrapper removal — so the range's deleted closer must stay deleted, not be
758
+ * "kept". Scanned over its own contiguous lines so quote/comment state never
759
+ * bleeds in from elsewhere in the patch.
760
+ */
761
+ function netDeletedPrefixBalance(
762
+ group: ReplacementGroup,
763
+ deletedLines: ReadonlySet<number>,
764
+ insertedByLine: ReadonlyMap<number, readonly string[]>,
765
+ fileLines: readonly string[],
766
+ ): DelimiterBalance {
767
+ const deleted: string[] = [];
768
+ const inserted: string[] = [];
769
+ for (let line = group.startLine - 1; line >= 1 && deletedLines.has(line); line--) {
770
+ deleted.unshift(fileLines[line - 1] ?? "");
771
+ const insertedAtLine = insertedByLine.get(line);
772
+ if (insertedAtLine) inserted.unshift(...insertedAtLine);
773
+ }
774
+ return balanceDelta(computeDelimiterBalance(deleted), computeDelimiterBalance(inserted));
775
+ }
776
+
777
+ /**
778
+ * Net delimiter balance a slot contributes, computed over the slot's own
779
+ * contiguous insert/delete lines only. Summing these per-slot deltas — never one
780
+ * concatenated scan across non-adjacent hunks — keeps backtick/block-comment
781
+ * state local, so an unterminated quote in one hunk cannot mask a real delimiter
782
+ * in another.
783
+ */
784
+ function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): DelimiterBalance {
785
+ if (slot.kind === "candidate") return slot.delta;
786
+ const inserted: string[] = [];
787
+ const deleted: string[] = [];
788
+ for (const edit of slot.edits) {
789
+ if (edit.kind === "insert") inserted.push(edit.text);
790
+ else deleted.push(fileLines[edit.anchor.line - 1] ?? "");
791
+ }
792
+ return balanceDelta(computeDelimiterBalance(inserted), computeDelimiterBalance(deleted));
793
+ }
794
+
795
+ /**
796
+ * Normalize replacement groups so common off-by-one boundaries do not duplicate
797
+ * unchanged surrounding lines or wrongly drop/keep structural closers. Local
798
+ * repairs run in pass 1; the missing-closer repair is deferred to pass 2 and
799
+ * weighed against the whole-patch delimiter residual, so a closer the range
800
+ * deleted is only kept when the patch as a whole is missing it — never when
801
+ * another hunk already removed the matching opener. Returns the repaired edits
802
+ * plus one warning per repaired group.
803
+ */
804
+ function repairReplacementBoundaries(
805
+ edits: readonly AppliedEdit[],
806
+ fileLines: readonly string[],
807
+ ): {
808
+ edits: AppliedEdit[];
809
+ warnings: string[];
810
+ } {
811
+ // Pass 1: apply every repair whose correctness is local to one group
812
+ // (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
813
+ // it must weigh a group's imbalance against the whole patch, which is only
814
+ // known once the local repairs above have settled.
815
+ const slots: RepairSlot[] = [];
816
+ let i = 0;
817
+ while (i < edits.length) {
818
+ const group = findReplacementGroup(edits, i);
819
+ if (!group) {
820
+ slots.push({ kind: "edits", edits: [edits[i]] });
821
+ i++;
822
+ continue;
823
+ }
824
+ const inserts = group.insertIndices.map(idx => edits[idx]);
825
+ const deletes = group.deleteIndices.map(idx => edits[idx]);
826
+ i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
827
+
828
+ const boundaryEcho = findBoundaryEcho(group, fileLines);
829
+ if (boundaryEcho) {
830
+ slots.push({
831
+ kind: "edits",
832
+ edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes],
833
+ warning: describeBoundaryEchoRepair(group, boundaryEcho),
834
+ });
835
+ continue;
836
+ }
837
+
838
+ const delta = balanceDelta(
839
+ computeDelimiterBalance(group.payload),
840
+ computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
841
+ );
842
+ if (balanceIsZero(delta)) {
843
+ const oneSided = findOneSidedBoundaryEcho(group, fileLines);
844
+ if (oneSided) {
845
+ const trimmed =
846
+ oneSided.side === "leading"
847
+ ? inserts.slice(oneSided.count)
848
+ : inserts.slice(0, inserts.length - oneSided.count);
849
+ slots.push({
850
+ kind: "edits",
851
+ edits: [...trimmed, ...deletes],
852
+ warning: describeOneSidedEchoRepair(group, oneSided.side, oneSided.count),
853
+ });
854
+ continue;
855
+ }
856
+ slots.push({ kind: "edits", edits: [...inserts, ...deletes] });
857
+ continue;
858
+ }
859
+
860
+ const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
861
+ if (dupSuffix > 0) {
862
+ slots.push({
863
+ kind: "edits",
864
+ edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes],
865
+ warning: describeBoundaryRepair(
866
+ group,
867
+ `dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
868
+ ),
869
+ });
870
+ continue;
871
+ }
872
+ const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
873
+ if (dupPrefix > 0) {
874
+ slots.push({
875
+ kind: "edits",
876
+ edits: [...inserts.slice(dupPrefix), ...deletes],
877
+ warning: describeBoundaryRepair(
878
+ group,
879
+ `dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
880
+ ),
881
+ });
882
+ continue;
883
+ }
884
+ slots.push({ kind: "candidate", group, inserts, deletes, delta });
885
+ }
886
+
887
+ const projected: AppliedEdit[] = [];
888
+ for (const slot of slots) {
889
+ projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits));
890
+ }
891
+ const deletedLines = new Set<number>();
892
+ for (const edit of projected) {
893
+ if (edit.kind === "delete") deletedLines.add(edit.anchor.line);
894
+ }
895
+ const insertedByLine = new Map<number, string[]>();
896
+ const insertedLineMaps: { before: Map<number, string[]>; after: Map<number, string[]> } = {
897
+ before: new Map(),
898
+ after: new Map(),
899
+ };
900
+ for (const edit of projected) {
901
+ if (edit.kind !== "insert") continue;
902
+ for (const anchor of getCursorAnchors(edit.cursor)) {
903
+ const lines = insertedByLine.get(anchor.line);
904
+ if (lines) lines.push(edit.text);
905
+ else insertedByLine.set(anchor.line, [edit.text]);
906
+ }
907
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
908
+ const bySide = edit.cursor.kind === "before_anchor" ? insertedLineMaps.before : insertedLineMaps.after;
909
+ const lines = bySide.get(edit.cursor.anchor.line);
910
+ if (lines) lines.push(edit.text);
911
+ else bySide.set(edit.cursor.anchor.line, [edit.text]);
912
+ }
913
+ }
914
+ let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
915
+ for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
916
+
917
+ const out: AppliedEdit[] = [];
918
+ const warnings: string[] = [];
919
+ for (const slot of slots) {
920
+ if (slot.kind !== "candidate") {
921
+ if (slot.warning !== undefined) warnings.push(slot.warning);
922
+ out.push(...slot.edits);
923
+ continue;
924
+ }
925
+ const deletedPrefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines);
926
+ const droppedClosers = findDroppedSuffixClosers(
927
+ slot.group,
928
+ fileLines,
929
+ slot.delta,
930
+ remainingDelta,
931
+ deletedPrefixBalance,
932
+ deletedLines,
933
+ insertedByLine,
934
+ insertedLineMaps,
935
+ );
936
+ if (droppedClosers) {
937
+ warnings.push(
938
+ describeBoundaryRepair(
939
+ slot.group,
940
+ `kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`,
941
+ ),
942
+ );
943
+ out.push(
944
+ ...slot.inserts,
945
+ ...slot.deletes.filter(
946
+ edit =>
947
+ edit.kind !== "delete" ||
948
+ edit.anchor.line < droppedClosers.startLine ||
949
+ edit.anchor.line >= droppedClosers.startLine + droppedClosers.count,
950
+ ),
951
+ );
952
+ for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) {
953
+ deletedLines.delete(line);
954
+ }
955
+ remainingDelta = balanceSum(remainingDelta, droppedClosers.balance);
956
+ continue;
957
+ }
958
+ out.push(...slot.inserts, ...slot.deletes);
959
+ }
960
+ return { edits: out, warnings };
961
+ }
962
+
963
+ // ═══════════════════════════════════════════════════════════════════════════
964
+ // After-insert landing correction
965
+ //
966
+ // The body rows of an `insert after N:` hunk carry an implicit depth claim:
967
+ // their leading indentation says how deep the author expects the new lines
968
+ // to sit. Two corrections share that claim, in opposite directions:
969
+ //
970
+ // Outward (any after-insert): when the depth is shallower than line N itself,
971
+ // the hunk is inserting a sibling of some enclosing construct while anchored
972
+ // inside it — the common shape is anchoring on the last statement of a block
973
+ // and writing the body at the parent's depth. Sliding the landing point
974
+ // forward across the structural closer lines that follow (and nothing else —
975
+ // content lines are never crossed) places the body at the depth its
976
+ // indentation names.
977
+ //
978
+ // Inward (block-lowered inserts only): `insert_after_block N:` anchors on the
979
+ // resolved block's closing line, but a body indented deeper than that closer
980
+ // claims a depth inside the block — the common misreading of the op as
981
+ // "append at the end of block N's body". Sliding the landing point backward
982
+ // across the block's trailing closer lines places the body inside, at its
983
+ // claimed depth. Scoped to block-lowered inserts because there the author
984
+ // named the opener and never saw the closer; a plain `insert after M:` on a
985
+ // closer line stays literal (the escape hatch for genuinely-after content
986
+ // such as method-chain continuations).
987
+ //
988
+ // Both shifts are deliberately conservative: they fire only when the body
989
+ // and anchor indentation are comparable (one is a prefix of the other),
990
+ // cross only pure closing-delimiter lines, stop as soon as depth matches the
991
+ // body's claim, and are abandoned when any other edit in the patch targets a
992
+ // crossed line. Every shift is reported as a warning so the author can
993
+ // re-issue when the original landing was intended.
994
+
995
+ /** Leading run of tabs and spaces. */
996
+ function leadingIndent(line: string): string {
997
+ let end = 0;
998
+ while (end < line.length) {
999
+ const code = line.charCodeAt(end);
1000
+ if (code !== 9 && code !== 32) break;
1001
+ end++;
1002
+ }
1003
+ return line.slice(0, end);
1004
+ }
1005
+
1006
+ /** `deeper` strictly extends `shallower` (same indent style, more depth). */
1007
+ function isIndentDeeper(deeper: string, shallower: string): boolean {
1008
+ return deeper.length > shallower.length && deeper.startsWith(shallower);
1009
+ }
1010
+
1011
+ interface AfterInsertGroup {
1012
+ /** Anchor line shared by every insert row of the hunk. */
1013
+ anchor: number;
1014
+ /** Indices into the edit list, in patch order. */
1015
+ members: number[];
1016
+ /** First line of the resolved block when lowered from `insert_after_block N:`. */
1017
+ blockStart?: number;
1018
+ }
1019
+
1020
+ /**
1021
+ * Depth of an after-insert hunk's body: the shallowest indentation across its
1022
+ * non-blank rows. Returns `undefined` when no depth claim can be made — an
1023
+ * all-blank or all-closer body, or rows whose indentation styles are not
1024
+ * mutually comparable (tabs vs spaces).
1025
+ */
1026
+ function bodyTargetIndent(rows: readonly string[]): string | undefined {
1027
+ const nonBlank = rows.filter(hasNonWhitespace);
1028
+ if (nonBlank.length === 0) return undefined;
1029
+ // A body of pure closers re-balances delimiters; it claims no depth.
1030
+ if (nonBlank.every(row => STRUCTURAL_CLOSER_RE.test(row))) return undefined;
1031
+ let target = leadingIndent(nonBlank[0] ?? "");
1032
+ for (const row of nonBlank) {
1033
+ const indent = leadingIndent(row);
1034
+ if (indent.startsWith(target)) continue;
1035
+ if (target.startsWith(indent)) target = indent;
1036
+ else return undefined;
1037
+ }
1038
+ return target;
1039
+ }
1040
+
1041
+ /**
1042
+ * Resolve where an after-insert hunk anchored on `group.anchor` should land
1043
+ * given its body depth `target`: the last structural closer line in the run
1044
+ * directly below the anchor whose indentation still covers `target`. Returns
1045
+ * `undefined` when the landing stays put.
1046
+ */
1047
+ function resolveShiftedLanding(
1048
+ group: AfterInsertGroup,
1049
+ target: string,
1050
+ fileLines: readonly string[],
1051
+ targetedLines: ReadonlySet<number>,
1052
+ ): { line: number; crossed: number } | undefined {
1053
+ const anchorText = fileLines[group.anchor - 1];
1054
+ if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
1055
+ if (!isIndentDeeper(leadingIndent(anchorText), target)) return undefined;
1056
+
1057
+ let landing = group.anchor;
1058
+ let crossed = 0;
1059
+ for (let line = group.anchor + 1; line <= fileLines.length; line++) {
1060
+ const text = fileLines[line - 1] ?? "";
1061
+ if (!hasNonWhitespace(text)) continue; // look past blanks, never land on them
1062
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content is never crossed
1063
+ const indent = leadingIndent(text);
1064
+ if (!indent.startsWith(target)) break; // shallower than the body — crossing would over-escape
1065
+ if (targetedLines.has(line)) return undefined; // another hunk owns this closer
1066
+ landing = line;
1067
+ crossed++;
1068
+ if (indent.length === target.length) break; // depth returned to the body's level
1069
+ }
1070
+ return landing === group.anchor ? undefined : { line: landing, crossed };
1071
+ }
1072
+
1073
+ /**
1074
+ * Resolve where a block-lowered after-insert anchored on the block's closing
1075
+ * line should land given a body depth `target` deeper than that closer: just
1076
+ * above the block's trailing run of closer lines, bounded below by
1077
+ * `blockStart` (an empty block lands the body right after its opener).
1078
+ * Returns `undefined` when the landing stays put.
1079
+ */
1080
+ function resolveInwardLanding(
1081
+ group: AfterInsertGroup,
1082
+ target: string,
1083
+ blockStart: number,
1084
+ fileLines: readonly string[],
1085
+ targetedLines: ReadonlySet<number>,
1086
+ ): number | undefined {
1087
+ const anchorText = fileLines[group.anchor - 1];
1088
+ if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
1089
+ // Fires only when the block ends in a pure closer the body out-indents.
1090
+ // Blocks ending in content (indentation-only languages) already land the
1091
+ // body inside the block — nothing to correct.
1092
+ if (!STRUCTURAL_CLOSER_RE.test(anchorText)) return undefined;
1093
+ if (!isIndentDeeper(target, leadingIndent(anchorText))) return undefined;
1094
+
1095
+ let landing = group.anchor;
1096
+ for (let line = group.anchor; line > blockStart; line--) {
1097
+ const text = fileLines[line - 1] ?? "";
1098
+ if (!hasNonWhitespace(text)) {
1099
+ landing = line - 1; // look past trailing blanks, never land after one
1100
+ continue;
1101
+ }
1102
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content reached — land right after it
1103
+ const indent = leadingIndent(text);
1104
+ if (!isIndentDeeper(target, indent)) break; // closer at the body's depth — land after it
1105
+ // Another hunk owns this closer (the group's own rows put the anchor
1106
+ // itself in `targetedLines`; that one is ours to cross).
1107
+ if (line !== group.anchor && targetedLines.has(line)) return undefined;
1108
+ landing = line - 1;
1109
+ }
1110
+ return landing === group.anchor ? undefined : landing;
1111
+ }
1112
+
1113
+ /**
1114
+ * Slide mis-anchored after-insert hunks to the depth their body indentation
1115
+ * claims: outward past the structural closer lines that follow the anchor
1116
+ * when the body is shallower, or — for `insert_after_block N:` lowerings —
1117
+ * inward across the block's trailing closers when the body is deeper than
1118
+ * the block's closing line. Returns the corrected edit list plus one warning
1119
+ * per shifted hunk.
1120
+ */
1121
+ function repairAfterInsertLandings(
1122
+ edits: readonly AppliedEdit[],
1123
+ fileLines: readonly string[],
1124
+ ): { edits: readonly AppliedEdit[]; warnings: string[] } {
1125
+ // Group plain (non-replacement) after-anchor inserts per authored hunk:
1126
+ // rows of one hunk share the anchor line and the patch header line.
1127
+ const groups = new Map<string, AfterInsertGroup>();
1128
+ edits.forEach((edit, idx) => {
1129
+ if (edit.kind !== "insert" || edit.mode === "replacement") return;
1130
+ if (edit.cursor.kind !== "after_anchor") return;
1131
+ const key = `${edit.cursor.anchor.line}:${edit.lineNum}`;
1132
+ const group = groups.get(key);
1133
+ if (group === undefined)
1134
+ groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx], blockStart: edit.blockStart });
1135
+ else group.members.push(idx);
1136
+ });
1137
+ if (groups.size === 0) return { edits, warnings: [] };
1138
+
1139
+ // Lines explicitly targeted by any edit; a shift never crosses them.
1140
+ const targetedLines = new Set<number>();
1141
+ for (const edit of edits) {
1142
+ if (edit.kind === "delete") targetedLines.add(edit.anchor.line);
1143
+ else if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor")
1144
+ targetedLines.add(edit.cursor.anchor.line);
1145
+ }
1146
+
1147
+ let out: AppliedEdit[] | undefined;
1148
+ const warnings: string[] = [];
1149
+ const retarget = (group: AfterInsertGroup, line: number): void => {
1150
+ out ??= [...edits];
1151
+ for (const idx of group.members) {
1152
+ const edit = out[idx] as InsertEdit;
1153
+ out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
1154
+ }
1155
+ };
1156
+ for (const group of groups.values()) {
1157
+ const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
1158
+ if (target === undefined) continue;
1159
+ const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
1160
+ if (outward !== undefined) {
1161
+ retarget(group, outward.line);
1162
+ warnings.push(afterInsertLandingShiftWarning(group.anchor, outward.line, outward.crossed));
1163
+ continue;
1164
+ }
1165
+ if (group.blockStart === undefined) continue;
1166
+ const inward = resolveInwardLanding(group, target, group.blockStart, fileLines, targetedLines);
1167
+ if (inward === undefined) continue;
1168
+ retarget(group, inward);
1169
+ warnings.push(blockInsertLandingShiftWarning(group.blockStart, group.anchor, inward));
1170
+ }
1171
+ return { edits: out ?? edits, warnings };
1172
+ }
1173
+
1174
+ /**
1175
+ * Apply a parsed list of edits to a text body. Pure function — no I/O.
1176
+ *
1177
+ * Returns the post-edit text and the first changed line number (1-indexed).
1178
+ * Throws if an anchor is out of bounds.
1179
+ */
1180
+ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
1181
+ if (edits.length === 0) return { text, firstChangedLine: undefined };
1182
+
1183
+ // Block edits are deferred until `resolveBlockEdits` expands them into
1184
+ // concrete inserts + deletes. Reaching the applier with one still present
1185
+ // is an internal wiring bug, not authored-input error.
1186
+ for (const edit of edits) {
1187
+ if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
1188
+ }
1189
+ const appliedEdits = edits as readonly AppliedEdit[];
1190
+
1191
+ const fileLines = text.split("\n");
1192
+ const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
1193
+
1194
+ let firstChangedLine: number | undefined;
1195
+ const trackFirstChanged = (line: number) => {
1196
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
1197
+ };
1198
+
1199
+ const targetEdits = dropTrailingPhantomDeletes(
1200
+ appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
1201
+ fileLines,
1202
+ );
1203
+ validateLineBounds(targetEdits, fileLines);
1204
+ const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
1205
+ const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
1206
+ const warnings = [...boundaryWarnings, ...landingWarnings];
1207
+
1208
+ // Partition edits into bof, eof, and anchor-targeted buckets.
1209
+ const bofLines: string[] = [];
1210
+ const eofLines: string[] = [];
1211
+ const anchorEdits: IndexedEdit[] = [];
1212
+ landed.forEach((edit, idx) => {
1213
+ if (edit.kind === "insert" && edit.cursor.kind === "bof") {
1214
+ bofLines.push(edit.text);
1215
+ } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
1216
+ eofLines.push(edit.text);
1217
+ } else {
1218
+ anchorEdits.push({ edit, idx });
1219
+ }
1220
+ });
1221
+
1222
+ // Apply per-line buckets bottom-up so earlier indices stay valid.
1223
+ const byLine = bucketAnchorEditsByLine(anchorEdits);
1224
+ for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
1225
+ const bucket = byLine.get(line);
1226
+ if (!bucket) continue;
1227
+ bucket.sort((a, b) => a.idx - b.idx);
1228
+
1229
+ const idx = line - 1;
1230
+ const currentLine = fileLines[idx] ?? "";
1231
+ const beforeInsertLines: string[] = [];
1232
+ const afterInsertLines: string[] = [];
1233
+ const replacementLines: string[] = [];
1234
+ let deleteLine = false;
1235
+
1236
+ for (const { edit } of bucket) {
1237
+ if (isReplacementInsert(edit)) {
1238
+ replacementLines.push(edit.text);
1239
+ } else if (edit.kind === "insert" && edit.cursor.kind === "after_anchor") {
1240
+ afterInsertLines.push(edit.text);
1241
+ } else if (edit.kind === "insert") {
1242
+ beforeInsertLines.push(edit.text);
1243
+ } else if (edit.kind === "delete") {
1244
+ deleteLine = true;
1245
+ }
1246
+ }
1247
+ if (
1248
+ beforeInsertLines.length === 0 &&
1249
+ replacementLines.length === 0 &&
1250
+ afterInsertLines.length === 0 &&
1251
+ !deleteLine
1252
+ )
1253
+ continue;
1254
+
1255
+ const replacement = deleteLine
1256
+ ? [...beforeInsertLines, ...replacementLines, ...afterInsertLines]
1257
+ : [...beforeInsertLines, ...replacementLines, currentLine, ...afterInsertLines];
1258
+ const origins: LineOrigin[] = [];
1259
+ for (let i = 0; i < beforeInsertLines.length; i++) origins.push("insert");
1260
+ for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
1261
+ if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
1262
+ for (let i = 0; i < afterInsertLines.length; i++) origins.push("insert");
1263
+
1264
+ fileLines.splice(idx, 1, ...replacement);
1265
+ lineOrigins.splice(idx, 1, ...origins);
1266
+ trackFirstChanged(line);
1267
+ }
1268
+
1269
+ if (bofLines.length > 0) {
1270
+ insertAtStart(fileLines, lineOrigins, bofLines);
1271
+ trackFirstChanged(1);
1272
+ }
1273
+ const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
1274
+ if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
1275
+
1276
+ return {
1277
+ text: fileLines.join("\n"),
1278
+ firstChangedLine,
1279
+ ...(warnings.length > 0 ? { warnings } : {}),
1280
+ };
1281
+ }