@oh-my-pi/hashline 17.2.9 → 17.2.11

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.11] - 2026-08-07
6
+
7
+ ### Changed
8
+
9
+ - Pasting an empty named register (`PUT … @name` with no matching capture) now surfaces a warning listing available registers and removes the span target instead of throwing an error.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed an issue where pipe-numbered `read`/`search` rows copied into top-level and bare-body patch payloads were not properly recovered (#7905).
14
+
15
+ ## [17.2.10] - 2026-08-06
16
+
17
+ ### Changed
18
+
19
+ - Updated internal caching dependency to use `@oh-my-pi/pi-utils/lru`.
20
+
5
21
  ## [17.2.2] - 2026-07-31
6
22
 
7
23
  ### Breaking Changes
@@ -18,7 +18,7 @@ export interface ApplyEditsOptions {
18
18
  * across files; omitted, the call gets a private register.
19
19
  */
20
20
  clipboard?: Clipboard;
21
- /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
21
+ /** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */
22
22
  onEmptyPaste?: "throw" | "drop";
23
23
  }
24
24
  /**
@@ -3,8 +3,10 @@ import type { Clipboard, Edit } from "./types.js";
3
3
  export declare function hasClipboardEdit(edits: readonly Edit[]): boolean;
4
4
  /** Optional knobs for {@link resolveClipboardEdits}. */
5
5
  export interface ResolveClipboardEditsOptions {
6
- /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
6
+ /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). Named registers never throw — an empty named paste warns and pastes nothing. */
7
7
  onEmptyPaste?: "throw" | "drop";
8
+ /** Receives non-fatal diagnostics (e.g. an empty named-register paste). */
9
+ onWarning?: (message: string) => void;
8
10
  }
9
11
  /**
10
12
  * Resolve clipboard edits against the original file lines in authored order.
@@ -18,7 +20,8 @@ export declare function forkClipboard(source?: Clipboard): Clipboard;
18
20
  /** Publish a clipboard fork back to its source register (only named registers persist across batches). */
19
21
  export declare function commitClipboard(fork: Clipboard, target: Clipboard): void;
20
22
  /**
21
- * Validate that every paste has a preceding or persisted capture without
22
- * mutating the register or reading file content.
23
+ * Validate anonymous clipboard sequencing (empty or ambiguous unlabeled paste)
24
+ * without mutating the register or reading file content. Empty named-register
25
+ * pastes are non-fatal — they surface as apply-time warnings instead.
23
26
  */
24
27
  export declare function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void;
@@ -109,8 +109,8 @@ export declare const COLONLESS_PUT_TAKES_NO_BODY = "`PUT` without `:` is clipboa
109
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
110
  /** Anonymous paste ran with an empty anonymous register. */
111
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;
112
+ /** Named paste read a register that holds nothing; the paste applied as empty (span targets are still removed). */
113
+ export declare function emptyRegisterPasteWarning(name: string, known: readonly string[]): string;
114
114
  /** Unlabeled paste with two or more unlabeled cuts pending. */
115
115
  export declare function ambiguousAnonymousPasteMessage(pending: readonly string[]): string;
116
116
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
- * When a hashline payload is authored against `read`/`search` output, each
3
- * line is prefixed with either a hashline-mode line number (`123:`) or, for
4
- * diff-style echoes, a leading `+`. These helpers detect that and recover
5
- * the raw text. Two strip modes are exposed:
2
+ * When a payload is authored against `read`/`search` output, each line is
3
+ * prefixed with either a line number (`123:` in hashline mode or `123|`
4
+ * otherwise) or, for diff-style echoes, a leading `+`. These helpers detect
5
+ * that and recover the raw text. Two strip modes are exposed:
6
6
  *
7
7
  * - {@link stripNewLinePrefixes} — opportunistic: strips when the input
8
8
  * clearly carries hashline or diff prefixes, leaves it alone otherwise.
@@ -17,15 +17,15 @@
17
17
  export declare function isReadMetadataLine(line: string): boolean;
