@oh-my-pi/hashline 15.5.4 → 15.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prompt.md CHANGED
@@ -1,83 +1,130 @@
1
1
  Your patch language is a compact, line-anchored edit format.
2
2
 
3
3
  <payload>
4
- Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
5
- - No context rows, no gutters.
6
- - NEVER restate unchanged lines "for context".
7
- - Op lines carry NO payload. Every payload line lives on its own row and MUST start with `+`; that delimiter is stripped.
8
- - Payload indentation is literal.
4
+ Patch payload = one or more file sections:
5
+
6
+ ```
7
+ ¶PATH#HASH
8
+ A-B:
9
+ |replacement line
10
+ ↑inserted above line
11
+ ↓inserted below line
12
+ ```
13
+
14
+ - `HASH` comes from the latest `read`/`search` header. Missing? Re-`read`.
15
+ - No context rows, no gutters, no unchanged lines.
16
+ - Anchor rows are ALWAYS bare: `A-B:`, `A:`, `BOF:`, `EOF:`.
17
+ - Payload rows MUST start with `|`, `↑`, or `↓`.
18
+ - The first sigil is stripped; remaining bytes are file content.
9
19
  </payload>
10
20
 
11
- <ops>
12
- LINE↑ insert before (or BOF↑)
13
- LINE↓ insert after (or EOF↓)
14
- A-B: replace A..B (or A: == A..A)
15
- A-B! delete A..B (or A! == A..A)
16
- +PAYLOAD payload line for the preceding op
17
- </ops>
18
-
19
- <rules>
20
- - **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
21
- - **Use `+` for a blank payload line; use `++text` to write a line starting with `+text`.**
22
- - **Inserts add ONLY the rows you list.** The file's existing newlines around the anchor stay. NEVER tack a trailing `+` blank "for spacing" — it writes a literal blank line into the file, doubling whatever is already there.
23
- - **A bare `LINE↑`/`LINE↓` with no payload still inserts ONE blank line.** Not a no-op. Omit the op if you want nothing there.
24
- - **Go small.** Add → `↑`/`↓`; replace → `:`; delete → `!`.
25
- - **Line numbers are frozen references to what you have seen.** Later ops in the same hunk still use original line numbers; they do NOT shift as earlier ops apply.
26
- </rules>
21
+ <anchors>
22
+ `A-B:` anchor A..B inclusive.
23
+ `A:` shorthand for `A-A:`.
24
+ `BOF:` virtual position before line 1.
25
+ `EOF:` virtual position after the last line.
26
+ </anchors>
27
27
 
28
- <common-failures>
29
- - **NEVER replay past your range.** Stop before B+1; extend B if needed.
30
- - **Read lines look like replace ops.** `84:content` = "make line 84 content" and inline content is rejected. Don't echo read-style rows.
31
- - **NEVER fabricate file hashes.** Missing? Re-`read`.
32
- </common-failures>
28
+ <sigils>
29
+ `|content` replace A..B with `content`.
30
+ `↑content` — insert `content` before A.
31
+ `↓content` insert `content` after B.
32
+ </sigils>
33
33
 
34
- <example>
35
- ```a.ts#1a2b
36
- 1:const X = "a";
37
- 2:
38
- 3:export function f() { return X; }
39
- 4:f();
40
- ```
34
+ <semantics>
35
+ - **No payload → delete.** `5:` deletes line 5.
36
+ - **Buckets combine.** `↑` before A, `|` in place, `↓` after B.
37
+ - **Bucket order ignores interleaving.** Output order = all `↑`, then `|`/original, then all `↓`.
38
+ - **Order within a bucket is preserved.** Two `↑` rows stack top-down.
39
+ - **Blank payload = explicit.** Bare `|`, `↑`, or `↓` writes one blank line.
40
+ - **Line numbers are frozen.** Later anchors still reference pre-edit lines.
41
+ </semantics>
41
42
 
