@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/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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
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
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
</
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
|
127
|
-
*
|
|
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
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
|
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) ===
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
192
|
+
interface TargetScan {
|
|
193
|
+
target: BlockTarget;
|
|
194
|
+
nextIndex: number;
|
|
198
195
|
}
|
|
199
196
|
|
|
200
|
-
function
|
|
201
|
-
const
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
kind: "
|
|
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
|
|
212
|
-
|
|
213
|
-
range: ParsedRange;
|
|
212
|
+
interface ParsedBlockOp {
|
|
213
|
+
target: BlockTarget;
|
|
214
214
|
inlineBody: string | undefined;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
|
|
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
|
|
219
|
+
const target = scanBlockTarget(line, end);
|
|
220
|
+
if (target === null) return null;
|
|
228
221
|
|
|
229
|
-
|
|
230
|
-
if (
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
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-
|
|
353
|
-
| (TokenBase & { kind: "
|
|
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
|
-
|
|
374
|
-
|
|
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
|
|
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
|
-
| {
|
|
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. */
|