18
18
  /**
19
19
  * Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
20
- * most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
21
- * loop. Use this when the input carries at most one snapshot prefix (e.g. a
22
- * bare body row paste from `read` output) — recursive stripping would corrupt
23
- * content whose own text starts with `digits:`.
20
+ * most one leading line-number prefix (`N:`, `N|`, `>>>N:`, `+N:` etc.) and
21
+ * does NOT loop. Use this when the input carries at most one snapshot prefix
22
+ * (e.g. a bare body row paste from `read` output) — recursive stripping would
23
+ * corrupt content whose own text starts with a line-number prefix.
24
24
  */
25
25
  export declare function stripOneLeadingHashlinePrefix(line: string): string;
26
26
  /**
27
27
  * Strip whichever prefix scheme the lines appear to be carrying:
28
- * - hashline line-number prefixes (`123:`) when every content line has one
28
+ * - line-number prefixes (`123:` or `123|`) when every content line has one
29
29
  * - leading `+` (diff style) when at least half the lines have one
30
30
  * - mixed `+<n>:` form when present
31
31
  *
@@ -88,7 +88,7 @@ export interface InMemorySnapshotStoreOptions {
88
88
  maxTotalBytes?: number;
89
89
  }
90
90
  /**
91
- * In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path history is a
91
+ * In-memory {@link SnapshotStore} backed by a bounded LRU. Per-path history is a
92
92
  * short ring of full-file versions (oldest dropped first); per-session path
93
93
  * tracking is LRU-bounded so cold paths age out automatically.
94
94
  *
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.9",
4
+ "version": "17.2.11",
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,8 +33,8 @@
33
33
  "fmt": "biome format --write ."
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "17.2.9",
37
- "lru-cache": "11.5.2"
36
+ "@oh-my-pi/pi-natives": "17.2.11",
37
+ "@oh-my-pi/pi-utils": "17.2.11"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
package/src/apply.ts CHANGED
@@ -1301,7 +1301,7 @@ export interface ApplyEditsOptions {
1301
1301
  * across files; omitted, the call gets a private register.
1302
1302
  */
1303
1303
  clipboard?: Clipboard;
1304
- /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
1304
+ /** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */
1305
1305
  onEmptyPaste?: "throw" | "drop";
1306
1306
  }
1307
1307
 
@@ -1318,8 +1318,10 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1318
1318
 
1319
1319
  // Clipboard pre-pass: capture `cut` ranges from the original lines and
1320
1320
  // expand `paste` edits into plain inserts in authored order.
1321
+ const clipboardWarnings: string[] = [];
1321
1322
  const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
1322
1323
  ...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
1324
+ onWarning: message => clipboardWarnings.push(message),
1323
1325
  });
1324
1326
 
1325
1327
  // Block edits are deferred until `resolveBlockEdits` expands them into
@@ -1345,7 +1347,7 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1345
1347
  const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
1346
1348
  const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
1347
1349
  const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
1348
- const warnings = [...indentationWarnings, ...boundaryWarnings, ...landingWarnings];
1350
+ const warnings = [...clipboardWarnings, ...indentationWarnings, ...boundaryWarnings, ...landingWarnings];
1349
1351
 
1350
1352
  // Partition edits into bof, eof, and anchor-targeted buckets.
1351
1353
  const bofLines: string[] = [];
package/src/clipboard.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * (`lines`) is batch-local and resets between calls.
9
9
  */
10
10
  import { HL_CUT_KEYWORD, HL_PUT_KEYWORD, HL_RANGE_SEP } from "./format";
11
- import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, unknownRegisterMessage } from "./messages";
11
+ import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, emptyRegisterPasteWarning } from "./messages";
12
12
  import { cloneCursor } from "./tokenizer";
13
13
  import type { Clipboard, Edit } from "./types";
14
14
 
@@ -33,25 +33,30 @@ export function hasClipboardEdit(edits: readonly Edit[]): boolean {
33
33
 
34
34
  /** Optional knobs for {@link resolveClipboardEdits}. */
35
35
  export interface ResolveClipboardEditsOptions {
36
- /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
36
+ /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). Named registers never throw — an empty named paste warns and pastes nothing. */
37
37
  onEmptyPaste?: "throw" | "drop";
38
+ /** Receives non-fatal diagnostics (e.g. an empty named-register paste). */
39
+ onWarning?: (message: string) => void;
38
40
  }
39
41
 
