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.
This commit is contained in:
can1357
2026-08-07 06:00:08 +02:00
parent 102eaa4543
commit ab78d3091e
5 changed files with 38 additions and 12 deletions
+4
View File
@@ -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
+4 -2
View File
@@ -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[] = [];
+13 -7
View File
@@ -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);
+3 -3
View File
@@ -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(", ")}.`;
}
+14
View File
@@ -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");