@oh-my-pi/hashline 15.13.2 → 16.0.0

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,12 @@
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
+
5
11
  ## [15.13.2] - 2026-06-15
6
12
 
7
13
  ### 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
- SWAP 1..1:
29
+ SWAP 1.=1:
30
30
  +const greeting = "hello";`);
31
31
  const result = await patcher.apply(patch);
32
32
 
@@ -47,9 +47,9 @@ 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
- - `SWAP A..B:` — replace lines A..B with following `+TEXT` body rows.
50
+ - `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows.
51
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.
52
+ - `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
53
53
  - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
54
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).
@@ -32,8 +32,8 @@ export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
32
32
  export declare const HL_HEADER_COLON = ":";
33
33
  /** Separator between a hashline file path and its opaque snapshot tag. */
34
34
  export declare const HL_FILE_HASH_SEP = "#";
35
- /** Separator between two line numbers in a range, e.g. `5..10`. */
36
- 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 = ".=";
37
37
  /** Separator between a line number and displayed line content in hashline mode. */
38
38
  export declare const HL_LINE_BODY_SEP = ":";
39
39
  /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
@@ -17,13 +17,13 @@ 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 `SWAP 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.";
21
21
  /** Bare body rows auto-converted to literal `+` rows. */
22
22
  export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
23
23
  /** Unified-diff-style `-` row in a hunk body. */
24
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`.";
25
25
  /** Replace hunk with no body. */
26
- export declare const EMPTY_REPLACE = "`SWAP N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N..M`.";
26
+ export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
27
27
  /** `replace_block N:` hunk with no body. */
28
28
  export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
29
29
  /**
@@ -55,7 +55,7 @@ export declare function insertAfterBlockUnresolvedLoweredWarning(line: number):
55
55
  */
56
56
  export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
57
57
  /** Delete hunk received a body row. */
58
- export declare const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP N..M:`.";
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
59
  /** `delete_block N` hunk received a body row. */
60
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:`.";
61
61
  /** Insert hunk with no body. */
@@ -53,7 +53,7 @@ export type Edit = {
53
53
  * time — it is computed by {@link resolveBlockEdits} once file text +
54
54
  * path (→ language) are available, then expanded into concrete edits:
55
55
  * a non-empty `payloads` without `mode` (from `replace_block`) becomes
56
- * the same `replacement` inserts + deletes that `replace start..end:`
56
+ * the same `replacement` inserts + deletes that `replace start.=end:`
57
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
@@ -82,7 +82,7 @@ export interface ApplyResult {
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;
@@ -132,7 +132,7 @@ export interface BlockSpan {
132
132
  /**
133
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
  */
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.2",
4
+ "version": "16.0.0",
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
package/src/block.ts CHANGED
@@ -7,7 +7,7 @@
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.
@@ -145,7 +145,7 @@ 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
151
  // `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
package/src/format.ts CHANGED
@@ -38,8 +38,8 @@ export const HL_HEADER_COLON = ":";
38
38
  /** Separator between a hashline file path and its opaque snapshot tag. */
39
39
  export const HL_FILE_HASH_SEP = "#";
40
40
 
41
- /** Separator between two line numbers in a range, e.g. `5..10`. */
42
- export const HL_RANGE_SEP = "..";
41
+ /** Separator between two line numbers in a range, e.g. `5.=10`. */
42
+ export const HL_RANGE_SEP = ".=";
43
43
 
44
44
  /** Separator between a line number and displayed line content in hashline mode. */
45
45
  export const HL_LINE_BODY_SEP = ":";
package/src/grammar.lark CHANGED
@@ -21,7 +21,7 @@ insert_block_anchor: "INS.BLK.POST " LID ":"
21
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/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,11 +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 `SWAP 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
- ("Dropped a bare hunk overlapped by the concrete hunk after it. One `SWAP 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.`;
51
50
 
52
51
  /** Bare body rows auto-converted to literal `+` rows. */
53
52
  export const BARE_BODY_AUTO_PIPED_WARNING =
@@ -58,7 +57,7 @@ export const MINUS_ROW_REJECTED =
58
57
  "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
59
58
 
60
59
  /** Replace hunk with no body. */
61
- export const EMPTY_REPLACE = "`SWAP N..M:` needs at least one `+TEXT` body row. To delete lines, use `DEL 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\`.`;
62
61
 
63
62
  /** `replace_block N:` hunk with no body. */
64
63
  export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