40
42
  /**
41
- * Read lines from a register. Throws on missing/ambiguous register unless `onEmptyPaste === "drop"`.
43
+ * Read lines from a register. A missing named register warns and reads as
44
+ * empty; anonymous misuse throws unless `onEmptyPaste === "drop"`.
42
45
  */
43
46
  function readRegister(
44
47
  register: string | undefined,
45
48
  clipboard: Clipboard,
46
49
  lineNum: number,
47
50
  onEmptyPaste: "throw" | "drop",
51
+ onWarning?: (message: string) => void,
48
52
  ): readonly string[] | null {
49
53
  if (register !== undefined) {
50
54
  const lines = clipboard.named?.get(register);
51
55
  if (lines !== undefined) return lines;
52
56
  if (onEmptyPaste === "drop") return null;
53
57
  const known = clipboard.named ? [...clipboard.named.keys()] : [];
54
- throw new Error(`line ${lineNum}: ${unknownRegisterMessage(register, known)}`);
58
+ onWarning?.(`line ${lineNum}: ${emptyRegisterPasteWarning(register, known)}`);
59
+ return [];
55
60
  }
56
61
 
57
62
  const pending = clipboard.pendingAnonCuts ?? [];
@@ -112,7 +117,7 @@ export function resolveClipboardEdits(
112
117
  continue;
113
118
  }
114
119
  if (edit.kind === "paste") {
115
- const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste);
120
+ const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste, options.onWarning);
116
121
  if (lines === null) continue;
117
122
 
