@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/src/tokenizer.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  */
11
11
  import {
12
12
  describeAnchorExamples,
13
- HL_DELETE_BLOCK_KEYWORD,
14
- HL_DELETE_KEYWORD,
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
- function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
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 cursor = skipWhitespace(line, index + 1, end);
275
- const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
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 { target: { kind: "insert_before", anchor: { line: anchor.line } }, nextIndex };
291
+ return { cursor: { kind: "before_anchor", anchor: { line: anchor.line } }, nextIndex };
281
292
  }
282
- const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
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 { target: { kind: "insert_after", anchor: { line: anchor.line } }, nextIndex };
298
+ return { cursor: { kind: "after_anchor", anchor: { line: anchor.line } }, nextIndex };
288
299
  }
289
- const headEnd = scanKeyword(line, cursor, end, HL_INSERT_HEAD);
290
- if (headEnd !== null) return { target: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) };
291
- const tailEnd = scanKeyword(line, cursor, end, HL_INSERT_TAIL);
292
- if (tailEnd !== null) return { target: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) };
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
- * Deferred block edit (`replace_block N:` / `delete_block N` /
46
- * `insert_after_block N:`). The exact line span is unknown at parse
47
- * time — it is computed by {@link resolveBlockEdits} once file text +
48
- * path (→ language) are available, then expanded into concrete edits:
49
- * a non-empty `payloads` without `mode` (from `replace_block`) becomes
50
- * the same `replacement` inserts + deletes that `replace start.=end:`
51
- * produces; an empty `payloads` (from `delete_block`) becomes a pure
52
- * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
53
- * inserts at the block's last line. `applyEdits` never sees this
54
- * variant.
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 `replace_block`/`delete_block` op in this apply,
77
- * in patch order. Present only when the apply matched the tagged content
78
- * (the common no-drift path), so the line numbers line up with what the
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 `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
139
- * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
140
- * host can echo "block N → lines start.=end" and let the model catch a wrong
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" | "delete" | "insert_after";
172
+ op: "replace" | "insert_after" | "cut" | "paste_after";
153
173
  }
154
174
 
155
- /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
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 `replace_block N:` anchor to the line span of the syntactic block
167
- * that begins on line N. Returns `null` when no block can be resolved
168
- * (unrecognized language, blank/out-of-range line, no node begins there, or the
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
+ }