@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/messages.ts CHANGED
@@ -27,41 +27,18 @@ export const ABORT_WARNING =
27
27
  "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
28
28
 
29
29
  /**
30
- * Warning text appended when two consecutive `A-B:` ops on the exact same
31
- * range get coalesced (model painted a before/after pair). The second op wins;
32
- * the first op's payload is silently discarded.
30
+ * Warning text appended when two consecutive blocks target the exact same
31
+ * concrete range. The second block wins; the first block is discarded.
33
32
  */
34
33
  export const REPLACE_PAIR_COALESCED_WARNING =
35
- "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.";
34
+ "Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range — payload is the final desired content, never both old and new.";
36
35
 
37
- /**
38
- * Warning text appended when un-prefixed continuation lines are accepted as
39
- * implicit payload (lenient legacy behavior). The author wrote a multi-line
40
- * replace without `+` prefixes; the parser accepted it because the lines did
41
- * not classify as ops/headers/payloads, but the canonical syntax requires `+`
42
- * on every continuation line after the op.
43
- */
44
- export const IMPLICIT_CONTINUATION_WARNING =
45
- "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
36
+ /** Error text prefix emitted when an anchor line carries inline payload. */
37
+ export const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
46
38
 
47
- /**
48
- * Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
49
- * op arrives while an outer `A-B:` replace is still pending and the inner
50
- * anchor falls inside the outer range. The author used the read-output
51
- * `LINE:TEXT` format as if it were a payload-continuation line; we strip the
52
- * `LINE:` prefix and append the body to the pending payload, but warn so the
53
- * canonical `+`-continuation form remains preferred.
54
- */
55
- export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
56
- "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` — never reuse the read-output gutter format.";
57
-
58
- /**
59
- * Warning text appended when an op carries an inline payload (`LINE:TEXT`,
60
- * `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
61
- * by `+`-prefixed payload rows on the next line(s).
62
- */
63
- export const INLINE_PAYLOAD_ACCEPTED_WARNING =
64
- "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE↑CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
39
+ /** Error text emitted when `|` replacement payload targets BOF/EOF. */
40
+ export const VIRTUAL_REPLACE_REJECTED_MESSAGE =
41
+ "BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `↑` or `↓` payload lines.";
65
42
 
66
43
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
67
44
  export const RECOVERY_EXTERNAL_WARNING =
@@ -71,6 +48,14 @@ export const RECOVERY_EXTERNAL_WARNING =
71
48
  export const RECOVERY_SESSION_CHAIN_WARNING =
72
49
  "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
73
50
 
74
- /** Warning text emitted by `Recovery` when the session-chain fast-path was taken. */
51
+ /**
52
+ * Warning text emitted by `Recovery` when the session-chain replay
53
+ * fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
54
+ * because replay is the less-certain mode: the structured-patch 3-way
55
+ * merge refused, the anchor-content gate passed, but a coincidental
56
+ * insert+delete pair earlier in the chain could still leave an anchor's
57
+ * line number pointing at a duplicated row. Surface the hedge so the
58
+ * model verifies before continuing.
59
+ */
75
60
  export const RECOVERY_SESSION_REPLAY_WARNING =
76
61
  "Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
package/src/parser.ts CHANGED
@@ -6,29 +6,28 @@
6
6
  * Lifecycle:
7
7
  *
8
8
  * 1. Construct one {@link Executor} per hunk (or share one with `reset()`).
9
- * 2. Feed it tokens via {@link Executor.feed}. Multi-line payloads are
10
- * accumulated across tokens until the next op flushes them.
11
- * 3. Call {@link Executor.end} to flush the trailing pending op and validate
12
- * cross-op invariants (no overlapping deletes, etc.).
9
+ * 2. Feed it tokens via {@link Executor.feed}. Block payload rows are
10
+ * accumulated across tokens until the next anchor block flushes them.
11
+ * 3. Call {@link Executor.end} to flush the trailing pending block and validate
12
+ * cross-block invariants (no overlapping deletes, etc.).
13
13
  *
14
14
  * Convenience entry point: {@link parsePatch}.
15
15
  */