118
123
  if (edit.at.kind === "gap") {
@@ -187,8 +192,9 @@ export function commitClipboard(fork: Clipboard, target: Clipboard): void {
187
192
  }
188
193
 
189
194
  /**
190
- * Validate that every paste has a preceding or persisted capture without
191
- * mutating the register or reading file content.
195
+ * Validate anonymous clipboard sequencing (empty or ambiguous unlabeled paste)
196
+ * without mutating the register or reading file content. Empty named-register
197
+ * pastes are non-fatal — they surface as apply-time warnings instead.
192
198
  */
193
199
  export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
194
200
  const fork = forkClipboard(clipboard);
package/src/messages.ts CHANGED
@@ -331,9 +331,9 @@ export const COLONLESS_SPAN_PUT = `Colonless \`PUT\` is clipboard-backed, and sp
331
331
  /** Anonymous paste ran with an empty anonymous register. */
332
332
  export 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${HL_RANGE_SEP}M\` / \`CUT N*\` above it, or use named registers (\`CUT … @name\` → \`PUT … @name\`) for cross-call moves.`;
333
333
 
334
- /** Named paste read a register that holds nothing. */
335
- export function unknownRegisterMessage(name: string, known: readonly string[]): string {
336
- const base = `\`@${name}\` is empty: no \`CUT … @${name}\` precedes this op in this call and no persisted register has that name.`;
334
+ /** Named paste read a register that holds nothing; the paste applied as empty (span targets are still removed). */
335
+ export function emptyRegisterPasteWarning(name: string, known: readonly string[]): string {
336
+ const base = `\`@${name}\` was empty no \`CUT … @${name}\` precedes this op in this call and no persisted register has that name — so nothing was pasted (a span target is still removed).`;
337
337
  return known.length === 0 ? base : `${base} Available registers: ${known.map(k => `\`@${k}\``).join(", ")}.`;
338
338
  }
339
339
 
package/src/parser.ts CHANGED
@@ -112,7 +112,7 @@ function bodylessTargetMessage(target: BlockTarget, hadColon: boolean): string |
112
112
  */
113
113
  const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
114
114
 
115
- const TOP_LEVEL_SNAPSHOT_ROW_RE = /^\s*([1-9]\d*):(.*)$/;
115
+ const TOP_LEVEL_SNAPSHOT_ROW_RE = /^\s*([1-9]\d*)[:|](.*)$/;
116
116
 
117
117
  function parseTopLevelSnapshotRow(text: string): { line: number; text: string } | null {
118
118
  const match = TOP_LEVEL_SNAPSHOT_ROW_RE.exec(text);
@@ -595,11 +595,11 @@ export class Executor {
595
595
  }
596
596
 
597
597
  /**
598
- * Strip a single read-output line-number prefix (`N:`) from every bare body
599
- * row, but only when *all* bare rows carry one. A uniform set of prefixes is
600
- * the signature of content pasted straight from `read`/`search` output; a
601
- * mixed set means the `N:` is genuine payload content and must stay. Rows
602
- * authored with an explicit `+` are not bare and are never touched.
598
+ * Strip a single read-output line-number prefix (`N:` or `N|`) from every
599
+ * bare body row, but only when *all* bare rows carry one. A uniform set of
600
+ * prefixes is the signature of content pasted straight from `read`/`search`
601
+ * output; a mixed set means the prefix is genuine payload content and must
602
+ * stay. Rows authored with an explicit `+` are not bare and are never touched.
603
603
  */
604
604
  #stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
605
605
  let sawBare = false;
package/src/prefixes.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * When a hashline payload is authored against `read`/`search` output, each
3
- * line is prefixed with either a hashline-mode line number (`123:`) or, for
4
- * diff-style echoes, a leading `+`. These helpers detect that and recover
5
- * the raw text. Two strip modes are exposed:
2
+ * When a payload is authored against `read`/`search` output, each line is
3
+ * prefixed with either a line number (`123:` in hashline mode or `123|`
4
+ * otherwise) or, for diff-style echoes, a leading `+`. These helpers detect
5
+ * that and recover the raw text. Two strip modes are exposed:
6
6
  *
7
7
  * - {@link stripNewLinePrefixes} — opportunistic: strips when the input
8
8
  * clearly carries hashline or diff prefixes, leaves it alone otherwise.
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { HL_FILE_HASH_LENGTH } from "./format";
18
18
 
19
- const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
19
+ const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+[:|]/;
20
20
  const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
21
21
  const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
22
22
  const DIFF_PLUS_RE = /^[+](?![+])/;
@@ -41,10 +41,10 @@ function stripLeadingHashlinePrefixes(line: string): string {
41
41
  }
42
42
  /**
43
43
  * Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
44
- * most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
45
- * loop. Use this when the input carries at most one snapshot prefix (e.g. a
46
- * bare body row paste from `read` output) — recursive stripping would corrupt
47
- * content whose own text starts with `digits:`.
44
+ * most one leading line-number prefix (`N:`, `N|`, `>>>N:`, `+N:` etc.) and
45
+ * does NOT loop. Use this when the input carries at most one snapshot prefix
46
+ * (e.g. a bare body row paste from `read` output) — recursive stripping would
47
+ * corrupt content whose own text starts with a line-number prefix.
48
48
  */
49
49
  export function stripOneLeadingHashlinePrefix(line: string): string {
50
50
  return line.replace(HL_PREFIX_RE, "");
@@ -90,7 +90,7 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
90
90
 
91
91
  /**
92
92
  * Strip whichever prefix scheme the lines appear to be carrying:
93
- * - hashline line-number prefixes (`123:`) when every content line has one
93
+ * - line-number prefixes (`123:` or `123|`) when every content line has one
94
94
  * - leading `+` (diff style) when at least half the lines have one
95
95
  * - mixed `+<n>:` form when present
96
96
  *
package/src/snapshots.ts CHANGED
@@ -15,11 +15,11 @@
15
15
  *
16
16
  * The abstract base class lets callers plug in whatever storage they like
17
17
  * (LRU, persistent SQLite, etc.). {@link InMemorySnapshotStore} ships as a
18
- * sensible default backed by `lru-cache`: a bounded set of paths, each with a
18
+ * sensible default backed by a bounded LRU: a limited set of paths, each with a
19
19
  * short history of full-file versions so in-session edit chains can still
20
20
  * recover against the version a stale tag names.
21
21
  */
22
- import { LRUCache } from "lru-cache/raw";
22
+ import { LRUCache } from "@oh-my-pi/pi-utils/lru";
23
23
  import { computeFileHash } from "./format";
24
24
 
25
25
  /**
@@ -137,7 +137,7 @@ export interface InMemorySnapshotStoreOptions {
137
137
  }
138
138
 
139
139
  /**
140
- * In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path history is a
140
+ * In-memory {@link SnapshotStore} backed by a bounded LRU. Per-path history is a
141
141
  * short ring of full-file versions (oldest dropped first); per-session path
142
142
  * tracking is LRU-bounded so cold paths age out automatically.
143
143
  *