@oh-my-pi/hashline 15.13.1 → 15.13.3
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/CHANGELOG.md +18 -0
- package/README.md +6 -6
- package/dist/types/block.d.ts +2 -2
- package/dist/types/format.d.ts +15 -12
- package/dist/types/input.d.ts +2 -2
- package/dist/types/messages.d.ts +17 -19
- package/dist/types/patcher.d.ts +3 -3
- package/dist/types/types.d.ts +13 -13
- package/package.json +1 -1
- package/src/apply.ts +5 -5
- package/src/block.ts +8 -8
- package/src/format.ts +19 -16
- package/src/grammar.lark +8 -8
- package/src/input.ts +3 -3
- package/src/messages.ts +29 -33
- package/src/parser.ts +13 -11
- package/src/patcher.ts +5 -5
- package/src/prompt.md +38 -38
- package/src/tokenizer.ts +46 -38
- package/src/types.ts +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.13.3] - 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Changed the recommended hashline range separator from `..` to `.=` (e.g. `SWAP 1.=3:`, `DEL 4.=5`) so the inclusive `<=`-style end is self-evident. `HL_RANGE_SEP` is now `.=`; the prompt, grammar, error messages, and emitted headers all use it. The lenient parser still accepts the legacy `..` (and `-`/`…`/space) forms.
|
|
10
|
+
|
|
11
|
+
## [15.13.2] - 2026-06-15
|
|
12
|
+
|
|
13
|
+
### Breaking Changes
|
|
14
|
+
|
|
15
|
+
- Renamed all hashline DSL operators to concise abbreviated keywords:
|
|
16
|
+
- `replace` -> `SWAP`
|
|
17
|
+
- `delete` -> `DEL`
|
|
18
|
+
- `insert before`/`after`/`head`/`tail` -> `INS.PRE`/`POST`/`HEAD`/`TAIL`
|
|
19
|
+
- `replace_block` -> `SWAP.BLK`
|
|
20
|
+
- `delete_block` -> `DEL.BLK`
|
|
21
|
+
- `insert_after_block` -> `INS.BLK.POST`
|
|
22
|
+
|
|
5
23
|
## [15.13.1] - 2026-06-15
|
|
6
24
|
|
|
7
25
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before);
|
|
|
26
26
|
const tag = snapshots.record("hello.ts", before);
|
|
27
27
|
const patcher = new Patcher({ fs, snapshots });
|
|
28
28
|
const patch = Patch.parse(String.raw`[hello.ts#${tag}]
|
|
29
|
-
|
|
29
|
+
SWAP 1.=1:
|
|
30
30
|
+const greeting = "hello";`);
|
|
31
31
|
const result = await patcher.apply(patch);
|
|
32
32
|
|
|
@@ -47,11 +47,11 @@ still matches the recorded content hash, and refusing or attempting
|
|
|
47
47
|
session-aware recovery on mismatch.
|
|
48
48
|
|
|
49
49
|
Inside a section:
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
- `
|
|
53
|
-
- `
|
|
54
|
-
- `
|
|
50
|
+
- `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows.
|
|
51
|
+
- `SWAP.BLK A:` — replace the syntactic block beginning on line A.
|
|
52
|
+
- `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
|
|
53
|
+
- `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
|
|
54
|
+
- `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
|
|
55
55
|
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
|
56
56
|
|
|
57
57
|
## Abstractions
|
package/dist/types/block.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface ResolveBlockEditsOptions {
|
|
|
6
6
|
* `blockUnresolvedMessage` error — used by the authoritative apply + final
|
|
7
7
|
* preview paths. `"drop"` silently skips the edit — used by the streaming
|
|
8
8
|
* preview, where a half-written file or transient parse error must not
|
|
9
|
-
* throw. Unresolvable `
|
|
9
|
+
* throw. Unresolvable `insert_after_block N:` edits never reach this: they
|
|
10
10
|
* are lowered to plain `insert after N:` with a warning.
|
|
11
11
|
*/
|
|
12
12
|
onUnresolved?: "throw" | "drop";
|
|
@@ -19,7 +19,7 @@ export interface ResolveBlockEditsOptions {
|
|
|
19
19
|
onResolved?: (resolution: BlockResolution) => void;
|
|
20
20
|
/**
|
|
21
21
|
* Invoked once per diagnostic produced while resolving — currently the
|
|
22
|
-
* `
|
|
22
|
+
* `insert_after_block N:` lowerings (closer anchor or unresolvable block).
|
|
23
23
|
* Hosts should surface these on the apply result's `warnings`.
|
|
24
24
|
*/
|
|
25
25
|
onWarning?: (message: string) => void;
|
package/dist/types/format.d.ts
CHANGED
|
@@ -10,27 +10,30 @@ export declare const HL_FILE_SUFFIX = "]";
|
|
|
10
10
|
/** Payload sigil for literal body rows. */
|
|
11
11
|
export declare const HL_PAYLOAD_REPLACE = "+";
|
|
12
12
|
/** Hunk-header keyword for concrete line replacement. */
|
|
13
|
-
export declare const HL_REPLACE_KEYWORD = "
|
|
14
|
-
/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
|
|
15
|
-
export declare const HL_BLOCK_KEYWORD = "block";
|
|
13
|
+
export declare const HL_REPLACE_KEYWORD = "SWAP";
|
|
16
14
|
/** Hunk-header keyword for concrete line deletion. */
|
|
17
|
-
export declare const HL_DELETE_KEYWORD = "
|
|
15
|
+
export declare const HL_DELETE_KEYWORD = "DEL";
|
|
18
16
|
/** Hunk-header keyword for insertion operations. */
|
|
19
|
-
export declare const HL_INSERT_KEYWORD = "
|
|
17
|
+
export declare const HL_INSERT_KEYWORD = "INS";
|
|
20
18
|
/** Insert position keyword for inserting before a concrete line. */
|
|
21
|
-
export declare const HL_INSERT_BEFORE = "
|
|
19
|
+
export declare const HL_INSERT_BEFORE = "PRE";
|
|
22
20
|
/** Insert position keyword for inserting after a concrete line. */
|
|
23
|
-
export declare const HL_INSERT_AFTER = "
|
|
21
|
+
export declare const HL_INSERT_AFTER = "POST";
|
|
24
22
|
/** Insert position keyword for inserting at the start of the file. */
|
|
25
|
-
export declare const HL_INSERT_HEAD = "
|
|
23
|
+
export declare const HL_INSERT_HEAD = "HEAD";
|
|
26
24
|
/** Insert position keyword for inserting at the end of the file. */
|
|
27
|
-
export declare const HL_INSERT_TAIL = "
|
|
28
|
-
/** Hunk-header
|
|
25
|
+
export declare const HL_INSERT_TAIL = "TAIL";
|
|
26
|
+
/** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
|
|
27
|
+
export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
|
28
|
+
/** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
|
|
29
|
+
export declare const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
|
|
30
|
+
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
|
|
31
|
+
export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
|
|
29
32
|
export declare const HL_HEADER_COLON = ":";
|
|
30
33
|
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
31
34
|
export declare const HL_FILE_HASH_SEP = "#";
|
|
32
|
-
/** Separator between two line numbers in a range, e.g. `5
|
|
33
|
-
export declare const HL_RANGE_SEP = "
|
|
35
|
+
/** Separator between two line numbers in a range, e.g. `5.=10`. */
|
|
36
|
+
export declare const HL_RANGE_SEP = ".=";
|
|
34
37
|
/** Separator between a line number and displayed line content in hashline mode. */
|
|
35
38
|
export declare const HL_LINE_BODY_SEP = ":";
|
|
36
39
|
/** Bare positive line-number Lid (no decorations, no captures, no anchors). */
|
package/dist/types/input.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export declare class PatchSection {
|
|
|
50
50
|
* method directly when you've already validated the file content and
|
|
51
51
|
* just want the result.
|
|
52
52
|
*
|
|
53
|
-
* `blockResolver` resolves any `
|
|
53
|
+
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
|
54
54
|
* unresolvable block throws (this is the final, authoritative preview path).
|
|
55
55
|
*/
|
|
56
56
|
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult;
|
|
@@ -61,7 +61,7 @@ export declare class PatchSection {
|
|
|
61
61
|
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
62
62
|
* path should always use {@link applyTo}.
|
|
63
63
|
*
|
|
64
|
-
* `blockResolver` resolves any `
|
|
64
|
+
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
|
65
65
|
* unresolvable block is silently dropped so a half-written file does not
|
|
66
66
|
* throw mid-stream.
|
|
67
67
|
*/
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -17,51 +17,49 @@ export declare const END_PATCH_MARKER = "*** End Patch";
|
|
|
17
17
|
*/
|
|
18
18
|
export declare const ABORT_MARKER = "*** Abort";
|
|
19
19
|
/** Two consecutive hunks targeted the exact same concrete range. */
|
|
20
|
-
export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `
|
|
21
|
-
/** Bare bodyless hunk followed by an overlapping concrete hunk. */
|
|
22
|
-
export declare const REPLACE_PAIR_COALESCED_OVERLAP_WARNING = "Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range \u2014 the body is the final content, never old+new.";
|
|
20
|
+
export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `SWAP N.=M:` hunk per range \u2014 the body is the final content, never old+new.";
|
|
23
21
|
/** Bare body rows auto-converted to literal `+` rows. */
|
|
24
22
|
export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
|
|
25
23
|
/** Unified-diff-style `-` row in a hunk body. */
|
|
26
24
|
export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-\u2026`.";
|
|
27
25
|
/** Replace hunk with no body. */
|
|
28
|
-
export declare const EMPTY_REPLACE = "`
|
|
29
|
-
/** `
|
|
30
|
-
export declare const EMPTY_BLOCK = "`
|
|
26
|
+
export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
|
|
27
|
+
/** `replace_block N:` hunk with no body. */
|
|
28
|
+
export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
|
|
31
29
|
/**
|
|
32
30
|
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
33
31
|
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
34
32
|
* parse error). Appends a {@link formatAnchoredContext} preview when
|
|
35
|
-
* `fileLines` is given. `
|
|
33
|
+
* `fileLines` is given. `insert_after_block N:` never reaches this — it is
|
|
36
34
|
* lowered to plain `insert after N:` instead (see
|
|
37
35
|
* {@link insertAfterBlockUnresolvedLoweredWarning}).
|
|
38
36
|
*/
|
|
39
37
|
export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete", fileLines?: readonly string[]): string;
|
|
40
38
|
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
|
41
|
-
export declare const BLOCK_RESOLVER_UNAVAILABLE = "`
|
|
39
|
+
export declare const BLOCK_RESOLVER_UNAVAILABLE = "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
|
|
42
40
|
/**
|
|
43
|
-
* `
|
|
41
|
+
* `insert_after_block N:` anchored on a closing-delimiter line, lowered to
|
|
44
42
|
* plain `insert after N:` — the closer ends a block, and inserting after it
|
|
45
43
|
* is exactly what the plain form does.
|
|
46
44
|
*/
|
|
47
45
|
export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
|
|
48
46
|
/**
|
|
49
|
-
* `
|
|
47
|
+
* `insert_after_block N:` anchor unresolvable (unsupported language, blank
|
|
50
48
|
* line, parse error, or no resolver), lowered to plain `insert after N:` —
|
|
51
49
|
* applying with a warning beats failing the patch.
|
|
52
50
|
*/
|
|
53
51
|
export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
|
|
54
52
|
/**
|
|
55
|
-
* Internal invariant: `applyEdits` received an unresolved `
|
|
53
|
+
* Internal invariant: `applyEdits` received an unresolved `replace_block N:`
|
|
56
54
|
* edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
|
|
57
55
|
*/
|
|
58
|
-
export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `
|
|
56
|
+
export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
|
|
59
57
|
/** Delete hunk received a body row. */
|
|
60
|
-
export declare const DELETE_TAKES_NO_BODY = "`
|
|
61
|
-
/** `
|
|
62
|
-
export declare const DELETE_BLOCK_TAKES_NO_BODY = "`
|
|
58
|
+
export declare const DELETE_TAKES_NO_BODY = "`DEL N.=M` does not take body rows. Remove the body, or use `SWAP N.=M:`.";
|
|
59
|
+
/** `delete_block N` hunk received a body row. */
|
|
60
|
+
export declare const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
|
|
63
61
|
/** Insert hunk with no body. */
|
|
64
|
-
export declare const EMPTY_INSERT = "`
|
|
62
|
+
export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
|
|
65
63
|
/**
|
|
66
64
|
* `insert after` body indented shallower than the anchor: the landing slid
|
|
67
65
|
* forward past trailing closer lines — the common "anchored on the last line
|
|
@@ -69,7 +67,7 @@ export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body ro
|
|
|
69
67
|
*/
|
|
70
68
|
export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
|
|
71
69
|
/**
|
|
72
|
-
* `
|
|
70
|
+
* `insert_after_block N:` body indented deeper than the block's closer: the
|
|
73
71
|
* landing was pulled inside the block — a deeper body almost always means
|
|
74
72
|
* "append inside the block's body".
|
|
75
73
|
*/
|
|
@@ -90,7 +88,7 @@ export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying y
|
|
|
90
88
|
* Head/tail position is content-independent, so drift is non-fatal: apply
|
|
91
89
|
* onto live content and warn instead of hard-failing.
|
|
92
90
|
*/
|
|
93
|
-
export declare const HEADTAIL_DRIFT_WARNING = "Applied the `
|
|
91
|
+
export declare const HEADTAIL_DRIFT_WARNING = "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
|
|
94
92
|
/**
|
|
95
93
|
* Section omitted the mandatory snapshot tag. Shared by the apply
|
|
96
94
|
* ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
|
|
@@ -106,7 +104,7 @@ export declare function unseenLinesMessage(sectionPath: string, unseenLines: rea
|
|
|
106
104
|
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
|
107
105
|
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
108
106
|
/**
|
|
109
|
-
* A `
|
|
107
|
+
* A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
|
|
110
108
|
* single line — almost always a bare statement the model mis-anchored, not a
|
|
111
109
|
* multi-line construct. The plain op is unambiguous for one line; the block
|
|
112
110
|
* form only earns its keep when it spares counting a closing line you cannot
|
package/dist/types/patcher.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export interface PatcherOptions {
|
|
|
10
10
|
/** Snapshot store that minted and resolves hashline section tags. Required. */
|
|
11
11
|
snapshots: SnapshotStore;
|
|
12
12
|
/**
|
|
13
|
-
* Resolves `
|
|
14
|
-
* Optional: when omitted, any `
|
|
13
|
+
* Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
|
|
14
|
+
* Optional: when omitted, any `replace_block N:` edit throws on apply (the
|
|
15
15
|
* host did not wire a resolver). Plain line-range ops never need it.
|
|
16
16
|
*/
|
|
17
17
|
blockResolver?: BlockResolver;
|
|
@@ -41,7 +41,7 @@ export interface PatchSectionResult {
|
|
|
41
41
|
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
|
42
42
|
warnings: string[];
|
|
43
43
|
/**
|
|
44
|
-
* Resolved spans for any `
|
|
44
|
+
* Resolved spans for any `replace_block`/`delete_block` ops, present when the
|
|
45
45
|
* apply matched the tagged content. Undefined for patches with no block ops
|
|
46
46
|
* (and for resolutions routed through drift recovery, where numbers shift).
|
|
47
47
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type Edit = {
|
|
|
34
34
|
index: number;
|
|
35
35
|
mode?: "replacement";
|
|
36
36
|
/**
|
|
37
|
-
* Present on inserts lowered from `
|
|
37
|
+
* Present on inserts lowered from `insert_after_block N:`: the
|
|
38
38
|
* resolved block's first line. Lets the applier slide a body that
|
|
39
39
|
* claims a depth inside the block back across the block's trailing
|
|
40
40
|
* closer lines (never above this line).
|
|
@@ -48,13 +48,13 @@ export type Edit = {
|
|
|
48
48
|
oldAssertion?: string;
|
|
49
49
|
} | {
|
|
50
50
|
/**
|
|
51
|
-
* Deferred block edit (`
|
|
52
|
-
* `
|
|
51
|
+
* Deferred block edit (`replace_block N:` / `delete_block N` /
|
|
52
|
+
* `insert_after_block N:`). The exact line span is unknown at parse
|
|
53
53
|
* time — it is computed by {@link resolveBlockEdits} once file text +
|
|
54
54
|
* path (→ language) are available, then expanded into concrete edits:
|
|
55
|
-
* a non-empty `payloads` without `mode` (from `
|
|
56
|
-
* the same `replacement` inserts + deletes that `replace start
|
|
57
|
-
* produces; an empty `payloads` (from `
|
|
55
|
+
* a non-empty `payloads` without `mode` (from `replace_block`) becomes
|
|
56
|
+
* the same `replacement` inserts + deletes that `replace start.=end:`
|
|
57
|
+
* produces; an empty `payloads` (from `delete_block`) becomes a pure
|
|
58
58
|
* range deletion; `mode: "insert_after"` becomes plain `after_anchor`
|
|
59
59
|
* inserts at the block's last line. `applyEdits` never sees this
|
|
60
60
|
* variant.
|
|
@@ -75,14 +75,14 @@ export interface ApplyResult {
|
|
|
75
75
|
/** Diagnostic warnings collected by the parser, patcher, or recovery. */
|
|
76
76
|
warnings?: string[];
|
|
77
77
|
/**
|
|
78
|
-
* Resolved spans for each `
|
|
78
|
+
* Resolved spans for each `replace_block`/`delete_block` op in this apply,
|
|
79
79
|
* in patch order. Present only when the apply matched the tagged content
|
|
80
80
|
* (the common no-drift path), so the line numbers line up with what the
|
|
81
81
|
* caller read. Absent when there were no block ops.
|
|
82
82
|
*/
|
|
83
83
|
blockResolutions?: BlockResolution[];
|
|
84
84
|
}
|
|
85
|
-
/** A parsed `[A
|
|
85
|
+
/** A parsed `[A.=B]` line range. */
|
|
86
86
|
export interface ParsedRange {
|
|
87
87
|
start: Anchor;
|
|
88
88
|
end: Anchor;
|
|
@@ -121,7 +121,7 @@ export interface CompactDiffOptions {
|
|
|
121
121
|
maxUnchangedRun?: number;
|
|
122
122
|
}
|
|
123
123
|
/**
|
|
124
|
-
* Resolved 1-indexed inclusive line span of a `
|
|
124
|
+
* Resolved 1-indexed inclusive line span of a `replace_block N:` target.
|
|
125
125
|
*/
|
|
126
126
|
export interface BlockSpan {
|
|
127
127
|
/** First line of the block (1-indexed, inclusive). */
|
|
@@ -130,9 +130,9 @@ export interface BlockSpan {
|
|
|
130
130
|
end: number;
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
* One `
|
|
133
|
+
* One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
|
|
134
134
|
* resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
|
|
135
|
-
* host can echo "block N → lines start
|
|
135
|
+
* host can echo "block N → lines start.=end" and let the model catch a wrong
|
|
136
136
|
* opener — e.g. a decorator or doc-comment that sits in a separate node
|
|
137
137
|
* outside the resolved block.
|
|
138
138
|
*/
|
|
@@ -146,7 +146,7 @@ export interface BlockResolution {
|
|
|
146
146
|
/** Which block op produced this resolution. */
|
|
147
147
|
op: "replace" | "delete" | "insert_after";
|
|
148
148
|
}
|
|
149
|
-
/** Request handed to a {@link BlockResolver} to resolve one `
|
|
149
|
+
/** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
|
|
150
150
|
export interface BlockResolverRequest {
|
|
151
151
|
/** Target file path (used to infer language by extension). */
|
|
152
152
|
path: string;
|
|
@@ -156,7 +156,7 @@ export interface BlockResolverRequest {
|
|
|
156
156
|
line: number;
|
|
157
157
|
}
|
|
158
158
|
/**
|
|
159
|
-
* Resolves a `
|
|
159
|
+
* Resolves a `replace_block N:` anchor to the line span of the syntactic block
|
|
160
160
|
* that begins on line N. Returns `null` when no block can be resolved
|
|
161
161
|
* (unrecognized language, blank/out-of-range line, no node begins there, or the
|
|
162
162
|
* resolved subtree has a syntax error). Pure seam: the hashline core declares
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.13.
|
|
4
|
+
"version": "15.13.3",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
package/src/apply.ts
CHANGED
|
@@ -232,7 +232,7 @@ interface ReplacementGroup {
|
|
|
232
232
|
* Detect a replacement group starting at `start`: a run of `before_anchor`
|
|
233
233
|
* replacement inserts sharing one source op line, immediately followed by the
|
|
234
234
|
* contiguous range deletes for that same op. Mirrors how the parser lowers an
|
|
235
|
-
* `replace N
|
|
235
|
+
* `replace N.=M:` hunk with a body.
|
|
236
236
|
*/
|
|
237
237
|
function findReplacementGroup(edits: readonly AppliedEdit[], start: number): ReplacementGroup | undefined {
|
|
238
238
|
const first = edits[start];
|
|
@@ -453,7 +453,7 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
|
|
453
453
|
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
|
454
454
|
*
|
|
455
455
|
* Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line
|
|
456
|
-
* `replace N
|
|
456
|
+
* `replace N.=N` expanding into several lines is an *expansion* where every
|
|
457
457
|
* payload line is intentional new content, so a payload line that happens to
|
|
458
458
|
* equal a neighbor stays — only a genuine block rewrite retypes a boundary
|
|
459
459
|
* keeper by mistake. The dropped lines must be delimiter-neutral so removing the
|
|
@@ -592,7 +592,7 @@ function repairReplacementBoundaries(
|
|
|
592
592
|
// content lines are never crossed) places the body at the depth its
|
|
593
593
|
// indentation names.
|
|
594
594
|
//
|
|
595
|
-
// Inward (block-lowered inserts only): `
|
|
595
|
+
// Inward (block-lowered inserts only): `insert_after_block N:` anchors on the
|
|
596
596
|
// resolved block's closing line, but a body indented deeper than that closer
|
|
597
597
|
// claims a depth inside the block — the common misreading of the op as
|
|
598
598
|
// "append at the end of block N's body". Sliding the landing point backward
|
|
@@ -630,7 +630,7 @@ interface AfterInsertGroup {
|
|
|
630
630
|
anchor: number;
|
|
631
631
|
/** Indices into the edit list, in patch order. */
|
|
632
632
|
members: number[];
|
|
633
|
-
/** First line of the resolved block when lowered from `
|
|
633
|
+
/** First line of the resolved block when lowered from `insert_after_block N:`. */
|
|
634
634
|
blockStart?: number;
|
|
635
635
|
}
|
|
636
636
|
|
|
@@ -730,7 +730,7 @@ function resolveInwardLanding(
|
|
|
730
730
|
/**
|
|
731
731
|
* Slide mis-anchored after-insert hunks to the depth their body indentation
|
|
732
732
|
* claims: outward past the structural closer lines that follow the anchor
|
|
733
|
-
* when the body is shallower, or — for `
|
|
733
|
+
* when the body is shallower, or — for `insert_after_block N:` lowerings —
|
|
734
734
|
* inward across the block's trailing closers when the body is deeper than
|
|
735
735
|
* the block's closing line. Returns the corrected edit list plus one warning
|
|
736
736
|
* per shifted hunk.
|
package/src/block.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Expand deferred block edits (`
|
|
3
|
-
* `
|
|
2
|
+
* Expand deferred block edits (`replace_block N:` / `delete_block N` /
|
|
3
|
+
* `insert_after_block N:`) into concrete inserts + deletes.
|
|
4
4
|
*
|
|
5
5
|
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
6
6
|
* unknown until file text + path (→ language) are available. This transform
|
|
7
7
|
* runs at every apply/preview boundary that has text: it calls the injected
|
|
8
8
|
* {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
|
|
9
9
|
* the exact same edits the concrete form produces in the parser: `replace
|
|
10
|
-
* start
|
|
10
|
+
* start.=end:` inserts + deletes for a replace, a pure range delete for a
|
|
11
11
|
* delete, and plain `after_anchor` inserts at `end` for an insert-after. After
|
|
12
12
|
* it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
|
|
13
13
|
* ever see resolved edits.
|
|
@@ -29,7 +29,7 @@ export interface ResolveBlockEditsOptions {
|
|
|
29
29
|
* `blockUnresolvedMessage` error — used by the authoritative apply + final
|
|
30
30
|
* preview paths. `"drop"` silently skips the edit — used by the streaming
|
|
31
31
|
* preview, where a half-written file or transient parse error must not
|
|
32
|
-
* throw. Unresolvable `
|
|
32
|
+
* throw. Unresolvable `insert_after_block N:` edits never reach this: they
|
|
33
33
|
* are lowered to plain `insert after N:` with a warning.
|
|
34
34
|
*/
|
|
35
35
|
onUnresolved?: "throw" | "drop";
|
|
@@ -42,7 +42,7 @@ export interface ResolveBlockEditsOptions {
|
|
|
42
42
|
onResolved?: (resolution: BlockResolution) => void;
|
|
43
43
|
/**
|
|
44
44
|
* Invoked once per diagnostic produced while resolving — currently the
|
|
45
|
-
* `
|
|
45
|
+
* `insert_after_block N:` lowerings (closer anchor or unresolvable block).
|
|
46
46
|
* Hosts should surface these on the apply result's `warnings`.
|
|
47
47
|
*/
|
|
48
48
|
onWarning?: (message: string) => void;
|
|
@@ -82,7 +82,7 @@ export function resolveBlockEdits(
|
|
|
82
82
|
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
|
|
83
83
|
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
84
84
|
if (span === null) {
|
|
85
|
-
// `
|
|
85
|
+
// `insert_after_block N:` never fails the patch — lower it to plain
|
|
86
86
|
// `insert after N:` with a warning instead. Two flavors:
|
|
87
87
|
// - anchored on a pure closing-delimiter line: no block begins
|
|
88
88
|
// there, but line N IS the end of one, and "after the end of the
|
|
@@ -145,10 +145,10 @@ export function resolveBlockEdits(
|
|
|
145
145
|
}
|
|
146
146
|
continue;
|
|
147
147
|
}
|
|
148
|
-
// Mirror the parser's `replace start
|
|
148
|
+
// Mirror the parser's `replace start.=end:` expansion exactly: one
|
|
149
149
|
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
150
150
|
// then one delete per line across `[span.start, span.end]`. An empty
|
|
151
|
-
// `payloads` (from `
|
|
151
|
+
// `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
|
|
152
152
|
for (const payload of edit.payloads) {
|
|
153
153
|
const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
|
|
154
154
|
resolved.push({
|
package/src/format.ts
CHANGED
|
@@ -14,29 +14,32 @@ export const HL_FILE_SUFFIX = "]";
|
|
|
14
14
|
export const HL_PAYLOAD_REPLACE = "+";
|
|
15
15
|
|
|
16
16
|
/** Hunk-header keyword for concrete line replacement. */
|
|
17
|
-
export const HL_REPLACE_KEYWORD = "
|
|
18
|
-
/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
|
|
19
|
-
export const HL_BLOCK_KEYWORD = "block";
|
|
17
|
+
export const HL_REPLACE_KEYWORD = "SWAP";
|
|
20
18
|
/** Hunk-header keyword for concrete line deletion. */
|
|
21
|
-
export const HL_DELETE_KEYWORD = "
|
|
19
|
+
export const HL_DELETE_KEYWORD = "DEL";
|
|
22
20
|
/** Hunk-header keyword for insertion operations. */
|
|
23
|
-
export const HL_INSERT_KEYWORD = "
|
|
21
|
+
export const HL_INSERT_KEYWORD = "INS";
|
|
24
22
|
/** Insert position keyword for inserting before a concrete line. */
|
|
25
|
-
export const HL_INSERT_BEFORE = "
|
|
23
|
+
export const HL_INSERT_BEFORE = "PRE";
|
|
26
24
|
/** Insert position keyword for inserting after a concrete line. */
|
|
27
|
-
export const HL_INSERT_AFTER = "
|
|
25
|
+
export const HL_INSERT_AFTER = "POST";
|
|
28
26
|
/** Insert position keyword for inserting at the start of the file. */
|
|
29
|
-
export const HL_INSERT_HEAD = "
|
|
27
|
+
export const HL_INSERT_HEAD = "HEAD";
|
|
30
28
|
/** Insert position keyword for inserting at the end of the file. */
|
|
31
|
-
export const HL_INSERT_TAIL = "
|
|
32
|
-
/** Hunk-header
|
|
29
|
+
export const HL_INSERT_TAIL = "TAIL";
|
|
30
|
+
/** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
|
|
31
|
+
export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
|
32
|
+
/** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
|
|
33
|
+
export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
|
|
34
|
+
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
|
|
35
|
+
export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
|
|
33
36
|
export const HL_HEADER_COLON = ":";
|
|
34
37
|
|
|
35
38
|
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
36
39
|
export const HL_FILE_HASH_SEP = "#";
|
|
37
40
|
|
|
38
|
-
/** Separator between two line numbers in a range, e.g. `5
|
|
39
|
-
export const HL_RANGE_SEP = "
|
|
41
|
+
/** Separator between two line numbers in a range, e.g. `5.=10`. */
|
|
42
|
+
export const HL_RANGE_SEP = ".=";
|
|
40
43
|
|
|
41
44
|
/** Separator between a line number and displayed line content in hashline mode. */
|
|
42
45
|
export const HL_LINE_BODY_SEP = ":";
|
|
@@ -65,13 +68,13 @@ export function formatDeleteHeader(start: number, end = start): string {
|
|
|
65
68
|
export function formatInsertHeader(cursor: Cursor): string {
|
|
66
69
|
switch (cursor.kind) {
|
|
67
70
|
case "before_anchor":
|
|
68
|
-
return `${HL_INSERT_KEYWORD}
|
|
71
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
69
72
|
case "after_anchor":
|
|
70
|
-
return `${HL_INSERT_KEYWORD}
|
|
73
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
71
74
|
case "bof":
|
|
72
|
-
return `${HL_INSERT_KEYWORD}
|
|
75
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
|
|
73
76
|
case "eof":
|
|
74
|
-
return `${HL_INSERT_KEYWORD}
|
|
77
|
+
return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
package/src/grammar.lark
CHANGED
|
@@ -12,16 +12,16 @@ replace_hunk: replace_anchor LF emit_op*
|
|
|
12
12
|
replace_block_hunk: replace_block_anchor LF emit_op+
|
|
13
13
|
insert_hunk: insert_anchor LF emit_op+
|
|
14
14
|
insert_block_hunk: insert_block_anchor LF emit_op+
|
|
15
|
-
delete_hunk: "
|
|
16
|
-
delete_block_hunk: "
|
|
17
|
-
replace_anchor: "
|
|
18
|
-
replace_block_anchor: "
|
|
19
|
-
insert_anchor: "
|
|
20
|
-
insert_block_anchor: "
|
|
21
|
-
insert_pos: "
|
|
15
|
+
delete_hunk: "DEL " header_range LF
|
|
16
|
+
delete_block_hunk: "DEL.BLK " LID LF
|
|
17
|
+
replace_anchor: "SWAP " header_range ":"
|
|
18
|
+
replace_block_anchor: "SWAP.BLK " LID ":"
|
|
19
|
+
insert_anchor: "INS." insert_pos ":"
|
|
20
|
+
insert_block_anchor: "INS.BLK.POST " LID ":"
|
|
21
|
+
insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
|
|
22
22
|
emit_op: "+" /(.*)/ LF
|
|
23
23
|
|
|
24
|
-
header_range: LID "
|
|
24
|
+
header_range: LID ".=" LID
|
|
25
25
|
LID: /[1-9]\d*/
|
|
26
26
|
|
|
27
27
|
%import common.LF
|
package/src/input.ts
CHANGED
|
@@ -273,7 +273,7 @@ export class PatchSection {
|
|
|
273
273
|
get hasAnchorScopedEdit(): boolean {
|
|
274
274
|
return this.edits.some(edit => {
|
|
275
275
|
if (edit.kind === "delete") return true;
|
|
276
|
-
// A `
|
|
276
|
+
// A `replace_block N:` edit is anchored to concrete content on line N.
|
|
277
277
|
if (edit.kind === "block") return true;
|
|
278
278
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
279
279
|
});
|
|
@@ -305,7 +305,7 @@ export class PatchSection {
|
|
|
305
305
|
* method directly when you've already validated the file content and
|
|
306
306
|
* just want the result.
|
|
307
307
|
*
|
|
308
|
-
* `blockResolver` resolves any `
|
|
308
|
+
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
|
309
309
|
* unresolvable block throws (this is the final, authoritative preview path).
|
|
310
310
|
*/
|
|
311
311
|
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
|
@@ -331,7 +331,7 @@ export class PatchSection {
|
|
|
331
331
|
* empty-payload edit. Intended for incremental diff previews; the writer
|
|
332
332
|
* path should always use {@link applyTo}.
|
|
333
333
|
*
|
|
334
|
-
* `blockResolver` resolves any `
|
|
334
|
+
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
|
335
335
|
* unresolvable block is silently dropped so a half-written file does not
|
|
336
336
|
* throw mid-stream.
|
|
337
337
|
*/
|
package/src/messages.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Centralized error/warning text for the hashline parser, applier, and patcher. */
|
|
2
2
|
|
|
3
|
-
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
3
|
+
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP } from "./format";
|
|
4
4
|
|
|
5
5
|
/** Lines of context shown either side of a hash mismatch. */
|
|
6
6
|
export const MISMATCH_CONTEXT = 2;
|
|
@@ -43,12 +43,10 @@ export const END_PATCH_MARKER = "*** End Patch";
|
|
|
43
43
|
export const ABORT_MARKER = "*** Abort";
|
|
44
44
|
|
|
45
45
|
/** Two consecutive hunks targeted the exact same concrete range. */
|
|
46
|
-
export const REPLACE_PAIR_COALESCED_WARNING =
|
|
47
|
-
"Two hunks targeted the same range; kept only the second. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
|
|
46
|
+
export const REPLACE_PAIR_COALESCED_WARNING = `Two hunks targeted the same range; kept only the second. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`;
|
|
48
47
|
|
|
49
48
|
/** Bare bodyless hunk followed by an overlapping concrete hunk. */
|
|
50
|
-
|
|
51
|
-
"Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
|
|
49
|
+
`Dropped a bare hunk overlapped by the concrete hunk after it. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`;
|
|
52
50
|
|
|
53
51
|
/** Bare body rows auto-converted to literal `+` rows. */
|
|
54
52
|
export const BARE_BODY_AUTO_PIPED_WARNING =
|
|
@@ -59,17 +57,16 @@ export const MINUS_ROW_REJECTED =
|
|
|
59
57
|
"`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
|
|
60
58
|
|
|
61
59
|
/** Replace hunk with no body. */
|
|
62
|
-
export const EMPTY_REPLACE =
|
|
60
|
+
export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N${HL_RANGE_SEP}M\`.`;
|
|
63
61
|
|
|
64
|
-
/** `
|
|
65
|
-
export const EMPTY_BLOCK =
|
|
66
|
-
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
|
|
62
|
+
/** `replace_block N:` hunk with no body. */
|
|
63
|
+
export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
|
|
67
64
|
|
|
68
65
|
/**
|
|
69
66
|
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
70
67
|
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
71
68
|
* parse error). Appends a {@link formatAnchoredContext} preview when
|
|
72
|
-
* `fileLines` is given. `
|
|
69
|
+
* `fileLines` is given. `insert_after_block N:` never reaches this — it is
|
|
73
70
|
* lowered to plain `insert after N:` instead (see
|
|
74
71
|
* {@link insertAfterBlockUnresolvedLoweredWarning}).
|
|
75
72
|
*/
|
|
@@ -78,8 +75,8 @@ export function blockUnresolvedMessage(
|
|
|
78
75
|
op: "replace" | "delete" = "replace",
|
|
79
76
|
fileLines?: readonly string[],
|
|
80
77
|
): string {
|
|
81
|
-
const phrase = op === "delete" ? `
|
|
82
|
-
const fallback = op === "delete" ? `
|
|
78
|
+
const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
|
|
79
|
+
const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
|
|
83
80
|
let message =
|
|
84
81
|
`\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
|
|
85
82
|
`(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
|
|
@@ -92,42 +89,41 @@ export function blockUnresolvedMessage(
|
|
|
92
89
|
|
|
93
90
|
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
|
94
91
|
export const BLOCK_RESOLVER_UNAVAILABLE =
|
|
95
|
-
"`
|
|
92
|
+
"`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
|
|
96
93
|
|
|
97
94
|
/**
|
|
98
|
-
* `
|
|
95
|
+
* `insert_after_block N:` anchored on a closing-delimiter line, lowered to
|
|
99
96
|
* plain `insert after N:` — the closer ends a block, and inserting after it
|
|
100
97
|
* is exactly what the plain form does.
|
|
101
98
|
*/
|
|
102
99
|
export function insertAfterBlockCloserLoweredWarning(line: number): string {
|
|
103
|
-
return `\`
|
|
100
|
+
return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
|
|
104
101
|
}
|
|
105
102
|
|
|
106
103
|
/**
|
|
107
|
-
* `
|
|
104
|
+
* `insert_after_block N:` anchor unresolvable (unsupported language, blank
|
|
108
105
|
* line, parse error, or no resolver), lowered to plain `insert after N:` —
|
|
109
106
|
* applying with a warning beats failing the patch.
|
|
110
107
|
*/
|
|
111
108
|
export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
|
|
112
|
-
return `\`
|
|
109
|
+
return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
/**
|
|
116
|
-
* Internal invariant: `applyEdits` received an unresolved `
|
|
113
|
+
* Internal invariant: `applyEdits` received an unresolved `replace_block N:`
|
|
117
114
|
* edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
|
|
118
115
|
*/
|
|
119
116
|
export const UNRESOLVED_BLOCK_INTERNAL =
|
|
120
|
-
"internal error: unresolved `
|
|
117
|
+
"internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
|
|
121
118
|
|
|
122
119
|
/** Delete hunk received a body row. */
|
|
123
|
-
export const DELETE_TAKES_NO_BODY =
|
|
120
|
+
export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`;
|
|
124
121
|
|
|
125
|
-
/** `
|
|
126
|
-
export const DELETE_BLOCK_TAKES_NO_BODY =
|
|
127
|
-
"`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
|
|
122
|
+
/** `delete_block N` hunk received a body row. */
|
|
123
|
+
export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
|
|
128
124
|
|
|
129
125
|
/** Insert hunk with no body. */
|
|
130
|
-
export const EMPTY_INSERT = "`
|
|
126
|
+
export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
|
|
131
127
|
|
|
132
128
|
/**
|
|
133
129
|
* `insert after` body indented shallower than the anchor: the landing slid
|
|
@@ -135,16 +131,16 @@ export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
|
135
131
|
* I read instead of after the block" mistake.
|
|
136
132
|
*/
|
|
137
133
|
export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
|
|
138
|
-
return `
|
|
134
|
+
return `INS.POST ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
|
|
139
135
|
}
|
|
140
136
|
|
|
141
137
|
/**
|
|
142
|
-
* `
|
|
138
|
+
* `insert_after_block N:` body indented deeper than the block's closer: the
|
|
143
139
|
* landing was pulled inside the block — a deeper body almost always means
|
|
144
140
|
* "append inside the block's body".
|
|
145
141
|
*/
|
|
146
142
|
export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
|
|
147
|
-
return `
|
|
143
|
+
return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`;
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
/** `Recovery`: an external write matched a cached snapshot. */
|
|
@@ -170,7 +166,7 @@ export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
|
170
166
|
* onto live content and warn instead of hard-failing.
|
|
171
167
|
*/
|
|
172
168
|
export const HEADTAIL_DRIFT_WARNING =
|
|
173
|
-
"Applied the `
|
|
169
|
+
"Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
|
|
174
170
|
|
|
175
171
|
/**
|
|
176
172
|
* Section omitted the mandatory snapshot tag. Shared by the apply
|
|
@@ -220,20 +216,20 @@ export function unseenLinesMessage(sectionPath: string, unseenLines: readonly nu
|
|
|
220
216
|
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
221
217
|
|
|
222
218
|
/**
|
|
223
|
-
* A `
|
|
219
|
+
* A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
|
|
224
220
|
* single line — almost always a bare statement the model mis-anchored, not a
|
|
225
221
|
* multi-line construct. The plain op is unambiguous for one line; the block
|
|
226
222
|
* form only earns its keep when it spares counting a closing line you cannot
|
|
227
223
|
* see. Reject and point at both fixes.
|
|
228
224
|
*/
|
|
229
225
|
export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
|
230
|
-
const blockForm = op === "insert_after" ? "
|
|
226
|
+
const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
|
|
231
227
|
const plainForm =
|
|
232
228
|
op === "insert_after"
|
|
233
|
-
? `
|
|
229
|
+
? `INS.POST ${line}:`
|
|
234
230
|
: op === "delete"
|
|
235
|
-
? `
|
|
236
|
-
: `
|
|
231
|
+
? `DEL ${line}`
|
|
232
|
+
: `SWAP ${line}${HL_RANGE_SEP}${line}:`;
|
|
237
233
|
return (
|
|
238
234
|
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
|
239
235
|
`of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
|
package/src/parser.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* flat list of {@link Edit}s. Sits between the {@link Tokenizer} and the
|
|
4
4
|
* applier.
|
|
5
5
|
*/
|
|
6
|
-
import { HL_PAYLOAD_REPLACE } from "./format";
|
|
6
|
+
import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
|
|
7
7
|
import {
|
|
8
8
|
BARE_BODY_AUTO_PIPED_WARNING,
|
|
9
9
|
DELETE_BLOCK_TAKES_NO_BODY,
|
|
@@ -18,7 +18,9 @@ import type { Anchor, Cursor, Edit } from "./types";
|
|
|
18
18
|
|
|
19
19
|
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
|
|
20
20
|
if (range.end.line < range.start.line) {
|
|
21
|
-
throw new Error(
|
|
21
|
+
throw new Error(
|
|
22
|
+
`line ${lineNum}: range ${range.start.line}${HL_RANGE_SEP}${range.end.line} ends before it starts.`,
|
|
23
|
+
);
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -52,33 +54,33 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
|
|
|
52
54
|
return (
|
|
53
55
|
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
54
56
|
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
|
|
55
|
-
|
|
57
|
+
`Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
|
|
56
58
|
);
|
|
57
59
|
}
|
|
58
60
|
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
|
|
59
61
|
return (
|
|
60
62
|
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
|
|
61
|
-
|
|
63
|
+
`Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
|
|
62
64
|
);
|
|
63
65
|
}
|
|
64
66
|
if (trimmed.startsWith("@@")) {
|
|
65
67
|
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
|
66
68
|
return (
|
|
67
69
|
`\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
68
|
-
|
|
70
|
+
`Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.`
|
|
69
71
|
);
|
|
70
72
|
}
|
|
71
|
-
if (/^
|
|
72
|
-
return
|
|
73
|
+
if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
|
|
74
|
+
return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`;
|
|
73
75
|
}
|
|
74
76
|
if (/^[1-9]\d*\s*$/.test(trimmed)) {
|
|
75
|
-
return `hunk headers need a verb. Use \`
|
|
77
|
+
return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
|
|
76
78
|
}
|
|
77
|
-
const bareRange = /^([1-9]\d*)\s*[-.
|
|
79
|
+
const bareRange = /^([1-9]\d*)\s*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
|
|
78
80
|
if (bareRange !== null) {
|
|
79
81
|
return (
|
|
80
82
|
`bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
|
|
81
|
-
`Hunk headers need a verb: write \`
|
|
83
|
+
`Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`DEL ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.`
|
|
82
84
|
);
|
|
83
85
|
}
|
|
84
86
|
return null;
|
|
@@ -253,7 +255,7 @@ export class Executor {
|
|
|
253
255
|
if (text.trim().length === 0) return;
|
|
254
256
|
throw new Error(
|
|
255
257
|
`line ${lineNum}: payload line has no preceding hunk header. ` +
|
|
256
|
-
`Use \`
|
|
258
|
+
`Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
|
|
257
259
|
);
|
|
258
260
|
}
|
|
259
261
|
|
package/src/patcher.ts
CHANGED
|
@@ -41,8 +41,8 @@ export interface PatcherOptions {
|
|
|
41
41
|
/** Snapshot store that minted and resolves hashline section tags. Required. */
|
|
42
42
|
snapshots: SnapshotStore;
|
|
43
43
|
/**
|
|
44
|
-
* Resolves `
|
|
45
|
-
* Optional: when omitted, any `
|
|
44
|
+
* Resolves `replace_block N:` anchors to concrete line spans via tree-sitter.
|
|
45
|
+
* Optional: when omitted, any `replace_block N:` edit throws on apply (the
|
|
46
46
|
* host did not wire a resolver). Plain line-range ops never need it.
|
|
47
47
|
*/
|
|
48
48
|
blockResolver?: BlockResolver;
|
|
@@ -73,7 +73,7 @@ export interface PatchSectionResult {
|
|
|
73
73
|
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
|
74
74
|
warnings: string[];
|
|
75
75
|
/**
|
|
76
|
-
* Resolved spans for any `
|
|
76
|
+
* Resolved spans for any `replace_block`/`delete_block` ops, present when the
|
|
77
77
|
* apply matched the tagged content. Undefined for patches with no block ops
|
|
78
78
|
* (and for resolutions routed through drift recovery, where numbers shift).
|
|
79
79
|
*/
|
|
@@ -112,7 +112,7 @@ export class PreparedSection {
|
|
|
112
112
|
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
113
113
|
return edits.some(edit => {
|
|
114
114
|
if (edit.kind === "delete") return true;
|
|
115
|
-
// A `
|
|
115
|
+
// A `replace_block N:` edit anchors to concrete content on line N.
|
|
116
116
|
if (edit.kind === "block") return true;
|
|
117
117
|
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
118
118
|
});
|
|
@@ -386,7 +386,7 @@ export class Patcher {
|
|
|
386
386
|
const expected = exists ? section.fileHash : undefined;
|
|
387
387
|
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
|
|
388
388
|
|
|
389
|
-
// Resolve `
|
|
389
|
+
// Resolve `replace_block N:` edits to concrete ranges before recovery
|
|
390
390
|
// runs. Block anchors are expressed against the snapshot the section tag
|
|
391
391
|
// names, so resolve against that exact text:
|
|
392
392
|
// - live content matches the tag (or there is no tag) → resolve against
|
package/src/prompt.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `
|
|
1
|
+
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body.
|
|
2
2
|
|
|
3
3
|
<headers>
|
|
4
4
|
Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
|
|
5
5
|
</headers>
|
|
6
6
|
|
|
7
7
|
<ops>
|
|
8
|
-
`
|
|
9
|
-
`
|
|
10
|
-
`
|
|
11
|
-
`
|
|
12
|
-
`
|
|
13
|
-
`
|
|
14
|
-
`
|
|
15
|
-
`
|
|
16
|
-
`
|
|
17
|
-
Single line: `
|
|
8
|
+
`SWAP N.=M:` — replace original lines N.=M with the body rows below. INCLUSIVE — line M is consumed too.
|
|
9
|
+
`SWAP.BLK N:` — replace the whole syntactic block that BEGINS on line N; tree-sitter resolves the closing line. Body rows below.
|
|
10
|
+
`DEL N.=M` — delete original lines N.=M. No body.
|
|
11
|
+
`DEL.BLK N` — delete the whole syntactic block that BEGINS on line N.
|
|
12
|
+
`INS.PRE N:` — insert the body rows immediately before line N.
|
|
13
|
+
`INS.POST N:` — insert the body rows immediately after line N.
|
|
14
|
+
`INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`.
|
|
15
|
+
`INS.HEAD:` — insert the body rows at the very start of the file.
|
|
16
|
+
`INS.TAIL:` — insert the body rows at the very end of the file.
|
|
17
|
+
Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
|
|
18
18
|
</ops>
|
|
19
19
|
|
|
20
20
|
<body-rows>
|
|
@@ -34,11 +34,11 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
34
34
|
- On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
|
|
35
35
|
- One hunk per range; the body is the final content, never an old/new pair.
|
|
36
36
|
- Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
|
|
37
|
-
- Whole construct → `
|
|
38
|
-
- `
|
|
39
|
-
- Block ops (`
|
|
37
|
+
- Whole construct → `SWAP.BLK N` (tree-sitter resolves the end); lines inside it → `SWAP N.=M`.
|
|
38
|
+
- `SWAP.BLK N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `SWAP N.=M`.
|
|
39
|
+
- Block ops (`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`SWAP N.=N` / `DEL N` / `INS.POST N`) for one line, or point N at the real opener. Saw the closer? Use plain `INS.POST M:`.
|
|
40
40
|
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
|
|
41
|
-
- Pure additions use `
|
|
41
|
+
- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. A multi-line `SWAP` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
|
|
42
42
|
- NEVER format/restyle code with this tool; run the project formatter instead.
|
|
43
43
|
</rules>
|
|
44
44
|
|
|
@@ -55,14 +55,14 @@ Original (the exact shape `read` returns):
|
|
|
55
55
|
Insert a guard after line 1:
|
|
56
56
|
```
|
|
57
57
|
[greet.py#A1B2]
|
|
58
|
-
|
|
58
|
+
INS.POST 1:
|
|
59
59
|
+ if not name: name = "stranger"
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Replace line 2 with two lines:
|
|
63
63
|
```
|
|
64
64
|
[greet.py#A1B2]
|
|
65
|
-
|
|
65
|
+
SWAP 2.=2:
|
|
66
66
|
+ greeting = "Hi"
|
|
67
67
|
+ msg = f"{greeting}, {name}"
|
|
68
68
|
```
|
|
@@ -70,30 +70,30 @@ replace 2..2:
|
|
|
70
70
|
Delete line 3:
|
|
71
71
|
```
|
|
72
72
|
[greet.py#A1B2]
|
|
73
|
-
|
|
73
|
+
DEL 3
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
Add a header and trailer:
|
|
77
77
|
```
|
|
78
78
|
[greet.py#A1B2]
|
|
79
|
-
|
|
79
|
+
INS.HEAD:
|
|
80
80
|
+# generated header
|
|
81
|
-
|
|
81
|
+
INS.TAIL:
|
|
82
82
|
+greet("everyone")
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
Replace the whole `greet` function block — `
|
|
85
|
+
Replace the whole `greet` function block — `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
|
|
86
86
|
```
|
|
87
87
|
[greet.py#A1B2]
|
|
88
|
-
|
|
88
|
+
SWAP.BLK 1:
|
|
89
89
|
+def greet(name):
|
|
90
90
|
+ print(f"Hello, {name}")
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
A decorator or doc-comment is a SEPARATE block — `
|
|
93
|
+
A decorator or doc-comment is a SEPARATE block — `SWAP.BLK` on the `def`/`fn` line keeps it. Point N at the decorator to take both; here line 1 is `@cache`, so anchoring on the `def` (line 2) would resolve only the function and orphan `@cache`:
|
|
94
94
|
```
|
|
95
95
|
[svc.py#C3D4]
|
|
96
|
-
|
|
96
|
+
SWAP.BLK 1:
|
|
97
97
|
+@cache
|
|
98
98
|
+def load(key):
|
|
99
99
|
+ return store[key]
|
|
@@ -101,43 +101,43 @@ replace block 1:
|
|
|
101
101
|
</example>
|
|
102
102
|
|
|
103
103
|
<anti-patterns>
|
|
104
|
-
# WRONG — empty `
|
|
105
|
-
|
|
104
|
+
# WRONG — empty `SWAP` to delete. RIGHT: DEL 4
|
|
105
|
+
SWAP 4.=4:
|
|
106
106
|
|
|
107
|
-
# WRONG — range describes post-edit size. RIGHT:
|
|
108
|
-
|
|
107
|
+
# WRONG — range describes post-edit size. RIGHT: SWAP 1.=1: (body length is irrelevant)
|
|
108
|
+
SWAP 1.=2:
|
|
109
109
|
+def greet(name):
|
|
110
110
|
|
|
111
111
|
# WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
|
|
112
|
-
|
|
112
|
+
SWAP 3.=3:
|
|
113
113
|
msg = "Hello, " + name
|
|
114
114
|
- print(msg)
|
|
115
115
|
+ return msg
|
|
116
116
|
# RIGHT
|
|
117
|
-
|
|
117
|
+
SWAP 3.=3:
|
|
118
118
|
+ return msg
|
|
119
119
|
|
|
120
|
-
# WRONG — a pure insertion done as a widened `
|
|
121
|
-
# but you replace 2
|
|
122
|
-
|
|
120
|
+
# WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2,
|
|
121
|
+
# but you replace 2.=4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
|
|
122
|
+
SWAP 2.=4:
|
|
123
123
|
+ msg = "Hello, " + name
|
|
124
124
|
+ extra = compute(name)
|
|
125
125
|
+ print(msg)
|
|
126
126
|
# RIGHT — touch nothing you keep; the new line is the whole body.
|
|
127
|
-
|
|
127
|
+
INS.POST 2:
|
|
128
128
|
+ extra = compute(name)
|
|
129
129
|
|
|
130
|
-
# WRONG — `
|
|
131
|
-
|
|
130
|
+
# WRONG — `INS.BLK.POST N:` anchored on a closing delimiter / last visible line. RIGHT: plain `INS.POST M:`
|
|
131
|
+
INS.BLK.POST 3:
|
|
132
132
|
+after()
|
|
133
133
|
# RIGHT
|
|
134
|
-
|
|
134
|
+
INS.POST 3:
|
|
135
135
|
+after()
|
|
136
136
|
</anti-patterns>
|
|
137
137
|
|
|
138
138
|
<critical>
|
|
139
139
|
If you remember nothing else:
|
|
140
140
|
1. RE-GROUND AFTER EVERY EDIT. Every apply mints a fresh `#TAG` and renumbers — take the next edit's numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`.
|
|
141
|
-
2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `
|
|
141
|
+
2. RANGES ARE TIGHT. Cover only lines that change; a stale wide range shreds everything it spans. Whole construct → `SWAP.BLK N`.
|
|
142
142
|
3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows; never `-old`/context lines. The range does the deleting.
|
|
143
143
|
</critical>
|
package/src/tokenizer.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Format shape:
|
|
5
5
|
* ```
|
|
6
6
|
* [path/to/file.ts#1A2B]
|
|
7
|
-
* replace 5
|
|
7
|
+
* replace 5.=7:
|
|
8
8
|
* +literal new line
|
|
9
9
|
* ```
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
12
|
describeAnchorExamples,
|
|
13
|
-
|
|
13
|
+
HL_DELETE_BLOCK_KEYWORD,
|
|
14
14
|
HL_DELETE_KEYWORD,
|
|
15
15
|
HL_FILE_HASH_LENGTH,
|
|
16
16
|
HL_FILE_HASH_SEP,
|
|
@@ -18,11 +18,13 @@ import {
|
|
|
18
18
|
HL_FILE_SUFFIX,
|
|
19
19
|
HL_HEADER_COLON,
|
|
20
20
|
HL_INSERT_AFTER,
|
|
21
|
+
HL_INSERT_AFTER_BLOCK_KEYWORD,
|
|
21
22
|
HL_INSERT_BEFORE,
|
|
22
23
|
HL_INSERT_HEAD,
|
|
23
24
|
HL_INSERT_KEYWORD,
|
|
24
25
|
HL_INSERT_TAIL,
|
|
25
26
|
HL_PAYLOAD_REPLACE,
|
|
27
|
+
HL_REPLACE_BLOCK_KEYWORD,
|
|
26
28
|
HL_REPLACE_KEYWORD,
|
|
27
29
|
} from "./format";
|
|
28
30
|
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
|
|
@@ -38,6 +40,7 @@ const CHAR_SPACE = 32;
|
|
|
38
40
|
const CHAR_DOT = 46;
|
|
39
41
|
const CHAR_HYPHEN = 45;
|
|
40
42
|
const CHAR_ELLIPSIS = 0x2026;
|
|
43
|
+
const CHAR_EQUALS = 61;
|
|
41
44
|
|
|
42
45
|
const CHAR_UPPER_A = 65;
|
|
43
46
|
const CHAR_UPPER_F = 70;
|
|
@@ -165,7 +168,11 @@ function scanRangeSeparator(line: string, index: number, end: number): number |
|
|
|
165
168
|
consumedSeparator = true;
|
|
166
169
|
continue;
|
|
167
170
|
}
|
|
168
|
-
if (
|
|
171
|
+
if (
|
|
172
|
+
code === CHAR_DOT &&
|
|
173
|
+
cursor + 1 < end &&
|
|
174
|
+
(line.charCodeAt(cursor + 1) === CHAR_DOT || line.charCodeAt(cursor + 1) === CHAR_EQUALS)
|
|
175
|
+
) {
|
|
169
176
|
cursor += 2;
|
|
170
177
|
consumedSeparator = true;
|
|
171
178
|
continue;
|
|
@@ -218,7 +225,7 @@ function scanKeyword(line: string, index: number, end: number, keyword: string):
|
|
|
218
225
|
const next = index + keyword.length;
|
|
219
226
|
if (next < end) {
|
|
220
227
|
const code = line.charCodeAt(next);
|
|
221
|
-
if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
|
|
228
|
+
if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null;
|
|
222
229
|
}
|
|
223
230
|
return next;
|
|
224
231
|
}
|
|
@@ -229,7 +236,8 @@ function consumeOptionalColon(line: string, index: number, end: number): number
|
|
|
229
236
|
}
|
|
230
237
|
|
|
231
238
|
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
|
|
232
|
-
|
|
239
|
+
if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
|
|
240
|
+
const cursor = skipWhitespace(line, index + 1, end);
|
|
233
241
|
const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
|
|
234
242
|
if (beforeEnd !== null) {
|
|
235
243
|
const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
|
|
@@ -239,16 +247,6 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
|
|
|
239
247
|
}
|
|
240
248
|
const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
|
|
241
249
|
if (afterEnd !== null) {
|
|
242
|
-
// `insert after block N:` — resolve N to a tree-sitter block range at
|
|
243
|
-
// apply time and insert after its last line. Try the `block` sub-keyword
|
|
244
|
-
// before falling back to a literal `insert after N:` anchor.
|
|
245
|
-
const blockEnd = scanKeyword(line, skipWhitespace(line, afterEnd, end), end, HL_BLOCK_KEYWORD);
|
|
246
|
-
if (blockEnd !== null) {
|
|
247
|
-
const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
|
|
248
|
-
if (anchor === null) return null;
|
|
249
|
-
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
|
250
|
-
return { target: { kind: "insert_after_block", anchor: { line: anchor.line } }, nextIndex };
|
|
251
|
-
}
|
|
252
250
|
const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
|
|
253
251
|
if (anchor === null) return null;
|
|
254
252
|
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
|
@@ -263,20 +261,19 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
|
|
|
263
261
|
|
|
264
262
|
function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
|
|
265
263
|
const cursor = skipWhitespace(line, start, end);
|
|
264
|
+
|
|
265
|
+
// `replace_block N:` — resolve N to a tree-sitter block range at apply time.
|
|
266
|
+
const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
|
|
267
|
+
if (replaceBlockEnd !== null) {
|
|
268
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
|
|
269
|
+
if (anchor === null) return null;
|
|
270
|
+
return {
|
|
271
|
+
target: { kind: "block", anchor: { line: anchor.line } },
|
|
272
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
266
275
|
const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
|
|
267
276
|
if (replaceEnd !== null) {
|
|
268
|
-
// `replace block N:` — resolve N to a tree-sitter block range at apply
|
|
269
|
-
// time. Try the `block` sub-keyword before falling back to a literal
|
|
270
|
-
// `replace N..M:` range.
|
|
271
|
-
const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
|
|
272
|
-
if (blockEnd !== null) {
|
|
273
|
-
const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
|
|
274
|
-
if (anchor === null) return null;
|
|
275
|
-
return {
|
|
276
|
-
target: { kind: "block", anchor: { line: anchor.line } },
|
|
277
|
-
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
277
|
const range = scanHeaderRange(line, replaceEnd, end, true);
|
|
281
278
|
if (range === null) return null;
|
|
282
279
|
return {
|
|
@@ -284,25 +281,36 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
284
281
|
nextIndex: consumeOptionalColon(line, range.nextIndex, end),
|
|
285
282
|
};
|
|
286
283
|
}
|
|
284
|
+
// `delete_block N` — resolve N to a tree-sitter block range at apply time
|
|
285
|
+
// and delete its whole span. Like `delete N.=M`, it takes no body and no
|
|
286
|
+
// trailing colon.
|
|
287
|
+
const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
|
|
288
|
+
if (deleteBlockEnd !== null) {
|
|
289
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end);
|
|
290
|
+
if (anchor === null) return null;
|
|
291
|
+
const next = skipWhitespace(line, anchor.nextIndex, end);
|
|
292
|
+
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
293
|
+
return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
|
|
294
|
+
}
|
|
287
295
|
const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
|
|
288
296
|
if (deleteEnd !== null) {
|
|
289
|
-
// `delete block N` — resolve N to a tree-sitter block range at apply
|
|
290
|
-
// time and delete its whole span. Like `delete N..M`, it takes no body
|
|
291
|
-
// and no trailing colon.
|
|
292
|
-
const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
|
|
293
|
-
if (blockEnd !== null) {
|
|
294
|
-
const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
|
|
295
|
-
if (anchor === null) return null;
|
|
296
|
-
const next = skipWhitespace(line, anchor.nextIndex, end);
|
|
297
|
-
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
298
|
-
return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
|
|
299
|
-
}
|
|
300
297
|
const range = scanHeaderRange(line, deleteEnd, end, true);
|
|
301
298
|
if (range === null) return null;
|
|
302
299
|
const next = skipWhitespace(line, range.nextIndex, end);
|
|
303
300
|
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
304
301
|
return { target: { kind: "delete", range: range.range }, nextIndex: next };
|
|
305
302
|
}
|
|
303
|
+
// `insert_after_block N:` — insert after the last line of the tree-sitter
|
|
304
|
+
// block at N.
|
|
305
|
+
const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
|
|
306
|
+
if (insertAfterBlockEnd !== null) {
|
|
307
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
|
|
308
|
+
if (anchor === null) return null;
|
|
309
|
+
return {
|
|
310
|
+
target: { kind: "insert_after_block", anchor: { line: anchor.line } },
|
|
311
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
306
314
|
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
|
|
307
315
|
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
|
|
308
316
|
return null;
|
package/src/types.ts
CHANGED
|
@@ -32,7 +32,7 @@ export type Edit =
|
|
|
32
32
|
index: number;
|
|
33
33
|
mode?: "replacement";
|
|
34
34
|
/**
|
|
35
|
-
* Present on inserts lowered from `
|
|
35
|
+
* Present on inserts lowered from `insert_after_block N:`: the
|
|
36
36
|
* resolved block's first line. Lets the applier slide a body that
|
|
37
37
|
* claims a depth inside the block back across the block's trailing
|
|
38
38
|
* closer lines (never above this line).
|
|
@@ -42,13 +42,13 @@ export type Edit =
|
|
|
42
42
|
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
43
43
|
| {
|
|
44
44
|
/**
|
|
45
|
-
* Deferred block edit (`
|
|
46
|
-
* `
|
|
45
|
+
* Deferred block edit (`replace_block N:` / `delete_block N` /
|
|
46
|
+
* `insert_after_block N:`). The exact line span is unknown at parse
|
|
47
47
|
* time — it is computed by {@link resolveBlockEdits} once file text +
|
|
48
48
|
* path (→ language) are available, then expanded into concrete edits:
|
|
49
|
-
* a non-empty `payloads` without `mode` (from `
|
|
50
|
-
* the same `replacement` inserts + deletes that `replace start
|
|
51
|
-
* produces; an empty `payloads` (from `
|
|
49
|
+
* a non-empty `payloads` without `mode` (from `replace_block`) becomes
|
|
50
|
+
* the same `replacement` inserts + deletes that `replace start.=end:`
|
|
51
|
+
* produces; an empty `payloads` (from `delete_block`) becomes a pure
|
|
52
52
|
* range deletion; `mode: "insert_after"` becomes plain `after_anchor`
|
|
53
53
|
* inserts at the block's last line. `applyEdits` never sees this
|
|
54
54
|
* variant.
|
|
@@ -70,7 +70,7 @@ export interface ApplyResult {
|
|
|
70
70
|
/** Diagnostic warnings collected by the parser, patcher, or recovery. */
|
|
71
71
|
warnings?: string[];
|
|
72
72
|
/**
|
|
73
|
-
* Resolved spans for each `
|
|
73
|
+
* Resolved spans for each `replace_block`/`delete_block` op in this apply,
|
|
74
74
|
* in patch order. Present only when the apply matched the tagged content
|
|
75
75
|
* (the common no-drift path), so the line numbers line up with what the
|
|
76
76
|
* caller read. Absent when there were no block ops.
|
|
@@ -78,7 +78,7 @@ export interface ApplyResult {
|
|
|
78
78
|
blockResolutions?: BlockResolution[];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** A parsed `[A
|
|
81
|
+
/** A parsed `[A.=B]` line range. */
|
|
82
82
|
export interface ParsedRange {
|
|
83
83
|
start: Anchor;
|
|
84
84
|
end: Anchor;
|
|
@@ -122,7 +122,7 @@ export interface CompactDiffOptions {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* Resolved 1-indexed inclusive line span of a `
|
|
125
|
+
* Resolved 1-indexed inclusive line span of a `replace_block N:` target.
|
|
126
126
|
*/
|
|
127
127
|
export interface BlockSpan {
|
|
128
128
|
/** First line of the block (1-indexed, inclusive). */
|
|
@@ -132,9 +132,9 @@ export interface BlockSpan {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* One `
|
|
135
|
+
* One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
|
|
136
136
|
* resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
|
|
137
|
-
* host can echo "block N → lines start
|
|
137
|
+
* host can echo "block N → lines start.=end" and let the model catch a wrong
|
|
138
138
|
* opener — e.g. a decorator or doc-comment that sits in a separate node
|
|
139
139
|
* outside the resolved block.
|
|
140
140
|
*/
|
|
@@ -149,7 +149,7 @@ export interface BlockResolution {
|
|
|
149
149
|
op: "replace" | "delete" | "insert_after";
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
/** Request handed to a {@link BlockResolver} to resolve one `
|
|
152
|
+
/** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
|
|
153
153
|
export interface BlockResolverRequest {
|
|
154
154
|
/** Target file path (used to infer language by extension). */
|
|
155
155
|
path: string;
|
|
@@ -160,7 +160,7 @@ export interface BlockResolverRequest {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
|
-
* Resolves a `
|
|
163
|
+
* Resolves a `replace_block N:` anchor to the line span of the syntactic block
|
|
164
164
|
* that begins on line N. Returns `null` when no block can be resolved
|
|
165
165
|
* (unrecognized language, blank/out-of-range line, no node begins there, or the
|
|
166
166
|
* resolved subtree has a syntax error). Pure seam: the hashline core declares
|