@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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.0] - 2026-07-30
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed `DEL`, `DEL.BLK`, `COPY`, and `COPY.BLK` from the patch language. Use `CUT` / `CUT.BLK` for deletion; a cut does not require a following `PASTE` and leaves the removed content available to later pastes.
10
+
11
+ ### Added
12
+
13
+ - Added clipboard ops: `CUT N.=M` captures lines into a register (and deletes them), `CUT.BLK N` captures tree-sitter blocks, and `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` / `PASTE.BLK.POST N` insert the captured lines without retyping. The register flows top-to-bottom across sections, so content moves between files in one patch; `PASTE` does not consume it and the last capture wins.
14
+ - Added `PatcherOptions.clipboard` for a host-owned register that persists across `Patcher.apply` batches. Batches work on a fork (`forkClipboard`) published per landed section (`commitClipboard`), so failed batches never poison the register and a mid-batch write failure still preserves content already cut from disk.
15
+ - Added clipboard safety guards: a `PASTE` with an empty register, a capture overwriting un-pasted `CUT` content, and clipboard ops in same-path sections interleaved across another file's section are all rejected with targeted diagnostics. `CUT` ranges participate in overlap validation, the seen-lines guard, and drift recovery (every captured line must remap).
16
+
17
+ ### Changed
18
+
19
+ - Simplified `grammar.lark` around shared target and position shapes, collapsing the concrete and block `CUT` forms plus the `INS` / `PASTE` position variants into their common grammar rules.
20
+
21
+ ### Fixed
22
+
23
+ - Prevented CPU and memory exhaustion in streaming previews by rejecting line anchors above Number.MAX_SAFE_INTEGER and ranges spanning more than 100,000 lines.
24
+ - Fixed an issue where recorded snapshot tags desynced from disk when the filesystem transformed content on write (e.g., auto-formatting on save), which previously caused subsequent edits to incorrectly reformat unrelated parts of the file. `Patcher.commit` now correctly keys the returned file hash and snapshot on the actual content written to disk and issues a warning when a drift is detected.
25
+
5
26
  ## [17.1.5] - 2026-07-27
6
27
 
7
28
  ### Changed
package/README.md CHANGED
@@ -49,9 +49,10 @@ session-aware recovery on mismatch.
49
49
  Inside a section:
50
50
  - `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows.
51
51
  - `SWAP.BLK A:` — replace the syntactic block beginning on line A.
52
- - `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
52
+ - `CUT A.=B` / `CUT.BLK A` — delete concrete lines or a resolved block and capture them for `PASTE`.
53
53
  - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
54
54
  - `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
55
+ - `PASTE.PRE A` / `PASTE.POST A` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST A` — insert the clipboard; no body rows. The register flows top-to-bottom across sections (cross-file moves) and can be host-persisted across batches via `PatcherOptions.clipboard`.
55
56
  - `REM` — delete the whole file named by the section header.
