@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 CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.2] - 2026-07-31
6
+
7
+ ### Breaking Changes
8
+
9
+ - Replaced legacy SWAP, INS, and PASTE syntax with unified PUT and CUT hunks
10
+
11
+ ### Added
12
+
13
+ - Added named register support (@reg) and span paste capabilities to clipboard operations
14
+ - Added conservative recovery for uniformly omitted replacement indents near brace openers, preserving intentional indentation-only edits
15
+
16
+ ### Changed
17
+
18
+ - Made .= the canonical inclusive range separator while retaining legacy separator variants as lenient input
19
+ - Unified replacement, insertion, register paste, block, head/tail, move, and removal headers under a composable PUT, CUT, MV, and REM grammar
20
+
21
+ ### Fixed
22
+
23
+ - Improved resilience against common model output formatting errors, including numbered read rows, summarized ranges, diff-style old/new rows, empty PUT deletes, harmless CUT colons, and single-line span shorthand
24
+
5
25
  ## [17.2.0] - 2026-07-30
6
26
 
7
27
  ### Breaking Changes
package/README.md CHANGED
@@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before);
26
26
  const tag = snapshots.record("hello.ts", before);
27
27
  const patcher = new Patcher({ fs, snapshots });
28
28
  const patch = Patch.parse(String.raw`[hello.ts#${tag}]
29
- SWAP 1.=1:
29
+ PUT 1.=1:
30
30
  +const greeting = "hello";`);
31
31
  const result = await patcher.apply(patch);
32
32
 
@@ -47,12 +47,12 @@ still matches the recorded content hash, and refusing or attempting
47
47
  session-aware recovery on mismatch.
48
48
 
49
49
  Inside a section:
50
- - `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows.
51
- - `SWAP.BLK A:` — replace the syntactic block beginning on line A.
52
- - `CUT A.=B` / `CUT.BLK A`delete concrete lines or a resolved block and capture them for `PASTE`.
53
- - `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` insert following body rows.
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`.
50
+ - `PUT A.=B:` — replace lines A through B (inclusive) with following `+TEXT` body rows.
51
+ - `PUT A*:` — replace the syntactic block beginning on line A.
52
+ - `PUT <A:` / `PUT >A:`insert following body rows before/after line A (`<1` = head, `>$` = tail).
53
+ - `PUT >A*:` insert following body rows after the resolved block's last line.
54
+ - `PUT <A` / `PUT >A` / `PUT A.=B @name` / `PUT A* @name` paste a captured register at a gap, over a range, or over a resolved block (no `:` header or body rows; `@name` is optional only at gaps).
55
+ - `CUT A.=B` / `CUT A*`delete concrete lines or a resolved block and capture them (anonymous, or `@name` when given).
56
56
  - `REM` — delete the whole file named by the section header.