42
- # replace one line, insert after, delete
43
+ <examples>
44
+ # Replace line 1 with two lines; insert one line below the replacement.
43
45
  ```
44
46
  ¶a.ts#1a2b
45
47
  1:
46
- +const X = "b";
47
- +export const Y = X;
48
- 1
49
- +const Z = Y;
50
- 4!
48
+ |const X = "b";
49
+ |export const Y = X;
50
+ const Z = Y;
51
+ ```
52
+
53
+ # Insert above line 3. Line 3 survives because there is no `|` row.
54
+ ```
55
+ ¶a.ts#1a2b
56
+ 3:
57
+ ↑function helper() { return X; }
51
58
  ```
52
- </example>
59
+
60
+ # Delete lines 5..7.
61
+ ```
62
+ ¶a.ts#1a2b
63
+ 5-7:
64
+ ```
65
+
66
+ # Replace line 5 with one blank line.
67
+ ```
68
+ ¶a.ts#1a2b
69
+ 5:
70
+ |
71
+ ```
72
+ </examples>
73
+
74
+ <common-failures>
75
+ - **NEVER use inline payload.** `5:content` is invalid; write `5:` then `|content`.
76
+ - **Do not repeat preserved lines.** If line 5 should survive, omit `|`.
77
+ - **`↑`/`↓` payloads are new bytes only.** Never echo the anchor or a neighbor line — that line already exists; copying it into a `↓` row appends a duplicate.
78
+ - **Do not echo read gutters.** `84:content` is not payload.
79
+ - **Do not replay past B.** Stop before B+1; widen the anchor if B+1 changes.
80
+ - **NEVER fabricate file hashes.** Missing? Re-`read`.
81
+ </common-failures>
53
82
 
54
83
  <anti-pattern>
55
- # WRONG — inline payload after the sigil is rejected
56
- 1:const X = "b";
57
- 1↓const Z = Y;
58
- 1-2:const X = "b";
59
- +export const Y = X;
60
- # WRONG — INSERT used to change a line (old line survives)
61
- 1↓
62
- +const X = "b";
63
- # WRONG echoing read-style lines as context before the real op
64
- 1:const X = "a";
65
- 1-2:
66
- +const X = "b";
67
- +export const Y = X;
68
- # WRONG — trailing `+` blank writes a literal empty line; the new blank lands right next to the orig blank at line 2, doubling it
69
- 1
70
- +const Y = X;
71
- +
72
- # WRONG `2↓` still anchors at PRE-EDIT line 2 (frozen), NOT at the line just inserted by `1↓`. Both inserts land at their own anchors, giving three consecutive blanks (new from `1↓`, orig blank line 2, new from `2↓`).
73
- 1
74
- 2↓
84
+ # WRONG — inline payload after anchor.
85
+ 5:const X = "b";
86
+ # RIGHT
87
+ 5:
88
+ |const X = "b";
89
+
90
+ # WRONG — replacing line 5 just to keep it while inserting above.
91
+ 5:
92
+ ↑const Y = X;
93
+ |const X = "a";
94
+ # RIGHT — no `|`; line 5 survives automatically.
95
+ 5:
96
+ const Y = X;
97
+
98
+ # WRONG — echoing the anchor into a payload duplicates it.
99
+ # Line 5 already contains `const X = 1;`.
100
+ 5:
101
+ ↓const X = 1;
102
+ const Y = 2;
103
+ # RIGHT — payload is only the new line; the anchor survives automatically.
104
+ 5:
105
+ ↓const Y = 2;
106
+
107
+ # WRONG — read-output gutters inside payload.
108
+ 5-6:
109
+ 5:const X = "b";
110
+ 6:export const Y = X;
111
+ # RIGHT
112
+ 5-6:
113
+ |const X = "b";
114
+ |export const Y = X;
115
+
116
+ # WRONG — line numbers shifted mentally after the first block.
117
+ 1:
118
+ ↓new line
119
+ 2:
120
+ ↓another new line
121
+ # `2:` still targets original line 2, not `new line`.
75
122
  </anti-pattern>
76
123
 
77
124
  <critical>
78
- - One op per range, ever.
79
- - Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
80
- - Payload always lives on its own `+`-prefixed line — never inline with the op.
81
- - Payload is only what's NEW; never repeat anchor lines or neighbors.
82
- - Anchor exactly; don't anchor neighbors.
125
+ - Anchor rows are bare ranges ending in `:`.
126
+ - Payload rows start with `|`, `↑`, or `↓`.
127
+ - `|` means replace anchored lines.
128
+ - Only `↑`/`↓` means preserve anchored lines.
129
+ - Payload is only new content; no context rows.
83
130
  </critical>
