@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/README.md +8 -8
- package/dist/types/apply.d.ts +1 -2
- package/dist/types/format.d.ts +9 -11
- package/dist/types/input.d.ts +8 -0
- package/dist/types/messages.d.ts +16 -28
- package/dist/types/parser.d.ts +37 -11
- package/dist/types/recovery.d.ts +6 -2
- package/dist/types/tokenizer.d.ts +14 -18
- package/dist/types/types.d.ts +4 -1
- package/package.json +1 -1
- package/src/apply.ts +42 -82
- package/src/format.ts +9 -11
- package/src/grammar.lark +7 -8
- package/src/input.ts +17 -1
- package/src/messages.ts +17 -32
- package/src/parser.ts +225 -161
- package/src/prompt.md +112 -65
- package/src/recovery.ts +70 -10
- package/src/tokenizer.ts +54 -122
- package/src/types.ts +11 -2
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
|
|
31
|
-
* range
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
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}.
|
|
10
|
-
* accumulated across tokens until the next
|
|
11
|
-
* 3. Call {@link Executor.end} to flush the trailing pending
|
|
12
|
-
* cross-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
45
|
-
return
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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;
|
|
70
|
-
*
|
|
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
|
|
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.#
|
|
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-
|
|
116
|
-
this.#
|
|
117
|
-
if (token.
|
|
161
|
+
case "op-block":
|
|
162
|
+
this.#discardPendingSkippableComments();
|
|
163
|
+
if (token.inlineBody !== undefined) {
|
|
118
164
|
throw new Error(
|
|
119
|
-
`line ${token.lineNum}: ${
|
|
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
|
-
|
|
124
|
-
this.#
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
186
|
-
*
|
|
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
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
212
|
-
* any line ends up targeted by deletes originating from two
|
|
213
|
-
* source
|
|
214
|
-
* inconsistent.
|
|
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 [
|
|
246
|
+
const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
|
|
234
247
|
throw new Error(
|
|
235
|
-
`line ${
|
|
236
|
-
`Issue ONE
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
|
|
282
|
+
if (firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
|
|
283
283
|
throw new Error(
|
|
284
|
-
`line ${lineNum}: unrecognized
|
|
285
|
-
|
|
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
|
|
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 {
|
|
300
|
-
|
|
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 (
|
|
303
|
-
for (const text of
|
|
304
|
-
this.#
|
|
305
|
-
kind: "
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
341
|
+
lineNum,
|
|
342
|
+
"replacement",
|
|
343
|
+
);
|
|
311
344
|
}
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
+
}
|