57
57
  - `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
58
58
  - `+TEXT` — literal body row (use `+` alone for a blank line).
@@ -1,19 +1,21 @@
1
1
  import type { Clipboard, Edit } from "./types.js";
2
- /** True when at least one edit reads or writes the clipboard register. */
2
+ /** True when at least one edit reads or writes a clipboard register. */
3
3
  export declare function hasClipboardEdit(edits: readonly Edit[]): boolean;
4
4
  /** Optional knobs for {@link resolveClipboardEdits}. */
5
5
  export interface ResolveClipboardEditsOptions {
6
- /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
6
+ /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
7
7
  onEmptyPaste?: "throw" | "drop";
8
8
  }
9
9
  /**
10
10
  * Resolve clipboard edits against the original file lines in authored order.
11
- * Cuts fill the register and emit nothing; pastes become plain inserts.
11
+ * Cuts fill the register and emit nothing; pastes expand to inserts (+ deletes for span targets).
12
12
  */
13
13
  export declare function resolveClipboardEdits(edits: readonly Edit[], fileLines: readonly string[], clipboard: Clipboard, options?: ResolveClipboardEditsOptions): readonly Edit[];
14
+ /** Start a batch with persisted named registers but no anonymous state. */
15
+ export declare function startClipboardBatch(source?: Clipboard): Clipboard;
14
16
  /** Create a transactional working copy of a clipboard register. */
15
17
  export declare function forkClipboard(source?: Clipboard): Clipboard;
16
- /** Publish a clipboard fork back to its source register. */
18
+ /** Publish a clipboard fork back to its source register (only named registers persist across batches). */
17
19
  export declare function commitClipboard(fork: Clipboard, target: Clipboard): void;
18
20
  /**
19
21
  * Validate that every paste has a preceding or persisted capture without
@@ -9,38 +9,28 @@ export declare const HL_FILE_PREFIX = "[";
9
9
  export declare const HL_FILE_SUFFIX = "]";
10
10
  /** Payload sigil for literal body rows. */
11
11
  export declare const HL_PAYLOAD_REPLACE = "+";
12
- /** Hunk-header keyword for concrete line replacement. */
13
- export declare const HL_REPLACE_KEYWORD = "SWAP";
14
- /** Hunk-header keyword for insertion operations. */
15
- export declare const HL_INSERT_KEYWORD = "INS";
16
- /** Insert position keyword for inserting before a concrete line. */
17
- export declare const HL_INSERT_BEFORE = "PRE";
18
- /** Insert position keyword for inserting after a concrete line. */
19
- export declare const HL_INSERT_AFTER = "POST";
20
- /** Insert position keyword for inserting at the start of the file. */
21
- export declare const HL_INSERT_HEAD = "HEAD";
22
- /** Insert position keyword for inserting at the end of the file. */
23
- export declare const HL_INSERT_TAIL = "TAIL";
24
- /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
25
- export declare const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
26
- /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
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. */
12
+ /** Hunk-header keyword: `PUT` writes content (body rows) or a register at a span or gap. */
13
+ export declare const HL_PUT_KEYWORD = "PUT";
14
+ /** Hunk-header keyword: `CUT N.=M` / `CUT N*` deletes lines and captures them (anonymous register, or `@name` when given). */
29
15
  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";
36
16
  /** File-level keyword: `REM` deletes the whole file named by the section header. */
37
17
  export declare const HL_REM_KEYWORD = "REM";
38
18
  /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
39
19
  export declare const HL_MOVE_KEYWORD = "MV";
40
20
  export declare const HL_HEADER_COLON = ":";
21
+ /** Gap sigil: `<N` targets the gap before line N (`<1` = head). */
22
+ export declare const HL_GAP_BEFORE = "<";
23
+ /** Gap sigil: `>N` targets the gap after line N (`>$` = tail). */
24
+ export declare const HL_GAP_AFTER = ">";
25
+ /** Locator suffix: `N*` extends the anchor to the syntactic block opening at N. */
26
+ export declare const HL_BLOCK_SUFFIX = "*";
27
+ /** Gap anchor: `$` names the last line, so `>$` is end-of-file. */
28
+ export declare const HL_EOF_ANCHOR = "$";
29
+ /** Register sigil: `@name` selects a named clipboard register on `PUT`/`CUT`. */
30
+ export declare const HL_REGISTER_SIGIL = "@";
41
31
  /** Separator between a hashline file path and its opaque snapshot tag. */
42
32
  export declare const HL_FILE_HASH_SEP = "#";
43
- /** Separator between two line numbers in a range, e.g. `5.=10`. */
33
+ /** Canonical separator between inclusive range endpoints, e.g. `5.=10`. */
44
34
  export declare const HL_RANGE_SEP = ".=";
45
35
  /** Separator between a line number and displayed line content in hashline mode. */
46
36
  export declare const HL_LINE_BODY_SEP = ":";
@@ -48,12 +38,16 @@ export declare const HL_LINE_BODY_SEP = ":";
48
38
  export declare const HL_LINE_RE_RAW = "[1-9]\\d*";
49
39
  /** Capture-group form of {@link HL_LINE_RE_RAW}. */
50
40
  export declare const HL_LINE_CAPTURE_RE_RAW = "([1-9]\\d*)";
51
- /** Format a concrete replacement hunk header. */
41
+ /** Format a concrete replacement hunk header (`PUT 5.=9:`). */
52
42
  export declare function formatReplaceHeader(start: number, end: number): string;
53
- /** Format a concrete cut hunk header. */
43
+ /** Format a concrete cut hunk header (`CUT 5.=9`). */
54
44
  export declare function formatCutHeader(start: number, end?: number): string;
55
- /** Format an insertion hunk header for a cursor position. */
45
+ /** Format a gap locator for a cursor position (`<5`, `>5`, `<1`, `>$`). */
46
+ export declare function formatGapLocator(cursor: Cursor): string;
47
+ /** Format an insertion hunk header for a cursor position (`PUT <5:`, `PUT >$:`). */
56
48
  export declare function formatInsertHeader(cursor: Cursor): string;
49
+ /** Format a register reference (`@name`). */
50
+ export declare function formatRegister(name: string): string;
57
51
  /** Number of hex characters in a content-derived file-hash tag. */
58
52
  export declare const HL_FILE_HASH_LENGTH = 4;
59
53
  /** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
@@ -11,7 +11,7 @@ export declare function formatAnchoredContext(anchorLines: readonly number[], fi
11
11
  /** Concrete range operation rejected because its absolute end precedes its start. */
12
12
  export type AbsoluteRangeOp = "replace" | "cut";
13
13
  /** Explain absolute range endpoints and provide safe, non-applying retry forms. */
14
- export declare function invalidAbsoluteRangeMessage(patchLine: number, start: number, end: number, op: AbsoluteRangeOp, block?: BlockSpan): string;
14
+ export declare function invalidAbsoluteRangeMessage(patchLine: number, start: number, end: number, op: AbsoluteRangeOp, block?: BlockSpan, register?: string): string;
15
15
  /** Optional patch envelope start marker; silently consumed. */
16
16
  export declare const BEGIN_PATCH_MARKER = "*** Begin Patch";
17
17
  /** Optional patch envelope end marker; terminates parsing. */
@@ -21,22 +21,33 @@ export declare const END_PATCH_MARKER = "*** End Patch";
21
21
  * {@link END_PATCH_MARKER}, without a warning.
22
22
  */
23
23
  export declare const ABORT_MARKER = "*** Abort";
24
- /** Two consecutive hunks targeted the exact same concrete range. */
25
- export declare const REPLACE_PAIR_COALESCED_WARNING = "Two hunks targeted the same range; kept only the second. One `SWAP N.=M:` hunk per range \u2014 the body is the final content, never old+new.";
24
+ /** Exact-range duplicate hunks were normalized to the final hunk. */
25
+ export declare const REPLACE_PAIR_COALESCED_WARNING = "Multiple hunks targeted the same exact range; kept only the last. Issue one `PUT` or `CUT` hunk per range.";
26
+ /** Replacement body indentation was aligned from unchanged structural rows. */
27
+ export declare const REPLACEMENT_INDENT_AUTO_SHIFT_WARNING = "Auto-indented a replacement body to match unchanged structural rows in its source range.";
26
28
  /** Bare body rows auto-converted to literal `+` rows. */
27
29
  export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
30
+ /** Top-level read-output rows recovered as single-line replacements. */
31
+ export declare const SNAPSHOT_ROWS_AUTO_PUT_WARNING = "Recovered top-level `N:TEXT` snapshot row(s) as single-line `PUT N.=N:` replacements. Use explicit `PUT` headers for reliable edits.";
32
+ /** Bare range header recovered as an implicit replacement hunk. */
33
+ export declare const BARE_RANGE_AUTO_PUT_WARNING = "Recovered a bare `N.=M:` header as `PUT N.=M:`. Prefix replacement ranges with `PUT`.";
34
+ /** Copied read-output elision rows were ignored rather than written as source. */
35
+ export declare const READ_METADATA_IGNORED_WARNING = "Ignored copied read-output elision row(s). Re-read elided ranges before editing them.";
36
+ /** Empty span/block PUT recovered as a delete-only edit. */
37
+ export declare const EMPTY_PUT_AUTO_CUT_WARNING = "Interpreted an empty `PUT` body as deletion. Use `CUT N.=M` or `CUT N*` for bodyless deletes.";
38
+ /** A bodyless CUT carried a harmless trailing colon. */
39
+ export declare const CUT_COLON_IGNORED_WARNING = "Ignored a trailing `:` on bodyless `CUT`. Prefer `CUT N.=M` / `CUT N*` without a colon.";
28
40
  /**
29
41
  * Bare `-` body rows accepted as literal Markdown bullets. Only emitted when
30
42
  * the hunk is unambiguously a bullet list: every `-` row is bullet-shaped
43
+
31
44
  * (`- item`) and the body has no unified-diff `+new` counterpart rows.
32
45
  */
33
46
  export declare const MINUS_BULLET_AUTO_PIPED_WARNING = "Auto-prefixed bare `- ` bullet row(s) as literal content. `-` rows never remove lines \u2014 the range does that; always prefix literal body rows with `+`: `+- item`.";
47
+ /** Unified-diff old rows were discarded; explicit `+` rows are final content. */
48
+ export declare const DIFF_OLD_ROWS_IGNORED_WARNING = "Ignored unified-diff `-old` row(s); the range already removes old content, so only `+new` rows were kept.";
34
49
  /** Unified-diff-style `-` row in a hunk body. */
35
50
  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
- /** 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 `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
51
  /** Optional source-aware suggestions appended to block-anchor diagnostics. */