56
57
  - `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
57
58
  - `+TEXT` — literal body row (use `+` alone for a blank line).
@@ -1,10 +1,30 @@
1
- import type { ApplyResult, Edit } from "./types.js";
1
+ /**
2
+ * Apply a parsed list of {@link Edit}s to a text body and return the
3
+ * post-edit lines plus any diagnostic warnings. Pure function: no FS, no
4
+ * mutation of the input.
5
+ *
6
+ * Replacement groups are first normalized by {@link repairReplacementBoundaries},
7
+ * which absorbs common model mistakes where a payload restates unchanged range
8
+ * boundaries or duplicates/drops structural closers.
9
+ */
10
+ import type { ApplyResult, Clipboard, Edit } from "./types.js";
2
11
  /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
3
12
  export declare const STRUCTURAL_CLOSER_RE: RegExp;
13
+ /** Optional knobs for {@link applyEdits}. */
14
+ export interface ApplyEditsOptions {
15
+ /**
16
+ * Clipboard register filled by `cut` edits and read by `paste` edits.
17
+ * Thread one register through every section of a batch to move content
18
+ * across files; omitted, the call gets a private register.
19
+ */
20
+ clipboard?: Clipboard;
21
+ /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
22
+ onEmptyPaste?: "throw" | "drop";
23
+ }
4
24
  /**
5
25
  * Apply a parsed list of edits to a text body. Pure function — no I/O.
6
26
  *
7
27
  * Returns the post-edit text and the first changed line number (1-indexed).
8
28
  * Throws if an anchor is out of bounds.
9
29
  */
10
- export declare function applyEdits(text: string, edits: readonly Edit[]): ApplyResult;
30
+ export declare function applyEdits(text: string, edits: readonly Edit[], options?: ApplyEditsOptions): ApplyResult;
@@ -1,13 +1,10 @@
1
1
  import type { BlockResolution, BlockResolver, Edit } from "./types.js";
2
+ /** Optional knobs for {@link resolveBlockEdits}. */
2
3
  export interface ResolveBlockEditsOptions {
3
4
  /**
4
- * How to handle a replace/delete block edit that cannot be resolved
5
- * (missing resolver or a `null` span). `"throw"` (default) raises a
6
- * `blockUnresolvedMessage` error used by the authoritative apply + final
7
- * preview paths. `"drop"` silently skips the edit — used by the streaming
8
- * preview, where a half-written file or transient parse error must not
9
- * throw. Unresolvable `insert_after_block N:` edits never reach this: they
10
- * are lowered to plain `insert after N:` with a warning.
5
+ * How to handle a replace/cut block edit that cannot be resolved. `"throw"`
6
+ * (default) raises a block error; `"drop"` skips it for streaming previews.
7
+ * Unresolvable after-block edits lower to their plain after-line form.
11
8
  */
12
9
  onUnresolved?: "throw" | "drop";
13
10
  /**
@@ -0,0 +1,22 @@
1
+ import type { Clipboard, Edit } from "./types.js";
2
+ /** True when at least one edit reads or writes the clipboard register. */
3
+ export declare function hasClipboardEdit(edits: readonly Edit[]): boolean;
4
+ /** Optional knobs for {@link resolveClipboardEdits}. */
5
+ export interface ResolveClipboardEditsOptions {
6
+ /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
7
+ onEmptyPaste?: "throw" | "drop";
8
+ }
9
+ /**
10
+ * Resolve clipboard edits against the original file lines in authored order.
11
+ * Cuts fill the register and emit nothing; pastes become plain inserts.
12
+ */
13
+ export declare function resolveClipboardEdits(edits: readonly Edit[], fileLines: readonly string[], clipboard: Clipboard, options?: ResolveClipboardEditsOptions): readonly Edit[];
14
+ /** Create a transactional working copy of a clipboard register. */
15
+ export declare function forkClipboard(source?: Clipboard): Clipboard;
16
+ /** Publish a clipboard fork back to its source register. */
17
+ export declare function commitClipboard(fork: Clipboard, target: Clipboard): void;
18
+ /**
19
+ * Validate that every paste has a preceding or persisted capture without
20
+ * mutating the register or reading file content.
21
+ */
22
+ export declare function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void;
@@ -11,8 +11,6 @@ export declare const HL_FILE_SUFFIX = "]";
11
11
  export declare const HL_PAYLOAD_REPLACE = "+";
12
12
  /** Hunk-header keyword for concrete line replacement. */
13
13
  export declare const HL_REPLACE_KEYWORD = "SWAP";
14
- /** Hunk-header keyword for concrete line deletion. */
15
- export declare const HL_DELETE_KEYWORD = "DEL";
16
14
  /** Hunk-header keyword for insertion operations. */
17
15
  export declare const HL_INSERT_KEYWORD = "INS";
18
16
  /** Insert position keyword for inserting before a concrete line. */
@@ -25,10 +23,16 @@ export declare const HL_INSERT_HEAD = "HEAD";
25
23
  export declare const HL_INSERT_TAIL = "TAIL";
26
24
  /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
27
25
  export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
28
- /** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
29
- export declare const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
30
26
  /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
31
27
  export declare const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
28
+ /** Hunk-header keyword: `CUT N.=M` captures lines into the clipboard register and deletes them. */
29
+ export declare const HL_CUT_KEYWORD = "CUT";
30
+ /** Hunk-header keyword: `CUT.BLK N` captures the tree-sitter block at N and deletes its span. */
31
+ export declare const HL_CUT_BLOCK_KEYWORD = "CUT.BLK";
32
+ /** Hunk-header keyword prefix: `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` inserts the clipboard. */
33
+ export declare const HL_PASTE_KEYWORD = "PASTE";
34
+ /** Hunk-header keyword: `PASTE.BLK.POST N` inserts the clipboard after the tree-sitter block at N. */
35
+ export declare const HL_PASTE_AFTER_BLOCK_KEYWORD = "PASTE.BLK.POST";
32
36
  /** File-level keyword: `REM` deletes the whole file named by the section header. */
33
37
  export declare const HL_REM_KEYWORD = "REM";
34
38
  /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
@@ -46,8 +50,8 @@ export declare const HL_LINE_RE_RAW = "[1-9]\\d*";
46
50
  export declare const HL_LINE_CAPTURE_RE_RAW = "([1-9]\\d*)";
47
51
  /** Format a concrete replacement hunk header. */
48
52
  export declare function formatReplaceHeader(start: number, end: number): string;
49
- /** Format a concrete deletion hunk header. */
50
- export declare function formatDeleteHeader(start: number, end?: number): string;
53
+ /** Format a concrete cut hunk header. */
54
+ export declare function formatCutHeader(start: number, end?: number): string;
51
55
  /** Format an insertion hunk header for a cursor position. */
52
56
  export declare function formatInsertHeader(cursor: Cursor): string;
53
57
  /** Number of hex characters in a content-derived file-hash tag. */
@@ -1,5 +1,6 @@
1
1
  export * from "./apply.js";
2
2
  export * from "./block.js";
3
+ export * from "./clipboard.js";
3
4
  export * from "./diff-preview.js";
4
5
  export * from "./format.js";
5
6
  export * from "./fs.js";
@@ -1,8 +1,16 @@
1
- import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types.js";
1
+ import type { ApplyResult, BlockResolver, Clipboard, Edit, FileOp, SplitOptions } from "./types.js";
2
2
  interface RawSection {
3
3
  path: string;
4
4
  fileHash?: string;
5
5
  diff: string;
6
+ /**
7
+ * True when this section coalesced same-path sections that were NOT
8
+ * adjacent in the authored input (another file's section sat between
9
+ * them). Merging moves the later ops up to the first occurrence, which
10
+ * would silently reorder the clipboard register sequence — so clipboard
11
+ * ops are rejected in such sections.
12
+ */
13
+ interleaved?: boolean;
6
14
  }
7
15
  /**
8
16
  * Returns true when the input contains at least one line that the tokenizer
@@ -55,8 +63,11 @@ export declare class PatchSection {
55
63
  *
56
64
  * `blockResolver` resolves any `replace_block N:` edits against `text`; an
57
65
  * unresolvable block throws (this is the final, authoritative preview path).
66
+ *
67
+ * `clipboard` is the register shared by `CUT`/`PASTE` ops. Pass one when
68
+ * applying several sections so content can move across files.
58
69
  */
59
- applyTo(text: string, blockResolver?: BlockResolver): ApplyResult;
70
+ applyTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult;
60
71
  /**
61
72
  * Streaming-tolerant counterpart to {@link applyTo}. Uses
62
73
  * {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
@@ -66,9 +77,9 @@ export declare class PatchSection {
66
77
  *
67
78
  * `blockResolver` resolves any `replace_block N:` edits against `text`; an
68
79
  * unresolvable block is silently dropped so a half-written file does not
69
- * throw mid-stream.
80
+ * throw mid-stream. A `PASTE` with an empty register is dropped too.
70
81
  */
71
- applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult;
82
+ applyPartialTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult;
72
83
  /**
73
84
  * A copy of this section rebound to a different target `path`, preserving
74
85
  * the snapshot tag, diff body, and any cached parse result. Used by the
@@ -9,7 +9,7 @@ export declare const MISMATCH_CONTEXT = 2;
9
9
  */
10
10
  export declare function formatAnchoredContext(anchorLines: readonly number[], fileLines: readonly string[]): string[];
11
11
  /** Concrete range operation rejected because its absolute end precedes its start. */
12
- export type AbsoluteRangeOp = "replace" | "delete";
12
+ export type AbsoluteRangeOp = "replace" | "cut";
13
13
  /** Explain absolute range endpoints and provide safe, non-applying retry forms. */
14
14
  export declare function invalidAbsoluteRangeMessage(patchLine: number, start: number, end: number, op: AbsoluteRangeOp, block?: BlockSpan): string;
15
15
  /** Optional patch envelope start marker; silently consumed. */
@@ -34,9 +34,9 @@ export declare const MINUS_BULLET_AUTO_PIPED_WARNING = "Auto-prefixed bare `- `
34
34
  /** Unified-diff-style `-` row in a hunk body. */
35
35
  export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; the range already names the lines being changed. For Markdown bullets or other literal `-` lines, prefix the literal row with `+`: `+- item`.";
36
36
  /** Replace hunk with no body. */
37
- export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
38
- /** `replace_block N:` hunk with no body. */
39
- export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
37
+ export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `CUT N.=M`.";
38
+ /** `SWAP.BLK N:` hunk with no body. */
39
+ export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `CUT.BLK N`.";
40
40
  /** Optional source-aware suggestions appended to block-anchor diagnostics. */
41
41
  export interface BlockDiagnosticSuggestions {
42
42
  /** Closest following multi-line block that begins after the authored anchor. */
@@ -45,28 +45,21 @@ export interface BlockDiagnosticSuggestions {
45
45
  enclosingBlock?: BlockSpan;
46
46
  }
47
47
  /**
48
- * Block-anchored replace/delete could not resolve to a syntactic block
49
- * (unsupported language, blank/out-of-range line, no node beginning on N, or
50
- * parse error). Appends a {@link formatAnchoredContext} preview when
51
- * `fileLines` is given. `insert_after_block N:` never reaches this — it is
52
- * lowered to plain `insert after N:` instead (see
53
- * {@link insertAfterBlockUnresolvedLoweredWarning}).
54
- */
55
- export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete", fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions): string;
56
- /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
57
- export declare const BLOCK_RESOLVER_UNAVAILABLE = "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
58
- /**
59
- * `insert_after_block N:` anchored on a closing-delimiter line, lowered to
60
- * plain `insert after N:` — the closer ends a block, and inserting after it
61
- * is exactly what the plain form does.
48
+ * A block-anchored replace/cut could not resolve to a syntactic block.
49
+ * Appends a {@link formatAnchoredContext} preview when `fileLines` is given.
50
+ * `INS.BLK.POST N:` never reaches this path; it lowers to `INS.POST N:`.
62
51
  */
52
+ export declare function blockUnresolvedMessage(line: number, op?: AbsoluteRangeOp, fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions): string;
53
+ /** Block-anchored edit reached a path with no {@link BlockResolver} wired in. */
54
+ export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block ops (`SWAP.BLK`, `INS.BLK.POST`, `CUT.BLK`, `PASTE.BLK.POST`) are not available here (no block resolver configured). Use a concrete line range.";
55
+ /** `INS.BLK.POST N:` anchored on a closing-delimiter line; applied as `INS.POST N:`. */
63
56
  export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
64
- /**
65
- * `insert_after_block N:` anchor unresolvable (unsupported language, blank
66
- * line, parse error, or no resolver), lowered to plain `insert after N:` —
67
- * applying with a warning beats failing the patch.
68
- */
57
+ /** `INS.BLK.POST N:` anchor unresolvable; applied as `INS.POST N:`. */
69
58
  export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
59
+ /** `PASTE.BLK.POST N` anchored on a closing-delimiter line; applied as `PASTE.POST N`. */
60
+ export declare function pasteAfterBlockCloserLoweredWarning(line: number): string;
61
+ /** `PASTE.BLK.POST N` anchor unresolvable; applied as `PASTE.POST N`. */
62
+ export declare function pasteAfterBlockUnresolvedLoweredWarning(line: number): string;
70
63
  /**
71
64
  * A one-sided boundary echo whose payload is too short to be the widened
72
65
  * range's full content: dropping the echo deletes range line(s) the payload
@@ -85,18 +78,26 @@ export declare function ambiguousBoundaryEchoMessage(startLine: number, endLine:
85
78
  */
86
79
  export declare function ambiguousCloserSpareMessage(startLine: number, endLine: number, closerLine: number, count: number): string;
87
80
  /**
88
- * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
89
- * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
81
+ * Internal invariant: `applyEdits` received an unresolved block edit;
82
+ * `resolveBlockEdits` must run first.
90
83
  */
91
- export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
92
- /** Delete hunk received a body row. */
93
- export declare const DELETE_TAKES_NO_BODY = "`DEL N.=M` does not take body rows. Remove the body, or use `SWAP N.=M:`.";
84
+ export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved block edit reached the applier (resolveBlockEdits was not run).";
94
85
  /** `REM` received a body row or coexists with line edits. */
95
86
  export declare const REM_TAKES_NO_BODY = "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
96
87
  /** `MV` received a body row. */
97
88
  export declare const MOVE_TAKES_NO_BODY = "`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line.";
