@oh-my-pi/hashline 15.5.4

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,799 @@
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
+ * The applier is conservative about edits that look like authoring mistakes:
7
+ *
8
+ * - Replace ops on a blank line with non-empty payload are rejected outright
9
+ * (the model almost certainly miscounted; recommend `↑`/`↓` instead).
10
+ * - Multi-line replacement-boundary duplicates are auto-absorbed (model
11
+ * echoed surrounding context as if it were payload).
12
+ * - Single-line structural-boundary duplicates (`}`, `)`, `];`, …) are
13
+ * auto-absorbed when delimiter balance suggests the range truncated short.
14
+ *
15
+ * Diagnostics are returned as `warnings[]` in {@link ApplyResult}; they do
16
+ * not abort the apply.
17
+ */
18
+ import { cloneCursor } from "./tokenizer";
19
+ import type { Anchor, ApplyOptions, ApplyResult, Cursor, Edit } from "./types";
20
+
21
+ type LineOrigin = "original" | "insert" | "replacement";
22
+
23
+ interface IndexedEdit {
24
+ edit: Edit;
25
+ idx: number;
26
+ }
27
+
28
+ type DeleteEdit = Extract<Edit, { kind: "delete" }>;
29
+
30
+ interface ReplacementGroup {
31
+ startIndex: number;
32
+ endIndex: number;
33
+ sourceLineNum: number;
34
+ replacement: string[];
35
+ deletes: DeleteEdit[];
36
+ }
37
+
38
+ function getEditAnchors(edit: Edit): Anchor[] {
39
+ if (edit.kind === "delete") return [edit.anchor];
40
+ if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
41
+ if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
42
+ return [];
43
+ }
44
+
45
+ /**
46
+ * Verify every anchored edit points at an existing line. File-version binding is
47
+ * checked once per section via the header hash before this function runs.
48
+ */
49
+ function validateLineBounds(edits: Edit[], fileLines: string[]): void {
50
+ for (const edit of edits) {
51
+ for (const anchor of getEditAnchors(edit)) {
52
+ if (anchor.line < 1 || anchor.line > fileLines.length) {
53
+ throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Refuse a single-line replace whose target line is blank and whose payload is
61
+ * non-empty. The author is almost certainly miscounting: `A:CONTENT` overwrites
62
+ * the existing line, so applying it to a blank target deletes the blank cadence
63
+ * and inserts content in its place. To insert content at a blank line, use
64
+ * `A↑` (insert before) or `A↓` (insert after) instead.
65
+ *
66
+ * Only fires for the simple shape: exactly one `insert(before_anchor A)` + one
67
+ * `delete(A)` sharing the same source op line, no other inserts/deletes from
68
+ * that op.
69
+ */
70
+ function detectReplaceOnBlankTarget(edits: Edit[], fileLines: string[]): string | null {
71
+ interface Pair {
72
+ insert?: Extract<Edit, { kind: "insert" }>;
73
+ delete?: Extract<Edit, { kind: "delete" }>;
74
+ multi?: boolean;
75
+ }
76
+ const byOpLine = new Map<number, Pair>();
77
+ for (const edit of edits) {
78
+ const pair = byOpLine.get(edit.lineNum) ?? {};
79
+ if (pair.multi) continue;
80
+ if (edit.kind === "insert") {
81
+ if (pair.insert) pair.multi = true;
82
+ else pair.insert = edit;
83
+ } else {
84
+ if (pair.delete) pair.multi = true;
85
+ else pair.delete = edit;
86
+ }
87
+ byOpLine.set(edit.lineNum, pair);
88
+ }
89
+ for (const pair of byOpLine.values()) {
90
+ if (pair.multi || !pair.insert || !pair.delete) continue;
91
+ const insert = pair.insert;
92
+ const del = pair.delete;
93
+ if (insert.cursor.kind !== "before_anchor") continue;
94
+ if (insert.cursor.anchor.line !== del.anchor.line) continue;
95
+ if (insert.text.includes("\n")) continue;
96
+ if (insert.text.trim().length === 0) continue;
97
+ const targetLine = del.anchor.line;
98
+ const oldLine = fileLines[targetLine - 1];
99
+ if (oldLine === undefined || oldLine.trim().length !== 0) continue;
100
+ return (
101
+ `Edit rejected: replace at line ${targetLine} targets a blank line but the payload is non-empty. ` +
102
+ `'A:CONTENT' overwrites the line at A; to insert content next to a blank line, use 'A${"\u2191"}' (insert before) ` +
103
+ `or 'A${"\u2193"}' (insert after) instead. If you really meant to replace this blank with content, ` +
104
+ `widen the range to include surrounding non-blank lines so the intent is explicit.`
105
+ );
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
111
+ if (lines.length === 0) return;
112
+ const origins = lines.map((): LineOrigin => "insert");
113
+ if (fileLines.length === 1 && fileLines[0] === "") {
114
+ fileLines.splice(0, 1, ...lines);
115
+ lineOrigins.splice(0, 1, ...origins);
116
+ return;
117
+ }
118
+ fileLines.splice(0, 0, ...lines);
119
+ lineOrigins.splice(0, 0, ...origins);
120
+ }
121
+
122
+ function insertAtEnd(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): number | undefined {
123
+ if (lines.length === 0) return undefined;
124
+ const origins = lines.map((): LineOrigin => "insert");
125
+ if (fileLines.length === 1 && fileLines[0] === "") {
126
+ fileLines.splice(0, 1, ...lines);
127
+ lineOrigins.splice(0, 1, ...origins);
128
+ return 1;
129
+ }
130
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
131
+ const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
132
+ fileLines.splice(insertIndex, 0, ...lines);
133
+ lineOrigins.splice(insertIndex, 0, ...origins);
134
+ return insertIndex + 1;
135
+ }
136
+
137
+ /** Bucket edits by the line they target so we can apply each line's group in one splice. */
138
+ function getAnchorTargetLine(edit: Edit): number | undefined {
139
+ if (edit.kind === "delete") return edit.anchor.line;
140
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
141
+ return undefined;
142
+ }
143
+
144
+ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
145
+ const lines = new Set<number>();
146
+ for (const edit of edits) {
147
+ const line = getAnchorTargetLine(edit);
148
+ if (line !== undefined) lines.add(line);
149
+ }
150
+ return lines;
151
+ }
152
+
153
+ function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
154
+ const first = edits[startIndex];
155
+ if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
156
+
157
+ const sourceLineNum = first.lineNum;
158
+ const replacement: string[] = [];
159
+ let index = startIndex;
160
+ while (index < edits.length) {
161
+ const edit = edits[index];
162
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
163
+ replacement.push(edit.text);
164
+ index++;
165
+ }
166
+
167
+ const deletes: DeleteEdit[] = [];
168
+ while (index < edits.length) {
169
+ const edit = edits[index];
170
+ if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
171
+ deletes.push(edit);
172
+ index++;
173
+ }
174
+ if (deletes.length === 0) return undefined;
175
+
176
+ const startLine = deletes[0].anchor.line;
177
+ for (let offset = 0; offset < deletes.length; offset++) {
178
+ if (deletes[offset].anchor.line !== startLine + offset) return undefined;
179
+ }
180
+ const cursorLine = first.cursor.anchor.line;
181
+ if (cursorLine !== startLine) return undefined;
182
+
183
+ return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
184
+ }
185
+
186
+ function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
187
+ const max = Math.min(replacement.length, startLine - 1);
188
+ for (let count = max; count >= 2; count--) {
189
+ let matches = true;
190
+ for (let offset = 0; offset < count; offset++) {
191
+ if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
192
+ matches = false;
193
+ break;
194
+ }
195
+ }
196
+ if (matches) return count;
197
+ }
198
+ return 0;
199
+ }
200
+
201
+ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
202
+ const max = Math.min(replacement.length, fileLines.length - endLine);
203
+ for (let count = max; count >= 2; count--) {
204
+ let matches = true;
205
+ for (let offset = 0; offset < count; offset++) {
206
+ if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
207
+ matches = false;
208
+ break;
209
+ }
210
+ }
211
+ if (matches) return count;
212
+ }
213
+ return 0;
214
+ }
215
+
216
+ // Single-line replacement-boundary absorption is limited to structural closing
217
+ // delimiters. General one-line context is too easy to delete incorrectly, but
218
+ // duplicated `};` / `)` / `]` boundaries often mean a replacement range stopped
219
+ // one line early and would otherwise produce a syntax error.
220
+ const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
221
+
222
+ function isStructuralClosingBoundaryLine(line: string): boolean {
223
+ return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
224
+ }
225
+
226
+ interface DelimiterBalance {
227
+ paren: number;
228
+ bracket: number;
229
+ brace: number;
230
+ }
231
+
232
+ /**
233
+ * Naive bracket counter — does NOT skip string/template/comment contents. The
234
+ * single-line structural absorb relies on this being safe-by-asymmetry: the
235
+ * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
236
+ * to be pure delimiters, so noise in deleted lines or non-boundary kept
237
+ * payload tends to push `expected !== kept` and biases the heuristic toward
238
+ * NOT absorbing (the safe direction). If we ever extend this to opening
239
+ * boundaries or non-structural single lines, swap this for a real tokenizer.
240
+ */
241
+ function computeDelimiterBalance(lines: string[]): DelimiterBalance {
242
+ const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
243
+ for (const line of lines) {
244
+ for (const char of line) {
245
+ switch (char) {
246
+ case "(":
247
+ balance.paren++;
248
+ break;
249
+ case ")":
250
+ balance.paren--;
251
+ break;
252
+ case "[":
253
+ balance.bracket++;
254
+ break;
255
+ case "]":
256
+ balance.bracket--;
257
+ break;
258
+ case "{":
259
+ balance.brace++;
260
+ break;
261
+ case "}":
262
+ balance.brace--;
263
+ break;
264
+ }
265
+ }
266
+ }
267
+ return balance;
268
+ }
269
+
270
+ function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
271
+ return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
272
+ }
273
+
274
+ /**
275
+ * Decides whether the structural-boundary candidate should be dropped: the
276
+ * `keptPayload` (full payload with the boundary line removed) must restore the
277
+ * caller's `expectedBalance`, while the `fullPayload` (boundary line still
278
+ * present) must NOT. For replacements `expectedBalance` is the deleted
279
+ * region's net delimiter balance; for pure inserts it is zero.
280
+ */
281
+ function shouldDropSingleStructuralBoundary(
282
+ fullPayload: string[],
283
+ keptPayload: string[],
284
+ expectedBalance: DelimiterBalance,
285
+ ): boolean {
286
+ return (
287
+ delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
288
+ !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
289
+ );
290
+ }
291
+
292
+ function countMatchingSingleStructuralPrefixBoundary(
293
+ fileLines: string[],
294
+ startLine: number,
295
+ replacement: string[],
296
+ expectedBalance: DelimiterBalance,
297
+ ): number {
298
+ if (replacement.length === 0 || startLine <= 1) return 0;
299
+ const line = replacement[0];
300
+ if (!isStructuralClosingBoundaryLine(line)) return 0;
301
+ if (fileLines[startLine - 2] !== line) return 0;
302
+ return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
303
+ }
304
+
305
+ function countMatchingSingleStructuralSuffixBoundary(
306
+ fileLines: string[],
307
+ endLine: number,
308
+ replacement: string[],
309
+ expectedBalance: DelimiterBalance,
310
+ ): number {
311
+ if (replacement.length === 0 || endLine >= fileLines.length) return 0;
312
+ const line = replacement[replacement.length - 1];
313
+ if (!isStructuralClosingBoundaryLine(line)) return 0;
314
+ if (fileLines[endLine] !== line) return 0;
315
+ return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
316
+ }
317
+
318
+ /**
319
+ * Single-line non-structural boundary duplicate detector for replacement
320
+ * groups. Mirrors the same boundary check the pure-insert absorber uses for
321
+ * `ANCHOR↓` (leading) / `ANCHOR↑` (trailing) inserts, but applied to the
322
+ * top/bottom edges of an `A-B:payload` range. Catches mistakes like
323
+ * `103-138:const X = …` where line 102 already reads `const X = …` and the
324
+ * user really meant `103-138!` (delete only).
325
+ *
326
+ * Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
327
+ * absorb already runs unconditionally, and the structural single-line
328
+ * absorber is balance-validated; a non-structural single-line duplicate is
329
+ * ambiguous (could be an intentional `2:foo` over a line that happens to
330
+ * sit next to another `foo`), so we only fire when the user has opted in.
331
+ */
332
+ function countMatchingSingleNonStructuralPrefixDuplicate(
333
+ fileLines: string[],
334
+ startLine: number,
335
+ replacement: string[],
336
+ ): number {
337
+ if (replacement.length === 0 || startLine <= 1) return 0;
338
+ const line = replacement[0];
339
+ if (line.trim().length === 0) return 0;
340
+ if (isStructuralClosingBoundaryLine(line)) return 0;
341
+ if (fileLines[startLine - 2] !== line) return 0;
342
+ return 1;
343
+ }
344
+
345
+ function countMatchingSingleNonStructuralSuffixDuplicate(
346
+ fileLines: string[],
347
+ endLine: number,
348
+ replacement: string[],
349
+ ): number {
350
+ if (replacement.length === 0 || endLine >= fileLines.length) return 0;
351
+ const line = replacement[replacement.length - 1];
352
+ if (line.trim().length === 0) return 0;
353
+ if (isStructuralClosingBoundaryLine(line)) return 0;
354
+ if (fileLines[endLine] !== line) return 0;
355
+ return 1;
356
+ }
357
+
358
+ function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
359
+ for (const line of lines) {
360
+ if (externalTargetLines.has(line)) return true;
361
+ }
362
+ return false;
363
+ }
364
+
365
+ function contiguousRange(start: number, count: number): number[] {
366
+ return Array.from({ length: count }, (_, offset) => start + offset);
367
+ }
368
+
369
+ function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): Edit {
370
+ return {
371
+ kind: "delete",
372
+ anchor: { line },
373
+ lineNum: sourceLineNum,
374
+ index,
375
+ };
376
+ }
377
+
378
+ interface PureInsertGroup {
379
+ startIndex: number;
380
+ endIndex: number;
381
+ sourceLineNum: number;
382
+ cursor: Cursor;
383
+ payload: string[];
384
+ }
385
+
386
+ function cursorMatches(a: Cursor, b: Cursor): boolean {
387
+ if (a.kind !== b.kind) return false;
388
+ if (a.kind === "bof" || a.kind === "eof") return true;
389
+ const aAnchor = (a as { anchor: Anchor }).anchor;
390
+ const bAnchor = (b as { anchor: Anchor }).anchor;
391
+ return aAnchor.line === bAnchor.line;
392
+ }
393
+
394
+ /**
395
+ * Collects a run of consecutive `insert` edits that all share the same
396
+ * `lineNum` and `cursor`, IFF that run is not immediately followed by a
397
+ * `delete` at the same `lineNum` (which would make it a replacement group
398
+ * instead). Returns the contiguous payload so we can check it for boundary
399
+ * duplicates against the file.
400
+ */
401
+ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
402
+ const first = edits[startIndex];
403
+ if (first?.kind !== "insert") return undefined;
404
+
405
+ const sourceLineNum = first.lineNum;
406
+ const cursor = first.cursor;
407
+ const payload: string[] = [];
408
+ let index = startIndex;
409
+ while (index < edits.length) {
410
+ const edit = edits[index];
411
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
412
+ if (!cursorMatches(edit.cursor, cursor)) break;
413
+ payload.push(edit.text);
414
+ index++;
415
+ }
416
+
417
+ // If the run is followed by a delete at the same source lineNum, this is a
418
+ // replacement group (handled by absorbReplacement…). Decline.
419
+ if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
420
+ return undefined;
421
+ }
422
+
423
+ return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
424
+ }
425
+
426
+ /**
427
+ * For a pure-insert group, locate the file region adjacent to the insertion
428
+ * point. Returns 0-indexed bounds:
429
+ * - `aboveEndIdx`: index of the last file line strictly above the insertion
430
+ * point (-1 if none).
431
+ * - `belowStartIdx`: index of the first file line strictly below the
432
+ * insertion point (`fileLines.length` if none).
433
+ */
434
+ function pureInsertNeighborhood(cursor: Cursor, fileLines: string[]): { aboveEndIdx: number; belowStartIdx: number } {
435
+ if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
436
+ if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
437
+ if (cursor.kind === "before_anchor") {
438
+ return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
439
+ }
440
+ // after_anchor
441
+ return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
442
+ }
443
+
444
+ interface PureInsertAbsorbResult {
445
+ keptPayload: string[];
446
+ absorbedLeading: number;
447
+ absorbedTrailing: number;
448
+ leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
449
+ trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
450
+ }
451
+
452
+ /**
453
+ * For a pure-insert group, drop only multi-line context echoes that exactly
454
+ * duplicate the file lines adjacent to the insertion point. Single-line pure
455
+ * insert duplicates are ambiguous (`N↓}` may be an accidental anchor echo or an
456
+ * intentional inserted delimiter), so they are left literal even when generic
457
+ * duplicate absorption is enabled.
458
+ */
459
+ function tryAbsorbPureInsertGroup(
460
+ group: PureInsertGroup,
461
+ fileLines: string[],
462
+ allowGenericBoundaryAbsorb: boolean,
463
+ ): PureInsertAbsorbResult {
464
+ const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
465
+ if (group.payload.length === 0) return empty;
466
+
467
+ const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
468
+
469
+ // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
470
+ let absorbedLeading = 0;
471
+ if (allowGenericBoundaryAbsorb) {
472
+ const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
473
+ for (let count = maxLead; count >= 2; count--) {
474
+ let ok = true;
475
+ for (let offset = 0; offset < count; offset++) {
476
+ if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
477
+ ok = false;
478
+ break;
479
+ }
480
+ }
481
+ if (ok) {
482
+ absorbedLeading = count;
483
+ break;
484
+ }
485
+ }
486
+ }
487
+
488
+ // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
489
+ // Don't double-count payload lines already absorbed as leading.
490
+ let absorbedTrailing = 0;
491
+ const remaining = group.payload.length - absorbedLeading;
492
+ if (allowGenericBoundaryAbsorb) {
493
+ const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
494
+ for (let count = maxTrail; count >= 2; count--) {
495
+ let ok = true;
496
+ for (let offset = 0; offset < count; offset++) {
497
+ if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
498
+ ok = false;
499
+ break;
500
+ }
501
+ }
502
+ if (ok) {
503
+ absorbedTrailing = count;
504
+ break;
505
+ }
506
+ }
507
+ }
508
+
509
+ if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
510
+
511
+ return {
512
+ keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
513
+ absorbedLeading,
514
+ absorbedTrailing,
515
+ leadingFileRange:
516
+ absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
517
+ trailingFileRange:
518
+ absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
519
+ };
520
+ }
521
+
522
+ function absorbReplacementBoundaryDuplicates(
523
+ edits: Edit[],
524
+ fileLines: string[],
525
+ warnings: string[],
526
+ options: ApplyOptions,
527
+ ): Edit[] {
528
+ let nextSyntheticIndex = edits.length;
529
+ const absorbed: Edit[] = [];
530
+
531
+ // Anchor targets are stable across the loop because we only ever append
532
+ // synthetic deletes (never mutate originals). A line in this set that
533
+ // falls outside the current group's range is necessarily owned by another
534
+ // op, so absorbing it would silently steal its target.
535
+ const allTargetLines = collectAnchorTargetLines(edits);
536
+ const emittedAbsorbKeys = new Set<string>();
537
+
538
+ for (let index = 0; index < edits.length; index++) {
539
+ const group = findReplacementGroup(edits, index);
540
+ if (!group) {
541
+ const pureInsert = findPureInsertGroup(edits, index);
542
+ if (pureInsert) {
543
+ const result = tryAbsorbPureInsertGroup(
544
+ pureInsert,
545
+ fileLines,
546
+ options.autoDropPureInsertDuplicates === true,
547
+ );
548
+ if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
549
+ if (result.leadingFileRange) {
550
+ const { start, end } = result.leadingFileRange;
551
+ const key = `pure-insert-leading:${start}..${end}`;
552
+ if (!emittedAbsorbKeys.has(key)) {
553
+ emittedAbsorbKeys.add(key);
554
+ warnings.push(
555
+ `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
556
+ `(file lines ${start}..${end} already match the payload's leading lines).`,
557
+ );
558
+ }
559
+ }
560
+ if (result.trailingFileRange) {
561
+ const { start, end } = result.trailingFileRange;
562
+ const key = `pure-insert-trailing:${start}..${end}`;
563
+ if (!emittedAbsorbKeys.has(key)) {
564
+ emittedAbsorbKeys.add(key);
565
+ warnings.push(
566
+ `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
567
+ `(file lines ${start}..${end} already match the payload's trailing lines).`,
568
+ );
569
+ }
570
+ }
571
+ for (const text of result.keptPayload) {
572
+ absorbed.push({
573
+ kind: "insert",
574
+ cursor: cloneCursor(pureInsert.cursor),
575
+ text,
576
+ lineNum: pureInsert.sourceLineNum,
577
+ index: nextSyntheticIndex++,
578
+ });
579
+ }
580
+ index = pureInsert.endIndex;
581
+ continue;
582
+ }
583
+ for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
584
+ absorbed.push(edits[groupIndex]);
585
+ }
586
+ index = pureInsert.endIndex;
587
+ continue;
588
+ }
589
+ absorbed.push(edits[index]);
590
+ continue;
591
+ }
592
+
593
+ const startLine = group.deletes[0].anchor.line;
594
+ const endLine = group.deletes[group.deletes.length - 1].anchor.line;
595
+
596
+ const deletedBalance = computeDelimiterBalance(
597
+ group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
598
+ );
599
+ const optInSingleLineAbsorb = options.autoDropPureInsertDuplicates === true;
600
+ const prefixCount =
601
+ countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
602
+ countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance) ||
603
+ (optInSingleLineAbsorb
604
+ ? countMatchingSingleNonStructuralPrefixDuplicate(fileLines, startLine, group.replacement)
605
+ : 0);
606
+ const suffixCount =
607
+ countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
608
+ countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance) ||
609
+ (optInSingleLineAbsorb
610
+ ? countMatchingSingleNonStructuralSuffixDuplicate(fileLines, endLine, group.replacement)
611
+ : 0);
612
+ const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
613
+ const suffixLines = contiguousRange(endLine + 1, suffixCount);
614
+ const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
615
+ const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
616
+
617
+ if (safePrefixCount > 0) {
618
+ const absorbStart = startLine - safePrefixCount;
619
+ const key = `prefix:${absorbStart}..${startLine - 1}`;
620
+ if (!emittedAbsorbKeys.has(key)) {
621
+ emittedAbsorbKeys.add(key);
622
+ warnings.push(
623
+ `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
624
+ `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
625
+ `widened the deletion to absorb them).`,
626
+ );
627
+ }
628
+ }
629
+ if (safeSuffixCount > 0) {
630
+ const absorbEnd = endLine + safeSuffixCount;
631
+ const key = `suffix:${endLine + 1}..${absorbEnd}`;
632
+ if (!emittedAbsorbKeys.has(key)) {
633
+ emittedAbsorbKeys.add(key);
634
+ warnings.push(
635
+ `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
636
+ `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
637
+ `widened the deletion to absorb them).`,
638
+ );
639
+ }
640
+ }
641
+
642
+ for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
643
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
644
+ }
645
+ for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
646
+ absorbed.push(edits[groupIndex]);
647
+ }
648
+ for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
649
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++));
650
+ }
651
+
652
+ index = group.endIndex;
653
+ }
654
+
655
+ return absorbed;
656
+ }
657
+
658
+ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
659
+ const byLine = new Map<number, IndexedEdit[]>();
660
+ for (const entry of edits) {
661
+ const line =
662
+ entry.edit.kind === "delete"
663
+ ? entry.edit.anchor.line
664
+ : entry.edit.cursor.kind === "before_anchor"
665
+ ? entry.edit.cursor.anchor.line
666
+ : 0;
667
+ const bucket = byLine.get(line);
668
+ if (bucket) bucket.push(entry);
669
+ else byLine.set(line, [entry]);
670
+ }
671
+ return byLine;
672
+ }
673
+
674
+ /**
675
+ * Apply a parsed list of edits to a text body. Pure function — no I/O.
676
+ *
677
+ * Returns the post-edit text, the first changed line number (1-indexed), and
678
+ * any diagnostic warnings produced by the auto-absorb heuristics or by the
679
+ * structural-boundary delete check. Throws if an anchor is out of bounds or a
680
+ * blank-target replace is detected.
681
+ */
682
+ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions = {}): ApplyResult {
683
+ if (edits.length === 0) return { text, firstChangedLine: undefined };
684
+
685
+ const fileLines = text.split("\n");
686
+ const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
687
+ const warnings: string[] = [];
688
+
689
+ let firstChangedLine: number | undefined;
690
+ const trackFirstChanged = (line: number) => {
691
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
692
+ };
693
+
694
+ validateLineBounds(edits, fileLines);
695
+
696
+ const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
697
+ if (blankTargetError !== null) throw new Error(blankTargetError);
698
+
699
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
700
+ const targetEdits: Edit[] = [];
701
+
702
+ // Normalize after_anchor inserts to before_anchor of the next line, or EOF
703
+ // when the anchor is the final line. Keep the authored edit objects
704
+ // immutable: PatchSection caches parsed edits and callers may apply them
705
+ // repeatedly against different snapshots.
706
+ for (const edit of normalizedEdits) {
707
+ if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") {
708
+ targetEdits.push(edit);
709
+ continue;
710
+ }
711
+ const anchorLine = edit.cursor.anchor.line;
712
+ targetEdits.push({
713
+ ...edit,
714
+ cursor:
715
+ anchorLine >= fileLines.length
716
+ ? { kind: "eof" }
717
+ : {
718
+ kind: "before_anchor",
719
+ anchor: { line: anchorLine + 1 },
720
+ },
721
+ });
722
+ }
723
+
724
+ // Partition edits into BOF, EOF, and anchor-targeted buckets.
725
+ const bofLines: string[] = [];
726
+ const eofLines: string[] = [];
727
+ const anchorEdits: IndexedEdit[] = [];
728
+ targetEdits.forEach((edit, idx) => {
729
+ if (edit.kind === "insert" && edit.cursor.kind === "bof") {
730
+ bofLines.push(edit.text);
731
+ } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
732
+ eofLines.push(edit.text);
733
+ } else {
734
+ anchorEdits.push({ edit, idx });
735
+ }
736
+ });
737
+
738
+ // Apply per-line buckets bottom-up so earlier indices stay valid.
739
+ const byLine = bucketAnchorEditsByLine(anchorEdits);
740
+ for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
741
+ const bucket = byLine.get(line);
742
+ if (!bucket) continue;
743
+ bucket.sort((a, b) => a.idx - b.idx);
744
+
745
+ const idx = line - 1;
746
+ const currentLine = fileLines[idx] ?? "";
747
+ const beforeLines: string[] = [];
748
+ let deleteLine = false;
749
+
750
+ for (const { edit } of bucket) {
751
+ if (edit.kind === "insert") {
752
+ beforeLines.push(edit.text);
753
+ } else if (edit.kind === "delete") {
754
+ deleteLine = true;
755
+ }
756
+ }
757
+ if (beforeLines.length === 0 && !deleteLine) continue;
758
+
759
+ const replaceMode = beforeLines.length > 0;
760
+ if (deleteLine && !replaceMode) {
761
+ const balance = computeDelimiterBalance([currentLine]);
762
+ const trimmedCurrentLine = currentLine.trim();
763
+ const touchesStructuralBoundary =
764
+ trimmedCurrentLine.startsWith(")") ||
765
+ trimmedCurrentLine.startsWith("]") ||
766
+ trimmedCurrentLine.startsWith("}") ||
767
+ trimmedCurrentLine.endsWith("(") ||
768
+ trimmedCurrentLine.endsWith("[") ||
769
+ trimmedCurrentLine.endsWith("{");
770
+ if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
771
+ warnings.push(
772
+ `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
773
+ );
774
+ }
775
+ }
776
+ const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
777
+ const origins = replacement.map((): LineOrigin => (deleteLine ? "replacement" : "insert"));
778
+ if (!deleteLine) {
779
+ origins[origins.length - 1] = lineOrigins[idx] ?? "original";
780
+ }
781
+
782
+ fileLines.splice(idx, 1, ...replacement);
783
+ lineOrigins.splice(idx, 1, ...origins);
784
+ trackFirstChanged(line);
785
+ }
786
+
787
+ if (bofLines.length > 0) {
788
+ insertAtStart(fileLines, lineOrigins, bofLines);
789
+ trackFirstChanged(1);
790
+ }
791
+ const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
792
+ if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
793
+
794
+ return {
795
+ text: fileLines.join("\n"),
796
+ firstChangedLine,
797
+ ...(warnings.length > 0 ? { warnings } : {}),
798
+ };
799
+ }