@oh-my-pi/hashline 15.10.10 → 15.10.12

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/README.md CHANGED
@@ -51,6 +51,7 @@ Inside a section:
51
51
  - `replace block A:` — replace the syntactic block beginning on line A.
52
52
  - `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
53
53
  - `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
54
+ - `insert after block A:` — insert following body rows after the resolved block's last line.
54
55
  - `+TEXT` — literal body row (use `+` alone for a blank line).
55
56
 
56
57
  ## Abstractions
@@ -16,11 +16,11 @@ export interface ResolveBlockEditsOptions {
16
16
  */
17
17
  onResolved?: (resolution: BlockResolution) => void;
18
18
  }
19
- /** True when at least one edit is an unresolved `replace block N:` edit. */
19
+ /** True when at least one edit is an unresolved deferred block edit. */
20
20
  export declare function hasBlockEdit(edits: readonly Edit[]): boolean;
21
21
  /**
22
- * Resolve every `replace block N:` edit in `edits` against `text` (parsed as
23
- * the language inferred from `path`). Non-block edits pass through untouched.
22
+ * Resolve every deferred block edit in `edits` against `text` (parsed as the
23
+ * language inferred from `path`). Non-block edits pass through untouched.
24
24
  * Returns a fresh edit list with no `block` variants. The fast path returns the
25
25
  * input unchanged when there is nothing to resolve.
26
26
  *
@@ -29,19 +29,19 @@ export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT`
29
29
  /** Error text emitted when a `replace block N:` hunk has no body. */
30
30
  export declare const EMPTY_BLOCK = "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
31
31
  /**
32
- * Error text emitted when a `replace block N:` anchor cannot be resolved to a
32
+ * Error text emitted when a block-anchored op cannot be resolved to a
33
33
  * syntactic block (unrecognized language, blank/out-of-range line, no node
34
34
  * begins on line N such as a lone closing delimiter, or the resolved block has
35
35
  * a syntax error). Names the offending line and steers back to an explicit
36
- * `replace N..M:` range.
36
+ * concrete-line form.
37
37
  */
38
- export declare function blockUnresolvedMessage(line: number): string;
38
+ export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete" | "insert_after"): string;
39
39
  /**
40
- * Error text emitted when a `replace block N:` edit reaches a code path that
40
+ * Error text emitted when a block-anchored edit reaches a code path that
41
41
  * has no {@link BlockResolver} wired in. Indicates a host-configuration bug
42
42
  * rather than authored-input error.
43
43
  */
44
- export declare const BLOCK_RESOLVER_UNAVAILABLE = "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
44
+ export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block-anchored ops (`replace block N:`, `delete block N`, `insert after block N:`) are not available here (no tree-sitter block resolver is configured). Use a concrete line range instead.";
45
45
  /**
46
46
  * Internal invariant error: `applyEdits` received an unresolved `replace block
47
47
  * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
@@ -54,6 +54,15 @@ export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body ro
54
54
  export declare const DELETE_BLOCK_TAKES_NO_BODY = "`delete block N` does not take body rows. Remove the body, or use `replace block N:` to replace the block.";
55
55
  /** Error text emitted when an insert hunk has no body. */
56
56
  export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
57
+ /**
58
+ * Warning emitted when an `insert after` edit's body rows are indented
59
+ * shallower than the anchor line and the landing point was slid forward past
60
+ * the structural closer lines that follow. The body's indentation names the
61
+ * depth the author wants the new lines to sit at; anchoring inside a deeper
62
+ * construct is the common "insert after the block, anchored on the last line
63
+ * I read" mistake.
64
+ */
65
+ export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
57
66
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
58
67
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
59
68
  /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
@@ -34,6 +34,12 @@ export interface InMemorySnapshotStoreOptions {
34
34
  maxPaths?: number;
35
35
  /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
36
36
  maxVersionsPerPath?: number;
37
+ /**
38
+ * Global ceiling on retained snapshot text summed across every path's
39
+ * version history, measured in UTF-16 code units (default 64 MiB).
40
+ * Least-recently-used path histories are evicted to stay under it.
41
+ */
42
+ maxTotalBytes?: number;
37
43
  }
38
44
  /**
39
45
  * In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path history is a
@@ -21,6 +21,9 @@ export type BlockTarget = {
21
21
  } | {
22
22
  kind: "insert_after";
23
23
  anchor: Anchor;
24
+ } | {
25
+ kind: "insert_after_block";
26
+ anchor: Anchor;
24
27
  } | {
25
28
  kind: "bof";
26
29
  } | {
@@ -41,18 +41,21 @@ export type Edit = {
41
41
  oldAssertion?: string;
42
42
  } | {
43
43
  /**
44
- * Deferred block edit (`replace block N:` / `delete block N`). The exact
45
- * line span is unknown at parse time — it is computed by
46
- * {@link resolveBlockEdits} once file text + path (→ language) are
47
- * available, then expanded into concrete edits: a non-empty `payloads`
48
- * (from `replace block`) becomes the same `replacement` inserts + deletes
49
- * that `replace start..end:` produces; an empty `payloads` (from `delete
50
- * block`) becomes a pure range deletion. `applyEdits` never sees this
44
+ * Deferred block edit (`replace block N:` / `delete block N` /
45
+ * `insert after block N:`). The exact line span is unknown at parse
46
+ * time — it is computed by {@link resolveBlockEdits} once file text +
47
+ * path (→ language) are available, then expanded into concrete edits:
48
+ * a non-empty `payloads` without `mode` (from `replace block`) becomes
49
+ * the same `replacement` inserts + deletes that `replace start..end:`
50
+ * produces; an empty `payloads` (from `delete block`) becomes a pure
51
+ * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
52
+ * inserts at the block's last line. `applyEdits` never sees this
51
53
  * variant.
52
54
  */