98
- /** `delete_block N` hunk received a body row. */
99
- export declare const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
89
+ /** `CUT N.=M` hunk received a body row. */
90
+ export declare const CUT_TAKES_NO_BODY = "`CUT N.=M` captures + deletes lines and takes no body rows. To replace lines with new content, use `SWAP N.=M:`.";
91
+ /** `PASTE` hunk received a body row. */
92
+ export declare const PASTE_TAKES_NO_BODY = "`PASTE` inserts the clipboard content and takes no `+` body rows. To insert literal text, use `INS`.";
93
+ /** `PASTE` ran with an empty clipboard register. */
94
+ export declare const EMPTY_PASTE = "`PASTE` found nothing in the clipboard. Ops run top-to-bottom across the whole patch (sections included): put `CUT N.=M` or `CUT.BLK N` above the `PASTE`.";
95
+ /**
96
+ * Clipboard ops inside a same-path section that was merged across another
97
+ * file's section. Same-path sections coalesce into their first occurrence, so
98
+ * an interleaved layout would silently reorder the register sequence.
99
+ */
100
+ export declare const CLIPBOARD_INTERLEAVED_SECTIONS = "`CUT`/`PASTE` cannot be used in a file whose sections are interleaved with another file's: same-path sections merge into the first occurrence, which would reorder the clipboard sequence. Keep each file's ops under ONE `[path#TAG]` header.";
100
101
  /** Insert hunk with no body. */
