@oh-my-pi/hashline 15.13.1 → 15.13.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.13.3] - 2026-06-15
6
+
7
+ ### Changed
8
+
9
+ - Changed the recommended hashline range separator from `..` to `.=` (e.g. `SWAP 1.=3:`, `DEL 4.=5`) so the inclusive `<=`-style end is self-evident. `HL_RANGE_SEP` is now `.=`; the prompt, grammar, error messages, and emitted headers all use it. The lenient parser still accepts the legacy `..` (and `-`/`…`/space) forms.
10
+
11
+ ## [15.13.2] - 2026-06-15
12
+
13
+ ### Breaking Changes
14
+
15
+ - Renamed all hashline DSL operators to concise abbreviated keywords:
16
+ - `replace` -> `SWAP`
17
+ - `delete` -> `DEL`
18
+ - `insert before`/`after`/`head`/`tail` -> `INS.PRE`/`POST`/`HEAD`/`TAIL`
19
+ - `replace_block` -> `SWAP.BLK`
20
+ - `delete_block` -> `DEL.BLK`
21
+ - `insert_after_block` -> `INS.BLK.POST`
22
+
5
23
  ## [15.13.1] - 2026-06-15
6
24
 
7
25
  ### Breaking Changes
package/README.md CHANGED
@@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before);
26
26
  const tag = snapshots.record("hello.ts", before);
27
27
  const patcher = new Patcher({ fs, snapshots });
28
28
  const patch = Patch.parse(String.raw`[hello.ts#${tag}]
29
- replace 1..1:
29
+ SWAP 1.=1:
30
30
  +const greeting = "hello";`);
31
31
  const result = await patcher.apply(patch);
32
32
 
@@ -47,11 +47,11 @@ still matches the recorded content hash, and refusing or attempting
47
47
  session-aware recovery on mismatch.
48
48
 
49
49
  Inside a section:
50
- - `replace A..B:` — replace lines A..B with following `+TEXT` body rows.
51
- - `replace block A:` — replace the syntactic block beginning on line A.
52
- - `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
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.
50
+ - `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows.
51
+ - `SWAP.BLK A:` — replace the syntactic block beginning on line A.
52
+ - `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
53
+ - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
54
+ - `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
55
55
  - `+TEXT` — literal body row (use `+` alone for a blank line).
56
56
 
57
57
  ## Abstractions
