@oh-my-pi/hashline 17.2.0 → 17.2.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 +20 -0
- package/README.md +7 -7
- package/dist/types/clipboard.d.ts +6 -4
- package/dist/types/format.d.ts +21 -27
- package/dist/types/messages.d.ts +48 -27
- package/dist/types/parser.d.ts +2 -1
- package/dist/types/prefixes.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +12 -8
- package/dist/types/types.d.ts +53 -25
- package/package.json +2 -2
- package/src/apply.ts +72 -1
- package/src/block.ts +27 -5
- package/src/clipboard.ts +141 -42
- package/src/format.ts +35 -34
- package/src/grammar.lark +12 -11
- package/src/input.ts +14 -10
- package/src/messages.ts +129 -65
- package/src/parser.ts +307 -103
- package/src/patcher.ts +8 -8
- package/src/prefixes.ts +12 -4
- package/src/prompt.md +37 -38
- package/src/recovery.ts +56 -15
- package/src/tokenizer.ts +160 -191
- package/src/types.ts +48 -25
package/src/tokenizer.ts
CHANGED
|
@@ -10,26 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
12
|
describeAnchorExamples,
|
|
13
|
-
HL_CUT_BLOCK_KEYWORD,
|
|
14
13
|
HL_CUT_KEYWORD,
|
|
15
14
|
HL_FILE_HASH_LENGTH,
|
|
16
15
|
HL_FILE_HASH_SEP,
|
|
17
16
|
HL_FILE_PREFIX,
|
|
18
17
|
HL_FILE_SUFFIX,
|
|
19
18
|
HL_HEADER_COLON,
|
|
20
|
-
HL_INSERT_AFTER,
|
|
21
|
-
HL_INSERT_AFTER_BLOCK_KEYWORD,
|
|
22
|
-
HL_INSERT_BEFORE,
|
|
23
|
-
HL_INSERT_HEAD,
|
|
24
|
-
HL_INSERT_KEYWORD,
|
|
25
|
-
HL_INSERT_TAIL,
|
|
26
19
|
HL_MOVE_KEYWORD,
|
|
27
|
-
HL_PASTE_AFTER_BLOCK_KEYWORD,
|
|
28
|
-
HL_PASTE_KEYWORD,
|
|
29
20
|
HL_PAYLOAD_REPLACE,
|
|
21
|
+
HL_PUT_KEYWORD,
|
|
30
22
|
HL_REM_KEYWORD,
|
|
31
|
-
HL_REPLACE_BLOCK_KEYWORD,
|
|
32
|
-
HL_REPLACE_KEYWORD,
|
|
33
23
|
} from "./format";
|
|
34
24
|
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
|
|
35
25
|
import type { Anchor, Cursor, ParsedRange } from "./types";
|
|
@@ -41,11 +31,16 @@ const CHAR_NINE = 57;
|
|
|
41
31
|
const CHAR_HASH = 35;
|
|
42
32
|
const CHAR_TAB = 9;
|
|
43
33
|
const CHAR_SPACE = 32;
|
|
44
|
-
const CHAR_DOT = 46;
|
|
45
|
-
const CHAR_COMMA = 44;
|
|
46
34
|
const CHAR_HYPHEN = 45;
|
|
47
|
-
const
|
|
35
|
+
const CHAR_DOT = 46;
|
|
48
36
|
const CHAR_EQUALS = 61;
|
|
37
|
+
const CHAR_ELLIPSIS = 0x2026;
|
|
38
|
+
const CHAR_LESS_THAN = 60;
|
|
39
|
+
const CHAR_GREATER_THAN = 62;
|
|
40
|
+
const CHAR_STAR = 42;
|
|
41
|
+
const CHAR_DOLLAR = 36;
|
|
42
|
+
const CHAR_AT = 64;
|
|
43
|
+
const CHAR_UNDERSCORE = 95;
|
|
49
44
|
|
|
50
45
|
const CHAR_UPPER_A = 65;
|
|
51
46
|
const CHAR_UPPER_F = 70;
|
|
@@ -157,40 +152,33 @@ export function parseLid(raw: string, lineNum: number): Anchor {
|
|
|
157
152
|
interface RangeScan {
|
|
158
153
|
range: ParsedRange;
|
|
159
154
|
nextIndex: number;
|
|
155
|
+
hadSeparator: boolean;
|
|
160
156
|
}
|
|
161
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Range separator scanner. Canonical input is `.=`, while parsing remains
|
|
160
|
+
* deliberately lenient for model output: `-`, `=`, `.`, `..`, `…`, mixed
|
|
161
|
+
* runs, and whitespace-only separators all recover to the same range.
|
|
162
|
+
*/
|
|
162
163
|
function scanRangeSeparator(line: string, index: number, end: number): number | null {
|
|
163
164
|
let cursor = index;
|
|
164
165
|
let consumedSeparator = false;
|
|
165
166
|
while (cursor < end) {
|
|
166
167
|
const code = line.charCodeAt(cursor);
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
if (
|
|
169
|
+
isWhitespaceCode(code) ||
|
|
170
|
+
code === CHAR_HYPHEN ||
|
|
171
|
+
code === CHAR_DOT ||
|
|
172
|
+
code === CHAR_EQUALS ||
|
|
173
|
+
code === CHAR_ELLIPSIS
|
|
174
|
+
) {
|
|
173
175
|
cursor++;
|
|
174
176
|
consumedSeparator = true;
|
|
175
177
|
continue;
|
|
176
178
|
}
|
|
177
|
-
if (code === CHAR_DOT && cursor + 1 < end) {
|
|
178
|
-
const next = line.charCodeAt(cursor + 1);
|
|
179
|
-
if (next === CHAR_DOT || next === CHAR_EQUALS) {
|
|
180
|
-
cursor += 2;
|
|
181
|
-
consumedSeparator = true;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
if (isNonZeroDigitCode(next)) {
|
|
185
|
-
cursor++;
|
|
186
|
-
consumedSeparator = true;
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
179
|
break;
|
|
191
180
|
}
|
|
192
|
-
if (!consumedSeparator) return null;
|
|
193
|
-
if (cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null;
|
|
181
|
+
if (!consumedSeparator || cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null;
|
|
194
182
|
return cursor;
|
|
195
183
|
}
|
|
196
184
|
|
|
@@ -204,6 +192,7 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
|
|
|
204
192
|
return {
|
|
205
193
|
range: { start: { line: start.line }, end: { line: start.line } },
|
|
206
194
|
nextIndex: skipWhitespace(line, start.nextIndex, end),
|
|
195
|
+
hadSeparator: false,
|
|
207
196
|
};
|
|
208
197
|
}
|
|
209
198
|
const endNumber = scanLineNumber(line, afterFirst, end);
|
|
@@ -211,27 +200,35 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
|
|
|
211
200
|
return {
|
|
212
201
|
range: { start: { line: start.line }, end: { line: endNumber.line } },
|
|
213
202
|
nextIndex: skipWhitespace(line, endNumber.nextIndex, end),
|
|
203
|
+
hadSeparator: true,
|
|
214
204
|
};
|
|
215
205
|
}
|
|
216
206
|
|
|
217
207
|
export type BlockTarget =
|
|
218
|
-
| { kind: "replace"; range: ParsedRange }
|
|
219
|
-
| { kind: "block"; anchor: Anchor }
|
|
220
|
-
| { kind: "insert_before"; anchor: Anchor }
|
|
221
|
-
| { kind: "insert_after"; anchor: Anchor }
|
|
222
|
-
| { kind: "insert_after_block"; anchor: Anchor }
|
|
223
|
-
| { kind: "cut"; range: ParsedRange }
|
|
224
|
-
| { kind: "cut_block"; anchor: Anchor }
|
|
225
|
-
| { kind: "
|
|
226
|
-
| { kind: "
|
|
208
|
+
| { kind: "replace"; range: ParsedRange; register?: string }
|
|
209
|
+
| { kind: "block"; anchor: Anchor; register?: string }
|
|
210
|
+
| { kind: "insert_before"; anchor: Anchor; register?: string }
|
|
211
|
+
| { kind: "insert_after"; anchor: Anchor; register?: string }
|
|
212
|
+
| { kind: "insert_after_block"; anchor: Anchor; register?: string }
|
|
213
|
+
| { kind: "cut"; range: ParsedRange; register?: string }
|
|
214
|
+
| { kind: "cut_block"; anchor: Anchor; register?: string }
|
|
215
|
+
| { kind: "bof"; register?: string }
|
|
216
|
+
| { kind: "eof"; register?: string }
|
|
227
217
|
| { kind: "rem" }
|
|
228
|
-
| { kind: "move"; dest: string }
|
|
229
|
-
|
|
230
|
-
|
|
218
|
+
| { kind: "move"; dest: string };
|
|
219
|
+
|
|
220
|
+
/** Targets that may carry a `@register` suffix (everything but the file-level ops). */
|
|
221
|
+
type RegisterableTarget = Exclude<BlockTarget, { kind: "rem" } | { kind: "move" }>;
|
|
231
222
|
|
|
232
223
|
interface TargetScan {
|
|
233
224
|
target: BlockTarget;
|
|
234
225
|
nextIndex: number;
|
|
226
|
+
/**
|
|
227
|
+
* Whether the header carried a trailing `:`. The parser uses it to tell a
|
|
228
|
+
* literal insertion awaiting body rows (`PUT >40:`) from a bodyless
|
|
229
|
+
* anonymous paste (`PUT >40`).
|
|
230
|
+
*/
|
|
231
|
+
hadColon: boolean;
|
|
235
232
|
}
|
|
236
233
|
|
|
237
234
|
function scanKeyword(line: string, index: number, end: number, keyword: string): number | null {
|
|
@@ -239,85 +236,128 @@ function scanKeyword(line: string, index: number, end: number, keyword: string):
|
|
|
239
236
|
const next = index + keyword.length;
|
|
240
237
|
if (next < end) {
|
|
241
238
|
const code = line.charCodeAt(next);
|
|
242
|
-
if (!isWhitespaceCode(code) && code !== CHAR_COLON
|
|
239
|
+
if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
|
|
243
240
|
}
|
|
244
241
|
return next;
|
|
245
242
|
}
|
|
246
243
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
function
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
244
|
+
interface ColonScan {
|
|
245
|
+
nextIndex: number;
|
|
246
|
+
hadColon: boolean;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function consumeOptionalColon(line: string, index: number, end: number): ColonScan {
|
|
250
|
+
const cursor = skipWhitespace(line, index, end);
|
|
251
|
+
if (cursor < end && line.charCodeAt(cursor) === CHAR_COLON) {
|
|
252
|
+
return { nextIndex: skipWhitespace(line, cursor + 1, end), hadColon: true };
|
|
256
253
|
}
|
|
257
|
-
return
|
|
254
|
+
return { nextIndex: cursor, hadColon: false };
|
|
258
255
|
}
|
|
259
256
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
257
|
+
/** Maximum accepted register-name length; anything longer fails the header parse. */
|
|
258
|
+
const REGISTER_NAME_MAX = 64;
|
|
259
|
+
|
|
260
|
+
function isRegisterNameCode(code: number): boolean {
|
|
261
|
+
return (
|
|
262
|
+
isDigitCode(code) ||
|
|
263
|
+
(code >= CHAR_UPPER_A && code <= 90) ||
|
|
264
|
+
(code >= CHAR_LOWER_A && code <= 122) ||
|
|
265
|
+
code === CHAR_UNDERSCORE ||
|
|
266
|
+
code === CHAR_HYPHEN
|
|
267
|
+
);
|
|
264
268
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return skipWhitespace(line, afterEquals + 1, end);
|
|
269
|
+
|
|
270
|
+
/** Scan a `@name` register reference. */
|
|
271
|
+
function scanRegister(line: string, index: number, end: number): { name: string; nextIndex: number } | null {
|
|
272
|
+
if (index >= end || line.charCodeAt(index) !== CHAR_AT) return null;
|
|
273
|
+
const start = index + 1;
|
|
274
|
+
let cursor = start;
|
|
275
|
+
while (cursor < end && isRegisterNameCode(line.charCodeAt(cursor))) cursor++;
|
|
276
|
+
if (cursor === start || cursor - start > REGISTER_NAME_MAX) return null;
|
|
277
|
+
return { name: line.slice(start, cursor), nextIndex: cursor };
|
|
275
278
|
}
|
|
276
279
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Finish a `PUT`/`CUT` header: optional `@register`, optional trailing `:`.
|
|
282
|
+
* The parser decides whether a body is required; the tokenizer only records
|
|
283
|
+
* the shape.
|
|
284
|
+
*/
|
|
285
|
+
function finishTargetScan(line: string, index: number, end: number, target: RegisterableTarget): TargetScan {
|
|
286
|
+
let cursor = skipWhitespace(line, index, end);
|
|
287
|
+
const register = scanRegister(line, cursor, end);
|
|
288
|
+
if (register !== null) {
|
|
289
|
+
target = { ...target, register: register.name };
|
|
290
|
+
cursor = register.nextIndex;
|
|
291
|
+
}
|
|
292
|
+
const colon = consumeOptionalColon(line, cursor, end);
|
|
293
|
+
return { target, nextIndex: colon.nextIndex, hadColon: colon.hadColon };
|
|
280
294
|
}
|
|
281
295
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Scan the locator of a `PUT` header:
|
|
298
|
+
* span — `5` / `5-9` (replace lines), `5*` (replace the block opening at 5)
|
|
299
|
+
* gap — `<5` / `>5` (insert), `>5*` (after the block's end), `<1` (head), `>$` (tail)
|
|
300
|
+
*/
|
|
301
|
+
function scanPutTarget(line: string, index: number, end: number): TargetScan | null {
|
|
302
|
+
const cursor = skipWhitespace(line, index, end);
|
|
303
|
+
if (cursor >= end) return null;
|
|
304
|
+
const sigil = line.charCodeAt(cursor);
|
|
305
|
+
if (sigil === CHAR_LESS_THAN || sigil === CHAR_GREATER_THAN) {
|
|
306
|
+
const isAfter = sigil === CHAR_GREATER_THAN;
|
|
307
|
+
const probe = skipWhitespace(line, cursor + 1, end);
|
|
308
|
+
if (isAfter && probe < end && line.charCodeAt(probe) === CHAR_DOLLAR) {
|
|
309
|
+
return finishTargetScan(line, probe + 1, end, { kind: "eof" });
|
|
310
|
+
}
|
|
311
|
+
const anchor = scanLineNumber(line, probe, end);
|
|
289
312
|
if (anchor === null) return null;
|
|
290
|
-
|
|
291
|
-
|
|
313
|
+
let next = anchor.nextIndex;
|
|
314
|
+
let block = false;
|
|
315
|
+
if (next < end && line.charCodeAt(next) === CHAR_STAR) {
|
|
316
|
+
block = true;
|
|
317
|
+
next++;
|
|
318
|
+
}
|
|
319
|
+
if (isAfter) {
|
|
320
|
+
return finishTargetScan(
|
|
321
|
+
line,
|
|
322
|
+
next,
|
|
323
|
+
end,
|
|
324
|
+
block
|
|
325
|
+
? { kind: "insert_after_block", anchor: { line: anchor.line } }
|
|
326
|
+
: { kind: "insert_after", anchor: { line: anchor.line } },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
// `<N*` is the same gap as `<N`: a block anchored at N begins on line N,
|
|
330
|
+
// so "before the block" is "before line N". The star is dropped.
|
|
331
|
+
// `<1` is head — mapped to `bof` so it stays position-stable (never
|
|
332
|
+
// anchor-scoped) and works when creating empty files.
|
|
333
|
+
return finishTargetScan(
|
|
334
|
+
line,
|
|
335
|
+
next,
|
|
336
|
+
end,
|
|
337
|
+
anchor.line === 1 ? { kind: "bof" } : { kind: "insert_before", anchor: { line: anchor.line } },
|
|
338
|
+
);
|
|
292
339
|
}
|
|
293
|
-
const
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
340
|
+
const range = scanHeaderRange(line, cursor, end, true);
|
|
341
|
+
if (range === null) return null;
|
|
342
|
+
const next = range.nextIndex;
|
|
343
|
+
if (next < end && line.charCodeAt(next) === CHAR_STAR) {
|
|
344
|
+
// Block locators are single opening lines (`N*`), never ranges.
|
|
345
|
+
if (range.hadSeparator) return null;
|
|
346
|
+
return finishTargetScan(line, next + 1, end, { kind: "block", anchor: { line: range.range.start.line } });
|
|
299
347
|
}
|
|
300
|
-
|
|
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) };
|
|
304
|
-
return null;
|
|
348
|
+
return finishTargetScan(line, next, end, { kind: "replace", range: range.range });
|
|
305
349
|
}
|
|
306
350
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 };
|
|
351
|
+
/** Scan the locator of a `CUT` header: `N.=M` or `N*` (block). */
|
|
352
|
+
function scanCutTarget(line: string, index: number, end: number): TargetScan | null {
|
|
353
|
+
const range = scanHeaderRange(line, index, end, true);
|
|
354
|
+
if (range === null) return null;
|
|
355
|
+
const next = range.nextIndex;
|
|
356
|
+
if (next < end && line.charCodeAt(next) === CHAR_STAR) {
|
|
357
|
+
if (range.hadSeparator) return null;
|
|
358
|
+
return finishTargetScan(line, next + 1, end, { kind: "cut_block", anchor: { line: range.range.start.line } });
|
|
320
359
|
}
|
|
360
|
+
return finishTargetScan(line, next, end, { kind: "cut", range: range.range });
|
|
321
361
|
}
|
|
322
362
|
|
|
323
363
|
function unquotePath(pathText: string): string {
|
|
@@ -359,93 +399,24 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
|
|
359
399
|
if (remEnd !== null) {
|
|
360
400
|
const next = skipWhitespace(line, remEnd, end);
|
|
361
401
|
if (next !== end) return null;
|
|
362
|
-
return { target: { kind: "rem" }, nextIndex: next };
|
|
402
|
+
return { target: { kind: "rem" }, nextIndex: next, hadColon: false };
|
|
363
403
|
}
|
|
364
404
|
const moveEnd = scanKeyword(line, cursor, end, HL_MOVE_KEYWORD);
|
|
365
405
|
if (moveEnd !== null) {
|
|
366
406
|
const dest = scanMoveDest(line, moveEnd, end);
|
|
367
407
|
if (dest === null || dest.length === 0) return null;
|
|
368
|
-
return { target: { kind: "move", dest }, nextIndex: end };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// `replace_block N:` — resolve N to a tree-sitter block range at apply time.
|
|
372
|
-
const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
|
|
373
|
-
if (replaceBlockEnd !== null) {
|
|
374
|
-
const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
|
|
375
|
-
if (anchor === null) return null;
|
|
376
|
-
return {
|
|
377
|
-
target: { kind: "block", anchor: { line: anchor.line } },
|
|
378
|
-
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
|
|
382
|
-
if (replaceEnd !== null) {
|
|
383
|
-
const range = scanHeaderRange(line, replaceEnd, end, true);
|
|
384
|
-
if (range === null) return null;
|
|
385
|
-
return {
|
|
386
|
-
target: { kind: "replace", range: range.range },
|
|
387
|
-
nextIndex: consumeReplaceColon(line, range.nextIndex, end),
|
|
388
|
-
};
|
|
408
|
+
return { target: { kind: "move", dest }, nextIndex: end, hadColon: false };
|
|
389
409
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
|
|
393
|
-
if (insertAfterBlockEnd !== null) {
|
|
394
|
-
const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
|
|
395
|
-
if (anchor === null) return null;
|
|
396
|
-
return {
|
|
397
|
-
target: { kind: "insert_after_block", anchor: { line: anchor.line } },
|
|
398
|
-
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
|
399
|
-
};
|
|
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.
|
|
410
|
+
const putEnd = scanKeyword(line, cursor, end, HL_PUT_KEYWORD);
|
|
411
|
+
if (putEnd !== null) return scanPutTarget(line, putEnd, end);
|
|
432
412
|
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
|
-
}
|
|
442
|
-
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
|
|
443
|
-
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
|
|
413
|
+
if (cutEnd !== null) return scanCutTarget(line, cutEnd, end);
|
|
444
414
|
return null;
|
|
445
415
|
}
|
|
446
416
|
|
|
447
417
|
interface ParsedHunkHeader {
|
|
448
418
|
target: BlockTarget;
|
|
419
|
+
hadColon: boolean;
|
|
449
420
|
}
|
|
450
421
|
|
|
451
422
|
function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
|
|
@@ -455,7 +426,7 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
|
|
|
455
426
|
const scan = scanHunkAnchor(line, start, end);
|
|
456
427
|
if (scan === null) return null;
|
|
457
428
|
if (scan.nextIndex !== end) return null;
|
|
458
|
-
return { target: scan.target };
|
|
429
|
+
return { target: scan.target, hadColon: scan.hadColon };
|
|
459
430
|
}
|
|
460
431
|
|
|
461
432
|
function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
|
|
@@ -512,7 +483,7 @@ export type Token =
|
|
|
512
483
|
| (TokenBase & { kind: "envelope-end" })
|
|
513
484
|
| (TokenBase & { kind: "abort" })
|
|
514
485
|
| (TokenBase & { kind: "header"; path: string; fileHash?: string })
|
|
515
|
-
| (TokenBase & { kind: "op-block"; target: BlockTarget })
|
|
486
|
+
| (TokenBase & { kind: "op-block"; target: BlockTarget; hadColon: boolean })
|
|
516
487
|
| (TokenBase & { kind: "payload-literal"; text: string })
|
|
517
488
|
| (TokenBase & { kind: "raw"; text: string });
|
|
518
489
|
|
|
@@ -532,15 +503,13 @@ function classifyLine(line: string, lineNum: number): Token {
|
|
|
532
503
|
}
|
|
533
504
|
const lead = skipWhitespace(line, 0);
|
|
534
505
|
const isHunkLead =
|
|
535
|
-
line.startsWith(
|
|
536
|
-
line.startsWith(HL_INSERT_KEYWORD, lead) ||
|
|
537
|
-
line.startsWith(HL_REM_KEYWORD, lead) ||
|
|
538
|
-
line.startsWith(HL_MOVE_KEYWORD, lead) ||
|
|
506
|
+
line.startsWith(HL_PUT_KEYWORD, lead) ||
|
|
539
507
|
line.startsWith(HL_CUT_KEYWORD, lead) ||
|
|
540
|
-
line.startsWith(
|
|
508
|
+
line.startsWith(HL_REM_KEYWORD, lead) ||
|
|
509
|
+
line.startsWith(HL_MOVE_KEYWORD, lead);
|
|
541
510
|
if (isHunkLead) {
|
|
542
511
|
const hunk = tryParseHunkHeader(line);
|
|
543
|
-
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
|
|
512
|
+
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target, hadColon: hunk.hadColon };
|
|
544
513
|
}
|
|
545
514
|
if (firstCode === CHAR_PAYLOAD_REPLACE) return { kind: "payload-literal", lineNum, text: line.slice(1) };
|
|
546
515
|
return { kind: "raw", lineNum, text: line };
|
package/src/types.ts
CHANGED
|
@@ -42,50 +42,61 @@ export type Edit =
|
|
|
42
42
|
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
43
43
|
| {
|
|
44
44
|
/**
|
|
45
|
-
* Clipboard cut (`CUT N
|
|
46
|
-
* Captures the range's current lines into
|
|
45
|
+
* Clipboard cut (`CUT N-M @r`, or the resolved form of `CUT N* @r`).
|
|
46
|
+
* Captures the range's current lines into a {@link Clipboard}
|
|
47
47
|
* register during the applier's clipboard pre-pass and lowers to one
|
|
48
|
-
* `delete` per range line at parse/resolve time.
|
|
48
|
+
* `delete` per range line at parse/resolve time. `register` names the
|
|
49
|
+
* target slot; absent means the batch-local anonymous register.
|
|
49
50
|
*/
|
|
50
51
|
kind: "cut";
|
|
51
52
|
range: ParsedRange;
|
|
53
|
+
register?: string;
|
|
52
54
|
lineNum: number;
|
|
53
55
|
index: number;
|
|
54
56
|
}
|
|
55
57
|
| {
|
|
56
58
|
/**
|
|
57
|
-
* Clipboard insertion (`
|
|
58
|
-
* `
|
|
59
|
-
* by the clipboard pre-pass into one
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* Clipboard insertion or replacement (`PUT <N @r` / `PUT >N @r` /
|
|
60
|
+
* `PUT N-M @r`, their `>N*` block-resolved forms, or the register-less
|
|
61
|
+
* anonymous equivalents). Expanded by the clipboard pre-pass into one
|
|
62
|
+
* plain insert per captured line; `span` targets additionally expand
|
|
63
|
+
* into per-line deletes there — only after the register read
|
|
64
|
+
* succeeds, so a dropped paste (streaming preview with an empty
|
|
65
|
+
* register) never leaves destructive orphan deletes. `blockStart`
|
|
66
|
+
* mirrors the insert variant's field for block-lowered gap pastes so
|
|
67
|
+
* landing correction can slide the body across trailing closer lines.
|
|
63
68
|
*/
|
|
64
69
|
kind: "paste";
|
|
65
|
-
|
|
70
|
+
at: PasteTarget;
|
|
71
|
+
register?: string;
|
|
66
72
|
lineNum: number;
|
|
67
73
|
index: number;
|
|
68
74
|
blockStart?: number;
|
|
69
75
|
}
|
|
70
76
|
| {
|
|
71
77
|
/**
|
|
72
|
-
* Deferred block edit (`
|
|
73
|
-
* `
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `
|
|
78
|
-
*
|
|
79
|
-
*
|
|
78
|
+
* Deferred block edit (`PUT N*:`, `PUT >N*:`, `CUT N*`, or their
|
|
79
|
+
* `@register` forms). The exact line span is unknown at parse time;
|
|
80
|
+
* {@link resolveBlockEdits} computes it once file text and language
|
|
81
|
+
* are available, then expands it into concrete edits.
|
|
82
|
+
* `mode: "insert_after"` becomes plain `after_anchor` inserts,
|
|
83
|
+
* `"cut"` becomes a clipboard cut plus per-line deletes, and
|
|
84
|
+
* `"paste_after"` becomes a gap paste after the resolved block. No
|
|
85
|
+
* mode denotes a block replacement — from body payloads, or from
|
|
86
|
+
* `register` when one is named (a span paste over the resolved block).
|
|
80
87
|
*/
|
|
81
88
|
kind: "block";
|
|
82
89
|
anchor: Anchor;
|
|
83
90
|
payloads: string[];
|
|
84
91
|
mode?: "insert_after" | "cut" | "paste_after";
|
|
92
|
+
register?: string;
|
|
85
93
|
lineNum: number;
|
|
86
94
|
index: number;
|
|
87
95
|
};
|
|
88
96
|
|
|
97
|
+
/** Where a `paste` edit lands: an insertion gap, or a span it replaces. */
|
|
98
|
+
export type PasteTarget = { kind: "gap"; cursor: Cursor } | { kind: "span"; range: ParsedRange };
|
|
99
|
+
|
|
89
100
|
/** File-level operation parsed from a section body (`REM` / `MV`). */
|
|
90
101
|
export type FileOp = { kind: "rem" } | { kind: "move"; dest: string };
|
|
91
102
|
|
|
@@ -105,7 +116,7 @@ export interface ApplyResult {
|
|
|
105
116
|
blockResolutions?: BlockResolution[];
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
/** A parsed `
|
|
119
|
+
/** A parsed `A-B` inclusive line range. */
|
|
109
120
|
export interface ParsedRange {
|
|
110
121
|
start: Anchor;
|
|
111
122
|
end: Anchor;
|
|
@@ -190,13 +201,25 @@ export interface BlockResolverRequest {
|
|
|
190
201
|
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
|
|
191
202
|
|
|
192
203
|
/**
|
|
193
|
-
* Mutable clipboard
|
|
194
|
-
* by `CUT` edits and read by `
|
|
195
|
-
* sections, so content can move between files.
|
|
196
|
-
*
|
|
197
|
-
*
|
|
204
|
+
* Mutable clipboard registers threaded through one patch application. Filled
|
|
205
|
+
* by `CUT` edits and read by register `PUT`s in patch source order, across
|
|
206
|
+
* sections, so content can move between files.
|
|
207
|
+
*
|
|
208
|
+
* The anonymous register (`lines`) is batch-local: {@link forkClipboard}
|
|
209
|
+
* never copies it in, so it exists only between a `CUT` and a paste inside
|
|
210
|
+
* one patch. Named registers (`named`) persist across batches when the host
|
|
211
|
+
* owns the Clipboard (`PatcherOptions.clipboard`) — the sanctioned way to
|
|
212
|
+
* move content across separate edit calls.
|
|
198
213
|
*/
|
|
199
214
|
export interface Clipboard {
|
|
200
|
-
/**
|
|
215
|
+
/** Anonymous register: lines captured by the latest unlabeled `CUT` in this batch. */
|
|
201
216
|
lines?: readonly string[];
|
|
217
|
+
/** Named registers captured by `CUT … @name`. */
|
|
218
|
+
named?: Map<string, readonly string[]>;
|
|
219
|
+
/**
|
|
220
|
+
* Headers of unlabeled `CUT`s seen since the last unlabeled paste in this
|
|
221
|
+
* batch. Two or more make the next unlabeled paste ambiguous (which cut
|
|
222
|
+
* did the author mean?) and fail it with a labeling hint.
|
|
223
|
+
*/
|
|
224
|
+
pendingAnonCuts?: string[];
|
|
202
225
|
}
|