@oh-my-pi/hashline 15.13.0 → 15.13.2
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 +111 -34
- package/README.md +6 -6
- package/dist/types/block.d.ts +2 -2
- package/dist/types/format.d.ts +13 -10
- package/dist/types/input.d.ts +2 -2
- package/dist/types/messages.d.ts +33 -18
- package/dist/types/patcher.d.ts +3 -3
- package/dist/types/snapshots.d.ts +24 -3
- package/dist/types/types.d.ts +10 -10
- package/package.json +1 -1
- package/src/apply.ts +57 -3
- package/src/block.ts +16 -6
- package/src/format.ts +17 -14
- package/src/grammar.lark +7 -7
- package/src/input.ts +3 -3
- package/src/messages.ts +80 -26
- package/src/parser.ts +8 -8
- package/src/patcher.ts +26 -6
- package/src/prompt.md +39 -39
- package/src/snapshots.ts +40 -4
- package/src/tokenizer.ts +39 -36
- package/src/types.ts +10 -10
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>
|
|
@@ -27,18 +27,18 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
27
27
|
- Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
|
|
28
28
|
- Numbers refer to the ORIGINAL file; they do not shift as hunks apply.
|
|
29
29
|
- They die with the call: every applied edit mints a fresh `#TAG` and renumbers — anchor the next edit on the edit response or a fresh `read`.
|
|
30
|
-
- Touch only lines
|
|
31
|
-
- Elided regions (
|
|
30
|
+
- Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory of it. A hunk anchored on a line you never displayed is REJECTED — re-`read` those exact lines first. (Seeing a line ≠ it holding the code you mean: confirm the numbers map to the construct you intend, especially far from your last-read window.)
|
|
31
|
+
- Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M were shown) hide their interior. NEVER place or span a hunk inside one — `read` the range first.
|
|
32
32
|
- Never start or end a range mid-expression or mid-block.
|
|
33
33
|
- Indent body rows exactly for the depth they should live at.
|
|
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
|
-
- `
|
|
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 `
|
|
120
|
+
# WRONG — a pure insertion done as a widened `SWAP`: you only want to add one line after 2,
|
|
121
121
|
# but you replace 2..4, retype the keepers in the body, and drop one (here line 4, `greet("world")`).
|
|
122
|
-
|
|
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/snapshots.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface Snapshot {
|
|
|
36
36
|
readonly hash: string;
|
|
37
37
|
/** Timestamp (ms since epoch) the version was recorded. */
|
|
38
38
|
recordedAt: number;
|
|
39
|
+
/**
|
|
40
|
+
* 1-indexed file lines a producer (read/search) actually *displayed* under
|
|
41
|
+
* this tag. A partial read (range, or a structural summary that collapsed
|
|
42
|
+
* bodies) leaves this sparse; a whole-file read fills every line. Multiple
|
|
43
|
+
* reads of the same content union into one set. `undefined` means "no
|
|
44
|
+
* provenance recorded" — the patcher then skips the seen-line check and
|
|
45
|
+
* applies as before. Mutated in place as more of the same content is read.
|
|
46
|
+
*/
|
|
47
|
+
seenLines?: Set<number>;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -50,8 +59,20 @@ export abstract class SnapshotStore {
|
|
|
50
59
|
/** Recorded version for `path` whose tag equals `hash`, or `null`. */
|
|
51
60
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
52
61
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Record the full normalized text of `path` and return its content tag.
|
|
64
|
+
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
|
65
|
+
* they merge into {@link Snapshot.seenLines} across reads of identical text.
|
|
66
|
+
*/
|
|
67
|
+
abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
|
|
71
|
+
* equals `hash`. No-op when no such version is retained (the content aged
|
|
72
|
+
* out or was overwritten). Lets producers attach displayed lines after the
|
|
73
|
+
* tag was already minted (the body is formatted after the hash is computed).
|
|
74
|
+
*/
|
|
75
|
+
abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
|
55
76
|
|
|
56
77
|
/** Drop the version history for a single path. */
|
|
57
78
|
abstract invalidate(path: string): void;
|
|
@@ -65,6 +86,13 @@ const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
|
|
65
86
|
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
|
66
87
|
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
67
88
|
|
|
89
|
+
/** Union `lines` into `snapshot.seenLines`, lazily creating the set. */
|
|
90
|
+
function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined): void {
|
|
91
|
+
if (lines === undefined) return;
|
|
92
|
+
if (snapshot.seenLines === undefined) snapshot.seenLines = new Set<number>();
|
|
93
|
+
for (const line of lines) snapshot.seenLines.add(line);
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
export interface InMemorySnapshotStoreOptions {
|
|
69
97
|
/** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
|
|
70
98
|
maxPaths?: number;
|
|
@@ -114,15 +142,17 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
114
142
|
return history?.find(version => version.hash === hash) ?? null;
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
record(path: string, fullText: string): string {
|
|
145
|
+
record(path: string, fullText: string, seenLines?: Iterable<number>): string {
|
|
118
146
|
const hash = computeFileHash(fullText);
|
|
119
147
|
// `get` refreshes LRU recency for `path`.
|
|
120
148
|
const history = this.#versions.get(path) ?? [];
|
|
121
149
|
const existing = history.find(version => version.hash === hash);
|
|
122
150
|
if (existing) {
|
|
123
151
|
// Same content state observed again: refresh recency and promote to
|
|
124
|
-
// head (it is the current file content), then reuse the tag.
|
|
152
|
+
// head (it is the current file content), then reuse the tag. Union any
|
|
153
|
+
// newly-displayed lines so re-reading more of the file widens coverage.
|
|
125
154
|
existing.recordedAt = Date.now();
|
|
155
|
+
mergeSeenLines(existing, seenLines);
|
|
126
156
|
if (history[0] !== existing) {
|
|
127
157
|
this.#versions.set(path, [existing, ...history.filter(version => version !== existing)]);
|
|
128
158
|
}
|
|
@@ -130,10 +160,16 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
const snapshot: Snapshot = { path, text: fullText, hash, recordedAt: Date.now() };
|
|
163
|
+
mergeSeenLines(snapshot, seenLines);
|
|
133
164
|
this.#versions.set(path, [snapshot, ...history].slice(0, this.#maxVersionsPerPath));
|
|
134
165
|
return hash;
|
|
135
166
|
}
|
|
136
167
|
|
|
168
|
+
recordSeenLines(path: string, hash: string, lines: Iterable<number>): void {
|
|
169
|
+
const version = this.#versions.get(path)?.find(snapshot => snapshot.hash === hash);
|
|
170
|
+
if (version) mergeSeenLines(version, lines);
|
|
171
|
+
}
|
|
172
|
+
|
|
137
173
|
invalidate(path: string): void {
|
|
138
174
|
this.#versions.delete(path);
|
|
139
175
|
}
|
package/src/tokenizer.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
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";
|
|
@@ -218,7 +220,7 @@ function scanKeyword(line: string, index: number, end: number, keyword: string):
|
|
|
218
220
|
const next = index + keyword.length;
|
|
219
221
|
if (next < end) {
|
|
220
222
|
const code = line.charCodeAt(next);
|
|
221
|
-
if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
|
|
223
|
+
if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null;
|
|
222
224
|
}
|
|
223
225
|
return next;
|
|
224
226
|
}
|
|
@@ -229,7 +231,8 @@ function consumeOptionalColon(line: string, index: number, end: number): number
|
|
|
229
231
|
}
|
|
230
232
|
|
|
231
233
|
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
|
|
232
|
-
|
|
234
|
+
if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
|
|
235
|
+
const cursor = skipWhitespace(line, index + 1, end);
|
|
233
236
|
const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
|
|
234
237
|
if (beforeEnd !== null) {
|
|
235
238
|
const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
|
|
@@ -239,16 +242,6 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
|
|
|
239
242
|
}
|
|
240
243
|
const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
|
|
241
244
|
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
245
|
const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
|
|
253
246
|
if (anchor === null) return null;
|
|
254
247
|
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
|
@@ -263,20 +256,19 @@ function scanInsertTarget(line: string, index: number, end: number): TargetScan
|
|
|
263
256
|
|
|
264
257
|
function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
|
|
265
258
|
const cursor = skipWhitespace(line, start, end);
|
|
259
|
+
|
|
260
|
+
// `replace_block N:` — resolve N to a tree-sitter block range at apply time.
|
|
261
|
+
const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
|
|
262
|
+
if (replaceBlockEnd !== null) {
|
|
263
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
|
|
264
|
+
if (anchor === null) return null;
|
|
265
|
+
return {
|
|
266
|
+
target: { kind: "block", anchor: { line: anchor.line } },
|
|
267
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
266
270
|
const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
|
|
267
271
|
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
272
|
const range = scanHeaderRange(line, replaceEnd, end, true);
|
|
281
273
|
if (range === null) return null;
|
|
282
274
|
return {
|
|
@@ -284,25 +276,36 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
284
276
|
nextIndex: consumeOptionalColon(line, range.nextIndex, end),
|
|
285
277
|
};
|
|
286
278
|
}
|
|
279
|
+
// `delete_block N` — resolve N to a tree-sitter block range at apply time
|
|
280
|
+
// and delete its whole span. Like `delete N..M`, it takes no body and no
|
|
281
|
+
// trailing colon.
|
|
282
|
+
const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
|
|
283
|
+
if (deleteBlockEnd !== null) {
|
|
284
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end);
|
|
285
|
+
if (anchor === null) return null;
|
|
286
|
+
const next = skipWhitespace(line, anchor.nextIndex, end);
|
|
287
|
+
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
288
|
+
return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
|
|
289
|
+
}
|
|
287
290
|
const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
|
|
288
291
|
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
292
|
const range = scanHeaderRange(line, deleteEnd, end, true);
|
|
301
293
|
if (range === null) return null;
|
|
302
294
|
const next = skipWhitespace(line, range.nextIndex, end);
|
|
303
295
|
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
304
296
|
return { target: { kind: "delete", range: range.range }, nextIndex: next };
|
|
305
297
|
}
|
|
298
|
+
// `insert_after_block N:` — insert after the last line of the tree-sitter
|
|
299
|
+
// block at N.
|
|
300
|
+
const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
|
|
301
|
+
if (insertAfterBlockEnd !== null) {
|
|
302
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
|
|
303
|
+
if (anchor === null) return null;
|
|
304
|
+
return {
|
|
305
|
+
target: { kind: "insert_after_block", anchor: { line: anchor.line } },
|
|
306
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
306
309
|
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
|
|
307
310
|
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
|
|
308
311
|
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 `
|
|
49
|
+
* a non-empty `payloads` without `mode` (from `replace_block`) becomes
|
|
50
50
|
* the same `replacement` inserts + deletes that `replace start..end:`
|
|
51
|
-
* produces; an empty `payloads` (from `
|
|
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.
|
|
@@ -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,7 +132,7 @@ 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
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
|
|
@@ -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
|