53
55
  kind: "block";
54
56
  anchor: Anchor;
55
57
  payloads: string[];
58
+ mode?: "insert_after";
56
59
  lineNum: number;
57
60
  index: number;
58
61
  };
@@ -120,11 +123,11 @@ export interface BlockSpan {
120
123
  end: number;
121
124
  }
122
125
  /**
123
- * One `replace block N:` / `delete block N` anchor resolved to its concrete
124
- * line span. Surfaced on {@link ApplyResult} so the host can echo
125
- * "block N → lines start..end" and let the model catch a wrong opener — e.g. a
126
- * decorator or doc-comment that sits in a separate node outside the resolved
127
- * block.
126
+ * One `replace block N:` / `delete block N` / `insert after block N:` anchor
127
+ * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
128
+ * host can echo "block N → lines start..end" and let the model catch a wrong
129
+ * opener — e.g. a decorator or doc-comment that sits in a separate node
130
+ * outside the resolved block.
128
131
  */
129
132
  export interface BlockResolution {
130
133
  /** The 1-indexed line the block op was anchored on (the `N`). */
@@ -133,8 +136,8 @@ export interface BlockResolution {
133
136
  start: number;
134
137
  /** Last line of the resolved span (1-indexed, inclusive). */
135
138
  end: number;
136
- /** True for `delete block N`; false for `replace block N:`. */
137
- isDelete: boolean;
139
+ /** Which block op produced this resolution. */
140
+ op: "replace" | "delete" | "insert_after";
138
141
  }
139
142
  /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
140
143
  export interface BlockResolverRequest {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.10.10",
4
+ "version": "15.10.12",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
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, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
11
11
  import { cloneCursor } from "./tokenizer";
12
12
  import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
13
13
 
@@ -40,11 +40,21 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] {
40
40
  * checked once per section via the header hash before this function runs.
41
41
  */
42
42
  function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
43
+ // `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
44
+ // It is addressable for inserts (append-past-end), but deleting it would
45
+ // silently strip the file's final newline — an off-by-one that must error.
46
+ const phantomLine = fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
43
47
  for (const edit of edits) {
44
48
  for (const anchor of getEditAnchors(edit)) {
45
49
  if (anchor.line < 1 || anchor.line > fileLines.length) {
46
50
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
47
51
  }
52
+ if (edit.kind === "delete" && anchor.line === phantomLine) {
53
+ throw new Error(
54
+ `Line ${anchor.line} is the trailing blank sentinel of a newline-terminated file and has no content to delete. ` +
55
+ `End the range at line ${anchor.line - 1}, or use \`insert tail:\` to append.`,
56
+ );
57
+ }
48
58
  }
49
59
  }
50
60
  }
@@ -383,6 +393,21 @@ function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[])
383
393
  // repair would strip explicit replacement content with no signal that the
384
394
  // payload was a mistake rather than an intentional duplication.
385
395
  if (leadingMax + trailingMax >= group.payload.length) return undefined;
396
+ // Balance-neutrality guard (see header comment): the dropped echo lines must
397
+ // either be delimiter-neutral on their own or exactly cancel the payload/range
398
+ // balance delta. In brace-heavy code where bare closer lines repeat, an
399
+ // "echo" that shifts delimiter balance is structural content the payload
400
+ // placed intentionally — stripping it would corrupt the result.
401
+ const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
402
+ const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
403
+ const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
404
+ if (!balanceIsZero(droppedBalance)) {
405
+ const delta = balanceDelta(
406
+ computeDelimiterBalance(group.payload),
407
+ computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
408
+ );
409
+ if (!balanceEqual(droppedBalance, delta)) return undefined;
410
+ }
386
411
  return { leading: leadingMax, trailing: trailingMax };
387
412
  }
388
413
 
@@ -481,6 +506,150 @@ function repairReplacementBoundaries(
481
506
  return { edits: out, warnings };
482
507
  }
483
508
 
