@prometheus-ai/hashline 0.5.3 → 0.5.8

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
@@ -7,7 +7,7 @@
7
7
  * which absorbs common model mistakes where a payload restates unchanged range
8
8
  * boundaries or duplicates/drops structural closers.
9
9
  */
10
- import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
10
+ import { afterInsertLandingShiftWarning, blockInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
11
11
  import { cloneCursor } from "./tokenizer";
12
12
  import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
13
13
 
@@ -35,11 +35,26 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] {
35
35
  return getCursorAnchors(edit.cursor);
36
36
  }
37
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
+
38
53
  /**
39
54
  * Verify every anchored edit points at an existing line. File-version binding is
40
55
  * checked once per section via the header hash before this function runs.
41
56
  */
42
- function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
57
+ function validateLineBounds(edits: readonly AppliedEdit[], fileLines: readonly string[]): void {
43
58
  for (const edit of edits) {
44
59
  for (const anchor of getEditAnchors(edit)) {
45
60
  if (anchor.line < 1 || anchor.line > fileLines.length) {
@@ -113,7 +128,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
113
128
  // wrapper" mistake.
114
129
 
115
130
  /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
116
- const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
131
+ export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
117
132
 
118
133
  interface DelimiterBalance {
119
134
  paren: number;
@@ -303,11 +318,22 @@ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string
303
318
  return 0;
304
319
  }
305
320
 
321
+ function payloadEndsWithDeletedSuffix(group: ReplacementGroup, fileLines: readonly string[], count: number): boolean {
322
+ if (group.payload.length < count) return false;
323
+ const deletedStart = group.endLine - count;
324
+ const payloadStart = group.payload.length - count;
325
+ for (let offset = 0; offset < count; offset++) {
326
+ if (group.payload[payloadStart + offset] !== fileLines[deletedStart + offset]) return false;
327
+ }
328
+ return true;
329
+ }
330
+
306
331
  /**
307
332
  * Smallest `m` such that the range's last `m` deleted lines are all pure
308
- * structural closers and sparing them (keeping instead of deleting) zeroes
309
- * `delta`. The mirror mistake: a range that swallows a closing delimiter the
310
- * payload never restates.
333
+ * structural closers, the payload does not already restate those same suffix
334
+ * lines, and sparing them (keeping instead of deleting) zeroes `delta`. The
335
+ * mirror mistake: a range that swallows a closing delimiter the payload never
336
+ * restates.
311
337
  */
312
338
  function findDroppedSuffixClosers(
313
339
  group: ReplacementGroup,
@@ -318,6 +344,7 @@ function findDroppedSuffixClosers(
318
344
  const maxM = group.deleteIndices.length;
319
345
  for (let m = 1; m <= maxM; m++) {
320
346
  if (!STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - m] ?? "")) break;
347
+ if (payloadEndsWithDeletedSuffix(group, fileLines, m)) continue;
321
348
  if (balanceEqual(computeDelimiterBalance(fileLines.slice(group.endLine - m, group.endLine)), wanted)) return m;
322
349
  }
323
350
  return 0;
@@ -383,6 +410,21 @@ function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[])
383
410
  // repair would strip explicit replacement content with no signal that the
384
411
  // payload was a mistake rather than an intentional duplication.
385
412
  if (leadingMax + trailingMax >= group.payload.length) return undefined;
413
+ // Balance-neutrality guard (see header comment): the dropped echo lines must
414
+ // either be delimiter-neutral on their own or exactly cancel the payload/range
415
+ // balance delta. In brace-heavy code where bare closer lines repeat, an
416
+ // "echo" that shifts delimiter balance is structural content the payload
417
+ // placed intentionally — stripping it would corrupt the result.
418
+ const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
419
+ const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
420
+ const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
421
+ if (!balanceIsZero(droppedBalance)) {
422
+ const delta = balanceDelta(
423
+ computeDelimiterBalance(group.payload),
424
+ computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
425
+ );
426
+ if (!balanceEqual(droppedBalance, delta)) return undefined;
427
+ }
386
428
  return { leading: leadingMax, trailing: trailingMax };
387
429
  }
388
430
 
@@ -481,6 +523,217 @@ function repairReplacementBoundaries(
481
523
  return { edits: out, warnings };
482
524
  }
483
525
 
526
+ // ═══════════════════════════════════════════════════════════════════════════
527
+ // After-insert landing correction
528
+ //
529
+ // The body rows of an `insert after N:` hunk carry an implicit depth claim:
530
+ // their leading indentation says how deep the author expects the new lines
531
+ // to sit. Two corrections share that claim, in opposite directions:
532
+ //
533
+ // Outward (any after-insert): when the depth is shallower than line N itself,
534
+ // the hunk is inserting a sibling of some enclosing construct while anchored
535
+ // inside it — the common shape is anchoring on the last statement of a block
536
+ // and writing the body at the parent's depth. Sliding the landing point
537
+ // forward across the structural closer lines that follow (and nothing else —
538
+ // content lines are never crossed) places the body at the depth its
539
+ // indentation names.
540
+ //
541
+ // Inward (block-lowered inserts only): `insert after block N:` anchors on the
542
+ // resolved block's closing line, but a body indented deeper than that closer
543
+ // claims a depth inside the block — the common misreading of the op as
544
+ // "append at the end of block N's body". Sliding the landing point backward
545
+ // across the block's trailing closer lines places the body inside, at its
546
+ // claimed depth. Scoped to block-lowered inserts because there the author
547
+ // named the opener and never saw the closer; a plain `insert after M:` on a
548
+ // closer line stays literal (the escape hatch for genuinely-after content
549
+ // such as method-chain continuations).
550
+ //
551
+ // Both shifts are deliberately conservative: they fire only when the body
552
+ // and anchor indentation are comparable (one is a prefix of the other),
553
+ // cross only pure closing-delimiter lines, stop as soon as depth matches the
554
+ // body's claim, and are abandoned when any other edit in the patch targets a
555
+ // crossed line. Every shift is reported as a warning so the author can
556
+ // re-issue when the original landing was intended.
557
+
558
+ /** Leading run of tabs and spaces. */
559
+ function leadingIndent(line: string): string {
560
+ let end = 0;
561
+ while (end < line.length) {
562
+ const code = line.charCodeAt(end);
563
+ if (code !== 9 && code !== 32) break;
564
+ end++;
565
+ }
566
+ return line.slice(0, end);
567
+ }
568
+
569
+ /** `deeper` strictly extends `shallower` (same indent style, more depth). */
570
+ function isIndentDeeper(deeper: string, shallower: string): boolean {
571
+ return deeper.length > shallower.length && deeper.startsWith(shallower);
572
+ }
573
+
574
+ interface AfterInsertGroup {
575
+ /** Anchor line shared by every insert row of the hunk. */
576
+ anchor: number;
577
+ /** Indices into the edit list, in patch order. */
578
+ members: number[];
579
+ /** First line of the resolved block when lowered from `insert after block N:`. */
580
+ blockStart?: number;
581
+ }
582
+
583
+ /**
584
+ * Depth of an after-insert hunk's body: the shallowest indentation across its
585
+ * non-blank rows. Returns `undefined` when no depth claim can be made — an
586
+ * all-blank or all-closer body, or rows whose indentation styles are not
587
+ * mutually comparable (tabs vs spaces).
588
+ */
589
+ function bodyTargetIndent(rows: readonly string[]): string | undefined {
590
+ const nonBlank = rows.filter(hasNonWhitespace);
591
+ if (nonBlank.length === 0) return undefined;
592
+ // A body of pure closers re-balances delimiters; it claims no depth.
593
+ if (nonBlank.every(row => STRUCTURAL_CLOSER_RE.test(row))) return undefined;
594
+ let target = leadingIndent(nonBlank[0] ?? "");
595
+ for (const row of nonBlank) {
596
+ const indent = leadingIndent(row);
597
+ if (indent.startsWith(target)) continue;
598
+ if (target.startsWith(indent)) target = indent;
599
+ else return undefined;
600
+ }
601
+ return target;
602
+ }
603
+
604
+ /**
605
+ * Resolve where an after-insert hunk anchored on `group.anchor` should land
606
+ * given its body depth `target`: the last structural closer line in the run
607
+ * directly below the anchor whose indentation still covers `target`. Returns
608
+ * `undefined` when the landing stays put.
609
+ */
610
+ function resolveShiftedLanding(
611
+ group: AfterInsertGroup,
612
+ target: string,
613
+ fileLines: readonly string[],
614
+ targetedLines: ReadonlySet<number>,
615
+ ): { line: number; crossed: number } | undefined {
616
+ const anchorText = fileLines[group.anchor - 1];
617
+ if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
618
+ if (!isIndentDeeper(leadingIndent(anchorText), target)) return undefined;
619
+
620
+ let landing = group.anchor;
621
+ let crossed = 0;
622
+ for (let line = group.anchor + 1; line <= fileLines.length; line++) {
623
+ const text = fileLines[line - 1] ?? "";
624
+ if (!hasNonWhitespace(text)) continue; // look past blanks, never land on them
625
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content is never crossed
626
+ const indent = leadingIndent(text);
627
+ if (!indent.startsWith(target)) break; // shallower than the body — crossing would over-escape
628
+ if (targetedLines.has(line)) return undefined; // another hunk owns this closer
629
+ landing = line;
630
+ crossed++;
631
+ if (indent.length === target.length) break; // depth returned to the body's level
632
+ }
633
+ return landing === group.anchor ? undefined : { line: landing, crossed };
634
+ }
635
+
636
+ /**
637
+ * Resolve where a block-lowered after-insert anchored on the block's closing
638
+ * line should land given a body depth `target` deeper than that closer: just
639
+ * above the block's trailing run of closer lines, bounded below by
640
+ * `blockStart` (an empty block lands the body right after its opener).
641
+ * Returns `undefined` when the landing stays put.
642
+ */
643
+ function resolveInwardLanding(
644
+ group: AfterInsertGroup,
645
+ target: string,
646
+ blockStart: number,
647
+ fileLines: readonly string[],
648
+ targetedLines: ReadonlySet<number>,
649
+ ): number | undefined {
650
+ const anchorText = fileLines[group.anchor - 1];
651
+ if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
652
+ // Fires only when the block ends in a pure closer the body out-indents.
653
+ // Blocks ending in content (indentation-only languages) already land the
654
+ // body inside the block — nothing to correct.
655
+ if (!STRUCTURAL_CLOSER_RE.test(anchorText)) return undefined;
656
+ if (!isIndentDeeper(target, leadingIndent(anchorText))) return undefined;
657
+
658
+ let landing = group.anchor;
659
+ for (let line = group.anchor; line > blockStart; line--) {
660
+ const text = fileLines[line - 1] ?? "";
661
+ if (!hasNonWhitespace(text)) {
662
+ landing = line - 1; // look past trailing blanks, never land after one
663
+ continue;
664
+ }
665
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content reached — land right after it
666
+ const indent = leadingIndent(text);
667
+ if (!isIndentDeeper(target, indent)) break; // closer at the body's depth — land after it
668
+ // Another hunk owns this closer (the group's own rows put the anchor
669
+ // itself in `targetedLines`; that one is ours to cross).
670
+ if (line !== group.anchor && targetedLines.has(line)) return undefined;
671
+ landing = line - 1;
672
+ }
673
+ return landing === group.anchor ? undefined : landing;
674
+ }
675
+
676
+ /**
677
+ * Slide mis-anchored after-insert hunks to the depth their body indentation
678
+ * claims: outward past the structural closer lines that follow the anchor
679
+ * when the body is shallower, or — for `insert after block N:` lowerings —
680
+ * inward across the block's trailing closers when the body is deeper than
681
+ * the block's closing line. Returns the corrected edit list plus one warning
682
+ * per shifted hunk.
683
+ */
684
+ function repairAfterInsertLandings(
685
+ edits: readonly AppliedEdit[],
686
+ fileLines: readonly string[],
687
+ ): { edits: readonly AppliedEdit[]; warnings: string[] } {
688
+ // Group plain (non-replacement) after-anchor inserts per authored hunk:
689
+ // rows of one hunk share the anchor line and the patch header line.
690
+ const groups = new Map<string, AfterInsertGroup>();
691
+ edits.forEach((edit, idx) => {
692
+ if (edit.kind !== "insert" || edit.mode === "replacement") return;
693
+ if (edit.cursor.kind !== "after_anchor") return;
694
+ const key = `${edit.cursor.anchor.line}:${edit.lineNum}`;
695
+ const group = groups.get(key);
696
+ if (group === undefined)
697
+ groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx], blockStart: edit.blockStart });
698
+ else group.members.push(idx);
699
+ });
700
+ if (groups.size === 0) return { edits, warnings: [] };
701
+
702
+ // Lines explicitly targeted by any edit; a shift never crosses them.
703
+ const targetedLines = new Set<number>();
704
+ for (const edit of edits) {
705
+ if (edit.kind === "delete") targetedLines.add(edit.anchor.line);
706
+ else if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor")
707
+ targetedLines.add(edit.cursor.anchor.line);
708
+ }
709
+
710
+ let out: AppliedEdit[] | undefined;
711
+ const warnings: string[] = [];
712
+ const retarget = (group: AfterInsertGroup, line: number): void => {
713
+ out ??= [...edits];
714
+ for (const idx of group.members) {
715
+ const edit = out[idx] as InsertEdit;
716
+ out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
717
+ }
718
+ };
719
+ for (const group of groups.values()) {
720
+ const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
721
+ if (target === undefined) continue;
722
+ const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
723
+ if (outward !== undefined) {
724
+ retarget(group, outward.line);
725
+ warnings.push(afterInsertLandingShiftWarning(group.anchor, outward.line, outward.crossed));
726
+ continue;
727
+ }
728
+ if (group.blockStart === undefined) continue;
729
+ const inward = resolveInwardLanding(group, target, group.blockStart, fileLines, targetedLines);
730
+ if (inward === undefined) continue;
731
+ retarget(group, inward);
732
+ warnings.push(blockInsertLandingShiftWarning(group.blockStart, group.anchor, inward));
733
+ }
734
+ return { edits: out ?? edits, warnings };
735
+ }
736
+
484
737
  /**
485
738
  * Apply a parsed list of edits to a text body. Pure function — no I/O.
486
739
  *
@@ -506,15 +759,20 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
506
759
  if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
507
760
  };
508
761
 
509
- const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
762
+ const targetEdits = dropTrailingPhantomDeletes(
763
+ appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
764
+ fileLines,
765
+ );
510
766
  validateLineBounds(targetEdits, fileLines);
511
- const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
767
+ const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
768
+ const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
769
+ const warnings = [...boundaryWarnings, ...landingWarnings];
512
770
 
513
771
  // Partition edits into bof, eof, and anchor-targeted buckets.
514
772
  const bofLines: string[] = [];
515
773
  const eofLines: string[] = [];
516
774
  const anchorEdits: IndexedEdit[] = [];
517
- repaired.forEach((edit, idx) => {
775
+ landed.forEach((edit, idx) => {
518
776
  if (edit.kind === "insert" && edit.cursor.kind === "bof") {
519
777
  bofLines.push(edit.text);
520
778
  } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
package/src/block.ts CHANGED
@@ -1,36 +1,60 @@
1
1
  /**
2
- * Expand deferred `replace block N:` edits into concrete inserts + deletes.
2
+ * Expand deferred block edits (`replace block N:` / `delete block N` /
3
+ * `insert after block N:`) into concrete inserts + deletes.
3
4
  *
4
5
  * The hashline parser cannot expand a block edit on its own — the line span is
5
6
  * unknown until file text + path (→ language) are available. This transform
6
7
  * runs at every apply/preview boundary that has text: it calls the injected
7
8
  * {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
8
- * the exact same `before_anchor` replacement inserts + range deletes that
9
- * `replace start..end:` produces in the parser. After it runs, no `block` edits
10
- * remain, so {@link applyEdits} (and recovery) only ever see resolved edits.
9
+ * the exact same edits the concrete form produces in the parser: `replace
10
+ * start..end:` inserts + deletes for a replace, a pure range delete for a
11
+ * delete, and plain `after_anchor` inserts at `end` for an insert-after. After
12
+ * it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
13
+ * ever see resolved edits.
11
14
  */
12
- import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
13
- import type { BlockResolver, Cursor, Edit } from "./types";
15
+ import { STRUCTURAL_CLOSER_RE } from "./apply";
16
+ import {
17
+ BLOCK_RESOLVER_UNAVAILABLE,
18
+ blockUnresolvedMessage,
19
+ insertAfterBlockCloserLoweredWarning,
20
+ insertAfterBlockUnresolvedLoweredWarning,
21
+ } from "./messages";
22
+ import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
14
23
 
15
24
  export interface ResolveBlockEditsOptions {
16
25
  /**
17
- * How to handle a block edit that cannot be resolved (missing resolver or a
18
- * `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error —
19
- * used by the authoritative apply + final preview paths. `"drop"` silently
20
- * skips the edit — used by the streaming preview, where a half-written file
21
- * or transient parse error must not throw.
26
+ * How to handle a replace/delete block edit that cannot be resolved
27
+ * (missing resolver or a `null` span). `"throw"` (default) raises a
28
+ * `blockUnresolvedMessage` error — used by the authoritative apply + final
29
+ * preview paths. `"drop"` silently skips the edit — used by the streaming
30
+ * preview, where a half-written file or transient parse error must not
31
+ * throw. Unresolvable `insert after block N:` edits never reach this: they
32
+ * are lowered to plain `insert after N:` with a warning.
22
33
  */
23
34
  onUnresolved?: "throw" | "drop";
35
+ /**
36
+ * Invoked once per successfully resolved block edit, in patch order, with
37
+ * the anchor line and the concrete span it resolved to. Lets the host echo
38
+ * the resolution back to the caller. Never fired for dropped/unresolvable
39
+ * edits.
40
+ */
41
+ onResolved?: (resolution: BlockResolution) => void;
42
+ /**
43
+ * Invoked once per diagnostic produced while resolving — currently the
44
+ * `insert after block N:` lowerings (closer anchor or unresolvable block).
45
+ * Hosts should surface these on the apply result's `warnings`.
46
+ */
47
+ onWarning?: (message: string) => void;
24
48
  }