41
52
  export interface BlockDiagnosticSuggestions {
42
53
  /** Closest following multi-line block that begins after the authored anchor. */
@@ -47,18 +58,18 @@ export interface BlockDiagnosticSuggestions {
47
58
  /**
48
59
  * A block-anchored replace/cut could not resolve to a syntactic block.
49
60
  * Appends a {@link formatAnchoredContext} preview when `fileLines` is given.
50
- * `INS.BLK.POST N:` never reaches this path; it lowers to `INS.POST N:`.
61
+ * `PUT >N*` never reaches this path; it lowers to `PUT >N`.
51
62
  */
52
- export declare function blockUnresolvedMessage(line: number, op?: AbsoluteRangeOp, fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions): string;
63
+ export declare function blockUnresolvedMessage(line: number, op?: AbsoluteRangeOp, fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions, register?: string): string;
53
64
  /** 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:`. */
65
+ export declare const BLOCK_RESOLVER_UNAVAILABLE = "Block locators (`N*` in `PUT N*:`, `PUT >N*`, `CUT N*`) are not available here (no block resolver configured). Use a concrete line range.";
66
+ /** `PUT >N*:` anchored on a closing-delimiter line; applied as `PUT >N:`. */
56
67
  export declare function insertAfterBlockCloserLoweredWarning(line: number): string;
57
- /** `INS.BLK.POST N:` anchor unresolvable; applied as `INS.POST N:`. */
68
+ /** `PUT >N*:` anchor unresolvable; applied as `PUT >N:`. */
58
69
  export declare function insertAfterBlockUnresolvedLoweredWarning(line: number): string;
59
- /** `PASTE.BLK.POST N` anchored on a closing-delimiter line; applied as `PASTE.POST N`. */
70
+ /** Register `PUT >N*` anchored on a closing-delimiter line; applied as `PUT >N`. */
60
71
  export declare function pasteAfterBlockCloserLoweredWarning(line: number): string;
61
- /** `PASTE.BLK.POST N` anchor unresolvable; applied as `PASTE.POST N`. */
72
+ /** Register `PUT >N*` anchor unresolvable; applied as `PUT >N`. */
62
73
  export declare function pasteAfterBlockUnresolvedLoweredWarning(line: number): string;
63
74
  /**
64
75
  * A one-sided boundary echo whose payload is too short to be the widened
@@ -86,20 +97,30 @@ export declare const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved blo
86
97
  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.";
87
98
  /** `MV` received a body row. */
88
99
  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.";
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`.";
100
+ /** `CUT` hunk received a body row. */
101
+ export declare const CUT_TAKES_NO_BODY = "`CUT` deletes (and captures) the named lines and takes no body rows. To write new content, use `PUT N.=M:` with `+TEXT` rows.";
102
+ /** Register `PUT` header carried a `:`. */
103
+ export declare const COLON_ON_REGISTER_PUT = "`PUT \u2026 @name` pastes the register and never takes `:` \u2014 the colon promises body rows. Drop the colon (`PUT >40 @name`), or drop `@name` and write `+TEXT` body rows.";
104
+ /** Register `PUT` hunk received a body row. */
105
+ export declare const REGISTER_PUT_TAKES_NO_BODY = "A register `PUT` pastes captured lines and takes no `+` body rows. To write literal text, drop the `@name` and use `PUT \u2026:` with body rows.";
106
+ /** Colonless `PUT` hunk received a body row. */
107
+ export declare const COLONLESS_PUT_TAKES_NO_BODY = "`PUT` without `:` is clipboard-backed and takes no body rows. Add `:` after the locator to write literal content (`PUT >40:` then `+TEXT` rows).";
108
+ /** Colonless anonymous `PUT` on a span target. */
109
+ export declare const COLONLESS_SPAN_PUT = "Colonless `PUT` is clipboard-backed, and span targets need a named register (`PUT 5.=9 @name`); the anonymous register pastes only at gaps (`PUT >40`). To write literal content, add `:` and `+TEXT` body rows.";
110
+ /** Anonymous paste ran with an empty anonymous register. */
111
+ export declare const EMPTY_PASTE = "Nothing to paste: no unlabeled `CUT` precedes this `PUT` in this call, and the anonymous register never carries across calls. Put `CUT N.=M` / `CUT N*` above it, or use named registers (`CUT \u2026 @name` \u2192 `PUT \u2026 @name`) for cross-call moves.";
112
+ /** Named paste read a register that holds nothing. */
113
+ export declare function unknownRegisterMessage(name: string, known: readonly string[]): string;
114
+ /** Unlabeled paste with two or more unlabeled cuts pending. */
115
+ export declare function ambiguousAnonymousPasteMessage(pending: readonly string[]): string;
95
116
  /**
96
117
  * Clipboard ops inside a same-path section that was merged across another
97
118
  * file's section. Same-path sections coalesce into their first occurrence, so
98
119
  * an interleaved layout would silently reorder the register sequence.
99
120
  */
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.";
101
- /** Insert hunk with no body. */
102
- export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
121
+ export declare const CLIPBOARD_INTERLEAVED_SECTIONS = "`CUT`/register-`PUT` ops 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 register sequence. Keep each file's ops under ONE `[path#TAG]` header.";
122
+ /** Gap `PUT` with `:` but no body. */
123
+ export declare const EMPTY_INSERT = "`PUT <N:` / `PUT >N:` promises body rows and got none. Write `+TEXT` rows, or drop the `:` to paste a register (`PUT >N` = anonymous, `PUT >N @name` = named).";
103
124
  /**
104
125
  * `insert after` body indented shallower than the anchor: the landing slid
105
126
  * forward past trailing closer lines — the common "anchored on the last line
@@ -107,9 +128,9 @@ export declare const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."
107
128
  */
108
129
  export declare function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string;
109
130
  /**
110
- * `insert_after_block N:` body indented deeper than the block's closer: the
111
- * landing was pulled inside the block — a deeper body almost always means
112
- * "append inside the block's body".
131
+ * `PUT >N*:` body indented deeper than the block's closer: the landing was
132
+ * pulled inside the block — a deeper body almost always means "append inside
133
+ * the block's body".
113
134
  */
114
135
  export declare function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string;
115
136
  /** `Recovery`: an external write matched a cached snapshot. */
@@ -123,7 +144,7 @@ export declare const RECOVERY_LINE_REMAP_WARNING = "Recovered by remapping stale
123
144
  * Head/tail position is content-independent, so drift is non-fatal: apply
124
145
  * onto live content and warn instead of hard-failing.
125
146
  */
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.";
147
+ export declare const HEADTAIL_DRIFT_WARNING = "Applied the `PUT <1:`/`PUT >$:` 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
148
  /**
128
149
  * The `Filesystem` reported that what actually landed on disk differs from
129
150
  * what was written (see `WriteResult.text`) — most commonly an ACP-connected
@@ -11,7 +11,8 @@ export declare class InvalidAbsoluteRangeError extends Error {
11
11
  readonly endLine: number;
12
12
  /** Operation whose range was invalid. */
13
13
  readonly op: AbsoluteRangeOp;
14
- constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan);
14
+ readonly register?: string;
15
+ constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan, register?: string);
15
16
  /** Rebuild this error with a proven syntactic-block endpoint suggestion. */
16
17
  withBlock(block: BlockSpan): InvalidAbsoluteRangeError;
17
18
  }