509
+ // ═══════════════════════════════════════════════════════════════════════════
510
+ // After-insert landing correction
511
+ //
512
+ // The body rows of an `insert after N:` hunk carry an implicit depth claim:
513
+ // their leading indentation says how deep the author expects the new lines
514
+ // to sit. When that depth is shallower than line N itself, the hunk is
515
+ // inserting a sibling of some enclosing construct while anchored inside it —
516
+ // the common shape is anchoring on the last statement of a block and writing
517
+ // the body at the parent's depth. Sliding the landing point forward across
518
+ // the structural closer lines that follow (and nothing else — content lines
519
+ // are never crossed) places the body at the depth its indentation names.
520
+ //
521
+ // The shift is deliberately conservative: it fires only when the body and
522
+ // anchor indentation are comparable (one is a prefix of the other), crosses
523
+ // only pure closing-delimiter lines indented at or deeper than the body,
524
+ // stops as soon as depth returns to the body's level, and is abandoned when
525
+ // any other edit in the patch targets a crossed line. Every shift is
526
+ // reported as a warning so the author can re-issue with deeper indentation
527
+ // when the original landing was intended.
528
+
529
+ /** Leading run of tabs and spaces. */
530
+ function leadingIndent(line: string): string {
531
+ let end = 0;
532
+ while (end < line.length) {
533
+ const code = line.charCodeAt(end);
534
+ if (code !== 9 && code !== 32) break;
535
+ end++;
536
+ }
537
+ return line.slice(0, end);
538
+ }
539
+
540
+ /** `deeper` strictly extends `shallower` (same indent style, more depth). */
541
+ function isIndentDeeper(deeper: string, shallower: string): boolean {
542
+ return deeper.length > shallower.length && deeper.startsWith(shallower);
543
+ }
544
+
545
+ interface AfterInsertGroup {
546
+ /** Anchor line shared by every insert row of the hunk. */
547
+ anchor: number;
548
+ /** Indices into the edit list, in patch order. */
549
+ members: number[];
550
+ }
551
+
552
+ /**
553
+ * Depth of an after-insert hunk's body: the shallowest indentation across its
554
+ * non-blank rows. Returns `undefined` when no depth claim can be made — an
555
+ * all-blank or all-closer body, or rows whose indentation styles are not
556
+ * mutually comparable (tabs vs spaces).
557
+ */
558
+ function bodyTargetIndent(rows: readonly string[]): string | undefined {
559
+ const nonBlank = rows.filter(hasNonWhitespace);
560
+ if (nonBlank.length === 0) return undefined;
561
+ // A body of pure closers re-balances delimiters; it claims no depth.
562
+ if (nonBlank.every(row => STRUCTURAL_CLOSER_RE.test(row))) return undefined;
563
+ let target = leadingIndent(nonBlank[0] ?? "");
564
+ for (const row of nonBlank) {
565
+ const indent = leadingIndent(row);
566
+ if (indent.startsWith(target)) continue;
567
+ if (target.startsWith(indent)) target = indent;
568
+ else return undefined;
569
+ }
570
+ return target;
571
+ }
572
+
573
+ /**
574
+ * Resolve where an after-insert hunk anchored on `group.anchor` should land
575
+ * given its body depth `target`: the last structural closer line in the run
576
+ * directly below the anchor whose indentation still covers `target`. Returns
577
+ * `undefined` when the landing stays put.
578
+ */
579
+ function resolveShiftedLanding(
580
+ group: AfterInsertGroup,
581
+ target: string,
582
+ fileLines: readonly string[],
583
+ targetedLines: ReadonlySet<number>,
584
+ ): { line: number; crossed: number } | undefined {
585
+ const anchorText = fileLines[group.anchor - 1];
586
+ if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
587
+ if (!isIndentDeeper(leadingIndent(anchorText), target)) return undefined;
588
+
589
+ let landing = group.anchor;
590
+ let crossed = 0;
591
+ for (let line = group.anchor + 1; line <= fileLines.length; line++) {
592
+ const text = fileLines[line - 1] ?? "";
593
+ if (!hasNonWhitespace(text)) continue; // look past blanks, never land on them
594
+ if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content is never crossed
595
+ const indent = leadingIndent(text);
596
+ if (!indent.startsWith(target)) break; // shallower than the body — crossing would over-escape
597
+ if (targetedLines.has(line)) return undefined; // another hunk owns this closer
598
+ landing = line;
599
+ crossed++;
600
+ if (indent.length === target.length) break; // depth returned to the body's level
601
+ }
602
+ return landing === group.anchor ? undefined : { line: landing, crossed };
603
+ }
604
+
605
+ /**
606
+ * Slide mis-anchored `insert after N:` hunks past the structural closer lines
607
+ * that directly follow their anchor when the body's indentation says the new
608
+ * lines belong at a shallower depth. Returns the corrected edit list plus one
609
+ * warning per shifted hunk.
610
+ */
611
+ function repairAfterInsertLandings(
612
+ edits: readonly AppliedEdit[],
613
+ fileLines: readonly string[],
614
+ ): { edits: readonly AppliedEdit[]; warnings: string[] } {
615
+ // Group plain (non-replacement) after-anchor inserts per authored hunk:
616
+ // rows of one hunk share the anchor line and the patch header line.
617
+ const groups = new Map<string, AfterInsertGroup>();
618
+ edits.forEach((edit, idx) => {
619
+ if (edit.kind !== "insert" || edit.mode === "replacement") return;
620
+ if (edit.cursor.kind !== "after_anchor") return;
621
+ const key = `${edit.cursor.anchor.line}:${edit.lineNum}`;
622
+ const group = groups.get(key);
623
+ if (group === undefined) groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx] });
624
+ else group.members.push(idx);
625
+ });
626
+ if (groups.size === 0) return { edits, warnings: [] };
627
+
628
+ // Lines explicitly targeted by any edit; a shift never crosses them.
629
+ const targetedLines = new Set<number>();
630
+ for (const edit of edits) {
631
+ if (edit.kind === "delete") targetedLines.add(edit.anchor.line);
632
+ else if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor")
633
+ targetedLines.add(edit.cursor.anchor.line);
634
+ }
635
+
636
+ let out: AppliedEdit[] | undefined;
637
+ const warnings: string[] = [];
638
+ for (const group of groups.values()) {
639
+ const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
640
+ if (target === undefined) continue;
641
+ const landing = resolveShiftedLanding(group, target, fileLines, targetedLines);
642
+ if (landing === undefined) continue;
643
+ out ??= [...edits];
644
+ for (const idx of group.members) {
645
+ const edit = out[idx] as InsertEdit;
646
+ out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line: landing.line } } };
647
+ }
648
+ warnings.push(afterInsertLandingShiftWarning(group.anchor, landing.line, landing.crossed));
649
+ }
650
+ return { edits: out ?? edits, warnings };
651
+ }
652
+
484
653
  /**
485
654
  * Apply a parsed list of edits to a text body. Pure function — no I/O.
486
655
  *
@@ -508,13 +677,15 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
508
677
 
509
678
  const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
510
679
  validateLineBounds(targetEdits, fileLines);
511
- const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
680
+ const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
681
+ const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
682
+ const warnings = [...boundaryWarnings, ...landingWarnings];
512
683
 
513
684
  // Partition edits into bof, eof, and anchor-targeted buckets.
514
685
  const bofLines: string[] = [];
515
686
  const eofLines: string[] = [];
516
687
  const anchorEdits: IndexedEdit[] = [];
517
- repaired.forEach((edit, idx) => {
688
+ landed.forEach((edit, idx) => {
518
689
  if (edit.kind === "insert" && edit.cursor.kind === "bof") {
519
690
  bofLines.push(edit.text);
520
691
  } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
package/src/block.ts CHANGED
@@ -1,13 +1,16 @@
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
15
  import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
13
16
  import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
@@ -30,14 +33,14 @@ export interface ResolveBlockEditsOptions {
30
33
  onResolved?: (resolution: BlockResolution) => void;
31
34
  }
32
35
 
33
- /** True when at least one edit is an unresolved `replace block N:` edit. */
36
+ /** True when at least one edit is an unresolved deferred block edit. */
34
37
  export function hasBlockEdit(edits: readonly Edit[]): boolean {
35
38
  return edits.some(edit => edit.kind === "block");
36
39
  }