package/src/recovery.ts CHANGED
@@ -13,7 +13,7 @@ import { applyEdits } from "./apply";
13
13
  import { computeFileHash } from "./format";
14
14
  import { RECOVERY_EXTERNAL_WARNING, RECOVERY_SESSION_CHAIN_WARNING, RECOVERY_SESSION_REPLAY_WARNING } from "./messages";
15
15
  import type { Snapshot, SnapshotStore } from "./snapshots";
16
- import type { ApplyOptions, ApplyResult, Edit } from "./types";
16
+ import type { Anchor, ApplyOptions, ApplyResult, Edit } from "./types";
17
17
 
18
18
  // Section hashes are line-precise; never let Diff.applyPatch slide a hunk
19
19
  // onto a duplicate closer 100+ lines away. If snapshot replay does not
@@ -63,16 +63,72 @@ function applyEditsToSnapshot(
63
63
  return { text: merged, firstChangedLine, warnings };
64
64
  }
65
65
 
66
+ function collectAnchorLines(edits: readonly Edit[]): number[] {
67
+ const lines: number[] = [];
68
+ for (const edit of edits) {
69
+ for (const anchor of getEditAnchors(edit)) lines.push(anchor.line);
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ function getEditAnchors(edit: Edit): Anchor[] {
75
+ if (edit.kind === "delete") return [edit.anchor];
76
+ switch (edit.cursor.kind) {
77
+ case "before_anchor":
78
+ case "after_anchor":
79
+ return [edit.cursor.anchor];
80
+ case "bof":
81
+ case "eof":
82
+ return [];
83
+ default: {
84
+ const _exhaustive: never = edit.cursor;
85
+ return _exhaustive;
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Returns true when every anchor line in `edits` has identical content in
92
+ * `previousText` and `currentText`. The session-chain replay fast-path
93
+ * requires this: if the prior in-session edit rewrote the line the model is
94
+ * now re-targeting with a stale hash, replaying onto current would silently
95
+ * overwrite the new content with whatever the model authored against the
96
+ * old content — a corruption window, not a recovery.
97
+ */
98
+ function verifyAnchorContent(previousText: string, currentText: string, edits: readonly Edit[]): boolean {
99
+ const lines = collectAnchorLines(edits);
100
+ if (lines.length === 0) return true;
101
+ const prev = previousText.split("\n");
102
+ const curr = currentText.split("\n");
103
+ for (const line of lines) {
104
+ const idx = line - 1;
105
+ if (idx < 0 || idx >= prev.length || idx >= curr.length) return false;
106
+ if (prev[idx] !== curr[idx]) return false;
107
+ }
108
+ return true;
109
+ }
110
+
66
111
  function replaySessionChainOnCurrent(
67
112
  previousText: string,
68
113
  currentText: string,
69
114
  edits: readonly Edit[],
70
115
  options: ApplyOptions,
71
116
  ): RecoveryResult | null {
72
- // Only safe when no insert/delete shifted line counts in the prior edit
73
- // chain: if total line counts match, every line number in `edits` still
74
- // resolves to the same logical row.
117
+ // Two guards narrow the corruption window. Neither alone is sufficient,
118
+ // and even together they don't fully prove correctness replay is the
119
+ // less-certain recovery mode and emits RECOVERY_SESSION_REPLAY_WARNING
120
+ // so the caller can verify the diff.
121
+ // - Equal line counts: every line number in `edits` still resolves to
122
+ // SOME logical row (no net shift across the prior chain). A
123
+ // coincidental insert+delete pair can still leave indices pointing
124
+ // at different logical rows than the model anchored against.
125
+ // - Anchor-content alignment: the row at each anchor's line index has
126
+ // identical content in previous and current. Catches the common
127
+ // case of a prior edit rewriting the targeted line; can still be
128
+ // coincidentally satisfied by a duplicated row at the shifted
129
+ // index.
75
130
  if (previousText.split("\n").length !== currentText.split("\n").length) return null;
131
+ if (!verifyAnchorContent(previousText, currentText, edits)) return null;
76
132
  let applied: ApplyResult;
77
133
  try {
78
134
  applied = applyEdits(currentText, [...edits], options);
@@ -123,8 +179,12 @@ function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
123
179
  *
124
180
  * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
125
181
  * 2. (Session chain) If the snapshot wasn't the head, retry on current text
126
- * when line counts match the user's previous edit advanced the hash but
127
- * didn't shift line numbers.
182
+ * when line counts match AND every edit's anchor line content is unchanged
183
+ * between snapshot and current — the previous in-session edit advanced
184
+ * the hash and the model's anchors still name the same logical rows. Emits
185
+ * a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
186
+ * both guards a coincidental insert+delete pair on duplicate rows can
187
+ * still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
128
188
  * 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
129
189
  * text hashes to the expected value, then 3-way-merge.
130
190
  */
@@ -148,10 +208,10 @@ export class Recovery {
148
208
  if (snapshot.fullText !== undefined) {
149
209
  const merged = applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
150
210
  if (merged !== null) return merged;
151
- // Session-chain fast-path: prior in-session edit changed the same
152
- // line(s) the user is now re-targeting with the stale hash. When
153
- // line counts match, the edits' line numbers still resolve to the
154
- // right rows replay onto the current text directly.
211
+ // Session-chain fallback: the 3-way merge on the snapshot refused.
212
+ // Replay onto current is gated by line-count equality AND
213
+ // anchor-content alignment see `replaySessionChainOnCurrent`
214
+ // for why both guards together still don't fully prove correctness.
155
215
  if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits, options);
156
216
  return null;
157
217
  }
package/src/tokenizer.ts CHANGED
@@ -10,18 +10,17 @@
10
10
  * The tokenizer is intentionally permissive about decorations and prefixes
11
11
  * the model may echo back from `read`/`search` output — leading `*`/`>`/`-`
12
12
  * markers, CR-terminated lines, leading whitespace before line numbers, and
13
- * so on are all stripped before classification.
13
+ * so on are all stripped before anchor classification.
14
14
  */
15
15
 
16
16
  import {
17
17
  describeAnchorExamples,
18
18
  HL_FILE_HASH_SEP,
19
19
  HL_FILE_PREFIX,
20
- HL_OP_DELETE,
21
- HL_OP_INSERT_AFTER,
22
- HL_OP_INSERT_BEFORE,
23
20
  HL_OP_REPLACE,
24
- HL_PAYLOAD_PREFIX,
21
+ HL_PAYLOAD_ABOVE,
22
+ HL_PAYLOAD_BELOW,
23
+ HL_PAYLOAD_REPLACE,
25
24
  } from "./format";
26
25
  import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
27
26
  import type { Anchor, Cursor, ParsedRange } from "./types";
@@ -33,10 +32,14 @@ const CHAR_NINE = 57;
33
32
  const CHAR_HASH = 35;
34
33
  const CHAR_TAB = 9;
35
34
  const CHAR_SPACE = 32;
35
+ const CHAR_HYPHEN = 45;
36
36
  const CHAR_LOWER_A = 97;
37
37
  const CHAR_LOWER_F = 102;
38
38
  const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0);
39
- const CHAR_PAYLOAD_PREFIX = HL_PAYLOAD_PREFIX.charCodeAt(0);
39
+ const CHAR_OP_REPLACE = HL_OP_REPLACE.charCodeAt(0);
40
+ const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
41
+ const CHAR_PAYLOAD_ABOVE = HL_PAYLOAD_ABOVE.charCodeAt(0);
42
+ const CHAR_PAYLOAD_BELOW = HL_PAYLOAD_BELOW.charCodeAt(0);
40
43
  const FILE_HASH_LENGTH = 4;
41
44
 
42
45
  function isDigitCode(code: number): boolean {
@@ -48,7 +51,7 @@ function isNonZeroDigitCode(code: number): boolean {
48
51
  }
49
52
 
50
53
  function isDecorationCode(code: number): boolean {
51
- return code === 42 || code === 45 || code === 62;
54
+ return code === 42 || code === CHAR_HYPHEN || code === 62;
52
55
  }
53
56
 
54
57
  function isHexDigitCode(code: number): boolean {
@@ -109,7 +112,7 @@ export function cloneCursor(cursor: Cursor): Cursor {
109
112
 
110
113
  // Leniently accept anchors copied from read/search output:
111
114
  // - optional leading line-marker decoration (`*`, `>`, `-`)
112
- // - the required bare line number
115
+ // - the required bare line number / BOF / EOF anchor
113
116
  function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
114
117
  let index = skipWhitespace(line, 0, end);
115
118
  while (index < end && isDecorationCode(line.charCodeAt(index))) index++;
@@ -135,7 +138,7 @@ function scanLineNumber(line: string, index: number, end: number): NumberScan |
135
138
  return { line: lineNumber, nextIndex };
136
139
  }
137
140
 
138
- /** Parse a bare line-number anchor (used by insert ops). Throws on malformed input. */
141
+ /** Parse a bare line-number anchor. Throws on malformed input. */
139
142
  export function parseLid(raw: string, lineNum: number): Anchor {
140
143
  const end = trimEndIndex(raw);
141
144
  const numberStart = skipDecoratedAnchorPrefix(raw, end);
@@ -161,7 +164,7 @@ function scanRange(line: string, end = trimEndIndex(line)): RangeScan | null {
161
164
 
162
165
  let nextIndex = start.nextIndex;
163
166
  let rangeEnd = start.line;
164
- if (nextIndex < end && line.charCodeAt(nextIndex) === 45) {
167
+ if (nextIndex < end && line.charCodeAt(nextIndex) === CHAR_HYPHEN) {
165
168
  const endNumber = scanLineNumber(line, nextIndex + 1, end);
166
169
  if (endNumber === null) return null;
167
170
  rangeEnd = endNumber.line;
@@ -182,103 +185,55 @@ function startsWithWord(line: string, index: number, end: number, word: string):
182
185
  return true;
183
186
  }
184
187
 
185
- function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): Cursor {
186
- const end = trimEndIndex(raw);
187
- const targetStart = skipDecoratedAnchorPrefix(raw, end);
188
+ export type BlockTarget = { kind: "range"; range: ParsedRange } | { kind: "bof" } | { kind: "eof" };
188
189
 
189
- if (startsWithWord(raw, targetStart, end, "BOF") && skipWhitespace(raw, targetStart + 3, end) === end) {
190
- return { kind: "bof" };
191
- }
192
- if (startsWithWord(raw, targetStart, end, "EOF") && skipWhitespace(raw, targetStart + 3, end) === end) {
193
- return { kind: "eof" };
194
- }
190
+ export type PayloadBucket = "above" | "replace" | "below";
195
191
 
196
- const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
197
- return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
192
+ interface TargetScan {
193
+ target: BlockTarget;
194
+ nextIndex: number;
198
195
  }
199
196
 
200
- function scanInlineBody(line: string, index: number): string | undefined {
201
- const end = trimEndIndex(line);
202
- return index < end ? line.slice(index, end) : undefined;
203
- }
197
+ function scanBlockTarget(line: string, end = trimEndIndex(line)): TargetScan | null {
198
+ const targetStart = skipDecoratedAnchorPrefix(line, end);
199
+ if (startsWithWord(line, targetStart, end, "BOF")) {
200
+ const nextIndex = skipWhitespace(line, targetStart + 3, end);
201
+ return { target: { kind: "bof" }, nextIndex };
202
+ }
203
+ if (startsWithWord(line, targetStart, end, "EOF")) {
204
+ const nextIndex = skipWhitespace(line, targetStart + 3, end);
205
+ return { target: { kind: "eof" }, nextIndex };
206
+ }
204
207
 
205
- interface ParsedInsertOp {
206
- kind: "insert";
207
- cursor: Cursor;
208
- inlineBody: string | undefined;
208
+ const range = scanRange(line, end);
209
+ return range === null ? null : { target: { kind: "range", range: range.range }, nextIndex: range.nextIndex };
209
210
  }
210
211
 
211
- interface ParsedReplaceOp {
212
- kind: "replace";
213
- range: ParsedRange;
212
+ interface ParsedBlockOp {
213
+ target: BlockTarget;
214
214
  inlineBody: string | undefined;
215
215
  }
216
216
 
217
- interface ParsedDeleteOp {
218
- kind: "delete";
219
- range: ParsedRange;
220
- trailingPayload: boolean;
221
- }
222
-
223
- type ParsedOp = ParsedInsertOp | ParsedReplaceOp | ParsedDeleteOp;
224
-
225
- function tryParseInsertOp(line: string, sigil: string, kind: "before" | "after"): ParsedInsertOp | null {
217
+ function tryParseBlockOp(line: string): ParsedBlockOp | null {
226
218
  const end = trimEndIndex(line);
227
- const targetStart = skipDecoratedAnchorPrefix(line, end);
219
+ const target = scanBlockTarget(line, end);
220
+ if (target === null) return null;
228
221
 
229
- let targetEnd: number;
230
- if (startsWithWord(line, targetStart, end, "BOF") || startsWithWord(line, targetStart, end, "EOF")) {
231
- targetEnd = targetStart + 3;
232
- } else {
233
- const anchor = scanLineNumber(line, targetStart, end);
234
- if (anchor === null) return null;
235
- targetEnd = anchor.nextIndex;
236
- }
222
+ const opIndex = skipWhitespace(line, target.nextIndex, end);
223
+ if (opIndex >= end || line.charCodeAt(opIndex) !== CHAR_OP_REPLACE) return null;
237
224
 
238
- const opIndex = skipWhitespace(line, targetEnd, end);
239
- if (opIndex >= end || line[opIndex] !== sigil) return null;
240
-
241
- // parseInsertTarget can only throw on inputs that already passed the
242
- // BOF/EOF/line-number scan above, but guard the throw anyway — the
243
- // tokenizer contract forbids it and a future refactor of the prefix
244
- // scan must not silently start raising here.
245
- try {
246
- return {
247
- kind: "insert",
248
- cursor: parseInsertTarget(line.slice(0, opIndex), 0, kind),
249
- inlineBody: scanInlineBody(line, opIndex + sigil.length),
250
- };
251
- } catch {
252
- return null;
253
- }
254
- }
255
-
256
- function tryParseReplaceOp(line: string): ParsedReplaceOp | null {
257
- const end = trimEndIndex(line);
258
- const range = scanRange(line, end);
259
- if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_REPLACE) return null;
225
+ const inlineStart = opIndex + HL_OP_REPLACE.length;
260
226
  return {
261
- kind: "replace",
262
- range: range.range,
263
- inlineBody: scanInlineBody(line, range.nextIndex + HL_OP_REPLACE.length),
227
+ target: target.target,
228
+ inlineBody: skipWhitespace(line, inlineStart, end) === end ? undefined : line.slice(inlineStart, end),
264
229
  };
265
230
  }
266
231
 
267
- function tryParseDeleteOp(line: string): ParsedDeleteOp | null {
268
- const end = trimEndIndex(line);
269
- const range = scanRange(line, end);
270
- if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_DELETE) return null;
271
- const afterSigil = range.nextIndex + HL_OP_DELETE.length;
272
- return { kind: "delete", range: range.range, trailingPayload: afterSigil !== end };
273
- }
274
-
275
- function tryParseOp(line: string): ParsedOp | null {
276
- return (
277
- tryParseInsertOp(line, HL_OP_INSERT_BEFORE, "before") ??
278
- tryParseInsertOp(line, HL_OP_INSERT_AFTER, "after") ??
279
- tryParseReplaceOp(line) ??
280
- tryParseDeleteOp(line)
281
- );
232
+ function payloadBucketForCode(code: number): PayloadBucket | undefined {
233
+ if (code === CHAR_PAYLOAD_ABOVE) return "above";
234
+ if (code === CHAR_PAYLOAD_REPLACE) return "replace";
235
+ if (code === CHAR_PAYLOAD_BELOW) return "below";
236
+ return undefined;
282
237
  }
283
238
 
284
239
  /**
@@ -323,21 +278,6 @@ function tryParseHeader(line: string): { path: string; fileHash?: string } | nul
323
278
  return fileHash !== undefined ? { path, fileHash } : { path };
324
279
  }
325
280
 
326
- /**
327
- * Returns true when the line scans as `LINE!payload` (delete sigil followed
328
- * by additional content). The parser uses this for the dedicated "deletes
329
- * only" diagnostic, separate from the standard "unrecognized op" path.
330
- */
331
- export function isDeleteOpWithPayload(line: string): boolean {
332
- const range = scanRange(line, line.length);
333
- return (
334
- range !== null &&
335
- range.nextIndex < line.length &&
336
- line[range.nextIndex] === HL_OP_DELETE &&
337
- range.nextIndex + HL_OP_DELETE.length < line.length
338
- );
339
- }
340
-
341
281
  interface TokenBase {
342
282
  /** 1-indexed line number in the original input stream. */
343
283
  lineNum: number;
@@ -349,10 +289,8 @@ export type Token =
349
289
  | (TokenBase & { kind: "envelope-end" })
350
290
  | (TokenBase & { kind: "abort" })
351
291
  | (TokenBase & { kind: "header"; path: string; fileHash?: string })
352
- | (TokenBase & { kind: "op-insert"; cursor: Cursor; inlineBody: string | undefined })
353
- | (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
354
- | (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean })
355
- | (TokenBase & { kind: "payload"; text: string })
292
+ | (TokenBase & { kind: "op-block"; target: BlockTarget; inlineBody: string | undefined })
293
+ | (TokenBase & { kind: "payload"; bucket: PayloadBucket; text: string })
356
294
  | (TokenBase & { kind: "raw"; text: string });
357
295
 
358
296
  function classifyLine(line: string, lineNum: number): Token {
@@ -370,20 +308,14 @@ function classifyLine(line: string, lineNum: number): Token {
370
308
  }
371
309
  }
372
310
 
373
- if (line.charCodeAt(0) === CHAR_PAYLOAD_PREFIX) {
374
- return { kind: "payload", lineNum, text: line.slice(HL_PAYLOAD_PREFIX.length) };
375
- }
376
- const op = tryParseOp(line);
377
- if (op !== null) {
378
- if (op.kind === "insert") {
379
- return { kind: "op-insert", lineNum, cursor: op.cursor, inlineBody: op.inlineBody };
380
- }
381
- if (op.kind === "replace") {
382
- return { kind: "op-replace", lineNum, range: op.range, inlineBody: op.inlineBody };
383
- }
384
- return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload };
311
+ const payloadBucket = payloadBucketForCode(line.charCodeAt(0));
312
+ if (payloadBucket !== undefined) {
313
+ return { kind: "payload", lineNum, bucket: payloadBucket, text: line.slice(1) };
385
314
  }
386
315
 
316
+ const op = tryParseBlockOp(line);
317
+ if (op !== null) return { kind: "op-block", lineNum, target: op.target, inlineBody: op.inlineBody };
318
+
387
319
  return { kind: "raw", lineNum, text: line };
388
320
  }
389
321
 
@@ -452,7 +384,7 @@ export class Tokenizer {
452
384
  }
453
385
 
454
386
  isOp(line: string): boolean {
455
- return tryParseOp(line) !== null;
387
+ return tryParseBlockOp(line) !== null;
456
388
  }
457
389
 
458
390
  isHeader(line: string): boolean {
package/src/types.ts CHANGED
@@ -19,10 +19,19 @@ export type Cursor =
19
19
  /**
20
20
  * A single low-level edit produced by the parser and consumed by the applier.
21
21
  * Multi-line replacements decompose to one `insert` per replacement line plus
22
- * one `delete` per consumed line.
22
+ * one `delete` per consumed line. Replacement inserts are tagged so the applier
23
+ * can distinguish "insert above this deleted line" from "new content for this
24
+ * deleted line" when a source block carries both buckets.
23
25
  */
24
26
  export type Edit =
25
- | { kind: "insert"; cursor: Cursor; text: string; lineNum: number; index: number }
27
+ | {
28
+ kind: "insert";
29
+ cursor: Cursor;
30
+ text: string;
31
+ lineNum: number;
32
+ index: number;
33
+ mode?: "replacement";
34
+ }
26
35
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
27
36
 
28
37
  /** Result of applying a parsed set of edits to a text body. */