16
- import {
17
- HL_OP_CHARS,
18
- HL_OP_DELETE,
19
- HL_OP_INSERT_AFTER,
20
- HL_OP_INSERT_BEFORE,
21
- HL_OP_REPLACE,
22
- HL_PAYLOAD_PREFIX,
23
- } from "./format";
16
+ import { HL_OP_REPLACE, HL_PAYLOAD_ABOVE, HL_PAYLOAD_BELOW, HL_PAYLOAD_REPLACE } from "./format";
24
17
  import {
25
18
  ABORT_WARNING,
26
- IMPLICIT_CONTINUATION_WARNING,
27
- INLINE_PAYLOAD_ACCEPTED_WARNING,
28
- PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
19
+ INLINE_PAYLOAD_REJECTED_PREFIX,
29
20
  REPLACE_PAIR_COALESCED_WARNING,
21
+ VIRTUAL_REPLACE_REJECTED_MESSAGE,
30
22
  } from "./messages";
31
- import { cloneCursor, isDeleteOpWithPayload, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
23
+ import {
24
+ type BlockTarget,
25
+ cloneCursor,
26
+ type ParsedRange,
27
+ type PayloadBucket,
28
+ type Token,
29
+ Tokenizer,
30
+ } from "./tokenizer";
32
31
  import type { Anchor, Cursor, Edit } from "./types";
33
32
 
34
33
  function validateRangeOrder(range: ParsedRange, lineNum: number): void {
@@ -41,8 +40,8 @@ function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
41
40
  return a.start.line === b.start.line && a.end.line === b.end.line;
42
41
  }
43
42
 
44
- function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
45
- return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
43
+ function targetsEqualConcreteRange(a: BlockTarget, b: BlockTarget): boolean {
44
+ return a.kind === "range" && b.kind === "range" && rangesEqual(a.range, b.range);
46
45
  }
47
46
 
48
47
  function expandRange(range: ParsedRange): Anchor[] {
@@ -53,21 +52,46 @@ function expandRange(range: ParsedRange): Anchor[] {
53
52
  return anchors;
54
53
  }
55
54
 
56
- type PendingOp =
57
- | { kind: "insert"; cursor: Cursor; lineNum: number }
58
- | { kind: "replace"; range: ParsedRange; lineNum: number };
55
+ function isSkippableCommentLine(line: string): boolean {
56
+ return line.trimStart().startsWith("#");
57
+ }
58
+
59
+ function sigilForBucket(bucket: PayloadBucket): string {
60
+ if (bucket === "above") return HL_PAYLOAD_ABOVE;
61
+ if (bucket === "below") return HL_PAYLOAD_BELOW;
62
+ return HL_PAYLOAD_REPLACE;
63
+ }
64
+
65
+ function describeTarget(target: BlockTarget): string {
66
+ if (target.kind === "bof") return "BOF:";
67
+ if (target.kind === "eof") return "EOF:";
68
+ const { start, end } = target.range;
69
+ return start.line === end.line ? `${start.line}:` : `${start.line}-${end.line}:`;
70
+ }
71
+
72
+ interface PendingComment {
73
+ lineNum: number;
74
+ text: string;
75
+ }
76
+
77
+ interface PayloadRow {
78
+ bucket: PayloadBucket;
79
+ text: string;
80
+ lineNum: number;
81
+ }
59
82
 
60
83
  interface Pending {
61
- op: PendingOp;
62
- payload: string[];
84
+ target: BlockTarget;
85
+ lineNum: number;
86
+ payloads: PayloadRow[];
63
87
  }
64
88
 
65
89
  /**
66
90
  * Token-driven state machine that turns a stream of {@link Token}s into a
67
91
  * flat list of {@link Edit}s.
68
92
  *
69
- * `feed()` accepts tokens one at a time; multi-line payloads accumulate
70
- * until the next op or {@link end} flushes them. After `terminated` flips
93
+ * `feed()` accepts tokens one at a time; block payload rows accumulate until
94
+ * the next anchor block or {@link end} flushes them. After `terminated` flips
71
95
  * true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
72
96
  * so callers can keep draining their tokenizer.
73
97
  */
@@ -77,6 +101,18 @@ export class Executor {
77
101
  #editIndex = 0;
78
102
  #pending: Pending | undefined;
79
103
  #terminated = false;
104
+ #skippableComments: PendingComment[] = [];
105
+
106
+ #discardPendingSkippableComments(): void {
107
+ this.#skippableComments = [];
108
+ }
109
+
110
+ #consumePendingSkippableComments(): void {
111
+ if (this.#skippableComments.length === 0) return;
112
+ const comment = this.#skippableComments[0];
113
+ this.#skippableComments = [];
114
+ this.#handleRaw(comment.text, comment.lineNum);
115
+ }
80
116
 
81
117
  /** True once an `envelope-end` or `abort` token has been observed. */
82
118
  get terminated(): boolean {
@@ -85,7 +121,7 @@ export class Executor {
85
121
 
86
122
  /**
87
123
  * Consume one token. After `terminated` flips true subsequent feeds are
88
- * silently ignored so callers can keep draining their tokenizer without
124
+ * silently ignored so callers can keep draining the tokenizer without
89
125
  * explicit early-exit guards.
90
126
  */
91
127
  feed(token: Token): void {
@@ -93,8 +129,10 @@ export class Executor {
93
129
 
94
130
  switch (token.kind) {
95
131
  case "envelope-begin":
132
+ this.#consumePendingSkippableComments();
96
133
  return;
97
134
  case "envelope-end":
135
+ this.#consumePendingSkippableComments();
98
136
  this.#terminated = true;
99
137
  return;
100
138
  case "abort":
@@ -102,120 +140,95 @@ export class Executor {
102
140
  this.#terminated = true;
103
141
  return;
104
142
  case "header":
143
+ this.#consumePendingSkippableComments();
105
144
  this.#flushPending();
106
145
  return;
107
146
  case "blank":
147
+ this.#consumePendingSkippableComments();
108
148
  return;
109
149
  case "payload":
110
- this.#handlePayload(token.text, token.lineNum);
150
+ this.#consumePendingSkippableComments();
151
+ this.#handlePayload(token.bucket, token.text, token.lineNum);
111
152
  return;
112
153
  case "raw":
154
+ if (this.#pending === undefined && isSkippableCommentLine(token.text)) {
155
+ this.#skippableComments.push({ text: token.text, lineNum: token.lineNum });
156
+ return;
157
+ }
158
+ this.#consumePendingSkippableComments();
113
159
  this.#handleRaw(token.text, token.lineNum);
114
160
  return;
115
- case "op-delete":
116
- this.#flushPending();
117
- if (token.trailingPayload) {
161
+ case "op-block":
162
+ this.#discardPendingSkippableComments();
163
+ if (token.inlineBody !== undefined) {
118
164
  throw new Error(
119
- `line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
165
+ `line ${token.lineNum}: ${INLINE_PAYLOAD_REJECTED_PREFIX} ` +
166
+ `Use a bare anchor line such as ${describeTarget(token.target)}, then put payload on following rows prefixed with ` +
167
+ `${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW}.`,
120
168
  );
121
169
  }
122
- validateRangeOrder(token.range, token.lineNum);
123
- for (const anchor of expandRange(token.range)) {
124
- this.#edits.push({ kind: "delete", anchor, lineNum: token.lineNum, index: this.#editIndex++ });
125
- }
126
- return;
127
- case "op-insert":
128
- this.#flushPending();
129
- this.#pending = {
130
- op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
131
- payload: [],
132
- };
133
- if (token.inlineBody !== undefined) {
134
- this.#pending.payload.push(token.inlineBody);
135
- if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
136
- this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
137
- }
138
- }
139
- return;
140
- case "op-replace":
141
- validateRangeOrder(token.range, token.lineNum);
142
- if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
143
- const outer = this.#pending.op.range;
144
- const inner = token.range;
145
- if (rangesEqual(outer, inner)) {
146
- // Identical-range before/after pair. Drop the "before" payload
147
- // silently; the second op proceeds as the lone winner. Other
148
- // overlap shapes (different ranges, replace+delete, delete+delete)
149
- // still hit the post-hoc validator.
150
- this.#pending = undefined;
151
- if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
152
- this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
153
- }
154
- } else if (rangeContains(outer, inner)) {
155
- // Model wrote a payload line in read-output `LINE:TEXT` format
156
- // (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
157
- // The tokenizer can't tell payload from op when the anchor and
158
- // sigil shape are identical, so demote: append the op's inline
159
- // body to the pending payload, strip the `LINE:` prefix, and
160
- // keep accumulating. Without this the inner anchors would each
161
- // register as their own delete and clash with the outer range.
162
- this.#pending.payload.push(token.inlineBody ?? "");
163
- if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
164
- this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
165
- }
166
- return;
167
- }
168
- }
169
- this.#flushPending();
170
- this.#pending = {
171
- op: { kind: "replace", range: token.range, lineNum: token.lineNum },
172
- payload: [],
173
- };
174
- if (token.inlineBody !== undefined) {
175
- this.#pending.payload.push(token.inlineBody);
176
- if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
177
- this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
170
+ if (token.target.kind === "range") validateRangeOrder(token.target.range, token.lineNum);
171
+ if (this.#pending !== undefined && targetsEqualConcreteRange(this.#pending.target, token.target)) {
172
+ this.#pending = undefined;
173
+ if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
174
+ this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
178
175
  }
176
+ } else {
177
+ this.#flushPending();
179
178
  }
179
+ this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [] };
180
180
  return;
181
181
  }
182
182
  }
183
183
 
184
184
  /**
185
- * Flush any open pending op (with its full accumulated payload, including
186
- * explicit `+` blank lines) and return the accumulated edits and
187
- * warnings. The executor is single-use; {@link reset} is required for
188
- * reuse.
185
+ * Flush any open pending block and return the accumulated edits and
186
+ * warnings. The executor is single-use; {@link reset} is required for reuse.
189
187
  *
190
- * Throws if two replace/delete ops target the same line with non-identical
191
- * shapes (different ranges, replace+delete, delete+delete). Identical-range
192
- * `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
193
- * warning, so they never reach the validator.
188
+ * Throws if two replacement/delete blocks target the same line with
189
+ * non-identical ranges. Identical-range blocks in the same hunk are
190
+ * coalesced last-wins by `feed()` with a warning, so they never reach the
191
+ * validator.
194
192
  */
195
193
  end(): { edits: Edit[]; warnings: string[] } {
194
+ this.#consumePendingSkippableComments();
196
195
  this.#flushPending();
197
196
  this.#validateNoOverlappingDeletes();
198
197
  return { edits: this.#edits, warnings: this.#warnings };
199
198
  }
200
199
 
200
+ /**
201
+ * Streaming-tolerant variant of {@link end}. Identical, except a pending
202
+ * block whose payload has not yet accumulated any rows is treated as still
203
+ * in flight and dropped instead of flushed (which would otherwise preview a
204
+ * destructive bare delete while the model may still be typing payload).
205
+ */
206
+ endStreaming(): { edits: Edit[]; warnings: string[] } {
207
+ this.#consumePendingSkippableComments();
208
+ if (this.#pending && this.#pending.payloads.length > 0) {
209
+ this.#flushPending();
210
+ } else {
211
+ this.#pending = undefined;
212
+ }
213
+ this.#validateNoOverlappingDeletes();
214
+ return { edits: this.#edits, warnings: this.#warnings };
215
+ }
216
+
201
217
  /** Reset to a fresh state so the same instance can drive another parse. */
202
218
  reset(): void {
203
219
  this.#edits = [];
204
220
  this.#warnings = [];
205
221
  this.#editIndex = 0;
206
222
  this.#pending = undefined;
223
+ this.#skippableComments = [];
207
224
  this.#terminated = false;
208
225
  }
209
226
 
210
227
  /**
211
- * Each `:` / `!` op contributes a delete edit per line in its range; if
212
- * any line ends up targeted by deletes originating from two different
213
- * source ops (distinguished by their `lineNum`), the patch is internally
214
- * inconsistent. Identical-range `A-B:` pairs are already collapsed by
215
- * `feed()`; remaining shapes here are an `A-B:` that overlaps a later
216
- * `N!`/`N:` with a different range, or two `!` deletes on the same line.
217
- * The applier would run both literally and the file would end up with two
218
- * copies of the line, not a chosen winner.
228
+ * Each replacement/delete block contributes a delete edit per line in its
229
+ * range; if any line ends up targeted by deletes originating from two
230
+ * different source blocks (distinguished by their `lineNum`), the patch is
231
+ * internally inconsistent.
219
232
  */
220
233
  #validateNoOverlappingDeletes(): void {
221
234
  const sourceLinesByAnchor = new Map<number, number[]>();
@@ -230,100 +243,118 @@ export class Executor {
230
243
  }
231
244
  for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
232
245
  if (sourceLines.length < 2) continue;
233
- const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
246
+ const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
234
247
  throw new Error(
235
- `line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
236
- `Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
248
+ `line ${secondBlock}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE} block on line ${firstBlock}. ` +
249
+ `Issue ONE block per range; payload is only the final desired content, never a before/after pair.`,
237
250
  );
238
251
  }
239
252
  }
240
253
 
241
- #handlePayload(text: string, lineNum: number): void {
242
- if (this.#pending) {
243
- this.#pending.payload.push(text);
244
- return;
254
+ #handlePayload(bucket: PayloadBucket, text: string, lineNum: number): void {
255
+ const pending = this.#pending;
256
+ if (!pending) {
257
+ throw new Error(
258
+ `line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. ` +
259
+ `Got ${JSON.stringify(`${sigilForBucket(bucket)}${text}`)}.`,
260
+ );
245
261
  }
246
-
247
- throw new Error(
248
- `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
249
- `Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
250
- );
262
+ if (bucket === "replace" && pending.target.kind !== "range") {
263
+ throw new Error(`line ${lineNum}: ${VIRTUAL_REPLACE_REJECTED_MESSAGE}`);
264
+ }
265
+ pending.payloads.push({ bucket, text, lineNum });
251
266
  }
252
267
 
253
268
  #handleRaw(text: string, lineNum: number): void {
254
269
  if (this.#pending) {
255
270
  if (text.trim().length === 0) return;
256
- // Lenient legacy fallback: the tokenizer routes a line to `raw` only
257
- // when it does not parse as an op, header, payload, or envelope
258
- // marker. A `raw` token while a pending op exists is therefore an
259
- // unambiguous continuation row that the author wrote without the
260
- // `+` prefix. Accept it as payload and warn so the canonical
261
- // `+`-prefixed form remains preferred.
262
- this.#pending.payload.push(text);
263
- if (!this.#warnings.includes(IMPLICIT_CONTINUATION_WARNING)) {
264
- this.#warnings.push(IMPLICIT_CONTINUATION_WARNING);
265
- }
266
- return;
271
+ throw new Error(
272
+ `line ${lineNum}: payload row in a hashline block must start with ` +
273
+ `${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW}. Got ${JSON.stringify(text)}.`,
274
+ );
267
275
  }
268
276
 
269
- // Whitespace-only raw lines outside any pending op are silently dropped;
277
+ // Whitespace-only raw lines outside any pending block are silently dropped;
270
278
  // fully empty lines arrive as `blank` tokens.
271
279
  if (text.trim().length === 0) return;
272
- // Orphan raw text outside any pending op: pick the most specific
273
- // diagnostic so the user sees the actionable hint.
274
- if (isDeleteOpWithPayload(text)) {
275
- throw new Error(
276
- `line ${lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
277
- );
278
- }
279
280
 
280
281
  const firstChar = text[0];
281
- const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar);
282
- if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
282
+ if (firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
283
283
  throw new Error(
284
- `line ${lineNum}: unrecognized op. Use LINE${HL_OP_INSERT_BEFORE} (insert before), LINE${HL_OP_INSERT_AFTER} (insert after), LINE${HL_OP_REPLACE} / A-B${HL_OP_REPLACE} (replace), or LINE${HL_OP_DELETE} / A-B${HL_OP_DELETE} (delete). ` +
285
- `Got ${JSON.stringify(text)}.`,
284
+ `line ${lineNum}: unrecognized hashline block. Use A-B:, A:, BOF:, or EOF: anchors followed by ` +
285
+ `${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW} payload rows. Got ${JSON.stringify(text)}.`,
286
286
  );
287
287
  }
288
288
 
289
289
  throw new Error(
290
- `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
291
- `Got ${JSON.stringify(text)}.`,
290
+ `line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. Got ${JSON.stringify(text)}.`,
292
291
  );
293
292
  }
294
293
 
294
+ #pushInsert(cursor: Cursor, text: string, lineNum: number, mode?: "replacement"): void {
295
+ this.#edits.push({
296
+ kind: "insert",
297
+ cursor: cloneCursor(cursor),
298
+ text,
299
+ lineNum,
300
+ index: this.#editIndex++,
301
+ ...(mode === undefined ? {} : { mode }),
302
+ });
303
+ }
304
+
305
+ #pushDelete(anchor: Anchor, lineNum: number): void {
306
+ this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
307
+ }
308
+
295
309
  #flushPending(): void {
296
310
  const pending = this.#pending;
297
311
  if (!pending) return;
298
312
 
299
- const { op, payload } = pending;
300
- const linesToInsert = payload.length === 0 ? [""] : payload;
313
+ const { target, lineNum, payloads } = pending;
314
+ if (target.kind === "bof" || target.kind === "eof") {
315
+ const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" };
316
+ for (const payload of payloads) {
317
+ this.#pushInsert(cursor, payload.text, lineNum);
318
+ }
319
+ this.#pending = undefined;
320
+ return;
321
+ }
322
+
323
+ const above: string[] = [];
324
+ const replacement: string[] = [];
325
+ const below: string[] = [];
326
+ for (const payload of payloads) {
327
+ if (payload.bucket === "above") above.push(payload.text);
328
+ else if (payload.bucket === "below") below.push(payload.text);
329
+ else replacement.push(payload.text);
330
+ }
331
+
332
+ for (const text of above) {
333
+ this.#pushInsert({ kind: "before_anchor", anchor: { ...target.range.start } }, text, lineNum);
334
+ }
301
335
 
302
- if (op.kind === "insert") {
303
- for (const text of linesToInsert) {
304
- this.#edits.push({
305
- kind: "insert",
306
- cursor: cloneCursor(op.cursor),
336
+ if (replacement.length > 0) {
337
+ for (const text of replacement) {
338
+ this.#pushInsert(
339
+ { kind: "before_anchor", anchor: { ...target.range.start } },
307
340
  text,
308
- lineNum: op.lineNum,
309
- index: this.#editIndex++,
310
- });
341
+ lineNum,
342
+ "replacement",
343
+ );
311
344
  }
312
- } else {
313
- for (const text of linesToInsert) {
314
- this.#edits.push({
315
- kind: "insert",
316
- cursor: { kind: "before_anchor", anchor: { ...op.range.start } },
317
- text,
318
- lineNum: op.lineNum,
319
- index: this.#editIndex++,
320
- });
345
+ for (const anchor of expandRange(target.range)) {
346
+ this.#pushDelete(anchor, lineNum);
321
347
  }
322
- for (const anchor of expandRange(op.range)) {
323
- this.#edits.push({ kind: "delete", anchor, lineNum: op.lineNum, index: this.#editIndex++ });
348
+ } else if (above.length === 0 && below.length === 0) {
349
+ for (const anchor of expandRange(target.range)) {
350
+ this.#pushDelete(anchor, lineNum);
324
351
  }
325
352
  }
326
353
 
354
+ for (const text of below) {
355
+ this.#pushInsert({ kind: "after_anchor", anchor: { ...target.range.end } }, text, lineNum);
356
+ }
357
+
327
358
  this.#pending = undefined;
328
359
  }
329
360
  }
@@ -348,3 +379,36 @@ export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] }
348
379
  drain(tokenizer.end());
349
380
  return executor.end();
350
381
  }
382
+
383
+ /**
384
+ * Streaming-tolerant variant of {@link parsePatch}. Returns whatever edits
385
+ * parsed successfully when the diff is still being typed:
386
+ *
387
+ * - per-token feed errors stop the drain but preserve the edits already
388
+ * collected (the trailing block is malformed mid-stream — wait for the next
389
+ * chunk),
390
+ * - the trailing pending block is dropped if it has no payload yet (avoids a
391
+ * destructive bare-delete preview while payload may still be coming).
392
+ *
393
+ * Throws only on the cross-block overlap validator, which catches conflicting
394
+ * shapes (two replacements/deletes hitting the same anchor). Streaming preview
395
+ * callers should treat any throw here as "no preview this tick".
396
+ */
397
+ export function parsePatchStreaming(diff: string): { edits: Edit[]; warnings: string[] } {
398
+ const tokenizer = new Tokenizer();
399
+ const executor = new Executor();
400
+ const drain = (tokens: Token[]): boolean => {
401
+ for (const token of tokens) {
402
+ if (executor.terminated) return false;
403
+ try {
404
+ executor.feed(token);
405
+ } catch {
406
+ return true; // stop on first parse error; keep what's collected
407
+ }
408
+ }
409
+ return false;
410
+ };
411
+ if (drain(tokenizer.feed(diff))) return executor.endStreaming();
412
+ drain(tokenizer.end());
413
+ return executor.endStreaming();
414
+ }