37
40
 
38
41
  /**
39
- * Resolve every `replace block N:` edit in `edits` against `text` (parsed as
40
- * the language inferred from `path`). Non-block edits pass through untouched.
42
+ * Resolve every deferred block edit in `edits` against `text` (parsed as the
43
+ * language inferred from `path`). Non-block edits pass through untouched.
41
44
  * Returns a fresh edit list with no `block` variants. The fast path returns the
42
45
  * input unchanged when there is nothing to resolve.
43
46
  *
@@ -61,19 +64,29 @@ export function resolveBlockEdits(
61
64
  resolved.push(edit);
62
65
  continue;
63
66
  }
67
+ const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
64
68
  const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
65
69
  if (span === null) {
66
70
  if (onUnresolved === "drop") continue;
67
71
  throw new Error(
68
- `line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
72
+ `line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line, op) : BLOCK_RESOLVER_UNAVAILABLE}`,
69
73
  );
70
74
  }
71
75
  options.onResolved?.({
72
76
  anchorLine: edit.anchor.line,
73
77
  start: span.start,
74
78
  end: span.end,
75
- isDelete: edit.payloads.length === 0,
79
+ op,
76
80
  });
81
+ if (op === "insert_after") {
82
+ // Mirror the parser's `insert after N:` lowering: one `after_anchor`
83
+ // insert per payload row, anchored on the block's last line.
84
+ for (const payload of edit.payloads) {
85
+ const cursor: Cursor = { kind: "after_anchor", anchor: { line: span.end } };
86
+ resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
87
+ }
88
+ continue;
89
+ }
77
90
  // Mirror the parser's `replace start..end:` expansion exactly: one
78
91
  // `before_anchor` replacement insert per payload row at `span.start`,
79
92
  // then one delete per line across `[span.start, span.end]`. An empty
@@ -15,11 +15,22 @@ import type { CompactDiffOptions, CompactDiffPreview } from "./types";
15
15
  const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2;
16
16
 
17
17
  const PREVIEW_ELISION_MARKER = "…";
18
+ /** Blank row separating non-contiguous regions of a numbered diff. */
19
+ const PREVIEW_GAP_ROW = "";
18
20
  const RAW_ELISION_MARKERS = new Set(["...", PREVIEW_ELISION_MARKER, `+${PREVIEW_ELISION_MARKER}`]);
19
21
 
22
+ function isPreviewSeparator(line: string | undefined): boolean {
23
+ return line === PREVIEW_ELISION_MARKER || line === PREVIEW_GAP_ROW;
24
+ }
25
+
20
26
  function appendPreviewLine(output: string[], line: string): void {
21
27
  const normalized = RAW_ELISION_MARKERS.has(line) ? PREVIEW_ELISION_MARKER : line;
22
- if (normalized === PREVIEW_ELISION_MARKER && output[output.length - 1] === PREVIEW_ELISION_MARKER) return;
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
+ }
23
34
  output.push(normalized);
24
35
  }
25
36
 
@@ -107,6 +118,7 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption
107
118
  }
108
119
  }
109
120
  flushAddedRun();
121
+ while (formatted.length > 0 && isPreviewSeparator(formatted[formatted.length - 1])) formatted.pop();
110
122
 
111
123
  return { preview: formatted.join("\n"), addedLines, removedLines };
112
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
 
package/src/messages.ts CHANGED
@@ -47,27 +47,39 @@ export const EMPTY_BLOCK =
47
47
  "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete N..M` with the block's line range.";
48
48
 
49
49
  /**
50
- * Error text emitted when a `replace block N:` anchor cannot be resolved to a
50
+ * Error text emitted when a block-anchored op cannot be resolved to a
51
51
  * syntactic block (unrecognized language, blank/out-of-range line, no node
52
52
  * begins on line N such as a lone closing delimiter, or the resolved block has
53
53
  * a syntax error). Names the offending line and steers back to an explicit
54
- * `replace N..M:` range.
54
+ * concrete-line form.
55
55
  */