@@ -77,7 +76,7 @@ export function blockUnresolvedMessage(
77
76
  fileLines?: readonly string[],
78
77
  ): string {
79
78
  const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
80
- const fallback = op === "delete" ? `DEL ${line}..M` : `SWAP ${line}..M:`;
79
+ const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
81
80
  let message =
82
81
  `\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
83
82
  `(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
@@ -118,7 +117,7 @@ export const UNRESOLVED_BLOCK_INTERNAL =
118
117
  "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
119
118
 
120
119
  /** Delete hunk received a body row. */
121
- export const DELETE_TAKES_NO_BODY = "`DEL N..M` does not take body rows. Remove the body, or use `SWAP 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:\`.`;
122
121
 
123
122
  /** `delete_block N` hunk received a body row. */
124
123
  export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
@@ -226,7 +225,11 @@ export type BlockOp = "replace" | "delete" | "insert_after";
226
225
  export function blockSingleLineMessage(line: number, op: BlockOp): string {
227
226
  const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
228
227
  const plainForm =
229
- op === "insert_after" ? `INS.POST ${line}:` : op === "delete" ? `DEL ${line}` : `SWAP ${line}..${line}:`;
228
+ op === "insert_after"
229
+ ? `INS.POST ${line}:`
230
+ : op === "delete"
231
+ ? `DEL ${line}`
232
+ : `SWAP ${line}${HL_RANGE_SEP}${line}:`;
230
233
  return (
231
234
  `\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
232
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 `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|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 `SWAP N..M:`, `DEL N..M`, or `INS.PRE|POST|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 `SWAP 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 (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
72
- return "`DEL 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 \`SWAP ${trimmed}..${trimmed}:\` to replace, or \`DEL ${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 \`SWAP ${bareRange[1]}..${bareRange[2]}:\` or \`DEL ${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 \`SWAP N..M:\`, \`DEL N..M\`, or \`INS.PRE|POST|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/prompt.md CHANGED
@@ -5,16 +5,16 @@ Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag fro
5
5
  </headers>
6
6
 
7
7
  <ops>
8
- `SWAP N..M:` — replace original lines N..M with the body rows below. INCLUSIVE — line M is consumed too.
8
+ `SWAP N.=M:` — replace original lines N.=M with the body rows below. INCLUSIVE — line M is consumed too.
9
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.
10
+ `DEL N.=M` — delete original lines N.=M. No body.
11
11
  `DEL.BLK N` — delete the whole syntactic block that BEGINS on line N.
12
12
  `INS.PRE N:` — insert the body rows immediately before line N.
13
13
  `INS.POST N:` — insert the body rows immediately after line N.
14
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
15
  `INS.HEAD:` — insert the body rows at the very start of the file.
16
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:`).
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,9 +34,9 @@ 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 → `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:`.
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
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.
@@ -62,7 +62,7 @@ INS.POST 1:
62
62
  Replace line 2 with two lines:
63
63
  ```
64
64
  [greet.py#A1B2]
65
- SWAP 2..2:
65
+ SWAP 2.=2:
66
66
  + greeting = "Hi"
67
67
  + msg = f"{greeting}, {name}"
68
68
  ```
@@ -102,24 +102,24 @@ SWAP.BLK 1:
102
102
 
103
103
  <anti-patterns>
104
104
  # WRONG — empty `SWAP` to delete. RIGHT: DEL 4
105
- SWAP 4..4:
105
+ SWAP 4.=4:
106
106
 
107
- # WRONG — range describes post-edit size. RIGHT: SWAP 1..1: (body length is irrelevant)
108
- SWAP 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
- SWAP 3..3:
112
+ SWAP 3.=3:
113
113
  msg = "Hello, " + name
114
114
  - print(msg)
115
115
  + return msg
116
116
  # RIGHT
117
- SWAP 3..3:
117
+ SWAP 3.=3:
118
118
  + return msg
119
119
 
120
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:
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)
package/src/tokenizer.ts CHANGED
@@ -4,7 +4,7 @@
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
  */
@@ -40,6 +40,7 @@ const CHAR_SPACE = 32;
40
40
  const CHAR_DOT = 46;
41
41
  const CHAR_HYPHEN = 45;
42
42
  const CHAR_ELLIPSIS = 0x2026;
43
+ const CHAR_EQUALS = 61;
43
44
 
44
45
  const CHAR_UPPER_A = 65;
45
46
  const CHAR_UPPER_F = 70;
@@ -167,7 +168,11 @@ function scanRangeSeparator(line: string, index: number, end: number): number |
167
168
  consumedSeparator = true;
168
169
  continue;
169
170
  }
170
- 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
+ ) {
171
176
  cursor += 2;
172
177
  consumedSeparator = true;
173
178
  continue;
@@ -277,7 +282,7 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
277
282
  };
278
283
  }
279
284
  // `delete_block N` — resolve N to a tree-sitter block range at apply time
280
- // and delete its whole span. Like `delete N..M`, it takes no body and no
285
+ // and delete its whole span. Like `delete N.=M`, it takes no body and no
281
286
  // trailing colon.
282
287
  const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
283
288
  if (deleteBlockEnd !== null) {
package/src/types.ts CHANGED
@@ -47,7 +47,7 @@ export type Edit =
47
47
  * time — it is computed by {@link resolveBlockEdits} once file text +
48
48
  * path (→ language) are available, then expanded into concrete edits:
49
49
  * a non-empty `payloads` without `mode` (from `replace_block`) becomes
50
- * the same `replacement` inserts + deletes that `replace start..end:`
50
+ * the same `replacement` inserts + deletes that `replace start.=end:`
51
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
@@ -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;
@@ -134,7 +134,7 @@ export interface BlockSpan {
134
134
  /**
135
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
  */