@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/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 CHAR_ELLIPSIS = 0x2026;
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 (isWhitespaceCode(code)) {
168
- cursor++;
169
- consumedSeparator = true;
170
- continue;
171
- }
172
- if (code === CHAR_COMMA || code === CHAR_HYPHEN || code === CHAR_ELLIPSIS) {
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: "paste"; cursor: Cursor }
226
- | { kind: "paste_after_block"; anchor: Anchor }
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
- | { kind: "bof" }
230
- | { kind: "eof" };
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 && code !== CHAR_DOT) return null;
239
+ if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
243
240
  }
244
241
  return next;
245
242
  }
246
243
 
247
- /**
248
- * GLM 5.2 inserts a stray `.` between the line number/range and the trailing
249
- * `:` (e.g. `SWAP 2.=3.:`, `INS.POST 2.:`). A `.` is never valid syntax at
250
- * this position, so skip it when it precedes an optional `:` or end-of-line.
251
- */
252
- function skipStrayDot(line: string, index: number, end: number): number {
253
- if (index < end && line.charCodeAt(index) === CHAR_DOT) {
254
- const after = skipWhitespace(line, index + 1, end);
255
- if (after === end || line.charCodeAt(after) === CHAR_COLON) return after;
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 index;
254
+ return { nextIndex: cursor, hadColon: false };
258
255
  }
259
256
 
260
- function consumeOptionalColon(line: string, index: number, end: number): number {
261
- let cursor = skipWhitespace(line, index, end);
262
- cursor = skipStrayDot(line, cursor, end);
263
- return cursor < end && line.charCodeAt(cursor) === CHAR_COLON ? skipWhitespace(line, cursor + 1, end) : cursor;
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
- * Recover local-model replace trailers that permute `:` and `=` as `:=:` or
267
- * `=:`. The range has already been parsed, so these suffixes are unambiguous.
268
- */
269
- function consumeReplaceColon(line: string, index: number, end: number): number {
270
- const canonical = consumeOptionalColon(line, index, end);
271
- if (canonical >= end || line.charCodeAt(canonical) !== CHAR_EQUALS) return canonical;
272
- const afterEquals = skipWhitespace(line, canonical + 1, end);
273
- if (afterEquals >= end || line.charCodeAt(afterEquals) !== CHAR_COLON) return canonical;
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
- interface PositionScan {
278
- cursor: Cursor;
279
- nextIndex: number;
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
- /** 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 {
284
- if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
285
- const probe = skipWhitespace(line, index + 1, end);
286
- const beforeEnd = scanKeyword(line, probe, end, HL_INSERT_BEFORE);
287
- if (beforeEnd !== null) {
288
- const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
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
- const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
291
- return { cursor: { kind: "before_anchor", anchor: { line: anchor.line } }, nextIndex };
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 afterEnd = scanKeyword(line, probe, end, HL_INSERT_AFTER);
294
- if (afterEnd !== null) {
295
- const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
296
- if (anchor === null) return null;
297
- const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
298
- return { cursor: { kind: "after_anchor", anchor: { line: anchor.line } }, nextIndex };
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
- 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) };
304
- return null;
348
+ return finishTargetScan(line, next, end, { kind: "replace", range: range.range });
305
349
  }
306
350
 
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 };
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
- // `insert_after_block N:` — insert after the last line of the tree-sitter
391
- // block at N.
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(HL_REPLACE_KEYWORD, lead) ||
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(HL_PASTE_KEYWORD, lead);
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.=M`, or the resolved form of `CUT.BLK N`).
46
- * Captures the range's current lines into the {@link Clipboard}
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 (`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.
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
- cursor: Cursor;
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 (`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.
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 `[A.=B]` line range. */
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 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.
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
- /** Lines captured by the most recent `CUT`, or unset. */
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
  }