56
- export function blockUnresolvedMessage(line: number): string {
56
+ export function blockUnresolvedMessage(line: number, op: "replace" | "delete" | "insert_after" = "replace"): string {
57
+ const phrase =
58
+ op === "delete"
59
+ ? `delete block ${line}`
60
+ : op === "insert_after"
61
+ ? `insert after block ${line}:`
62
+ : `replace block ${line}:`;
63
+ const fallback =
64
+ op === "delete"
65
+ ? `\`delete ${line}..M\``
66
+ : op === "insert_after"
67
+ ? `\`insert after M:\` with the block's explicit last line`
68
+ : `\`replace ${line}..M:\` with the block's explicit end line`;
57
69
  return (
58
- `\`replace block ${line}:\` could not resolve a syntactic block beginning on line ${line}. ` +
70
+ `\`${phrase}\` could not resolve a syntactic block beginning on line ${line}. ` +
59
71
  `The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. ` +
60
- `Use \`replace ${line}..M:\` with the block's explicit end line instead.`
72
+ `Use ${fallback} instead.`
61
73
  );
62
74
  }
63
75
 
64
76
  /**
65
- * Error text emitted when a `replace block N:` edit reaches a code path that
77
+ * Error text emitted when a block-anchored edit reaches a code path that
66
78
  * has no {@link BlockResolver} wired in. Indicates a host-configuration bug
67
79
  * rather than authored-input error.
68
80
  */
69
81
  export const BLOCK_RESOLVER_UNAVAILABLE =
70
- "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
82
+ "Block-anchored ops (`replace block N:`, `delete block N`, `insert after block N:`) are not available here (no tree-sitter block resolver is configured). Use a concrete line range instead.";
71
83
 
72
84
  /**
73
85
  * Internal invariant error: `applyEdits` received an unresolved `replace block
@@ -87,6 +99,22 @@ export const DELETE_BLOCK_TAKES_NO_BODY =
87
99
  /** Error text emitted when an insert hunk has no body. */
88
100
  export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
89
101
 
102
+ /**
103
+ * Warning emitted when an `insert after` edit's body rows are indented
104
+ * shallower than the anchor line and the landing point was slid forward past
105
+ * the structural closer lines that follow. The body's indentation names the
106
+ * depth the author wants the new lines to sit at; anchoring inside a deeper
107
+ * construct is the common "insert after the block, anchored on the last line
108
+ * I read" mistake.
109
+ */
110
+ export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
111
+ return (
112
+ `insert after ${anchorLine}: the body is indented shallower than line ${anchorLine}, so the landing was moved past ` +
113
+ `${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. ` +
114
+ `If you meant the deeper position inside the block, re-issue with the body indented to match.`
115
+ );
116
+ }
117
+
90
118
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
91
119
  export const RECOVERY_EXTERNAL_WARNING =
92
120
  "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
package/src/parser.ts CHANGED
@@ -32,6 +32,13 @@ function isSkippableCommentLine(line: string): boolean {
32
32
  return line.trimStart().startsWith("#");
33
33
  }
34
34
 
35
+ /**
36
+ * Stripped remainder of a bare `N: <value>` row that is a lone quoted or
37
+ * numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
38
+ * dict/YAML body rather than read-output paste.
39
+ */
40
+ const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
41
+
35
42
  function detectApplyPatchContamination(text: string, _hasPending: boolean): string | null {
36
43
  const trimmed = text.trimStart();
37
44
  if (trimmed.length === 0) return null;
@@ -88,6 +95,12 @@ interface Pending {
88
95
  target: BlockTarget;
89
96
  lineNum: number;
90
97
  payloads: PayloadRow[];
98
+ /**
99
+ * Blank rows seen after the body started. Interior blanks are committed to
100
+ * the payload when the next non-blank row arrives; trailing blanks before
101
+ * the next header/op are layout separators and are discarded on flush.
102
+ */
103
+ deferredBlanks: PayloadRow[];
91
104
  }
92
105
 
93
106
  export class Executor {
@@ -127,6 +140,7 @@ export class Executor {
127
140
  return;
128
141
  case "blank":
129
142
  this.#consumePendingSkippableComments();
143
+ this.#handleBlank("", token.lineNum);
130
144
  return;
131
145
  case "payload-literal":
132
146
  this.#consumePendingSkippableComments();
@@ -146,7 +160,7 @@ export class Executor {
146
160
  validateRangeOrder(token.target.range, token.lineNum);
147
161
  }
148
162
  this.#flushPending();
149
- this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [] };
163
+ this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
150
164
  return;
151
165
  }
152
166
  }
@@ -208,6 +222,7 @@ export class Executor {
208
222
  }
209
223
  if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
210
224
  if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
225
+ this.#commitDeferredBlanks(pending);
211
226
  pending.payloads.push({ kind: "literal", text, lineNum });
212
227
  }
213
228
 