25
49
 
26
- /** True when at least one edit is an unresolved `replace block N:` edit. */
50
+ /** True when at least one edit is an unresolved deferred block edit. */
27
51
  export function hasBlockEdit(edits: readonly Edit[]): boolean {
28
52
  return edits.some(edit => edit.kind === "block");
29
53
  }
30
54
 
31
55
  /**
32
- * Resolve every `replace block N:` edit in `edits` against `text` (parsed as
33
- * the language inferred from `path`). Non-block edits pass through untouched.
56
+ * Resolve every deferred block edit in `edits` against `text` (parsed as the
57
+ * language inferred from `path`). Non-block edits pass through untouched.
34
58
  * Returns a fresh edit list with no `block` variants. The fast path returns the
35
59
  * input unchanged when there is nothing to resolve.
36
60
  *
@@ -54,13 +78,63 @@ export function resolveBlockEdits(
54
78
  resolved.push(edit);
55
79
  continue;
56
80
  }
81
+ const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
57
82
  const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
58
83
  if (span === null) {
84
+ // `insert after block N:` never fails the patch — lower it to plain
85
+ // `insert after N:` with a warning instead. Two flavors:
86
+ // - anchored on a pure closing-delimiter line: no block begins
87
+ // there, but line N IS the end of one, and "after the end of the
88
+ // block" is exactly the plain form — warn with the opener rule.
89
+ // - otherwise (unsupported language, blank line, unparsable block,
90
+ // or no resolver wired): "after the block at N" degrades to
91
+ // "after line N" — warn to verify the landing line.
92
+ if (op === "insert_after") {
93
+ const anchorText = text.split("\n")[edit.anchor.line - 1];
94
+ const isCloser = anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText);
95
+ options.onWarning?.(
96
+ isCloser
97
+ ? insertAfterBlockCloserLoweredWarning(edit.anchor.line)
98
+ : insertAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
99
+ );
100
+ for (const payload of edit.payloads) {
101
+ const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
102
+ resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
103
+ }
104
+ continue;
105
+ }
59
106
  if (onUnresolved === "drop") continue;
60
107
  throw new Error(
61
- `line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
108
+ `line ${edit.lineNum}: ${
109
+ resolver ? blockUnresolvedMessage(edit.anchor.line, op, text.split("\n")) : BLOCK_RESOLVER_UNAVAILABLE
110
+ }`,
62
111
  );
63
112
  }
113
+ options.onResolved?.({
114
+ anchorLine: edit.anchor.line,
115
+ start: span.start,
116
+ end: span.end,
117
+ op,
118
+ });
119
+ if (op === "insert_after") {
120
+ // Mirror the parser's `insert after N:` lowering: one `after_anchor`
121
+ // insert per payload row, anchored on the block's last line. The
122
+ // `blockStart` tag lets the applier's landing correction slide a
123
+ // body that claims a depth inside the block back across the block's
124
+ // trailing closer lines.
125
+ for (const payload of edit.payloads) {
126
+ const cursor: Cursor = { kind: "after_anchor", anchor: { line: span.end } };
127
+ resolved.push({
128
+ kind: "insert",
129
+ cursor,
130
+ text: payload,
131
+ lineNum: edit.lineNum,
132
+ index: synthIndex++,
133
+ blockStart: span.start,
134
+ });
135
+ }
136
+ continue;
137
+ }
64
138
  // Mirror the parser's `replace start..end:` expansion exactly: one
65
139
  // `before_anchor` replacement insert per payload row at `span.start`,
66
140
  // then one delete per line across `[span.start, span.end]`. An empty
@@ -1,49 +1,124 @@
1
1
  /**
2
2
  * Re-number a unified diff that uses the `+<lineNum>|content` /
3
3
  * `-<lineNum>|content` / ` <lineNum>|content` line format into a compact
4
- * preview that anchors every line to its post-edit position. Added lines,
5
- * removed lines, and context lines all end up with a hashline-style anchor
6
- * so a follow-up edit can reuse them directly.
4
+ * current-file preview. Removed lines are counted for stats and post-edit
5
+ * offset tracking, but omitted from the preview. Added and context lines are
6
+ * anchored to their post-edit positions so a follow-up edit can reuse visible
7
+ * concrete lines directly. Long contiguous added runs are summarized with a
8
+ * `…` marker instead of echoing every inserted line.
7
9
  *
8
10
  * This is intentionally decoupled from the diff producer: anything that
9
11
  * emits the `<sign><lineNum>|<content>` shape works.
10
12
  */