@@ -13,6 +13,8 @@
13
13
  * common case for echoed file content, and erroneously echoed prefixes will
14
14
  * otherwise turn every content line into a (malformed) op.
15
15
  */
16
+ /** Whether a row is display-only metadata emitted by `read`, never source. */
17
+ export declare function isReadMetadataLine(line: string): boolean;
16
18
  /**
17
19
  * Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
18
20
  * most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
@@ -6,39 +6,42 @@ export declare function parseLid(raw: string, lineNum: number): Anchor;
6
6
  export type BlockTarget = {
7
7
  kind: "replace";
8
8
  range: ParsedRange;
9
+ register?: string;
9
10
  } | {
10
11
  kind: "block";
11
12
  anchor: Anchor;
13
+ register?: string;
12
14
  } | {
13
15
  kind: "insert_before";
14
16
  anchor: Anchor;
17
+ register?: string;
15
18
  } | {
16
19
  kind: "insert_after";
17
20
  anchor: Anchor;
21
+ register?: string;
18
22
  } | {
19
23
  kind: "insert_after_block";
20
24
  anchor: Anchor;
25
+ register?: string;
21
26
  } | {
22
27
  kind: "cut";
23
28
  range: ParsedRange;
29
+ register?: string;
24
30
  } | {
25
31
  kind: "cut_block";
26
32
  anchor: Anchor;
33
+ register?: string;
27
34
  } | {
28
- kind: "paste";
29
- cursor: Cursor;
35
+ kind: "bof";
36
+ register?: string;
30
37
  } | {
31
- kind: "paste_after_block";
32
- anchor: Anchor;
38
+ kind: "eof";
39
+ register?: string;
33
40
  } | {
34
41
  kind: "rem";
35
42
  } | {
36
43
  kind: "move";
37
44
  dest: string;
38
- } | {
39
- kind: "bof";
40
- } | {
41
- kind: "eof";
42
45
  };
43
46
  interface TokenBase {
44
47
  lineNum: number;
@@ -58,6 +61,7 @@ export type Token = (TokenBase & {
58
61
  }) | (TokenBase & {
59
62
  kind: "op-block";
60
63
  target: BlockTarget;
64
+ hadColon: boolean;
61
65
  }) | (TokenBase & {
62
66
  kind: "payload-literal";
63
67
  text: string;
@@ -48,47 +48,63 @@ export type Edit = {
48
48
  oldAssertion?: string;
49
49
  } | {
50
50
  /**
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}
51
+ * Clipboard cut (`CUT N-M @r`, or the resolved form of `CUT N* @r`).
52
+ * Captures the range's current lines into a {@link Clipboard}
53
53
  * register during the applier's clipboard pre-pass and lowers to one
54
- * `delete` per range line at parse/resolve time.
54
+ * `delete` per range line at parse/resolve time. `register` names the
55
+ * target slot; absent means the batch-local anonymous register.
55
56
  */
56
57
  kind: "cut";
57
58
  range: ParsedRange;
59
+ register?: string;
58
60
  lineNum: number;
59
61
  index: number;
60
62
  } | {
61
63
  /**
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.
64
+ * Clipboard insertion or replacement (`PUT <N @r` / `PUT >N @r` /
65
+ * `PUT N-M @r`, their `>N*` block-resolved forms, or the register-less
66
+ * anonymous equivalents). Expanded by the clipboard pre-pass into one
67
+ * plain insert per captured line; `span` targets additionally expand
68
+ * into per-line deletes there only after the register read
69
+ * succeeds, so a dropped paste (streaming preview with an empty
70
+ * register) never leaves destructive orphan deletes. `blockStart`
71
+ * mirrors the insert variant's field for block-lowered gap pastes so
72
+ * landing correction can slide the body across trailing closer lines.
68
73
  */
69
74
  kind: "paste";
70
- cursor: Cursor;
75
+ at: PasteTarget;
76
+ register?: string;
71
77
  lineNum: number;
72
78
  index: number;
73
79
  blockStart?: number;
74
80
  } | {
75
81
  /**
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.
82
+ * Deferred block edit (`PUT N*:`, `PUT >N*:`, `CUT N*`, or their
83
+ * `@register` forms). The exact line span is unknown at parse time;
84
+ * {@link resolveBlockEdits} computes it once file text and language
85
+ * are available, then expands it into concrete edits.
86
+ * `mode: "insert_after"` becomes plain `after_anchor` inserts,
87
+ * `"cut"` becomes a clipboard cut plus per-line deletes, and
88
+ * `"paste_after"` becomes a gap paste after the resolved block. No
89
+ * mode denotes a block replacement — from body payloads, or from
90
+ * `register` when one is named (a span paste over the resolved block).
84
91
  */
85
92
  kind: "block";
86
93
  anchor: Anchor;
87
94
  payloads: string[];
88
95
  mode?: "insert_after" | "cut" | "paste_after";
96
+ register?: string;
89
97
  lineNum: number;
90
98
  index: number;
91
99
  };