@@ -215,12 +230,16 @@ export class Executor {
215
230
  const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
216
231
  if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
217
232
  if (this.#pending) {
218
- if (text.trim().length === 0) return;
233
+ if (text.trim().length === 0) {
234
+ this.#handleBlank(text, lineNum);
235
+ return;
236
+ }
219
237
  if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
220
238
  if (this.#pending.target.kind === "delete_block")
221
239
  throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
222
240
  if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
223
241
  if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
242
+ this.#commitDeferredBlanks(this.#pending);
224
243
  // Defer read-output line-number stripping to #flushPending: a bare
225
244
  // "N:text" row is only a copy-paste artifact from snapshot output
226
245
  // when *every* bare row in the hunk carries that prefix. Stripping a
@@ -238,6 +257,28 @@ export class Executor {
238
257
  );
239
258
  }
240
259
 
260
+ /**
261
+ * A blank row inside a hunk body is ambiguous: interior blanks are body
262
+ * content (a bare-pasted body legitimately contains empty lines), while
263
+ * blanks before the body starts or trailing into the next op are layout.
264
+ * Defer them; {@link #commitDeferredBlanks} folds them in only when a later
265
+ * non-blank row proves they were interior.
266
+ */
267
+ #handleBlank(text: string, lineNum: number): void {
268
+ const pending = this.#pending;
269
+ if (!pending) return;
270
+ if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
271
+ if (pending.payloads.length === 0) return;
272
+ pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
273
+ }
274
+
275
+ #commitDeferredBlanks(pending: Pending): void {
276
+ if (pending.deferredBlanks.length === 0) return;
277
+ if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
278
+ pending.payloads.push(...pending.deferredBlanks);
279
+ pending.deferredBlanks = [];
280
+ }
281
+
241
282
  /**
242
283
  * Strip a single read-output line-number prefix (`N:`) from every bare body
243
284
  * row, but only when *all* bare rows carry one. A uniform set of prefixes is
@@ -247,14 +288,22 @@ export class Executor {
247
288
  */
248
289
  #stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
249
290
  let sawBare = false;
291
+ let allLiteralValues = true;
250
292
  for (const row of payloads) {
251
- if (!row.bare) continue;
293
+ if (!row.bare || row.text.trim().length === 0) continue;
252
294
  sawBare = true;
253
- if (stripOneLeadingHashlinePrefix(row.text) === row.text) return;
295
+ const stripped = stripOneLeadingHashlinePrefix(row.text);
296
+ if (stripped === row.text) return;
297
+ allLiteralValues &&= BARE_LITERAL_VALUE_RE.test(stripped);
254
298
  }
255
299
  if (!sawBare) return;
300
+ // A body where every stripped remainder is a lone quoted/numeric literal
301
+ // (optionally comma-terminated) is the shape of a numeric-keyed dict or
302
+ // YAML mapping (`1: "one",`), not read-output paste; stripping the "N:"
303
+ // keys would mangle every line. Leave such bodies untouched.
304
+ if (allLiteralValues) return;
256
305
  for (const row of payloads) {
257
- if (row.bare) row.text = stripOneLeadingHashlinePrefix(row.text);
306
+ if (row.bare && row.text.trim().length > 0) row.text = stripOneLeadingHashlinePrefix(row.text);
258
307
  }
259
308
  }
260
309
 
@@ -273,11 +322,12 @@ export class Executor {
273
322
  this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
274
323
  }
275
324
 
276
- #pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number): void {
325
+ #pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
277
326
  this.#edits.push({
278
327
  kind: "block",
279
328
  anchor: { ...anchor },
280
329
  payloads: payloads.map(payload => payload.text),
330
+ ...(mode === undefined ? {} : { mode }),
281
331
  lineNum,
282
332
  index: this.#editIndex++,
283
333
  });
@@ -307,6 +357,11 @@ export class Executor {
307
357
  this.#pushBlock(target.anchor, payloads, lineNum);
308
358
  return;
309
359
  }