11
13
  import type { CompactDiffOptions, CompactDiffPreview } from "./types";
12
14
 
13
- export function buildCompactDiffPreview(diff: string, _options: CompactDiffOptions = {}): CompactDiffPreview {
15
+ const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2;
16
+
17
+ const PREVIEW_ELISION_MARKER = "…";
18
+ /** Blank row separating non-contiguous regions of a numbered diff. */
19
+ const PREVIEW_GAP_ROW = "";
20
+ const RAW_ELISION_MARKERS = new Set(["...", PREVIEW_ELISION_MARKER, `+${PREVIEW_ELISION_MARKER}`]);
21
+
22
+ function isPreviewSeparator(line: string | undefined): boolean {
23
+ return line === PREVIEW_ELISION_MARKER || line === PREVIEW_GAP_ROW;
24
+ }
25
+
26
+ function appendPreviewLine(output: string[], line: string): void {
27
+ const normalized = RAW_ELISION_MARKERS.has(line) ? PREVIEW_ELISION_MARKER : line;
28
+ // Separators (elision markers, blank gap rows) never stack: omitted
29
+ // removed lines between two separators would otherwise leave them
30
+ // adjacent. A leading separator is dropped outright.
31
+ if (isPreviewSeparator(normalized) && (output.length === 0 || isPreviewSeparator(output[output.length - 1]))) {
32
+ return;
33
+ }
34
+ output.push(normalized);
35
+ }
36
+
37
+ interface ParsedDiffLine {
38
+ kind: "+" | "-" | " ";
39
+ lineNumber: number;
40
+ content: string;
41
+ }
42
+
43
+ function normalizeAddedRunContext(value: number | undefined): number {
44
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_ADDED_RUN_CONTEXT_LINES;
45
+ return Math.max(1, Math.trunc(value));
46
+ }
47
+
48
+ function parseNumberedDiffLine(line: string): ParsedDiffLine | undefined {
49
+ const kind = line[0];
50
+ if (kind !== "+" && kind !== "-" && kind !== " ") return undefined;
51
+
52
+ const body = line.slice(1);
53
+ const sep = body.indexOf("|");
54
+ if (sep === -1) return undefined;
55
+
56
+ const lineNumber = Number.parseInt(body.slice(0, sep), 10);
57
+ if (!Number.isFinite(lineNumber)) return undefined;
58
+
59
+ return { kind, lineNumber, content: body.slice(sep + 1) };
60
+ }
61
+
62
+ function appendAddedRun(output: string[], run: string[], edgeLines: number): void {
63
+ if (run.length === 0) return;
64
+
65
+ const collapseThreshold = edgeLines * 2 + 1;
66
+ if (run.length <= collapseThreshold) {
67
+ for (const text of run) appendPreviewLine(output, text);
68
+ return;
69
+ }
70
+
71
+ for (let i = 0; i < edgeLines; i++) appendPreviewLine(output, run[i]);
72
+ appendPreviewLine(output, PREVIEW_ELISION_MARKER);
73
+ for (let i = run.length - edgeLines; i < run.length; i++) appendPreviewLine(output, run[i]);
74
+ }
75
+
76
+ export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview {
14
77
  const lines = diff.length === 0 ? [] : diff.split("\n");
78
+ const addedRunContext = normalizeAddedRunContext(options.maxAddedRunContext ?? options.maxUnchangedRun);
15
79
  let addedLines = 0;
16
80
  let removedLines = 0;
81
+ const formatted: string[] = [];
82
+ const addedRun: string[] = [];
83
+
84
+ const flushAddedRun = (): void => {
85
+ appendAddedRun(formatted, addedRun, addedRunContext);
86
+ addedRun.length = 0;
87
+ };
17
88
 
18
89
  // External diff producers number `+` lines with the post-edit line number,
19
90
  // `-` lines with the pre-edit line number, and context lines with the
20
91
  // pre-edit line number. To emit fresh line numbers usable for follow-up
21
92
  // edits, convert context-line numbers to post-edit positions by tracking
22
93
  // the running offset (added so far - removed so far) as we walk the diff.
23
- const formatted = lines.map(line => {
24
- const kind = line[0];
25
- if (kind !== "+" && kind !== "-" && kind !== " ") return line;
26
-
27
- const body = line.slice(1);
28
- const sep = body.indexOf("|");
29
- if (sep === -1) return line;
30
-
31
- const lineNumber = Number.parseInt(body.slice(0, sep), 10);
32
- const content = body.slice(sep + 1);
94
+ for (const line of lines) {
95
+ const parsed = parseNumberedDiffLine(line);
96
+ if (!parsed) {
97
+ flushAddedRun();
98
+ appendPreviewLine(formatted, line);
99
+ continue;
100
+ }
33
101
 
34
- switch (kind) {
35
- case "+":
102
+ switch (parsed.kind) {
103
+ case "+": {
36
104
  addedLines++;
37
- return `+${lineNumber}:${content}`;
105
+ addedRun.push(`${parsed.lineNumber}:${parsed.content}`);
106
+ break;
107
+ }
38
108
  case "-":
109
+ flushAddedRun();
39
110
  removedLines++;
40
- return `-${lineNumber}:${content}`;
111
+ break;
41
112
  default: {
42
- const newLineNumber = lineNumber + addedLines - removedLines;
43
- return ` ${newLineNumber}:${content}`;
113
+ flushAddedRun();
114
+ const newLineNumber = parsed.lineNumber + addedLines - removedLines;
115
+ appendPreviewLine(formatted, `${newLineNumber}:${parsed.content}`);
116
+ break;
44
117
  }
45
118
  }
46
- });
119
+ }
120
+ flushAddedRun();
121
+ while (formatted.length > 0 && isPreviewSeparator(formatted[formatted.length - 1])) formatted.pop();
47
122
 
48
123
  return { preview: formatted.join("\n"), addedLines, removedLines };
49
124
  }
package/src/grammar.lark CHANGED
@@ -7,15 +7,17 @@ file_header: "[" filename "#" file_hash "]" LF
7
7
  file_hash: /[0-9A-F]{4}/
8
8
  filename: /[^#\r\n]+/
9
9
 
10
- hunk: replace_hunk | replace_block_hunk | insert_hunk | delete_hunk | delete_block_hunk
10
+ hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk
11
11
  replace_hunk: replace_anchor LF emit_op*
12
12
  replace_block_hunk: replace_block_anchor LF emit_op+
13
13
  insert_hunk: insert_anchor LF emit_op+
14
+ insert_block_hunk: insert_block_anchor LF emit_op+
14
15
  delete_hunk: "delete " header_range LF
15
16
  delete_block_hunk: "delete block " LID LF
16
17
  replace_anchor: "replace " header_range ":"
17
18
  replace_block_anchor: "replace block " LID ":"
18
19
  insert_anchor: "insert " insert_pos ":"
20
+ insert_block_anchor: "insert after block " LID ":"
19
21
  insert_pos: "before " LID | "after " LID | "head" | "tail"
20
22
  emit_op: "+" /(.*)/ LF
21
23