100
+ /** Where a `paste` edit lands: an insertion gap, or a span it replaces. */
101
+ export type PasteTarget = {
102
+ kind: "gap";
103
+ cursor: Cursor;
104
+ } | {
105
+ kind: "span";
106
+ range: ParsedRange;
107
+ };
92
108
  /** File-level operation parsed from a section body (`REM` / `MV`). */
93
109
  export type FileOp = {
94
110
  kind: "rem";
@@ -111,7 +127,7 @@ export interface ApplyResult {
111
127
  */
112
128
  blockResolutions?: BlockResolution[];
113
129
  }
114
- /** A parsed `[A.=B]` line range. */
130
+ /** A parsed `A-B` inclusive line range. */
115
131
  export interface ParsedRange {
116
132
  start: Anchor;
117
133
  end: Anchor;
@@ -187,13 +203,25 @@ export interface BlockResolverRequest {
187
203
  */
188
204
  export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
189
205
  /**
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.
206
+ * Mutable clipboard registers threaded through one patch application. Filled
207
+ * by `CUT` edits and read by register `PUT`s in patch source order, across
208
+ * sections, so content can move between files.
209
+ *
210
+ * The anonymous register (`lines`) is batch-local: {@link forkClipboard}
211
+ * never copies it in, so it exists only between a `CUT` and a paste inside
212
+ * one patch. Named registers (`named`) persist across batches when the host
213
+ * owns the Clipboard (`PatcherOptions.clipboard`) — the sanctioned way to
214
+ * move content across separate edit calls.
195
215
  */
196
216
  export interface Clipboard {
197
- /** Lines captured by the most recent `CUT`, or unset. */
217
+ /** Anonymous register: lines captured by the latest unlabeled `CUT` in this batch. */
198
218
  lines?: readonly string[];
219
+ /** Named registers captured by `CUT … @name`. */
220
+ named?: Map<string, readonly string[]>;
221
+ /**
222
+ * Headers of unlabeled `CUT`s seen since the last unlabeled paste in this
223
+ * batch. Two or more make the next unlabeled paste ambiguous (which cut
224
+ * did the author mean?) and fail it with a labeling hint.
225
+ */
226
+ pendingAnonCuts?: string[];
199
227
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "17.2.0",
4
+ "version": "17.2.2",
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.2.0",
36
+ "@oh-my-pi/pi-natives": "17.2.2",
37
37
  "lru-cache": "11.5.2"
38
38
  },
39
39
  "devDependencies": {
package/src/apply.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  ambiguousBoundaryEchoMessage,
15
15
  ambiguousCloserSpareMessage,
16
16
  blockInsertLandingShiftWarning,
17
+ REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
17
18
  UNRESOLVED_BLOCK_INTERNAL,
18
19
  } from "./messages";
19
20
  import { cloneCursor } from "./tokenizer";
@@ -390,6 +391,66 @@ function findReplacementGroup(edits: readonly AppliedEdit[], start: number): Rep
390
391
  };
391
392
  }
392
393
 
394
+ /**
395
+ * Restore a uniformly omitted base indent only when the payload would escape
396
+ * a surviving `{` opener immediately above the replacement. Matching unchanged
397
+ * rows then prove the uniform shift; ordinary indentation-only edits stay exact.
398
+ */
399
+ function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly string[]): string[] {
400
+ let repaired = false;
401
+ for (let start = 0; start < edits.length; ) {
402
+ const group = findReplacementGroup(edits, start);
403
+ if (group === undefined) {
404
+ start++;
405
+ continue;
406
+ }
407
+ const lastDeleteIndex = group.deleteIndices.at(-1);
408
+ if (lastDeleteIndex === undefined) continue;
409
+ start = lastDeleteIndex + 1;
410
+ if (group.payload.length !== group.deleteIndices.length) continue;
411
+ const preceding = fileLines[group.startLine - 2] ?? "";
412
+ const sourceFirst = fileLines[group.startLine - 1] ?? "";
413
+ const payloadFirst = group.payload[0] ?? "";
414
+ if (
415
+ !preceding.trimEnd().endsWith("{") ||
416
+ !isIndentDeeper(leadingIndent(sourceFirst), leadingIndent(preceding)) ||
417
+ isIndentDeeper(leadingIndent(payloadFirst), leadingIndent(preceding))
418
+ ) {
419
+ continue;
420
+ }
421
+
422
+ let shift: string | undefined;
423
+ let matches = 0;
424
+ let consistent = true;
425
+ for (let offset = 0; offset < group.payload.length; offset++) {
426
+ const source = fileLines[group.startLine - 1 + offset] ?? "";
427
+ const payload = group.payload[offset];
428
+ if (source.trim().length === 0 || source.trimStart() !== payload.trimStart()) continue;
429
+ const sourceIndent = leadingIndent(source);
430
+ const payloadIndent = leadingIndent(payload);
431
+ if (!sourceIndent.endsWith(payloadIndent)) {
432
+ consistent = false;
433
+ break;
434
+ }
435
+ const candidate = sourceIndent.slice(0, sourceIndent.length - payloadIndent.length);
436
+ if (shift === undefined) shift = candidate;
437
+ else if (shift !== candidate) {
438
+ consistent = false;
439
+ break;
440
+ }
441
+ matches++;
442
+ }
443
+ if (!consistent || !shift || matches < 2 || matches * 2 <= group.payload.length) continue;
444
+ for (const index of group.insertIndices) {
445
+ const edit = edits[index];
446
+ if (edit.kind !== "insert" || edit.text.trim().length === 0) continue;
447
+ edits[index] = { ...edit, text: `${shift}${edit.text}` };
448
+ }
449
+ repaired = true;
450
+ }
451
+ return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : [];
452
+ }
453
+
393
454
  /**
394
455
  * Largest `k` such that the payload's last `k` lines exactly equal the `k`
395
456
  * surviving file lines just below the range AND dropping them zeroes `delta`.
@@ -927,6 +988,15 @@ function repairReplacementBoundaries(
927
988
  after: new Map(),
928
989
  };
929
990
  for (const edit of projected) {
991
+ if (edit.kind === "insert" && edit.cursor.kind === "bof") {
992
+ const inserted = insertedByLine.get(1);
993
+ if (inserted) inserted.push(edit.text);
994
+ else insertedByLine.set(1, [edit.text]);
995
+ const before = insertedLineMaps.before.get(1);
996
+ if (before) before.push(edit.text);
997
+ else insertedLineMaps.before.set(1, [edit.text]);
998
+ continue;
999
+ }
930
1000
  if (edit.kind !== "insert") continue;
931
1001
  for (const anchor of getCursorAnchors(edit.cursor)) {
932
1002
  const lines = insertedByLine.get(anchor.line);
@@ -1272,9 +1342,10 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1272
1342
  fileLines,
1273
1343
  );
1274
1344
  validateLineBounds(targetEdits, fileLines);
1345
+ const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
1275
1346
  const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
1276
1347
  const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
1277
- const warnings = [...boundaryWarnings, ...landingWarnings];
1348
+ const warnings = [...indentationWarnings, ...boundaryWarnings, ...landingWarnings];
1278
1349
 
1279
1350
  // Partition edits into bof, eof, and anchor-targeted buckets.
1280
1351
  const bofLines: string[] = [];