360
+ if (target.kind === "insert_after_block") {
361
+ if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
362
+ this.#pushBlock(target.anchor, payloads, lineNum, "insert_after");
363
+ return;
364
+ }
310
365
  if (payloads.length === 0) {
311
366
  if (target.kind === "replace") {
312
367
  for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
package/src/patcher.ts CHANGED
@@ -199,7 +199,24 @@ export class Patcher {
199
199
  }
200
200
 
201
201
  const results: PatchSectionResult[] = [];
202
- for (const entry of prepared) results.push(await this.commit(entry));
202
+ for (let index = 0; index < prepared.length; index++) {
203
+ try {
204
+ results.push(await this.commit(prepared[index]));
205
+ } catch (error) {
206
+ // A mid-batch write failure leaves earlier sections on disk with no
207
+ // rollback; report exactly which sections landed so the caller can
208
+ // re-issue only the missing ones instead of double-applying.
209
+ const written = prepared.slice(0, index).map(entry => entry.section.path);
210
+ const notWritten = prepared.slice(index + 1).map(entry => entry.section.path);
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ throw new Error(
213
+ `Failed to write ${prepared[index].section.path}: ${message}` +
214
+ (written.length > 0 ? ` Sections already written: ${written.join(", ")}.` : "") +
215
+ (notWritten.length > 0 ? ` Sections not written: ${notWritten.join(", ")}.` : ""),
216
+ { cause: error },
217
+ );
218
+ }
219
+ }
203
220
  return { sections: results };
204
221
  }
205
222
 
package/src/prompt.md CHANGED
@@ -5,14 +5,15 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- replace N..M: replace original lines N..M with the body rows below. CAUTION, IT IS INCLUSIVE! MAKE SURE YOU INTEND TO DELETE BOTH ENDS!
9
- replace block N: replace the whole syntactic block that BEGINS on line N — header line through closing line — resolved with tree-sitter, so you never count the end. Body rows below. Reach for this to rewrite a whole construct (function/`if`/loop/class body): the end can't be mis-counted or clipped mid-block. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line. The span is EXACTLY that node — a leading decorator/attribute/doc-comment is a separate node and is NOT swept in (see rules).
10
- delete N..M delete original lines N..M. No body.
11
- delete block N delete the whole syntactic block that BEGINS on line N.
12
- insert before N: insert the body rows immediately before line N.
13
- insert after N: insert the body rows immediately after line N.
14
- insert head: insert the body rows at the very start of the file.
15
- insert tail: insert the body rows at the very end of the file.
8
+ `replace N..M:` — replace original lines N..M with the body rows below. CAUTION, IT IS INCLUSIVE! MAKE SURE YOU INTEND TO DELETE BOTH ENDS!
9
+ `replace block N:` — replace the whole syntactic block that BEGINS on line N — header line through closing line — resolved with tree-sitter, so you never count the end. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line; a leading decorator/attribute/doc-comment is a separate node and is NOT swept in (see rules).
10
+ `delete N..M` — delete original lines N..M. No body.
11
+ `delete block N` — delete the whole syntactic block that BEGINS on line N.
12
+ `insert before N:` — insert the body rows immediately before line N.
13
+ `insert after N:` — insert the body rows immediately after line N.
14
+ `insert after block N:` — insert the body rows after the END of the syntactic block that BEGINS on line N (resolved like `replace block`).
15
+ `insert head:` — insert the body rows at the very start of the file.
16
+ `insert tail:` — insert the body rows at the very end of the file.
16
17
  Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
17
18
  </ops>
18
19
 
@@ -27,12 +28,14 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
27
28
  - Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
28
29
  - Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
29
30
  - A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
31
+ - Body indentation is a depth claim: indent body rows for the depth they should live at — an `insert after` body indented shallower than its anchor lands past the closing lines below it (the result warns and names the landing line).
30
32
  - A valid `#TAG` is NOT permission to patch the whole file — it certifies the snapshot, not your knowledge of it. Authority to touch a line comes from having literally seen that line as a `LINE:TEXT` row in a `read`/`search`, not from holding the tag. Every line in a hunk's range, and the lines bounding it, must be lines you actually saw.
31
33
  - An elided or partial read is NOT a read of the gap. A `…` (or any collapsed/truncated region) between two excerpts means those lines are UNSEEN — treat them exactly like lines you never opened. Never place a hunk on, or span a range across, an elided region; `read` that range explicitly first. Reconstructing it from memory of "what the code probably looks like" is how ranges drift off-by-N and shred neighboring blocks.
32
34
  - On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
33
35
  - One hunk per range; the body is the final content, never an old/new pair.
34
- - Keep every range as tight as the change: a range must cover ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside — change one line with `replace N..N`, not the whole block around it. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale one-line range corrupts one line, while a stale wide range shreds every line it spans. (This is about hand-counted `replace N..M` ranges; the `replace block N` operator is the opposite — tree-sitter fixes the end, so it can't be mis-counted or clipped.)
35
- - `replace block N` vs `replace N..M`: use `replace block N` to rewrite a WHOLE construct (function / `if` / loop / class body) — tree-sitter resolves its closing line, so a long body can't be mis-counted and a stale end can't clip it mid-block; the edit result echoes the span it matched (`replace block N → resolved lines A-B`), so glance at it to confirm you got what you meant. Use `replace N..M` to change specific lines inside a construct. The resolved span is EXACTLY the node beginning on line N: a leading decorator, attribute, or doc-comment is a separate node and is NOT included. To replace a decorated/annotated definition together with its decorator, point N at the FIRST decorator line (Python parses `@dec` + `def` as one block). A leading line-comment that parses as its own node (e.g. Rust `///`) is not captured by any single opener — use `replace N..M` spanning the comment and the construct.
36
+ - Keep every range as tight as the change: a range covers ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside — change one line with `replace N..N`, not the whole block around it. Tightness means excluding unchanged lines, not being short: a range where every line genuinely changes is correctly long. Tight ranges bound the blast radius of a stale number: a stale one-line range corrupts one line; a stale wide range shreds every line it spans. This applies to hand-counted `replace N..M` ranges; `replace block N` is exempt — tree-sitter fixes the end.
37
+ - `replace block N` vs `replace N..M`: use `replace block N` to rewrite a WHOLE construct (function / `if` / loop / class body) — tree-sitter resolves its closing line, so a long body can't be mis-counted and a stale end can't clip it mid-block. The edit result echoes the span it matched (`replace block N → resolved lines A-B`); glance at it to confirm you got what you meant. Use `replace N..M` to change specific lines inside a construct.
38
+ - The resolved span of `replace block N` is EXACTLY the node beginning on line N. A leading decorator, attribute, or doc-comment is a separate node and is NOT included; to take a decorated definition together with its decorator, point N at the FIRST decorator line (Python parses `@dec` + `def` as one block). A leading line-comment that parses as its own node (e.g. Rust `///`) is not captured by any single opener — use `replace N..M` spanning the comment and the construct.
36
39
  - To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
37
40
  - Pure additions use `insert`, never a widened `replace`. If the change only adds lines, `insert before/after` the spot and keep every existing line out of all ranges. Do NOT `replace` a span of keepers and retype them around the new line "to preserve" them — those retyped keepers are exactly what gets silently dropped when one is forgotten. A keeper that never enters your body cannot be lost. `replace` is only for lines whose own text changes.
38
41
  - NEVER use this tool to format code — reordering imports, re-indenting, aligning columns, or any mechanical restyling. That is the project formatter's job; run it instead of hand-editing layout here.
package/src/snapshots.ts CHANGED
@@ -62,12 +62,20 @@ export abstract class SnapshotStore {
62
62
 
63
63
  const DEFAULT_MAX_PATHS = 30;
64
64
  const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
65
+ /** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
66
+ const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
65
67
 
66
68
  export interface InMemorySnapshotStoreOptions {
67
69
  /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
68
70
  maxPaths?: number;
69
71
  /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
70
72
  maxVersionsPerPath?: number;
73
+ /**
74
+ * Global ceiling on retained snapshot text summed across every path's
75
+ * version history, measured in UTF-16 code units (default 64 MiB).
76
+ * Least-recently-used path histories are evicted to stay under it.
77
+ */
78
+ maxTotalBytes?: number;
71
79
  }
72
80
 
73
81
  /**
@@ -85,7 +93,15 @@ export class InMemorySnapshotStore extends SnapshotStore {
85
93
 
86
94
  constructor(options: InMemorySnapshotStoreOptions = {}) {
87
95
  super();
88
- this.#versions = new LRUCache<string, Snapshot[]>({ max: options.maxPaths ?? DEFAULT_MAX_PATHS });
96
+ this.#versions = new LRUCache<string, Snapshot[]>({
97
+ max: options.maxPaths ?? DEFAULT_MAX_PATHS,
98
+ maxSize: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
99
+ sizeCalculation: history => {
100
+ let total = 1;
101
+ for (const version of history) total += version.text.length;
102
+ return total;
103
+ },
104
+ });
89
105
  this.#maxVersionsPerPath = options.maxVersionsPerPath ?? DEFAULT_MAX_VERSIONS_PER_PATH;
90
106
  }
91
107
 
package/src/tokenizer.ts CHANGED
@@ -204,6 +204,7 @@ export type BlockTarget =
204
204
  | { kind: "delete_block"; anchor: Anchor }
205
205
  | { kind: "insert_before"; anchor: Anchor }
206
206
  | { kind: "insert_after"; anchor: Anchor }
207
+ | { kind: "insert_after_block"; anchor: Anchor }
207
208
  | { kind: "bof" }
208
209
  | { kind: "eof" };
209
210
 
@@ -238,6 +239,16 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
238
239
  }
239
240
  const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
240
241
  if (afterEnd !== null) {
242
+ // `insert after block N:` — resolve N to a tree-sitter block range at
243
+ // apply time and insert after its last line. Try the `block` sub-keyword
244
+ // before falling back to a literal `insert after N:` anchor.
245
+ const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD);
246
+ if (blockEnd !== null) {
247
+ const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
248
+ if (anchor === null) return null;
249
+ const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
250
+ return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex };
251
+ }
241
252
  const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
242
253
  if (anchor === null) return null;
243
254
  const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
package/src/types.ts CHANGED
@@ -35,18 +35,21 @@ export type Edit =
35
35
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
36
36
  | {
37
37
  /**
38
- * Deferred block edit (`replace block N:` / `delete block N`). The exact
39
- * line span is unknown at parse time — it is computed by
40
- * {@link resolveBlockEdits} once file text + path (→ language) are
41
- * available, then expanded into concrete edits: a non-empty `payloads`
42
- * (from `replace block`) becomes the same `replacement` inserts + deletes
43
- * that `replace start..end:` produces; an empty `payloads` (from `delete
44
- * block`) becomes a pure range deletion. `applyEdits` never sees this
38
+ * Deferred block edit (`replace block N:` / `delete block N` /
39
+ * `insert after block N:`). The exact line span is unknown at parse
40
+ * time — it is computed by {@link resolveBlockEdits} once file text +
41
+ * path (→ language) are available, then expanded into concrete edits:
42
+ * a non-empty `payloads` without `mode` (from `replace block`) becomes
43
+ * the same `replacement` inserts + deletes that `replace start..end:`
44
+ * produces; an empty `payloads` (from `delete block`) becomes a pure
45
+ * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
46
+ * inserts at the block's last line. `applyEdits` never sees this
45
47
  * variant.
46
48
  */
47
49
  kind: "block";
48
50
  anchor: Anchor;
49
51
  payloads: string[];
52
+ mode?: "insert_after";
50
53
  lineNum: number;
51
54
  index: number;
52
55
  };
@@ -122,11 +125,11 @@ export interface BlockSpan {
122
125
  }
123
126
 
124
127
  /**
125
- * One `replace block N:` / `delete block N` anchor resolved to its concrete
126
- * line span. Surfaced on {@link ApplyResult} so the host can echo
127
- * "block N → lines start..end" and let the model catch a wrong opener — e.g. a
128
- * decorator or doc-comment that sits in a separate node outside the resolved
129
- * block.
128
+ * One `replace block N:` / `delete block N` / `insert after block N:` anchor
129
+ * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
130
+ * host can echo "block N → lines start..end" and let the model catch a wrong
131
+ * opener — e.g. a decorator or doc-comment that sits in a separate node
132
+ * outside the resolved block.
130
133
  */
131
134
  export interface BlockResolution {
132
135
  /** The 1-indexed line the block op was anchored on (the `N`). */
@@ -135,8 +138,8 @@ export interface BlockResolution {
135
138
  start: number;
136
139
  /** Last line of the resolved span (1-indexed, inclusive). */
137
140
  end: number;
138
- /** True for `delete block N`; false for `replace block N:`. */
139
- isDelete: boolean;
141
+ /** Which block op produced this resolution. */
142
+ op: "replace" | "delete" | "insert_after";
140
143
  }
141
144
 
142
145
  /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */