From ab78d3091ed5b73bfc732ba114fafb8abb763971 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 7 Aug 2026 06:00:08 +0200 Subject: [PATCH] fix(hashline): warn instead of erroring on empty named-register paste PUT ... @name with no matching capture no longer fails the patch: it pastes nothing (a span target is still removed, i.e. it degrades to a cut) and surfaces a warning naming the available registers. Anonymous empty/ambiguous pastes still error. validateClipboardSequence now only guards anonymous sequencing; applyEdits threads clipboard warnings into ApplyResult.warnings. --- packages/hashline/CHANGELOG.md | 4 ++++ packages/hashline/src/apply.ts | 6 ++++-- packages/hashline/src/clipboard.ts | 20 +++++++++++++------- packages/hashline/src/messages.ts | 6 +++--- packages/hashline/test/clipboard.test.ts | 14 ++++++++++++++ 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 35e2c0ade..58ea50d80 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Pasting an empty named register (`PUT … @name` with no matching capture) no longer errors — it pastes nothing (a span target is still removed) and surfaces a warning naming the available registers + ## [17.2.10] - 2026-08-06 ### Changed diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 8c52783db..71f38a3fa 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -1301,7 +1301,7 @@ export interface ApplyEditsOptions { * across files; omitted, the call gets a private register. */ clipboard?: Clipboard; - /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */ + /** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */ onEmptyPaste?: "throw" | "drop"; } @@ -1318,8 +1318,10 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE // Clipboard pre-pass: capture `cut` ranges from the original lines and // expand `paste` edits into plain inserts in authored order. + const clipboardWarnings: string[] = []; const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, { ...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }), + onWarning: message => clipboardWarnings.push(message), }); // Block edits are deferred until `resolveBlockEdits` expands them into @@ -1345,7 +1347,7 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines); const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines); const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines); - const warnings = [...indentationWarnings, ...boundaryWarnings, ...landingWarnings]; + const warnings = [...clipboardWarnings, ...indentationWarnings, ...boundaryWarnings, ...landingWarnings]; // Partition edits into bof, eof, and anchor-targeted buckets. const bofLines: string[] = []; diff --git a/packages/hashline/src/clipboard.ts b/packages/hashline/src/clipboard.ts index c3ce3c5f9..376e85467 100644 --- a/packages/hashline/src/clipboard.ts +++ b/packages/hashline/src/clipboard.ts @@ -8,7 +8,7 @@ * (`lines`) is batch-local and resets between calls. */ import { HL_CUT_KEYWORD, HL_PUT_KEYWORD, HL_RANGE_SEP } from "./format"; -import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, unknownRegisterMessage } from "./messages"; +import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, emptyRegisterPasteWarning } from "./messages"; import { cloneCursor } from "./tokenizer"; import type { Clipboard, Edit } from "./types"; @@ -33,25 +33,30 @@ export function hasClipboardEdit(edits: readonly Edit[]): boolean { /** Optional knobs for {@link resolveClipboardEdits}. */ export interface ResolveClipboardEditsOptions { - /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */ + /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). Named registers never throw — an empty named paste warns and pastes nothing. */ onEmptyPaste?: "throw" | "drop"; + /** Receives non-fatal diagnostics (e.g. an empty named-register paste). */ + onWarning?: (message: string) => void; } /** - * Read lines from a register. Throws on missing/ambiguous register unless `onEmptyPaste === "drop"`. + * Read lines from a register. A missing named register warns and reads as + * empty; anonymous misuse throws unless `onEmptyPaste === "drop"`. */ function readRegister( register: string | undefined, clipboard: Clipboard, lineNum: number, onEmptyPaste: "throw" | "drop", + onWarning?: (message: string) => void, ): readonly string[] | null { if (register !== undefined) { const lines = clipboard.named?.get(register); if (lines !== undefined) return lines; if (onEmptyPaste === "drop") return null; const known = clipboard.named ? [...clipboard.named.keys()] : []; - throw new Error(`line ${lineNum}: ${unknownRegisterMessage(register, known)}`); + onWarning?.(`line ${lineNum}: ${emptyRegisterPasteWarning(register, known)}`); + return []; } const pending = clipboard.pendingAnonCuts ?? []; @@ -112,7 +117,7 @@ export function resolveClipboardEdits( continue; } if (edit.kind === "paste") { - const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste); + const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste, options.onWarning); if (lines === null) continue; if (edit.at.kind === "gap") { @@ -187,8 +192,9 @@ export function commitClipboard(fork: Clipboard, target: Clipboard): void { } /** - * Validate that every paste has a preceding or persisted capture without - * mutating the register or reading file content. + * Validate anonymous clipboard sequencing (empty or ambiguous unlabeled paste) + * without mutating the register or reading file content. Empty named-register + * pastes are non-fatal — they surface as apply-time warnings instead. */ export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void { const fork = forkClipboard(clipboard); diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 3b2b7f2e1..6bd71a1c7 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -331,9 +331,9 @@ export const COLONLESS_SPAN_PUT = `Colonless \`PUT\` is clipboard-backed, and sp /** Anonymous paste ran with an empty anonymous register. */ 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.`; -/** Named paste read a register that holds nothing. */ -export function unknownRegisterMessage(name: string, known: readonly string[]): string { - const base = `\`@${name}\` is empty: no \`CUT … @${name}\` precedes this op in this call and no persisted register has that name.`; +/** Named paste read a register that holds nothing; the paste applied as empty (span targets are still removed). */ +export function emptyRegisterPasteWarning(name: string, known: readonly string[]): string { + 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).`; return known.length === 0 ? base : `${base} Available registers: ${known.map(k => `\`@${k}\``).join(", ")}.`; } diff --git a/packages/hashline/test/clipboard.test.ts b/packages/hashline/test/clipboard.test.ts index 5a3216913..955512cb8 100644 --- a/packages/hashline/test/clipboard.test.ts +++ b/packages/hashline/test/clipboard.test.ts @@ -93,6 +93,20 @@ describe("clipboard apply semantics", () => { expect(() => section.applyTo("l1\nl2\n")).toThrow(/Nothing to paste/); }); + it("treats a span paste from an empty named register as a cut with a warning", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT 2-3 @gone`); + const result = section.applyTo("l1\nl2\nl3\nl4\n"); + expect(result.text).toBe("l1\nl4\n"); + expect(result.warnings?.join("\n")).toMatch(/`@gone` was empty/); + }); + + it("pastes nothing at a gap from an empty named register, with a warning", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1 @kept\nPUT >2 @gone`); + const result = section.applyTo("l1\nl2\n"); + expect(result.text).toBe("l2\n"); + expect(result.warnings?.join("\n")).toMatch(/`@gone` was empty.*Available registers: `@kept`/); + }); + it("drops an empty-register PUT on the streaming-tolerant path", () => { const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >1`); expect(section.applyPartialTo("l1\nl2\n").text).toBe("l1\nl2\n");