@oh-my-pi/hashline 15.5.4

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Centralized error and warning text emitted by the hashline parser, applier,
3
+ * and patcher. Consolidating these as named constants makes them easy to
4
+ * audit and keeps wording stable across the rendering paths that surface
5
+ * them.
6
+ */
7
+
8
+ /** Lines of context shown either side of a hash mismatch. */
9
+ export const MISMATCH_CONTEXT = 2;
10
+
11
+ /** Optional patch envelope start marker; silently consumed when present. */
12
+ export const BEGIN_PATCH_MARKER = "*** Begin Patch";
13
+
14
+ /** Optional patch envelope end marker; terminates parsing when encountered. */
15
+ export const END_PATCH_MARKER = "*** End Patch";
16
+
17
+ /**
18
+ * Recovery sentinel emitted by an agent loop when a contaminated tool-call
19
+ * stream is truncated mid-call. Behaves like {@link END_PATCH_MARKER} for
20
+ * parsing — terminates the line loop — and additionally surfaces a warning
21
+ * so the caller knows to re-issue any remaining edits.
22
+ */
23
+ export const ABORT_MARKER = "*** Abort";
24
+
25
+ /** Warning text appended to the tool result when {@link ABORT_MARKER} terminates parsing. */
26
+ export const ABORT_WARNING =
27
+ "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
28
+
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.
33
+ */
34
+ 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.";
36
+
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.";
46
+
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.";
65
+
66
+ /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
67
+ export const RECOVERY_EXTERNAL_WARNING =
68
+ "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
69
+
70
+ /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
71
+ export const RECOVERY_SESSION_CHAIN_WARNING =
72
+ "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
73
+
74
+ /** Warning text emitted by `Recovery` when the session-chain fast-path was taken. */
75
+ export const RECOVERY_SESSION_REPLAY_WARNING =
76
+ "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.";
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Error type raised when a section's file-hash does not match the live file
3
+ * content and recovery is unavailable / has failed.
4
+ *
5
+ * Carries enough context to render a useful diagnostic: the anchored lines
6
+ * plus a couple of lines of surrounding context. The {@link MismatchError}
7
+ * formats this into a message at construction time.
8
+ */
9
+ import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
10
+ import { MISMATCH_CONTEXT } from "./messages";
11
+
12
+ const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
13
+
14
+ /** Format the required-shape diagnostic shown when a line reference is malformed. */
15
+ export function formatFullAnchorRequirement(raw?: string): string {
16
+ const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
17
+ return (
18
+ `a bare line number from read/search output plus the section header file hash ` +
19
+ `(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b and line "160")${received}`
20
+ );
21
+ }
22
+
23
+ /** Parse a decorated bare line-number anchor like `42`, `*42:foo`, ` > 7`. */
24
+ export function parseTag(ref: string): { line: number } {
25
+ const match = ref.match(LINE_REF_RE);
26
+ if (!match) {
27
+ throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
28
+ }
29
+ const line = Number.parseInt(match[1], 10);
30
+ if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
31
+ return { line };
32
+ }
33
+
34
+ export interface MismatchDetails {
35
+ path?: string;
36
+ expectedFileHash: string;
37
+ actualFileHash: string;
38
+ fileLines: string[];
39
+ anchorLines?: readonly number[];
40
+ }
41
+
42
+ function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] {
43
+ const displayLines = new Set<number>();
44
+ for (const line of anchorLines) {
45
+ if (line < 1 || line > fileLines.length) continue;
46
+ const lo = Math.max(1, line - MISMATCH_CONTEXT);
47
+ const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT);
48
+ for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
49
+ }
50
+ return [...displayLines].sort((a, b) => a - b);
51
+ }
52
+
53
+ /**
54
+ * Raised when a hashline section's file hash doesn't match the live file's
55
+ * content (and recovery, if configured, declined the merge). Carries the
56
+ * file lines plus anchored lines so renderers can produce a richer
57
+ * diagnostic via {@link MismatchError.displayMessage}.
58
+ */
59
+ export class MismatchError extends Error {
60
+ readonly path: string | undefined;
61
+ readonly expectedFileHash: string;
62
+ readonly actualFileHash: string;
63
+ readonly fileLines: string[];
64
+ readonly anchorLines: readonly number[];
65
+
66
+ constructor(details: MismatchDetails) {
67
+ super(MismatchError.formatMessage(details));
68
+ this.name = "MismatchError";
69
+ this.path = details.path;
70
+ this.expectedFileHash = details.expectedFileHash;
71
+ this.actualFileHash = details.actualFileHash;
72
+ this.fileLines = details.fileLines;
73
+ this.anchorLines = details.anchorLines ?? [];
74
+ }
75
+
76
+ get displayMessage(): string {
77
+ return MismatchError.formatDisplayMessage({
78
+ path: this.path,
79
+ expectedFileHash: this.expectedFileHash,
80
+ actualFileHash: this.actualFileHash,
81
+ fileLines: this.fileLines,
82
+ anchorLines: this.anchorLines,
83
+ });
84
+ }
85
+
86
+ static rejectionHeader(details: MismatchDetails): string[] {
87
+ const pathText = details.path ? ` for ${details.path}` : "";
88
+ return [
89
+ `Edit rejected${pathText}: file changed between read and edit.`,
90
+ `Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If your previous edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash from that edit's response. Otherwise re-read the file before retrying.`,
91
+ ];
92
+ }
93
+
94
+ static formatDisplayMessage(details: MismatchDetails): string {
95
+ return MismatchError.formatMessage(details);
96
+ }
97
+
98
+ static formatMessage(details: MismatchDetails): string {
99
+ const anchorSet = new Set(details.anchorLines ?? []);
100
+ const lines = MismatchError.rejectionHeader(details);
101
+ const displayLines = getMismatchDisplayLines(details.anchorLines ?? [], details.fileLines);
102
+ if (displayLines.length === 0) return lines.join("\n");
103
+ lines.push("");
104
+ let previous = -1;
105
+ for (const lineNum of displayLines) {
106
+ if (previous !== -1 && lineNum > previous + 1) lines.push("...");
107
+ previous = lineNum;
108
+ const text = details.fileLines[lineNum - 1] ?? "";
109
+ const marker = anchorSet.has(lineNum) ? "*" : " ";
110
+ lines.push(`${marker}${formatNumberedLine(lineNum, text)}`);
111
+ }
112
+ return lines.join("\n");
113
+ }
114
+ }
115
+
116
+ /** Throws when the line reference is out of bounds for the given file. */
117
+ export function validateLineRef(ref: { line: number }, fileLines: string[]): void {
118
+ if (ref.line < 1 || ref.line > fileLines.length) {
119
+ throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
120
+ }
121
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Minimal text-shape normalization: line-ending detection / round-trip and
3
+ * BOM stripping. The patcher uses these to canonicalize text to LF before
4
+ * applying edits and to restore the original shape on write-back.
5
+ */
6
+
7
+ export type LineEnding = "\r\n" | "\n";
8
+
9
+ /** Detect the first line ending style in `content`. Defaults to LF when neither is present. */
10
+ export function detectLineEnding(content: string): LineEnding {
11
+ const crlfIdx = content.indexOf("\r\n");
12
+ const lfIdx = content.indexOf("\n");
13
+ if (lfIdx === -1) return "\n";
14
+ if (crlfIdx === -1) return "\n";
15
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
16
+ }
17
+
18
+ /** Normalize every line ending to LF. */
19
+ export function normalizeToLF(text: string): string {
20
+ return text.replace(/\r\n?/g, "\n");
21
+ }
22
+
23
+ /** Re-encode LF text with the requested line ending. */
24
+ export function restoreLineEndings(text: string, ending: LineEnding): string {
25
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
26
+ }
27
+
28
+ export interface BomResult {
29
+ /** Either the empty string or the BOM sequence (currently UTF-8 BOM). */
30
+ bom: string;
31
+ /** Text with any leading BOM removed. */
32
+ text: string;
33
+ }
34
+
35
+ /** Strip a UTF-8 BOM if present and return both the BOM and the trailing text. */
36
+ export function stripBom(content: string): BomResult {
37
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
38
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Token-driven state machine that turns a stream of {@link Token}s into a
3
+ * flat list of {@link Edit}s. Sits between the {@link Tokenizer} and the
4
+ * applier.
5
+ *
6
+ * Lifecycle:
7
+ *
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.).
13
+ *
14
+ * Convenience entry point: {@link parsePatch}.
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";
24
+ import {
25
+ ABORT_WARNING,
26
+ IMPLICIT_CONTINUATION_WARNING,
27
+ INLINE_PAYLOAD_ACCEPTED_WARNING,
28
+ PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
29
+ REPLACE_PAIR_COALESCED_WARNING,
30
+ } from "./messages";
31
+ import { cloneCursor, isDeleteOpWithPayload, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
32
+ import type { Anchor, Cursor, Edit } from "./types";
33
+
34
+ function validateRangeOrder(range: ParsedRange, lineNum: number): void {
35
+ if (range.end.line < range.start.line) {
36
+ throw new Error(`line ${lineNum}: range ${range.start.line}-${range.end.line} ends before it starts.`);
37
+ }
38
+ }
39
+
40
+ function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
41
+ return a.start.line === b.start.line && a.end.line === b.end.line;
42
+ }
43
+
44
+ function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
45
+ return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
46
+ }
47
+
48
+ function expandRange(range: ParsedRange): Anchor[] {
49
+ const anchors: Anchor[] = [];
50
+ for (let line = range.start.line; line <= range.end.line; line++) {
51
+ anchors.push({ line });
52
+ }
53
+ return anchors;
54
+ }
55
+
56
+ type PendingOp =
57
+ | { kind: "insert"; cursor: Cursor; lineNum: number }
58
+ | { kind: "replace"; range: ParsedRange; lineNum: number };
59
+
60
+ interface Pending {
61
+ op: PendingOp;
62
+ payload: string[];
63
+ }
64
+
65
+ /**
66
+ * Token-driven state machine that turns a stream of {@link Token}s into a
67
+ * flat list of {@link Edit}s.
68
+ *
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
71
+ * true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
72
+ * so callers can keep draining their tokenizer.
73
+ */
74
+ export class Executor {
75
+ #edits: Edit[] = [];
76
+ #warnings: string[] = [];
77
+ #editIndex = 0;
78
+ #pending: Pending | undefined;
79
+ #terminated = false;
80
+
81
+ /** True once an `envelope-end` or `abort` token has been observed. */
82
+ get terminated(): boolean {
83
+ return this.#terminated;
84
+ }
85
+
86
+ /**
87
+ * Consume one token. After `terminated` flips true subsequent feeds are
88
+ * silently ignored so callers can keep draining their tokenizer without
89
+ * explicit early-exit guards.
90
+ */
91
+ feed(token: Token): void {
92
+ if (this.#terminated) return;
93
+
94
+ switch (token.kind) {
95
+ case "envelope-begin":
96
+ return;
97
+ case "envelope-end":
98
+ this.#terminated = true;
99
+ return;
100
+ case "abort":
101
+ this.#warnings.push(ABORT_WARNING);
102
+ this.#terminated = true;
103
+ return;
104
+ case "header":
105
+ this.#flushPending();
106
+ return;
107
+ case "blank":
108
+ return;
109
+ case "payload":
110
+ this.#handlePayload(token.text, token.lineNum);
111
+ return;
112
+ case "raw":
113
+ this.#handleRaw(token.text, token.lineNum);
114
+ return;
115
+ case "op-delete":
116
+ this.#flushPending();
117
+ if (token.trailingPayload) {
118
+ 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.`,
120
+ );
121
+ }
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);
178
+ }
179
+ }
180
+ return;
181
+ }
182
+ }
183
+
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.
189
+ *
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.
194
+ */
195
+ end(): { edits: Edit[]; warnings: string[] } {
196
+ this.#flushPending();
197
+ this.#validateNoOverlappingDeletes();
198
+ return { edits: this.#edits, warnings: this.#warnings };
199
+ }
200
+
201
+ /** Reset to a fresh state so the same instance can drive another parse. */
202
+ reset(): void {
203
+ this.#edits = [];
204
+ this.#warnings = [];
205
+ this.#editIndex = 0;
206
+ this.#pending = undefined;
207
+ this.#terminated = false;
208
+ }
209
+
210
+ /**
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.
219
+ */
220
+ #validateNoOverlappingDeletes(): void {
221
+ const sourceLinesByAnchor = new Map<number, number[]>();
222
+ for (const edit of this.#edits) {
223
+ if (edit.kind !== "delete") continue;
224
+ let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
225
+ if (sourceLines === undefined) {
226
+ sourceLines = [];
227
+ sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
228
+ }
229
+ if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
230
+ }
231
+ for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
232
+ if (sourceLines.length < 2) continue;
233
+ const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
234
+ 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.`,
237
+ );
238
+ }
239
+ }
240
+
241
+ #handlePayload(text: string, lineNum: number): void {
242
+ if (this.#pending) {
243
+ this.#pending.payload.push(text);
244
+ return;
245
+ }
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
+ );
251
+ }
252
+
253
+ #handleRaw(text: string, lineNum: number): void {
254
+ if (this.#pending) {
255
+ 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;
267
+ }
268
+
269
+ // Whitespace-only raw lines outside any pending op are silently dropped;
270
+ // fully empty lines arrive as `blank` tokens.
271
+ 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
+ const firstChar = text[0];
281
+ const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar);
282
+ if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
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)}.`,
286
+ );
287
+ }
288
+
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)}.`,
292
+ );
293
+ }
294
+
295
+ #flushPending(): void {
296
+ const pending = this.#pending;
297
+ if (!pending) return;
298
+
299
+ const { op, payload } = pending;
300
+ const linesToInsert = payload.length === 0 ? [""] : payload;
301
+
302
+ if (op.kind === "insert") {
303
+ for (const text of linesToInsert) {
304
+ this.#edits.push({
305
+ kind: "insert",
306
+ cursor: cloneCursor(op.cursor),
307
+ text,
308
+ lineNum: op.lineNum,
309
+ index: this.#editIndex++,
310
+ });
311
+ }
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
+ });
321
+ }
322
+ for (const anchor of expandRange(op.range)) {
323
+ this.#edits.push({ kind: "delete", anchor, lineNum: op.lineNum, index: this.#editIndex++ });
324
+ }
325
+ }
326
+
327
+ this.#pending = undefined;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Drive a full hashline diff through the tokenizer + executor pipeline and
333
+ * return the resulting edits plus any parse-time warnings. This is the
334
+ * convenience entry point most callers want; reach for {@link Tokenizer} /
335
+ * {@link Executor} directly only when you need streaming feeds, cross-section
336
+ * state, or custom token handling.
337
+ */
338
+ export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] } {
339
+ const tokenizer = new Tokenizer();
340
+ const executor = new Executor();
341
+ const drain = (tokens: Token[]): void => {
342
+ for (const token of tokens) {
343
+ if (executor.terminated) return;
344
+ executor.feed(token);
345
+ }
346
+ };
347
+ drain(tokenizer.feed(diff));
348
+ drain(tokenizer.end());
349
+ return executor.end();
350
+ }