101
102
  export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
102
103
  /**
@@ -123,6 +124,15 @@ export declare const RECOVERY_LINE_REMAP_WARNING = "Recovered by remapping stale
123
124
  * onto live content and warn instead of hard-failing.
124
125
  */
125
126
  export declare const HEADTAIL_DRIFT_WARNING = "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) \u2014 head/tail position is content-independent. Re-read if the drift was unexpected.";
127
+ /**
128
+ * The `Filesystem` reported that what actually landed on disk differs from
129
+ * what was written (see `WriteResult.text`) — most commonly an ACP-connected
130
+ * editor reformatting the buffer on save (e.g. `format_on_save` with tab/space
131
+ * settings that don't match the file). The recorded snapshot is re-keyed on
132
+ * the real, post-write content so the next edit's tag validation matches
133
+ * reality instead of silently drifting.
134
+ */
135
+ export declare function writeDriftWarning(path: string): string;
126
136
  /**
127
137
  * Section omitted the mandatory snapshot tag. Shared by the apply
128
138
  * ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
@@ -164,12 +174,10 @@ export interface UnseenLinesReveal {
164
174
  */
165
175
  export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string, reveal?: UnseenLinesReveal): string;
166
176
  /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
167
- export type BlockOp = "replace" | "delete" | "insert_after";
177
+ export type BlockOp = "replace" | "insert_after" | "cut" | "paste_after";
168
178
  /**
169
- * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
170
- * single line almost always a bare statement the model mis-anchored, not a
171
- * multi-line construct. The plain op is unambiguous for one line; the block
172
- * form only earns its keep when it spares counting a closing line you cannot
173
- * see. Reject and point at both fixes.
179
+ * A block-op anchor resolved to a single line: line N is a bare statement,
180
+ * not the opening line of a multi-line construct. The plain op is exact for
181
+ * one line, so reject and point at it.
174
182
  */
