@oh-my-pi/hashline 17.1.7 → 17.2.0
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 +21 -0
- package/README.md +2 -1
- package/dist/types/apply.d.ts +22 -2
- package/dist/types/block.d.ts +4 -7
- package/dist/types/clipboard.d.ts +22 -0
- package/dist/types/format.d.ts +10 -6
- package/dist/types/index.d.ts +1 -0
- package/dist/types/input.d.ts +15 -4
- package/dist/types/messages.d.ts +44 -36
- package/dist/types/patcher.d.ts +21 -6
- package/dist/types/recovery.d.ts +3 -1
- package/dist/types/tokenizer.d.ts +12 -6
- package/dist/types/types.d.ts +57 -30
- package/package.json +2 -2
- package/src/apply.ts +26 -5
- package/src/block.ts +52 -24
- package/src/clipboard.ts +110 -0
- package/src/format.ts +11 -7
- package/src/grammar.lark +12 -15
- package/src/index.ts +1 -0
- package/src/input.ts +44 -7
- package/src/messages.ts +113 -59
- package/src/parser.ts +96 -34
- package/src/patcher.ts +89 -15
- package/src/prompt.md +51 -89
- package/src/recovery.ts +28 -4
- package/src/tokenizer.ts +85 -38
- package/src/types.ts +60 -30
package/src/tokenizer.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
12
|
describeAnchorExamples,
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
HL_CUT_BLOCK_KEYWORD,
|
|
14
|
+
HL_CUT_KEYWORD,
|
|
15
15
|
HL_FILE_HASH_LENGTH,
|
|
16
16
|
HL_FILE_HASH_SEP,
|
|
17
17
|
HL_FILE_PREFIX,
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
HL_INSERT_KEYWORD,
|
|
25
25
|
HL_INSERT_TAIL,
|
|
26
26
|
HL_MOVE_KEYWORD,
|
|
27
|
+
HL_PASTE_AFTER_BLOCK_KEYWORD,
|
|
28
|
+
HL_PASTE_KEYWORD,
|
|
27
29
|
HL_PAYLOAD_REPLACE,
|
|
28
30
|
HL_REM_KEYWORD,
|
|
29
31
|
HL_REPLACE_BLOCK_KEYWORD,
|
|
@@ -132,6 +134,7 @@ function scanLineNumber(line: string, index: number, end: number): NumberScan |
|
|
|
132
134
|
const code = line.charCodeAt(nextIndex);
|
|
133
135
|
if (!isDigitCode(code)) break;
|
|
134
136
|
lineNumber = lineNumber * 10 + (code - CHAR_ZERO);
|
|
137
|
+
if (!Number.isSafeInteger(lineNumber)) return null;
|
|
135
138
|
nextIndex++;
|
|
136
139
|
}
|
|
137
140
|
return { line: lineNumber, nextIndex };
|
|
@@ -214,11 +217,13 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
|
|
|
214
217
|
export type BlockTarget =
|
|
215
218
|
| { kind: "replace"; range: ParsedRange }
|
|
216
219
|
| { kind: "block"; anchor: Anchor }
|
|
217
|
-
| { kind: "delete"; range: ParsedRange }
|
|
218
|
-
| { kind: "delete_block"; anchor: Anchor }
|
|
219
220
|
| { kind: "insert_before"; anchor: Anchor }
|
|
220
221
|
| { kind: "insert_after"; anchor: Anchor }
|
|
221
222
|
| { kind: "insert_after_block"; anchor: Anchor }
|
|
223
|
+
| { kind: "cut"; range: ParsedRange }
|
|
224
|
+
| { kind: "cut_block"; anchor: Anchor }
|
|
225
|
+
| { kind: "paste"; cursor: Cursor }
|
|
226
|
+
| { kind: "paste_after_block"; anchor: Anchor }
|
|
222
227
|
| { kind: "rem" }
|
|
223
228
|
| { kind: "move"; dest: string }
|
|
224
229
|
| { kind: "bof" }
|
|
@@ -269,30 +274,52 @@ function consumeReplaceColon(line: string, index: number, end: number): number {
|
|
|
269
274
|
return skipWhitespace(line, afterEquals + 1, end);
|
|
270
275
|
}
|
|
271
276
|
|
|
272
|
-
|
|
277
|
+
interface PositionScan {
|
|
278
|
+
cursor: Cursor;
|
|
279
|
+
nextIndex: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Scan the `.PRE N` / `.POST N` / `.HEAD` / `.TAIL` positional suffix shared by `INS` and `PASTE`. */
|
|
283
|
+
function scanPositionalSuffix(line: string, index: number, end: number): PositionScan | null {
|
|
273
284
|
if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
|
|
274
|
-
const
|
|
275
|
-
const beforeEnd = scanKeyword(line,
|
|
285
|
+
const probe = skipWhitespace(line, index + 1, end);
|
|
286
|
+
const beforeEnd = scanKeyword(line, probe, end, HL_INSERT_BEFORE);
|
|
276
287
|
if (beforeEnd !== null) {
|
|
277
288
|
const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
|
|
278
289
|
if (anchor === null) return null;
|
|
279
290
|
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
|
280
|
-
return {
|
|
291
|
+
return { cursor: { kind: "before_anchor", anchor: { line: anchor.line } }, nextIndex };
|
|
281
292
|
}
|
|
282
|
-
const afterEnd = scanKeyword(line,
|
|
293
|
+
const afterEnd = scanKeyword(line, probe, end, HL_INSERT_AFTER);
|
|
283
294
|
if (afterEnd !== null) {
|
|
284
295
|
const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
|
|
285
296
|
if (anchor === null) return null;
|
|
286
297
|
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
|
287
|
-
return {
|
|
298
|
+
return { cursor: { kind: "after_anchor", anchor: { line: anchor.line } }, nextIndex };
|
|
288
299
|
}
|
|
289
|
-
const headEnd = scanKeyword(line,
|
|
290
|
-
if (headEnd !== null) return {
|
|
291
|
-
const tailEnd = scanKeyword(line,
|
|
292
|
-
if (tailEnd !== null) return {
|
|
300
|
+
const headEnd = scanKeyword(line, probe, end, HL_INSERT_HEAD);
|
|
301
|
+
if (headEnd !== null) return { cursor: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) };
|
|
302
|
+
const tailEnd = scanKeyword(line, probe, end, HL_INSERT_TAIL);
|
|
303
|
+
if (tailEnd !== null) return { cursor: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) };
|
|
293
304
|
return null;
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
|
|
308
|
+
const scan = scanPositionalSuffix(line, index, end);
|
|
309
|
+
if (scan === null) return null;
|
|
310
|
+
const { cursor, nextIndex } = scan;
|
|
311
|
+
switch (cursor.kind) {
|
|
312
|
+
case "before_anchor":
|
|
313
|
+
return { target: { kind: "insert_before", anchor: cursor.anchor }, nextIndex };
|
|
314
|
+
case "after_anchor":
|
|
315
|
+
return { target: { kind: "insert_after", anchor: cursor.anchor }, nextIndex };
|
|
316
|
+
case "bof":
|
|
317
|
+
return { target: { kind: "bof" }, nextIndex };
|
|
318
|
+
case "eof":
|
|
319
|
+
return { target: { kind: "eof" }, nextIndex };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
296
323
|
function unquotePath(pathText: string): string {
|
|
297
324
|
if (pathText.length < 2) return pathText;
|
|
298
325
|
const first = pathText[0];
|
|
@@ -360,28 +387,6 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
360
387
|
nextIndex: consumeReplaceColon(line, range.nextIndex, end),
|
|
361
388
|
};
|
|
362
389
|
}
|
|
363
|
-
// `delete_block N` — resolve N to a tree-sitter block range at apply time
|
|
364
|
-
// and delete its whole span. Like `delete N.=M`, it takes no body and no
|
|
365
|
-
// trailing colon.
|
|
366
|
-
const deleteBlockEnd = scanKeyword(line, cursor, end, HL_DELETE_BLOCK_KEYWORD);
|
|
367
|
-
if (deleteBlockEnd !== null) {
|
|
368
|
-
const anchor = scanLineNumber(line, skipWhitespace(line, deleteBlockEnd, end), end);
|
|
369
|
-
if (anchor === null) return null;
|
|
370
|
-
let next = skipWhitespace(line, anchor.nextIndex, end);
|
|
371
|
-
next = skipStrayDot(line, next, end);
|
|
372
|
-
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
373
|
-
return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
|
|
374
|
-
}
|
|
375
|
-
// `delete N.=M` — like `delete_block N`, takes no body and no trailing
|
|
376
|
-
// colon; a colon here falls through to contamination detection.
|
|
377
|
-
const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
|
|
378
|
-
if (deleteEnd !== null) {
|
|
379
|
-
const range = scanHeaderRange(line, deleteEnd, end, true);
|
|
380
|
-
if (range === null) return null;
|
|
381
|
-
const next = skipStrayDot(line, range.nextIndex, end);
|
|
382
|
-
if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
|
|
383
|
-
return { target: { kind: "delete", range: range.range }, nextIndex: next };
|
|
384
|
-
}
|
|
385
390
|
// `insert_after_block N:` — insert after the last line of the tree-sitter
|
|
386
391
|
// block at N.
|
|
387
392
|
const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
|
|
@@ -393,6 +398,47 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
393
398
|
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
394
399
|
};
|
|
395
400
|
}
|
|
401
|
+
// `PASTE.BLK.POST N` — insert the clipboard after the tree-sitter block
|
|
402
|
+
// at N. Like all clipboard ops, takes no body rows.
|
|
403
|
+
const pasteAfterBlockEnd = scanKeyword(line, cursor, end, HL_PASTE_AFTER_BLOCK_KEYWORD);
|
|
404
|
+
if (pasteAfterBlockEnd !== null) {
|
|
405
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, pasteAfterBlockEnd, end), end);
|
|
406
|
+
if (anchor === null) return null;
|
|
407
|
+
return {
|
|
408
|
+
target: { kind: "paste_after_block", anchor: { line: anchor.line } },
|
|
409
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
// `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` — insert the clipboard at the position.
|
|
413
|
+
const pasteEnd = scanKeyword(line, cursor, end, HL_PASTE_KEYWORD);
|
|
414
|
+
if (pasteEnd !== null) {
|
|
415
|
+
const scan = scanPositionalSuffix(line, pasteEnd, end);
|
|
416
|
+
if (scan === null) return null;
|
|
417
|
+
return { target: { kind: "paste", cursor: scan.cursor }, nextIndex: scan.nextIndex };
|
|
418
|
+
}
|
|
419
|
+
// `CUT.BLK N` captures and deletes the tree-sitter block beginning at N.
|
|
420
|
+
// Scan it before the plain form so `.BLK` is not parsed as a range.
|
|
421
|
+
const cutBlockEnd = scanKeyword(line, cursor, end, HL_CUT_BLOCK_KEYWORD);
|
|
422
|
+
if (cutBlockEnd !== null) {
|
|
423
|
+
const anchor = scanLineNumber(line, skipWhitespace(line, cutBlockEnd, end), end);
|
|
424
|
+
if (anchor === null) return null;
|
|
425
|
+
return {
|
|
426
|
+
target: { kind: "cut_block", anchor: { line: anchor.line } },
|
|
427
|
+
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// `CUT N.=M` captures and deletes concrete lines. A trailing colon is
|
|
431
|
+
// tolerated and ignored; body rows are rejected by the parser.
|
|
432
|
+
const cutEnd = scanKeyword(line, cursor, end, HL_CUT_KEYWORD);
|
|
433
|
+
if (cutEnd !== null) {
|
|
434
|
+
const range = scanHeaderRange(line, cutEnd, end, true);
|
|
435
|
+
if (range === null) return null;
|
|
436
|
+
const next = skipStrayDot(line, range.nextIndex, end);
|
|
437
|
+
return {
|
|
438
|
+
target: { kind: "cut", range: range.range },
|
|
439
|
+
nextIndex: consumeOptionalColon(line, next, end),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
396
442
|
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
|
|
397
443
|
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
|
|
398
444
|
return null;
|
|
@@ -487,10 +533,11 @@ function classifyLine(line: string, lineNum: number): Token {
|
|
|
487
533
|
const lead = skipWhitespace(line, 0);
|
|
488
534
|
const isHunkLead =
|
|
489
535
|
line.startsWith(HL_REPLACE_KEYWORD, lead) ||
|
|
490
|
-
line.startsWith(HL_DELETE_KEYWORD, lead) ||
|
|
491
536
|
line.startsWith(HL_INSERT_KEYWORD, lead) ||
|
|
492
537
|
line.startsWith(HL_REM_KEYWORD, lead) ||
|
|
493
|
-
line.startsWith(HL_MOVE_KEYWORD, lead)
|
|
538
|
+
line.startsWith(HL_MOVE_KEYWORD, lead) ||
|
|
539
|
+
line.startsWith(HL_CUT_KEYWORD, lead) ||
|
|
540
|
+
line.startsWith(HL_PASTE_KEYWORD, lead);
|
|
494
541
|
if (isHunkLead) {
|
|
495
542
|
const hunk = tryParseHunkHeader(line);
|
|
496
543
|
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
|
package/src/types.ts
CHANGED
|
@@ -42,21 +42,46 @@ export type Edit =
|
|
|
42
42
|
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
43
43
|
| {
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
* Clipboard cut (`CUT N.=M`, or the resolved form of `CUT.BLK N`).
|
|
46
|
+
* Captures the range's current lines into the {@link Clipboard}
|
|
47
|
+
* register during the applier's clipboard pre-pass and lowers to one
|
|
48
|
+
* `delete` per range line at parse/resolve time.
|
|
49
|
+
*/
|
|
50
|
+
kind: "cut";
|
|
51
|
+
range: ParsedRange;
|
|
52
|
+
lineNum: number;
|
|
53
|
+
index: number;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
/**
|
|
57
|
+
* Clipboard insertion (`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` /
|
|
58
|
+
* `PASTE.TAIL`, or the resolved form of `PASTE.BLK.POST N`). Expanded
|
|
59
|
+
* by the clipboard pre-pass into one plain insert per captured line.
|
|
60
|
+
* `blockStart` mirrors the insert variant's field for block-lowered
|
|
61
|
+
* pastes so landing correction can slide the body across trailing
|
|
62
|
+
* closer lines.
|
|
63
|
+
*/
|
|
64
|
+
kind: "paste";
|
|
65
|
+
cursor: Cursor;
|
|
66
|
+
lineNum: number;
|
|
67
|
+
index: number;
|
|
68
|
+
blockStart?: number;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
/**
|
|
72
|
+
* Deferred block edit (`SWAP.BLK N:`, `INS.BLK.POST N:`,
|
|
73
|
+
* `CUT.BLK N`, or `PASTE.BLK.POST N`). The exact line span is
|
|
74
|
+
* unknown at parse time; {@link resolveBlockEdits} computes it once
|
|
75
|
+
* file text and language are available, then expands it into
|
|
76
|
+
* concrete edits. `mode: "insert_after"` becomes plain
|
|
77
|
+
* `after_anchor` inserts, `"cut"` becomes a clipboard cut plus
|
|
78
|
+
* per-line deletes, and `"paste_after"` becomes a paste after the
|
|
79
|
+
* resolved block. No mode denotes a block replacement.
|
|
55
80
|
*/
|
|
56
81
|
kind: "block";
|
|
57
82
|
anchor: Anchor;
|
|
58
83
|
payloads: string[];
|
|
59
|
-
mode?: "insert_after";
|
|
84
|
+
mode?: "insert_after" | "cut" | "paste_after";
|
|
60
85
|
lineNum: number;
|
|
61
86
|
index: number;
|
|
62
87
|
};
|
|
@@ -73,10 +98,9 @@ export interface ApplyResult {
|
|
|
73
98
|
/** Diagnostic warnings collected by the parser, patcher, or recovery. */
|
|
74
99
|
warnings?: string[];
|
|
75
100
|
/**
|
|
76
|
-
* Resolved spans for each
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* caller read. Absent when there were no block ops.
|
|
101
|
+
* Resolved spans for each block op in this apply, in patch order. Present
|
|
102
|
+
* only when the apply matched the tagged content, so the line numbers match
|
|
103
|
+
* what the caller read. Absent when there were no block ops.
|
|
80
104
|
*/
|
|
81
105
|
blockResolutions?: BlockResolution[];
|
|
82
106
|
}
|
|
@@ -124,9 +148,7 @@ export interface CompactDiffOptions {
|
|
|
124
148
|
maxUnchangedRun?: number;
|
|
125
149
|
}
|
|
126
150
|
|
|
127
|
-
/**
|
|
128
|
-
* Resolved 1-indexed inclusive line span of a `replace_block N:` target.
|
|
129
|
-
*/
|
|
151
|
+
/** Resolved 1-indexed inclusive line span of a block target. */
|
|
130
152
|
export interface BlockSpan {
|
|
131
153
|
/** First line of the block (1-indexed, inclusive). */
|
|
132
154
|
start: number;
|
|
@@ -135,11 +157,9 @@ export interface BlockSpan {
|
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
/**
|
|
138
|
-
* One
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* opener — e.g. a decorator or doc-comment that sits in a separate node
|
|
142
|
-
* outside the resolved block.
|
|
160
|
+
* One block-op anchor resolved to its concrete line span. Surfaced on
|
|
161
|
+
* {@link ApplyResult} so the host can echo the resolved range and let the
|
|
162
|
+
* model catch an anchor on the wrong opener.
|
|
143
163
|
*/
|
|
144
164
|
export interface BlockResolution {
|
|
145
165
|
/** The 1-indexed line the block op was anchored on (the `N`). */
|
|
@@ -149,10 +169,10 @@ export interface BlockResolution {
|
|
|
149
169
|
/** Last line of the resolved span (1-indexed, inclusive). */
|
|
150
170
|
end: number;
|
|
151
171
|
/** Which block op produced this resolution. */
|
|
152
|
-
op: "replace" | "
|
|
172
|
+
op: "replace" | "insert_after" | "cut" | "paste_after";
|
|
153
173
|
}
|
|
154
174
|
|
|
155
|
-
/** Request handed to a {@link BlockResolver} to resolve one
|
|
175
|
+
/** Request handed to a {@link BlockResolver} to resolve one block-op anchor. */
|
|
156
176
|
export interface BlockResolverRequest {
|
|
157
177
|
/** Target file path (used to infer language by extension). */
|
|
158
178
|
path: string;
|
|
@@ -163,10 +183,20 @@ export interface BlockResolverRequest {
|
|
|
163
183
|
}
|
|
164
184
|
|
|
165
185
|
/**
|
|
166
|
-
* Resolves a
|
|
167
|
-
* that
|
|
168
|
-
*
|
|
169
|
-
* resolved subtree has a syntax error). Pure seam: the hashline core declares
|
|
170
|
-
* the contract; the host injects a tree-sitter-backed implementation.
|
|
186
|
+
* Resolves a block-op anchor to the line span of the syntactic block beginning
|
|
187
|
+
* on that line. Returns `null` for an unsupported language, invalid line, absent
|
|
188
|
+
* opener, or syntax error. The host injects the tree-sitter-backed implementation.
|
|
171
189
|
*/
|
|
172
190
|
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mutable clipboard register threaded through one patch application. Filled
|
|
194
|
+
* by `CUT` edits and read by `PASTE` edits in patch source order, across
|
|
195
|
+
* sections, so content can move between files. Create one per batch (`{}`)
|
|
196
|
+
* and hand it to every {@link Patcher.prepare} / `applyTo` call in that batch;
|
|
197
|
+
* callers that omit it get a private per-call register.
|
|
198
|
+
*/
|
|
199
|
+
export interface Clipboard {
|
|
200
|
+
/** Lines captured by the most recent `CUT`, or unset. */
|
|
201
|
+
lines?: readonly string[];
|
|
202
|
+
}
|