@@ -6,7 +6,7 @@ export interface ResolveBlockEditsOptions {
6
6
  * `blockUnresolvedMessage` error — used by the authoritative apply + final
7
7
  * preview paths. `"drop"` silently skips the edit — used by the streaming
8
8
  * preview, where a half-written file or transient parse error must not
9
- * throw. Unresolvable `insert after block N:` edits never reach this: they
9
+ * throw. Unresolvable `insert_after_block N:` edits never reach this: they
10
10
  * are lowered to plain `insert after N:` with a warning.
11
11
  */
12
12
  onUnresolved?: "throw" | "drop";
@@ -19,7 +19,7 @@ export interface ResolveBlockEditsOptions {
19
19
  onResolved?: (resolution: BlockResolution) => void;
20
20
  /**
21
21
  * Invoked once per diagnostic produced while resolving — currently the
22
- * `insert after block N:` lowerings (closer anchor or unresolvable block).
22
+ * `insert_after_block N:` lowerings (closer anchor or unresolvable block).
23
23
  * Hosts should surface these on the apply result's `warnings`.
24
24
  */
25
25
  onWarning?: (message: string) => void;
@@ -10,27 +10,30 @@ export declare const HL_FILE_SUFFIX = "]";
10
10
  /** Payload sigil for literal body rows. */
11
11
  export declare const HL_PAYLOAD_REPLACE = "+";
12
12
  /** Hunk-header keyword for concrete line replacement. */
13
- export declare const HL_REPLACE_KEYWORD = "replace";
14
- /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
15
- export declare const HL_BLOCK_KEYWORD = "block";
13
+ export declare const HL_REPLACE_KEYWORD = "SWAP";
16
14
  /** Hunk-header keyword for concrete line deletion. */
17
- export declare const HL_DELETE_KEYWORD = "delete";
15
+ export declare const HL_DELETE_KEYWORD = "DEL";
18
16
  /** Hunk-header keyword for insertion operations. */
19
- export declare const HL_INSERT_KEYWORD = "insert";
17
+ export declare const HL_INSERT_KEYWORD = "INS";
20
18
  /** Insert position keyword for inserting before a concrete line. */
21
- export declare const HL_INSERT_BEFORE = "before";
19
+ export declare const HL_INSERT_BEFORE = "PRE";
22
20
  /** Insert position keyword for inserting after a concrete line. */
23
- export declare const HL_INSERT_AFTER = "after";
21
+ export declare const HL_INSERT_AFTER = "POST";
24
22
  /** Insert position keyword for inserting at the start of the file. */
25
- export declare const HL_INSERT_HEAD = "head";
23
+ export declare const HL_INSERT_HEAD = "HEAD";
26
24
  /** Insert position keyword for inserting at the end of the file. */
27
- export declare const HL_INSERT_TAIL = "tail";
28
- /** Hunk-header terminator for body-bearing operations. */
25
+ export declare const HL_INSERT_TAIL = "TAIL";
26
+ /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
27
+ export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
28
+ /** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
29
+ export declare const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
30
+ /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
31
+ export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
29
32
  export declare const HL_HEADER_COLON = ":";
30
33
  /** Separator between a hashline file path and its opaque snapshot tag. */
31
34
  export declare const HL_FILE_HASH_SEP = "#";
32
- /** Separator between two line numbers in a range, e.g. `5..10`. */
33
- export declare const HL_RANGE_SEP = "..";
35
+ /** Separator between two line numbers in a range, e.g. `5.=10`. */
36
+ export declare const HL_RANGE_SEP = ".=";
34
37
  /** Separator between a line number and displayed line content in hashline mode. */
35
38
  export declare const HL_LINE_BODY_SEP = ":";
36
39
  /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
@@ -50,7 +50,7 @@ export declare class PatchSection {
50
50
  * method directly when you've already validated the file content and
51
51
  * just want the result.
52
52
  *
53
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
53
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
54
54
  * unresolvable block throws (this is the final, authoritative preview path).
55
55
  */
56
56
  applyTo(text: string, blockResolver?: BlockResolver): ApplyResult;
@@ -61,7 +61,7 @@ export declare class PatchSection {
61
61
  * empty-payload edit. Intended for incremental diff previews; the writer
62
62
  * path should always use {@link applyTo}.
63
63
  *
64
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
64
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
65
65
  * unresolvable block is silently dropped so a half-written file does not
66
66
  * throw mid-stream.
67
67
  */
@@ -17,51 +17,49 @@ export declare const END_PATCH_MARKER = "*** End Patch";
17
17
  */
18
18
  export declare const ABORT_MARKER = "*** Abort";
19
19
  /** Two consecutive hunks targeted the exact same concrete range. */
20
- export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `replace N..M:` hunk per range \u2014 the body is the final content, never old+new.";
21
- /** Bare bodyless hunk followed by an overlapping concrete hunk. */
22
- export declare const REPLACE_PAIR_COALESCED_OVERLAP_WARNING = "Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range \u2014 the body is the final content, never old+new.";
20
+ export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `SWAP N.=M:` hunk per range \u2014 the body is the final content, never old+new.";
23
21
  /** Bare body rows auto-converted to literal `+` rows. */
24
22
  export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
25
23
  /** Unified-diff-style `-` row in a hunk body. */
26
24
  export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-\u2026`.";
27
25
  /** Replace hunk with no body. */
28
- export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
29
- /** `replace block N:` hunk with no body. */
30
- export declare const EMPTY_BLOCK = "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
26
+ export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
27
+ /** `replace_block N:` hunk with no body. */
28
+ export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
31
29
  /**
32
30
  * Block-anchored replace/delete could not resolve to a syntactic block
33
31
  * (unsupported language, blank/out-of-range line, no node beginning on N, or
34
32
  * parse error). Appends a {@link formatAnchoredContext} preview when
35
- * `fileLines` is given. `insert after block N:` never reaches this — it is
33
+ * `fileLines` is given. `insert_after_block N:` never reaches this — it is
36
34
  * lowered to plain `insert after N:` instead (see
37
35
  * {@link insertAfterBlockUnresolvedLoweredWarning}).
38
36
  */
39
37
  export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete", fileLines?: readonly string[]): string;
40
38
  /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
41
- export declare const BLOCK_RESOLVER_UNAVAILABLE = "`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range.";
39
+ export declare const BLOCK_RESOLVER_UNAVAILABLE = "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
42
40
  /**
43
- * `insert after block N:` anchored on a closing-delimiter line, lowered to
41
+ * `insert_after_block N:` anchored on a closing-delimiter line, lowered to
44
42
  * plain `insert after N:` — the closer ends a block, and inserting after it
45
43
  * is exactly what the plain form does.
46
44
  */
47
45
  export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
48
46
  /**
49
- * `insert after block N:` anchor unresolvable (unsupported language, blank
47
+ * `insert_after_block N:` anchor unresolvable (unsupported language, blank
50
48
  * line, parse error, or no resolver), lowered to plain `insert after N:` —
51
49
  * applying with a warning beats failing the patch.
52
50
  */
53
51
  export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
54
52
  /**
55
- * Internal invariant: `applyEdits` received an unresolved `replace block N:`
53
+ * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
56
54
  * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
57
55
  */
58
- export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
56
+ export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
59
57
  /** Delete hunk received a body row. */
60
- export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
61
- /** `delete block N` hunk received a body row. */
62
- export declare const DELETE_BLOCK_TAKES_NO_BODY = "`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
58
+ export declare const DELETE_TAKES_NO_BODY = "`DEL N.=M` does not take body rows. Remove the body, or use `SWAP N.=M:`.";
59
+ /** `delete_block N` hunk received a body row. */
60
+ export declare const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
63
61
  /** Insert hunk with no body. */
64
- export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
62
+ export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
65
63
  /**
66
64
  * `insert after` body indented shallower than the anchor: the landing slid
67
65
  * forward past trailing closer lines — the common "anchored on the last line
@@ -69,7 +67,7 @@ export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body ro
69
67
  */
70
68
  export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
71
69
  /**
72
- * `insert after block N:` body indented deeper than the block's closer: the
70
+ * `insert_after_block N:` body indented deeper than the block's closer: the
73
71
  * landing was pulled inside the block — a deeper body almost always means
74
72
  * "append inside the block's body".
75
73
  */
@@ -90,7 +88,7 @@ export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying y
90
88
  * Head/tail position is content-independent, so drift is non-fatal: apply
91
89
  * onto live content and warn instead of hard-failing.
92
90
  */
93
- export declare const HEADTAIL_DRIFT_WARNING = "Applied the `insert head:`/`insert tail:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
91
+ export declare const HEADTAIL_DRIFT_WARNING = "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
94
92
  /**
95
93
  * Section omitted the mandatory snapshot tag. Shared by the apply
96
94
  * ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
@@ -106,7 +104,7 @@ export declare function unseenLinesMessage(sectionPath: string, unseenLines: rea
106
104
  /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
107
105
  export type BlockOp = "replace" | "delete" | "insert_after";
108
106
  /**
109
- * A `replace block`/`delete block`/`insert after block` anchor resolved to a
107
+ * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
110
108
  * single line — almost always a bare statement the model mis-anchored, not a
111
109
  * multi-line construct. The plain op is unambiguous for one line; the block
112
110
  * form only earns its keep when it spares counting a closing line you cannot
@@ -10,8 +10,8 @@ export interface PatcherOptions {
10
10
  /** Snapshot store that minted and resolves hashline section tags. Required. */
11
11
  snapshots: SnapshotStore;
12
12
  /**
13
- * Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
14
- * Optional: when omitted, any `replace block N:` edit throws on apply (the
13
+ * Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
14
+ * Optional: when omitted, any `replace_block N:` edit throws on apply (the
15
15
  * host did not wire a resolver). Plain line-range ops never need it.
16
16
  */
17
17
  blockResolver?: BlockResolver;
@@ -41,7 +41,7 @@ export interface PatchSectionResult {
41
41
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
42
42
  warnings: string[];
43
43
  /**
44
- * Resolved spans for any `replace block`/`delete block` ops, present when the
44
+ * Resolved spans for any `replace_block`/`delete_block` ops, present when the
45
45
  * apply matched the tagged content. Undefined for patches with no block ops
46
46
  * (and for resolutions routed through drift recovery, where numbers shift).
47
47
  */
@@ -34,7 +34,7 @@ export type Edit = {
34
34
  index: number;
35
35
  mode?: "replacement";
36
36
  /**
37
- * Present on inserts lowered from `insert after block N:`: the
37
+ * Present on inserts lowered from `insert_after_block N:`: the
38
38
  * resolved block's first line. Lets the applier slide a body that
39
39
  * claims a depth inside the block back across the block's trailing
40
40
  * closer lines (never above this line).
@@ -48,13 +48,13 @@ export type Edit = {
48
48
  oldAssertion?: string;
49
49
  } | {
50
50
  /**
51
- * Deferred block edit (`replace block N:` / `delete block N` /
52
- * `insert after block N:`). The exact line span is unknown at parse
51
+ * Deferred block edit (`replace_block N:` / `delete_block N` /
52
+ * `insert_after_block N:`). The exact line span is unknown at parse
53
53
  * time — it is computed by {@link resolveBlockEdits} once file text +
54
54
  * path (→ language) are available, then expanded into concrete edits:
55
- * a non-empty `payloads` without `mode` (from `replace block`) becomes
56
- * the same `replacement` inserts + deletes that `replace start..end:`
57
- * produces; an empty `payloads` (from `delete block`) becomes a pure
55
+ * a non-empty `payloads` without `mode` (from `replace_block`) becomes
56
+ * the same `replacement` inserts + deletes that `replace start.=end:`
57
+ * produces; an empty `payloads` (from `delete_block`) becomes a pure
58
58
  * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
59
59
  * inserts at the block's last line. `applyEdits` never sees this
60
60
  * variant.
@@ -75,14 +75,14 @@ export interface ApplyResult {
75
75
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
76
76
  warnings?: string[];
77
77
  /**
78
- * Resolved spans for each `replace block`/`delete block` op in this apply,
78
+ * Resolved spans for each `replace_block`/`delete_block` op in this apply,
79
79
  * in patch order. Present only when the apply matched the tagged content
80
80
  * (the common no-drift path), so the line numbers line up with what the
81
81
  * caller read. Absent when there were no block ops.
82
82
  */
83
83
  blockResolutions?: BlockResolution[];
84
84
  }
85
- /** A parsed `[A..B]` line range. */
85
+ /** A parsed `[A.=B]` line range. */
86
86
  export interface ParsedRange {
87
87
  start: Anchor;
88
88
  end: Anchor;
@@ -121,7 +121,7 @@ export interface CompactDiffOptions {
121
121
  maxUnchangedRun?: number;
122
122
  }
123
123
  /**
124
- * Resolved 1-indexed inclusive line span of a `replace block N:` target.
124
+ * Resolved 1-indexed inclusive line span of a `replace_block N:` target.
125
125
  */
126
126
  export interface BlockSpan {
127
127
  /** First line of the block (1-indexed, inclusive). */
@@ -130,9 +130,9 @@ export interface BlockSpan {
130
130
  end: number;
131
131
  }
132
132
  /**
133
- * One `replace block N:` / `delete block N` / `insert after block N:` anchor
133
+ * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
134
134
  * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
135
- * host can echo "block N → lines start..end" and let the model catch a wrong
135
+ * host can echo "block N → lines start.=end" and let the model catch a wrong
136
136
  * opener — e.g. a decorator or doc-comment that sits in a separate node
137
137
  * outside the resolved block.
138
138
  */
@@ -146,7 +146,7 @@ export interface BlockResolution {
146
146
  /** Which block op produced this resolution. */
147
147
  op: "replace" | "delete" | "insert_after";
148
148
  }
149
- /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
149
+ /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
150
150
  export interface BlockResolverRequest {
151
151
  /** Target file path (used to infer language by extension). */
152
152
  path: string;
@@ -156,7 +156,7 @@ export interface BlockResolverRequest {
156
156
  line: number;
157
157
  }
158
158
  /**
159
- * Resolves a `replace block N:` anchor to the line span of the syntactic block
159
+ * Resolves a `replace_block N:` anchor to the line span of the syntactic block
160
160
  * that begins on line N. Returns `null` when no block can be resolved
161
161
  * (unrecognized language, blank/out-of-range line, no node begins there, or the
162
162
  * resolved subtree has a syntax error). Pure seam: the hashline core declares
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.13.1",
4
+ "version": "15.13.3",
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
@@ -232,7 +232,7 @@ interface ReplacementGroup {
232
232
  * Detect a replacement group starting at `start`: a run of `before_anchor`
233
233
  * replacement inserts sharing one source op line, immediately followed by the
234
234
  * contiguous range deletes for that same op. Mirrors how the parser lowers an
235
- * `replace N..M:` hunk with a body.
235
+ * `replace N.=M:` hunk with a body.
236
236
  */
237
237
  function findReplacementGroup(edits: readonly AppliedEdit[], start: number): ReplacementGroup | undefined {
238
238
  const first = edits[start];
@@ -453,7 +453,7 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
453
453
  * by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
454
454
  *
455
455
  * Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line
456
- * `replace N..N` expanding into several lines is an *expansion* where every
456
+ * `replace N.=N` expanding into several lines is an *expansion* where every
457
457
  * payload line is intentional new content, so a payload line that happens to
458
458
  * equal a neighbor stays — only a genuine block rewrite retypes a boundary
459
459
  * keeper by mistake. The dropped lines must be delimiter-neutral so removing the
@@ -592,7 +592,7 @@ function repairReplacementBoundaries(
592
592
  // content lines are never crossed) places the body at the depth its
593
593
  // indentation names.
594
594
  //
595
- // Inward (block-lowered inserts only): `insert after block N:` anchors on the
595
+ // Inward (block-lowered inserts only): `insert_after_block N:` anchors on the
596
596
  // resolved block's closing line, but a body indented deeper than that closer
597
597
  // claims a depth inside the block — the common misreading of the op as
598
598
  // "append at the end of block N's body". Sliding the landing point backward
@@ -630,7 +630,7 @@ interface AfterInsertGroup {
630
630
  anchor: number;
631
631
  /** Indices into the edit list, in patch order. */
632
632
  members: number[];
633
- /** First line of the resolved block when lowered from `insert after block N:`. */
633
+ /** First line of the resolved block when lowered from `insert_after_block N:`. */
634
634
  blockStart?: number;
635
635
  }
636
636
 
@@ -730,7 +730,7 @@ function resolveInwardLanding(
730
730
  /**
731
731
  * Slide mis-anchored after-insert hunks to the depth their body indentation
732
732
  * claims: outward past the structural closer lines that follow the anchor
733
- * when the body is shallower, or — for `insert after block N:` lowerings —
733
+ * when the body is shallower, or — for `insert_after_block N:` lowerings —
734
734
  * inward across the block's trailing closers when the body is deeper than
735
735
  * the block's closing line. Returns the corrected edit list plus one warning
736
736
  * per shifted hunk.
package/src/block.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Expand deferred block edits (`replace block N:` / `delete block N` /
3
- * `insert after block N:`) into concrete inserts + deletes.
2
+ * Expand deferred block edits (`replace_block N:` / `delete_block N` /
3
+ * `insert_after_block N:`) into concrete inserts + deletes.
4
4
  *
5
5
  * The hashline parser cannot expand a block edit on its own — the line span is
6
6
  * unknown until file text + path (→ language) are available. This transform
7
7
  * runs at every apply/preview boundary that has text: it calls the injected
8
8
  * {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
9
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
10
+ * start.=end:` inserts + deletes for a replace, a pure range delete for a
11
11
  * delete, and plain `after_anchor` inserts at `end` for an insert-after. After
12
12
  * it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
13
13
  * ever see resolved edits.
@@ -29,7 +29,7 @@ export interface ResolveBlockEditsOptions {
29
29
  * `blockUnresolvedMessage` error — used by the authoritative apply + final
30
30
  * preview paths. `"drop"` silently skips the edit — used by the streaming
31
31
  * preview, where a half-written file or transient parse error must not
32
- * throw. Unresolvable `insert after block N:` edits never reach this: they
32
+ * throw. Unresolvable `insert_after_block N:` edits never reach this: they
33
33
  * are lowered to plain `insert after N:` with a warning.
34
34
  */
35
35
  onUnresolved?: "throw" | "drop";
@@ -42,7 +42,7 @@ export interface ResolveBlockEditsOptions {
42
42
  onResolved?: (resolution: BlockResolution) => void;
43
43
  /**
44
44
  * Invoked once per diagnostic produced while resolving — currently the
45
- * `insert after block N:` lowerings (closer anchor or unresolvable block).
45
+ * `insert_after_block N:` lowerings (closer anchor or unresolvable block).
46
46
  * Hosts should surface these on the apply result's `warnings`.
47
47
  */
48
48
  onWarning?: (message: string) => void;
@@ -82,7 +82,7 @@ export function resolveBlockEdits(
82
82
  const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
83
83
  const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
84
84
  if (span === null) {
85
- // `insert after block N:` never fails the patch — lower it to plain
85
+ // `insert_after_block N:` never fails the patch — lower it to plain
86
86
  // `insert after N:` with a warning instead. Two flavors:
87
87
  // - anchored on a pure closing-delimiter line: no block begins
88
88
  // there, but line N IS the end of one, and "after the end of the
@@ -145,10 +145,10 @@ export function resolveBlockEdits(
145
145
  }
146
146
  continue;
147
147
  }
148
- // Mirror the parser's `replace start..end:` expansion exactly: one
148
+ // Mirror the parser's `replace start.=end:` expansion exactly: one
149
149
  // `before_anchor` replacement insert per payload row at `span.start`,
150
150
  // then one delete per line across `[span.start, span.end]`. An empty
151
- // `payloads` (from `delete block N`) emits no inserts — a pure deletion.
151
+ // `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
152
152
  for (const payload of edit.payloads) {
153
153
  const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
154
154
  resolved.push({
package/src/format.ts CHANGED
@@ -14,29 +14,32 @@ export const HL_FILE_SUFFIX = "]";
14
14
  export const HL_PAYLOAD_REPLACE = "+";
15
15
 
16
16
  /** Hunk-header keyword for concrete line replacement. */
17
- export const HL_REPLACE_KEYWORD = "replace";
18
- /** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
19
- export const HL_BLOCK_KEYWORD = "block";
17
+ export const HL_REPLACE_KEYWORD = "SWAP";
20
18
  /** Hunk-header keyword for concrete line deletion. */
21
- export const HL_DELETE_KEYWORD = "delete";
19
+ export const HL_DELETE_KEYWORD = "DEL";
22
20
  /** Hunk-header keyword for insertion operations. */
23
- export const HL_INSERT_KEYWORD = "insert";
21
+ export const HL_INSERT_KEYWORD = "INS";
24
22
  /** Insert position keyword for inserting before a concrete line. */
25
- export const HL_INSERT_BEFORE = "before";
23
+ export const HL_INSERT_BEFORE = "PRE";
26
24
  /** Insert position keyword for inserting after a concrete line. */
27
- export const HL_INSERT_AFTER = "after";
25
+ export const HL_INSERT_AFTER = "POST";
28
26
  /** Insert position keyword for inserting at the start of the file. */
29
- export const HL_INSERT_HEAD = "head";
27
+ export const HL_INSERT_HEAD = "HEAD";
30
28
  /** Insert position keyword for inserting at the end of the file. */
31
- export const HL_INSERT_TAIL = "tail";
32
- /** Hunk-header terminator for body-bearing operations. */
29
+ export const HL_INSERT_TAIL = "TAIL";
30
+ /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
31
+ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
32
+ /** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
33
+ export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
34
+ /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
35
+ export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
33
36
  export const HL_HEADER_COLON = ":";
34
37
 
35
38
  /** Separator between a hashline file path and its opaque snapshot tag. */
36
39
  export const HL_FILE_HASH_SEP = "#";
37
40
 
38
- /** Separator between two line numbers in a range, e.g. `5..10`. */
39
- export const HL_RANGE_SEP = "..";
41
+ /** Separator between two line numbers in a range, e.g. `5.=10`. */
42
+ export const HL_RANGE_SEP = ".=";
40
43
 
41
44
  /** Separator between a line number and displayed line content in hashline mode. */
42
45
  export const HL_LINE_BODY_SEP = ":";
@@ -65,13 +68,13 @@ export function formatDeleteHeader(start: number, end = start): string {
65
68
  export function formatInsertHeader(cursor: Cursor): string {
66
69
  switch (cursor.kind) {
67
70
  case "before_anchor":
68
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
69
72
  case "after_anchor":
70
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
73
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
71
74
  case "bof":
72
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
75
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
73
76
  case "eof":
74
- return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
77
+ return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
75
78
  }
76
79
  }
77
80
 
package/src/grammar.lark CHANGED
@@ -12,16 +12,16 @@ 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
14
  insert_block_hunk: insert_block_anchor LF emit_op+
15
- delete_hunk: "delete " header_range LF
16
- delete_block_hunk: "delete block " LID LF
17
- replace_anchor: "replace " header_range ":"
18
- replace_block_anchor: "replace block " LID ":"
19
- insert_anchor: "insert " insert_pos ":"
20
- insert_block_anchor: "insert after block " LID ":"
21
- insert_pos: "before " LID | "after " LID | "head" | "tail"
15
+ delete_hunk: "DEL " header_range LF
16
+ delete_block_hunk: "DEL.BLK " LID LF
17
+ replace_anchor: "SWAP " header_range ":"
18
+ replace_block_anchor: "SWAP.BLK " LID ":"
19
+ insert_anchor: "INS." insert_pos ":"
20
+ insert_block_anchor: "INS.BLK.POST " LID ":"
21
+ insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
22
22
  emit_op: "+" /(.*)/ LF
23
23
 
24
- header_range: LID ".." LID
24
+ header_range: LID ".=" LID
25
25
  LID: /[1-9]\d*/
26
26
 
27
27
  %import common.LF
package/src/input.ts CHANGED
@@ -273,7 +273,7 @@ export class PatchSection {
273
273
  get hasAnchorScopedEdit(): boolean {
274
274
  return this.edits.some(edit => {
275
275
  if (edit.kind === "delete") return true;
276
- // A `replace block N:` edit is anchored to concrete content on line N.
276
+ // A `replace_block N:` edit is anchored to concrete content on line N.
277
277
  if (edit.kind === "block") return true;
278
278
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
279
279
  });
@@ -305,7 +305,7 @@ export class PatchSection {
305
305
  * method directly when you've already validated the file content and
306
306
  * just want the result.
307
307
  *
308
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
308
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
309
309
  * unresolvable block throws (this is the final, authoritative preview path).
310
310
  */
311
311
  applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
@@ -331,7 +331,7 @@ export class PatchSection {
331
331
  * empty-payload edit. Intended for incremental diff previews; the writer
332
332
  * path should always use {@link applyTo}.
333
333
  *
334
- * `blockResolver` resolves any `replace block N:` edits against `text`; an
334
+ * `blockResolver` resolves any `replace_block N:` edits against `text`; an
335
335
  * unresolvable block is silently dropped so a half-written file does not
336
336
  * throw mid-stream.
337
337
  */
package/src/messages.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Centralized error/warning text for the hashline parser, applier, and patcher. */
2
2
 
3
- import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
3
+ import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP } from "./format";
4
4
 
5
5
  /** Lines of context shown either side of a hash mismatch. */
6
6
  export const MISMATCH_CONTEXT = 2;
@@ -43,12 +43,10 @@ export const END_PATCH_MARKER = "*** End Patch";
43
43
  export const ABORT_MARKER = "*** Abort";
44
44
 
45
45
  /** Two consecutive hunks targeted the exact same concrete range. */
46
- export const REPLACE_PAIR_COALESCED_WARNING =
47
- "Two hunks targeted the same range; kept only the second. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
46
+ export const REPLACE_PAIR_COALESCED_WARNING = `Two hunks targeted the same range; kept only the second. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`;
48
47
 
49
48
  /** Bare bodyless hunk followed by an overlapping concrete hunk. */
50
- export const REPLACE_PAIR_COALESCED_OVERLAP_WARNING =
51
- "Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
49
+ `Dropped a bare hunk overlapped by the concrete hunk after it. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`;
52
50
 
53
51
  /** Bare body rows auto-converted to literal `+` rows. */
54
52
  export const BARE_BODY_AUTO_PIPED_WARNING =
@@ -59,17 +57,16 @@ export const MINUS_ROW_REJECTED =
59
57
  "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
60
58
 
61
59
  /** Replace hunk with no body. */
62
- export const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
60
+ export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N${HL_RANGE_SEP}M\`.`;
63
61
 
64
- /** `replace block N:` hunk with no body. */
65
- export const EMPTY_BLOCK =
66
- "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
62
+ /** `replace_block N:` hunk with no body. */
63
+ export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
67
64
 
68
65
  /**
69
66
  * Block-anchored replace/delete could not resolve to a syntactic block
70
67
  * (unsupported language, blank/out-of-range line, no node beginning on N, or
71
68
  * parse error). Appends a {@link formatAnchoredContext} preview when
72
- * `fileLines` is given. `insert after block N:` never reaches this — it is
69
+ * `fileLines` is given. `insert_after_block N:` never reaches this — it is
73
70
  * lowered to plain `insert after N:` instead (see
74
71
  * {@link insertAfterBlockUnresolvedLoweredWarning}).
75
72
  */
@@ -78,8 +75,8 @@ export function blockUnresolvedMessage(
78
75
  op: "replace" | "delete" = "replace",
79
76
  fileLines?: readonly string[],
80
77
  ): string {
81
- const phrase = op === "delete" ? `delete block ${line}` : `replace block ${line}:`;
82
- const fallback = op === "delete" ? `delete ${line}..M` : `replace ${line}..M:`;
78
+ const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
79
+ const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
83
80
  let message =
84
81
  `\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
85
82
  `(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
@@ -92,42 +89,41 @@ export function blockUnresolvedMessage(
92
89
 
93
90
  /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
94
91
  export const BLOCK_RESOLVER_UNAVAILABLE =
95
- "`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range.";
92
+ "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
96
93
 
97
94
  /**
98
- * `insert after block N:` anchored on a closing-delimiter line, lowered to
95
+ * `insert_after_block N:` anchored on a closing-delimiter line, lowered to
99
96
  * plain `insert after N:` — the closer ends a block, and inserting after it
100
97
  * is exactly what the plain form does.
101
98
  */
102
99
  export function insertAfterBlockCloserLoweredWarning(line: number): string {
103
- return `\`insert after block ${line}:\` anchors on a closing delimiter, so it was applied as plain \`insert after ${line}:\`. Anchor on the line that OPENS the construct.`;
100
+ return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
104
101
  }
105
102
 
106
103
  /**
107
- * `insert after block N:` anchor unresolvable (unsupported language, blank
104
+ * `insert_after_block N:` anchor unresolvable (unsupported language, blank
108
105
  * line, parse error, or no resolver), lowered to plain `insert after N:` —
109
106
  * applying with a warning beats failing the patch.
110
107
  */
111
108
  export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
112
- return `\`insert after block ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`insert after ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
109
+ return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
113
110
  }
114
111
 
115
112
  /**
116
- * Internal invariant: `applyEdits` received an unresolved `replace block N:`
113
+ * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
117
114
  * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
118
115
  */
119
116
  export const UNRESOLVED_BLOCK_INTERNAL =
120
- "internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
117
+ "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
121
118
 
122
119
  /** Delete hunk received a body row. */
123
- export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
120
+ export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`;
124
121
 
125
- /** `delete block N` hunk received a body row. */
126
- export const DELETE_BLOCK_TAKES_NO_BODY =
127
- "`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
122
+ /** `delete_block N` hunk received a body row. */
123
+ export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
128
124
 
129
125
  /** Insert hunk with no body. */
130
- export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
126
+ export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
131
127
 
132
128
  /**
133
129
  * `insert after` body indented shallower than the anchor: the landing slid
@@ -135,16 +131,16 @@ export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
135
131
  * I read instead of after the block" mistake.
136
132
  */
137
133
  export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
138
- return `insert after ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
134
+ return `INS.POST ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
139
135
  }
140
136
 
141
137
  /**
142
- * `insert after block N:` body indented deeper than the block's closer: the
138
+ * `insert_after_block N:` body indented deeper than the block's closer: the
143
139
  * landing was pulled inside the block — a deeper body almost always means
144
140
  * "append inside the block's body".
145
141
  */
146
142
  export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
147
- return `insert after block ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`insert after block\` lands AFTER the block at sibling depth — if inside was intended, use plain \`insert after ${closerLine}:\`.`;
143
+ return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`;
148
144
  }
149
145
 
150
146
  /** `Recovery`: an external write matched a cached snapshot. */
@@ -170,7 +166,7 @@ export const RECOVERY_SESSION_REPLAY_WARNING =
170
166
  * onto live content and warn instead of hard-failing.
171
167
  */
172
168
  export const HEADTAIL_DRIFT_WARNING =
173
- "Applied the `insert head:`/`insert tail:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
169
+ "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
174
170
 
175
171
  /**
176
172
  * Section omitted the mandatory snapshot tag. Shared by the apply
@@ -220,20 +216,20 @@ export function unseenLinesMessage(sectionPath: string, unseenLines: readonly nu
220
216
  export type BlockOp = "replace" | "delete" | "insert_after";
221
217
 
222
218
  /**
223
- * A `replace block`/`delete block`/`insert after block` anchor resolved to a
219
+ * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
224
220
  * single line — almost always a bare statement the model mis-anchored, not a
225
221
  * multi-line construct. The plain op is unambiguous for one line; the block
226
222
  * form only earns its keep when it spares counting a closing line you cannot
227
223
  * see. Reject and point at both fixes.
228
224
  */
229
225
  export function blockSingleLineMessage(line: number, op: BlockOp): string {
230
- const blockForm = op === "insert_after" ? "insert after block" : op === "delete" ? "delete block" : "replace block";
226
+ const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
231
227
  const plainForm =
232
228
  op === "insert_after"
233
- ? `insert after ${line}:`
229
+ ? `INS.POST ${line}:`
234
230
  : op === "delete"
235
- ? `delete ${line}`
236
- : `replace ${line}..${line}:`;
231
+ ? `DEL ${line}`
232
+ : `SWAP ${line}${HL_RANGE_SEP}${line}:`;
237
233
  return (
238
234
  `\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
239
235
  `of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
package/src/parser.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * flat list of {@link Edit}s. Sits between the {@link Tokenizer} and the
4
4
  * applier.
5
5
  */
6
- import { HL_PAYLOAD_REPLACE } from "./format";
6
+ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
7
7
  import {
8
8
  BARE_BODY_AUTO_PIPED_WARNING,
9
9
  DELETE_BLOCK_TAKES_NO_BODY,
@@ -18,7 +18,9 @@ import type { Anchor, Cursor, Edit } from "./types";
18
18
 
19
19
  function validateRangeOrder(range: ParsedRange, lineNum: number): void {
20
20
  if (range.end.line < range.start.line) {
21
- throw new Error(`line ${lineNum}: range ${range.start.line}..${range.end.line} ends before it starts.`);
21
+ throw new Error(
22
+ `line ${lineNum}: range ${range.start.line}${HL_RANGE_SEP}${range.end.line} ends before it starts.`,
23
+ );
22
24
  }
23
25
  }
24
26
 
@@ -52,33 +54,33 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
52
54
  return (
53
55
  `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
54
56
  "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
55
- "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
57
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
56
58
  );
57
59
  }
58
60
  if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
59
61
  return (
60
62
  "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
61
- "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
63
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
62
64
  );
63
65
  }
64
66
  if (trimmed.startsWith("@@")) {
65
67
  const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
66
68
  return (
67
69
  `\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
68
- "Drop the `@@ ... @@` brackets and write a verb header such as `replace N..M:`."
70
+ `Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.`
69
71
  );
70
72
  }
71
- if (/^delete\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
72
- return "`delete N..M` has no colon and no body. Remove the colon and body rows.";
73
+ if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
74
+ return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`;
73
75
  }
74
76
  if (/^[1-9]\d*\s*$/.test(trimmed)) {
75
- return `hunk headers need a verb. Use \`replace ${trimmed}..${trimmed}:\` to replace, or \`delete ${trimmed}\` to delete.`;
77
+ return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
76
78
  }
77
- const bareRange = /^([1-9]\d*)\s*[-. ]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
79
+ const bareRange = /^([1-9]\d*)\s*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
78
80
  if (bareRange !== null) {
79
81
  return (
80
82
  `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
81
- `Hunk headers need a verb: write \`replace ${bareRange[1]}..${bareRange[2]}:\` or \`delete ${bareRange[1]}..${bareRange[2]}\`.`
83
+ `Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`DEL ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.`
82
84
  );
83
85
  }
84
86
  return null;
@@ -253,7 +255,7 @@ export class Executor {
253
255
  if (text.trim().length === 0) return;
254
256
  throw new Error(
255
257
  `line ${lineNum}: payload line has no preceding hunk header. ` +
256
- `Use \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got ${JSON.stringify(text)}.`,
258
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
257
259
  );
258
260
  }
259
261
 
package/src/patcher.ts CHANGED
@@ -41,8 +41,8 @@ export interface PatcherOptions {
41
41
  /** Snapshot store that minted and resolves hashline section tags. Required. */
42
42
  snapshots: SnapshotStore;
43
43
  /**
44
- * Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
45
- * Optional: when omitted, any `replace block N:` edit throws on apply (the
44
+ * Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
45
+ * Optional: when omitted, any `replace_block N:` edit throws on apply (the
46
46
  * host did not wire a resolver). Plain line-range ops never need it.
47
47
  */
48
48
  blockResolver?: BlockResolver;
@@ -73,7 +73,7 @@ export interface PatchSectionResult {
73
73
  /** Warnings collected by the parser, applier, and (optionally) recovery. */
74
74
  warnings: string[];
75
75
  /**
76
- * Resolved spans for any `replace block`/`delete block` ops, present when the
76
+ * Resolved spans for any `replace_block`/`delete_block` ops, present when the
77
77
  * apply matched the tagged content. Undefined for patches with no block ops
78
78
  * (and for resolutions routed through drift recovery, where numbers shift).
79
79
  */
@@ -112,7 +112,7 @@ export class PreparedSection {
112
112
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
113
113
  return edits.some(edit => {
114
114
  if (edit.kind === "delete") return true;
115
- // A `replace block N:` edit anchors to concrete content on line N.
115
+ // A `replace_block N:` edit anchors to concrete content on line N.
116
116
  if (edit.kind === "block") return true;
117
117
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
118
118
  });
@@ -386,7 +386,7 @@ export class Patcher {
386
386
  const expected = exists ? section.fileHash : undefined;
387
387
  const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
388
388
 
389
- // Resolve `replace block N:` edits to concrete ranges before recovery
389
+ // Resolve `replace_block N:` edits to concrete ranges before recovery
390
390
  // runs. Block anchors are expressed against the snapshot the section tag
391
391
  // names, so resolve against that exact text:
392
392
  // - live content matches the tag (or there is no tag) → resolve against
package/src/prompt.md CHANGED
@@ -1,20 +1,20 @@
1
- Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
1
+ Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body.
2
2
 
3
3
  <headers>
4
4
  Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `replace N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too.
9
- `replace block N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
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 block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `insert after`.
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.
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:`).
8
+ `SWAP N.=M:` — replace original lines N.=M with the body rows below. INCLUSIVE — line M is consumed too.
9
+ `SWAP.BLK N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
10
+ `DEL N.=M` — delete original lines N.=M. No body.
11
+ `DEL.BLK N` — delete the whole syntactic block that BEGINS on line N.
12
+ `INS.PRE N:` — insert the body rows immediately before line N.
13
+ `INS.POST N:` — insert the body rows immediately after line N.
14
+ `INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`.
15
+ `INS.HEAD:` — insert the body rows at the very start of the file.
16
+ `INS.TAIL:` — insert the body rows at the very end of the file.
17
+ Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
18
18
  </ops>
19
19
 
20
20
  <body-rows>
@@ -34,11 +34,11 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
34
34
  - On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
35
35
  - One hunk per range; the body is the final content, never an old/new pair.
36
36
  - Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
37
- - Whole construct → `replace block N` (tree-sitter resolves the end); lines inside it → `replace N..M`.
38
- - `replace block N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `replace N..M`.
39
- - Block ops (`replace block`/`delete block`/`insert after block`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`replace N..N` / `delete N` / `insert after N`) for one line, or point N at the real opener. Saw the closer? Use plain `insert after M:`.
37
+ - Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N.=M`.
38
+ - `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N.=M`.
39
+ - Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`.
40
40
  - Non-adjacent changes = separate hunks; untouched lines stay out of every range.
41
- - Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped. A multi-line `replace` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
41
+ - Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. A multi-line `SWAP` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
42
42
  - NEVER format/restyle code with this tool; run the project formatter instead.
43
43
  </rules>
44
44
 
@@ -55,14 +55,14 @@ Original (the exact shape `read` returns):
55
55
  Insert a guard after line 1:
56
56
  ```
57
57
  [greet.py#A1B2]
58
- insert after 1:
58
+ INS.POST 1:
59
59
  + if not name: name = "stranger"
60
60
  ```
61
61
 
62
62
  Replace line 2 with two lines:
63
63
  ```
64
64
  [greet.py#A1B2]
65
- replace 2..2:
65
+ SWAP 2.=2:
66
66
  + greeting = "Hi"
67
67
  + msg = f"{greeting}, {name}"
68
68
  ```
@@ -70,30 +70,30 @@ replace 2..2:
70
70
  Delete line 3:
71
71
  ```
72
72
  [greet.py#A1B2]
73
- delete 3
73
+ DEL 3
74
74
  ```
75
75
 
76
76
  Add a header and trailer:
77
77
  ```
78
78
  [greet.py#A1B2]
79
- insert head:
79
+ INS.HEAD:
80
80
  +# generated header
81
- insert tail:
81
+ INS.TAIL:
82
82
  +greet("everyone")
83
83
  ```
84
84
 
85
- Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
85
+ Replace the whole `greet` function block — `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
86
86
  ```
87
87
  [greet.py#A1B2]
88
- replace block 1:
88
+ SWAP.BLK 1:
89
89
  +def greet(name):
90
90
  + print(f"Hello, {name}")
91
91
  ```
92
92
 
93
- A decorator or doc-comment is a SEPARATE block — `replace block` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`:
93
+ A decorator or doc-comment is a SEPARATE block — `SWAP.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`:
94
94
  ```
95
95
  [svc.py#C3D4]
96
- replace block 1:
96
+ SWAP.BLK 1:
97
97
  +@cache
98
98
  +def load(key):
99
99
  + return store[key]
@@ -101,43 +101,43 @@ replace block 1:
101
101
  </example>
102
102
 
103
103
  <anti-patterns>
104
- # WRONG — empty `replace` to delete. RIGHT: delete 4
105
- replace 4..4:
104
+ # WRONG — empty `SWAP` to delete. RIGHT: DEL 4
105
+ SWAP 4.=4:
106
106
 
107
- # WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant)
108
- replace 1..2:
107
+ # WRONG — range describes post-edit size. RIGHT: SWAP 1.=1: (body length is irrelevant)
108
+ SWAP 1.=2:
109
109
  +def greet(name):
110
110
 
111
111
  # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
112
- replace 3..3:
112
+ SWAP 3.=3:
113
113
  msg = "Hello, " + name
114
114
  - print(msg)
115
115
  + return msg
116
116
  # RIGHT
117
- replace 3..3:
117
+ SWAP 3.=3:
118
118
  + return msg
119
119
 
120
- # WRONG — a pure insertion done as a widened `replace`: you only want to add one line after 2,
121
- # but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
122
- replace 2..4:
120
+ # WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2,
121
+ # but you replace 2.=4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
122
+ SWAP 2.=4:
123
123
  + msg = "Hello, " + name
124
124
  + extra = compute(name)
125
125
  + print(msg)
126
126
  # RIGHT — touch nothing you keep; the new line is the whole body.
127
- insert after 2:
127
+ INS.POST 2:
128
128
  + extra = compute(name)
129
129
 
130
- # WRONG — `insert after block N:` anchored on a closing delimiter / last visible line. RIGHT: plain `insert after M:`
131
- insert after block 3:
130
+ # WRONG — `INS.BLK.POST N:` anchored on a closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
131
+ INS.BLK.POST 3:
132
132
  +after()
133
133
  # RIGHT
134
- insert after 3:
134
+ INS.POST 3:
135
135
  +after()
136
136
  </anti-patterns>
137
137
 
138
138
  <critical>
139
139
  If you remember nothing else:
140
140
  1. RE-GROUND AFTER EVERY EDIT. Every apply mints a fresh `#TAG` and renumbers — take the next edit's numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
141
- 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `replace block N`.
141
+ 2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `SWAP.BLK N`.
142
142
  3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows; never `-old`/context lines. The range does the deleting.
143
143
  </critical>
package/src/tokenizer.ts CHANGED
@@ -4,13 +4,13 @@
4
4
  * Format shape:
5
5
  * ```
6
6
  * [path/to/file.ts#1A2B]
7
- * replace 5..7:
7
+ * replace 5.=7:
8
8
  * +literal new line
9
9
  * ```
10
10
  */
11
11
  import {
12
12
  describeAnchorExamples,
13
- HL_BLOCK_KEYWORD,
13
+ HL_DELETE_BLOCK_KEYWORD,
14
14
  HL_DELETE_KEYWORD,
15
15
  HL_FILE_HASH_LENGTH,
16
16
  HL_FILE_HASH_SEP,
@@ -18,11 +18,13 @@ import {
18
18
  HL_FILE_SUFFIX,
19
19
  HL_HEADER_COLON,
20
20
  HL_INSERT_AFTER,
21
+ HL_INSERT_AFTER_BLOCK_KEYWORD,
21
22
  HL_INSERT_BEFORE,
22
23
  HL_INSERT_HEAD,
23
24
  HL_INSERT_KEYWORD,
24
25
  HL_INSERT_TAIL,
25
26
  HL_PAYLOAD_REPLACE,
27
+ HL_REPLACE_BLOCK_KEYWORD,
26
28
  HL_REPLACE_KEYWORD,
27
29
  } from "./format";
28
30
  import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
@@ -38,6 +40,7 @@ const CHAR_SPACE = 32;
38
40
  const CHAR_DOT = 46;
39
41
  const CHAR_HYPHEN = 45;
40
42
  const CHAR_ELLIPSIS = 0x2026;
43
+ const CHAR_EQUALS = 61;
41
44
 
42
45
  const CHAR_UPPER_A = 65;
43
46
  const CHAR_UPPER_F = 70;
@@ -165,7 +168,11 @@ function scanRangeSeparator(line: string, index: number, end: number): number |
165
168
  consumedSeparator = true;
166
169
  continue;
167
170
  }
168
- if (code === CHAR_DOT && cursor + 1 < end && line.charCodeAt(cursor + 1) === CHAR_DOT) {
171
+ if (
172
+ code === CHAR_DOT &&
173
+ cursor + 1 < end &&
174
+ (line.charCodeAt(cursor + 1) === CHAR_DOT || line.charCodeAt(cursor + 1) === CHAR_EQUALS)
175
+ ) {
169
176
  cursor += 2;
170
177
  consumedSeparator = true;
171
178
  continue;
@@ -218,7 +225,7 @@ function scanKeyword(line: string, index: number, end: number, keyword: string):
218
225
  const next = index + keyword.length;
219
226
  if (next < end) {
220
227
  const code = line.charCodeAt(next);
221
- if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
228
+ if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null;
222
229
  }
223
230
  return next;
224
231
  }
@@ -229,7 +236,8 @@ function consumeOptionalColon(line: string, index: number, end: number): number
229
236
  }
230
237
 
231
238
  function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
232
- const cursor = skipWhitespace(line, index, end);
239
+ if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
240
+ const cursor = skipWhitespace(line, index + 1, end);
233
241
  const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
234
242
  if (beforeEnd !== null) {
235
243
  const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
@@ -239,16 +247,6 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
239
247
  }
240
248
  const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
241
249
  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
- }
252
250
  const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
253
251
  if (anchor === null) return null;
254
252
  const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
@@ -263,20 +261,19 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
263
261
 
264
262
  function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
265
263
  const cursor = skipWhitespace(line, start, end);
264
+
265
+ // `replace_block N:` — resolve N to a tree-sitter block range at apply time.
266
+ const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
267
+ if (replaceBlockEnd !== null) {
268
+ const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
269
+ if (anchor === null) return null;
270
+ return {
271
+ target: { kind: "block", anchor: { line: anchor.line } },
272
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
273
+ };
274
+ }
266
275
  const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
267
276
  if (replaceEnd !== null) {
268
- // `replace block N:` — resolve N to a tree-sitter block range at apply
269
- // time. Try the `block` sub-keyword before falling back to a literal
270
- // `replace N..M:` range.
271
- const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
272
- if (blockEnd !== null) {
273
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
274
- if (anchor === null) return null;
275
- return {
276
- target: { kind: "block", anchor: { line: anchor.line } },
277
- nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
278
- };
279
- }
280
277
  const range = scanHeaderRange(line, replaceEnd, end, true);
281
278
  if (range === null) return null;
282
279
  return {
@@ -284,25 +281,36 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
284
281
  nextIndex: consumeOptionalColon(line, range.nextIndex, end),
285
282
  };
286
283
  }
284
+ // `delete_block N` — resolve N to a tree-sitter block range at apply time
285
+ // and delete its whole span. Like `delete N.=M`, it takes no body and no
286
+ // trailing colon.
287
+ const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
288
+ if (deleteBlockEnd !== null) {
289
+ const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end);
290
+ if (anchor === null) return null;
291
+ const next = skipWhitespace(line, anchor.nextIndex, end);
292
+ if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
293
+ return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
294
+ }
287
295
  const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
288
296
  if (deleteEnd !== null) {
289
- // `delete block N` — resolve N to a tree-sitter block range at apply
290
- // time and delete its whole span. Like `delete N..M`, it takes no body
291
- // and no trailing colon.
292
- const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
293
- if (blockEnd !== null) {
294
- const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
295
- if (anchor === null) return null;
296
- const next = skipWhitespace(line, anchor.nextIndex, end);
297
- if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
298
- return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
299
- }
300
297
  const range = scanHeaderRange(line, deleteEnd, end, true);
301
298
  if (range === null) return null;
302
299
  const next = skipWhitespace(line, range.nextIndex, end);
303
300
  if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
304
301
  return { target: { kind: "delete", range: range.range }, nextIndex: next };
305
302
  }
303
+ // `insert_after_block N:` — insert after the last line of the tree-sitter
304
+ // block at N.
305
+ const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
306
+ if (insertAfterBlockEnd !== null) {
307
+ const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
308
+ if (anchor === null) return null;
309
+ return {
310
+ target: { kind: "insert_after_block", anchor: { line: anchor.line } },
311
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
312
+ };
313
+ }
306
314
  const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
307
315
  if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
308
316
  return null;
package/src/types.ts CHANGED
@@ -32,7 +32,7 @@ export type Edit =
32
32
  index: number;
33
33
  mode?: "replacement";
34
34
  /**
35
- * Present on inserts lowered from `insert after block N:`: the
35
+ * Present on inserts lowered from `insert_after_block N:`: the
36
36
  * resolved block's first line. Lets the applier slide a body that
37
37
  * claims a depth inside the block back across the block's trailing
38
38
  * closer lines (never above this line).
@@ -42,13 +42,13 @@ export type Edit =
42
42
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
43
43
  | {
44
44
  /**
45
- * Deferred block edit (`replace block N:` / `delete block N` /
46
- * `insert after block N:`). The exact line span is unknown at parse
45
+ * Deferred block edit (`replace_block N:` / `delete_block N` /
46
+ * `insert_after_block N:`). The exact line span is unknown at parse
47
47
  * time — it is computed by {@link resolveBlockEdits} once file text +
48
48
  * path (→ language) are available, then expanded into concrete edits:
49
- * a non-empty `payloads` without `mode` (from `replace block`) becomes
50
- * the same `replacement` inserts + deletes that `replace start..end:`
51
- * produces; an empty `payloads` (from `delete block`) becomes a pure
49
+ * a non-empty `payloads` without `mode` (from `replace_block`) becomes
50
+ * the same `replacement` inserts + deletes that `replace start.=end:`
51
+ * produces; an empty `payloads` (from `delete_block`) becomes a pure
52
52
  * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
53
53
  * inserts at the block's last line. `applyEdits` never sees this
54
54
  * variant.
@@ -70,7 +70,7 @@ export interface ApplyResult {
70
70
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
71
71
  warnings?: string[];
72
72
  /**
73
- * Resolved spans for each `replace block`/`delete block` op in this apply,
73
+ * Resolved spans for each `replace_block`/`delete_block` op in this apply,
74
74
  * in patch order. Present only when the apply matched the tagged content
75
75
  * (the common no-drift path), so the line numbers line up with what the
76
76
  * caller read. Absent when there were no block ops.
@@ -78,7 +78,7 @@ export interface ApplyResult {
78
78
  blockResolutions?: BlockResolution[];
79
79
  }
80
80
 
81
- /** A parsed `[A..B]` line range. */
81
+ /** A parsed `[A.=B]` line range. */
82
82
  export interface ParsedRange {
83
83
  start: Anchor;
84
84
  end: Anchor;
@@ -122,7 +122,7 @@ export interface CompactDiffOptions {
122
122
  }
123
123
 
124
124
  /**
125
- * Resolved 1-indexed inclusive line span of a `replace block N:` target.
125
+ * Resolved 1-indexed inclusive line span of a `replace_block N:` target.
126
126
  */
127
127
  export interface BlockSpan {
128
128
  /** First line of the block (1-indexed, inclusive). */
@@ -132,9 +132,9 @@ export interface BlockSpan {
132
132
  }
133
133
 
134
134
  /**
135
- * One `replace block N:` / `delete block N` / `insert after block N:` anchor
135
+ * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
136
136
  * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
137
- * host can echo "block N → lines start..end" and let the model catch a wrong
137
+ * host can echo "block N → lines start.=end" and let the model catch a wrong
138
138
  * opener — e.g. a decorator or doc-comment that sits in a separate node
139
139
  * outside the resolved block.
140
140
  */
@@ -149,7 +149,7 @@ export interface BlockResolution {
149
149
  op: "replace" | "delete" | "insert_after";
150
150
  }
151
151
 
152
- /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
152
+ /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
153
153
  export interface BlockResolverRequest {
154
154
  /** Target file path (used to infer language by extension). */
155
155
  path: string;
@@ -160,7 +160,7 @@ export interface BlockResolverRequest {
160
160
  }
161
161
 
162
162
  /**
163
- * Resolves a `replace block N:` anchor to the line span of the syntactic block
163
+ * Resolves a `replace_block N:` anchor to the line span of the syntactic block
164
164
  * that begins on line N. Returns `null` when no block can be resolved
165
165
  * (unrecognized language, blank/out-of-range line, no node begins there, or the
166
166
  * resolved subtree has a syntax error). Pure seam: the hashline core declares