175
183
  export declare function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock?: BlockSpan): string;
@@ -3,7 +3,7 @@ import type { Patch, PatchSection } from "./input.js";
3
3
  import { type LineEnding } from "./normalize.js";
4
4
  import { Recovery } from "./recovery.js";
5
5
  import type { SnapshotStore } from "./snapshots.js";
6
- import type { ApplyResult, BlockResolution, BlockResolver, FileOp } from "./types.js";
6
+ import type { ApplyResult, BlockResolution, BlockResolver, Clipboard, FileOp } from "./types.js";
7
7
  export interface PatcherOptions {
8
8
  /** Storage backend used for all reads and writes. */
9
9
  fs: Filesystem;
@@ -21,6 +21,12 @@ export interface PatcherOptions {
21
21
  * validate on content hash alone and any anchor into the tagged content applies.
22
22
  */
23
23
  enforceSeenLines?: boolean;
24
+ /**
25
+ * Host-owned clipboard register shared across batches, so `CUT` content
26
+ * can be `PASTE`d by a later {@link Patcher.apply} call. Each batch works
27
+ * on a fork and publishes it back only after writes land.
28
+ */
29
+ clipboard?: Clipboard;
24
30
  }
25
31
  /** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
26
32
  export interface PatchSectionResult {
@@ -38,7 +44,13 @@ export interface PatchSectionResult {
38
44
  persisted: string;
39
45
  /** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
40
46
  written: string;
41
- /** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
47
+ /**
48
+ * 4-hex content-hash tag. Hashes the content the {@link Filesystem}
49
+ * reports actually landed on disk (see `written`), which normally equals
50
+ * `after` but can diverge when the write path transforms content (e.g. an
51
+ * ACP-bridge write reformatted by the client's format-on-save). Use to
52
+ * anchor follow-up edits.
53
+ */
42
54
  fileHash: string;
43
55
  /** Hashline section header (`[path#tag]`) of the post-edit content. */
44
56
  header: string;
@@ -49,9 +61,8 @@ export interface PatchSectionResult {
49
61
  /** Destination path when this section includes `MV DEST`. */
50
62
  moveDest?: string;
51
63
  /**
52
- * Resolved spans for any `replace_block`/`delete_block` ops, present when the
53
- * apply matched the tagged content. Undefined for patches with no block ops
54
- * (and for resolutions routed through drift recovery, where numbers shift).
64
+ * Resolved spans for block ops, present when the apply matched the tagged
65
+ * content. Undefined for patches with no block ops and for drift recovery.
55
66
  */
56
67
  blockResolutions?: BlockResolution[];
57
68
  }
@@ -91,6 +102,7 @@ export declare class Patcher {
91
102
  readonly snapshots: SnapshotStore;
92
103
  readonly recovery: Recovery;
93
104
  readonly blockResolver: BlockResolver | undefined;
105
+ readonly clipboard: Clipboard | undefined;
94
106
  constructor(options: PatcherOptions);
95
107
  /**
96
108
  * Apply every section in `patch`. `prepare` runs the full apply for each
@@ -110,10 +122,13 @@ export declare class Patcher {
110
122
  * {@link PreparedSection} which can be fed to {@link commit} to land
111
123
  * the result on the filesystem.
112
124
  *
125
+ * `clipboard` is the register shared by `CUT`/`PASTE` ops. Pass the batch
126
+ * register when preparing several sections so content can move across files.
127
+ *
113
128
  * Throws on parse error, missing-file-for-anchored-edit, or unrecovered
114
129
  * tag mismatch ({@link MismatchError}).
115
130
  */
116
- prepare(section: PatchSection): Promise<PreparedSection>;
131
+ prepare(section: PatchSection, clipboard?: Clipboard): Promise<PreparedSection>;
117
132
  /**
118
133
  * Commit a previously {@link prepare}d section to the filesystem.
119
134
  * Restores line endings and BOM, writes via the {@link Filesystem}, and
@@ -1,10 +1,12 @@
1
1
  import type { SnapshotStore } from "./snapshots.js";
2
- import type { Edit } from "./types.js";
2
+ import type { Clipboard, Edit } from "./types.js";
3
3
  export interface RecoveryArgs {
4
4
  path: string;
5
5
  currentText: string;
6
6
  fileHash: string;
7
7
  edits: readonly Edit[];
8
+ /** Shared clipboard register for `cut`/`paste` edits, threaded into the replay apply. */
9
+ clipboard?: Clipboard;
8
10
  }
9
11
  export interface RecoveryResult {
10
12
  /** Post-recovery text. */
@@ -9,12 +9,6 @@ export type BlockTarget = {
9
9
  } | {
10
10
  kind: "block";
11
11
  anchor: Anchor;
12
- } | {
13
- kind: "delete";
14
- range: ParsedRange;
15
- } | {
16
- kind: "delete_block";
17
- anchor: Anchor;
18
12
  } | {
19
13
  kind: "insert_before";
20
14
  anchor: Anchor;
@@ -24,6 +18,18 @@ export type BlockTarget = {
24
18
  } | {
25
19
  kind: "insert_after_block";
26
20
  anchor: Anchor;
21
+ } | {
22
+ kind: "cut";
23
+ range: ParsedRange;
24
+ } | {
25
+ kind: "cut_block";
26
+ anchor: Anchor;
27
+ } | {
28
+ kind: "paste";
29
+ cursor: Cursor;
30
+ } | {
31
+ kind: "paste_after_block";
32
+ anchor: Anchor;
27
33
  } | {
28
34
  kind: "rem";
29
35
  } | {
@@ -48,21 +48,44 @@ export type Edit = {
48
48
  oldAssertion?: string;
49
49
  } | {
50
50
  /**
51
- * Deferred block edit (`replace_block N:` / `delete_block N` /
52
- * `insert_after_block N:`). The exact line span is unknown at parse
53
- * time it is computed by {@link resolveBlockEdits} once file text +
54
- * path (→ language) are available, then expanded into concrete edits:
55
- * a non-empty `payloads` without `mode` (from `replace_block`) becomes
56
- * the same `replacement` inserts + deletes that `replace start.=end:`
57
- * produces; an empty `payloads` (from `delete_block`) becomes a pure
58
- * range deletion; `mode: "insert_after"` becomes plain `after_anchor`
59
- * inserts at the block's last line. `applyEdits` never sees this
60
- * variant.
51
+ * Clipboard cut (`CUT N.=M`, or the resolved form of `CUT.BLK N`).
52
+ * Captures the range's current lines into the {@link Clipboard}
53
+ * register during the applier's clipboard pre-pass and lowers to one
54
+ * `delete` per range line at parse/resolve time.
55
+ */
56
+ kind: "cut";
57
+ range: ParsedRange;
58
+ lineNum: number;
59
+ index: number;
60
+ } | {
61
+ /**
62
+ * Clipboard insertion (`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` /
63
+ * `PASTE.TAIL`, or the resolved form of `PASTE.BLK.POST N`). Expanded
64
+ * by the clipboard pre-pass into one plain insert per captured line.
65
+ * `blockStart` mirrors the insert variant's field for block-lowered
66
+ * pastes so landing correction can slide the body across trailing
67
+ * closer lines.
68
+ */
69
+ kind: "paste";
70
+ cursor: Cursor;
71
+ lineNum: number;
72
+ index: number;
73
+ blockStart?: number;
74
+ } | {
75
+ /**
76
+ * Deferred block edit (`SWAP.BLK N:`, `INS.BLK.POST N:`,
77
+ * `CUT.BLK N`, or `PASTE.BLK.POST N`). The exact line span is
78
+ * unknown at parse time; {@link resolveBlockEdits} computes it once
79
+ * file text and language are available, then expands it into
80
+ * concrete edits. `mode: "insert_after"` becomes plain
81
+ * `after_anchor` inserts, `"cut"` becomes a clipboard cut plus
82
+ * per-line deletes, and `"paste_after"` becomes a paste after the
83
+ * resolved block. No mode denotes a block replacement.
61
84
  */
62
85
  kind: "block";
63
86
  anchor: Anchor;
64
87
  payloads: string[];
65
- mode?: "insert_after";
88
+ mode?: "insert_after" | "cut" | "paste_after";
66
89
  lineNum: number;
67
90
  index: number;
68
91
  };
@@ -82,10 +105,9 @@ export interface ApplyResult {
82
105
  /** Diagnostic warnings collected by the parser, patcher, or recovery. */
83
106
  warnings?: string[];
84
107
  /**
85
- * Resolved spans for each `replace_block`/`delete_block` op in this apply,
86
- * in patch order. Present only when the apply matched the tagged content
87
- * (the common no-drift path), so the line numbers line up with what the
88
- * caller read. Absent when there were no block ops.
108
+ * Resolved spans for each block op in this apply, in patch order. Present
109
+ * only when the apply matched the tagged content, so the line numbers match
110
+ * what the caller read. Absent when there were no block ops.
89
111
  */
90
112
  blockResolutions?: BlockResolution[];
91
113
  }
@@ -127,9 +149,7 @@ export interface CompactDiffOptions {
127
149
  /** Back-compat alias for {@link maxAddedRunContext}. */
128
150
  maxUnchangedRun?: number;
129
151
  }
130
- /**
131
- * Resolved 1-indexed inclusive line span of a `replace_block N:` target.
132
- */
152
+ /** Resolved 1-indexed inclusive line span of a block target. */
133
153
  export interface BlockSpan {
134
154
  /** First line of the block (1-indexed, inclusive). */
135
155
  start: number;
@@ -137,11 +157,9 @@ export interface BlockSpan {
137
157
  end: number;
138
158
  }
139
159
  /**
140
- * One `replace_block N:` / `delete_block N` / `insert_after_block N:` anchor
141
- * resolved to its concrete line span. Surfaced on {@link ApplyResult} so the
142
- * host can echo "block N → lines start.=end" and let the model catch a wrong
143
- * opener — e.g. a decorator or doc-comment that sits in a separate node
144
- * 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.
145
163
  */
146
164
  export interface BlockResolution {
147
165
  /** The 1-indexed line the block op was anchored on (the `N`). */
@@ -151,9 +169,9 @@ export interface BlockResolution {
151
169
  /** Last line of the resolved span (1-indexed, inclusive). */
152
170
  end: number;
153
171
  /** Which block op produced this resolution. */
154
- op: "replace" | "delete" | "insert_after";
172
+ op: "replace" | "insert_after" | "cut" | "paste_after";
155
173
  }
156
- /** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
174
+ /** Request handed to a {@link BlockResolver} to resolve one block-op anchor. */
157
175
  export interface BlockResolverRequest {
158
176
  /** Target file path (used to infer language by extension). */
159
177
  path: string;
@@ -163,10 +181,19 @@ export interface BlockResolverRequest {
163
181
  line: number;
164
182
  }
165
183
  /**
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.
184
+ * Resolves a block-op anchor to the line span of the syntactic block beginning
185
+ * on that line. Returns `null` for an unsupported language, invalid line, absent
186
+ * opener, or syntax error. The host injects the tree-sitter-backed implementation.
171
187
  */
172
188
  export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
189
+ /**
190
+ * Mutable clipboard register threaded through one patch application. Filled
191
+ * by `CUT` edits and read by `PASTE` edits in patch source order, across
192
+ * sections, so content can move between files. Create one per batch (`{}`)
193
+ * and hand it to every {@link Patcher.prepare} / `applyTo` call in that batch;
194
+ * callers that omit it get a private per-call register.
195
+ */
196
+ export interface Clipboard {
197
+ /** Lines captured by the most recent `CUT`, or unset. */
198
+ lines?: readonly string[];
199
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "17.1.7",
4
+ "version": "17.2.0",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -33,7 +33,7 @@
33
33
  "fmt": "biome format --write ."
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "17.1.7",
36
+ "@oh-my-pi/pi-natives": "17.2.0",
37
37
  "lru-cache": "11.5.2"
38
38
  },
39
39
  "devDependencies": {