From 5ea583e413d6cb212d57e76278952d063dec188a Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 31 Jul 2026 00:19:52 +0200 Subject: [PATCH] feat: replaced legacy editing commands with unified put and cut syntax - Replaced legacy `SWAP`, `INS`, and `PASTE` commands with unified `PUT` and `CUT` hunks across parser, grammar, tokenizer, and test suites. - Added support for named registers and span paste operations in clipboard and block execution logic. - Implemented indentation repair and enhanced gap locator formatting for improved patch resilience. - Updated documentation, system prompts, and session analysis scripts to reflect the new syntax and header shapes. --- .../src/edit/hashline/block-resolver.ts | 10 +- .../coding-agent/src/edit/hashline/diff.ts | 37 +- .../coding-agent/src/edit/hashline/execute.ts | 23 +- packages/coding-agent/src/edit/renderer.ts | 6 +- .../src/prompts/system/plan-mode-active.md | 10 +- .../test/core/block-replace.test.ts | 54 +-- .../test/core/hashline-loop-guard.test.ts | 6 +- .../coding-agent/test/core/hashline.test.ts | 10 +- .../coding-agent/test/edit-acp-bridge.test.ts | 10 +- packages/coding-agent/test/edit-diff.test.ts | 20 +- .../test/edit-snapshot-details.test.ts | 2 +- .../test/edit-streaming-preview.test.ts | 28 +- .../test/edit/seen-line-guard.test.ts | 30 +- .../test/edit/streaming-matcher-paths.test.ts | 30 +- .../read-column-truncation-snapshot.test.ts | 2 +- .../test/read-edit-out-of-cwd.test.ts | 2 +- .../test/tools/edit-renderer.test.ts | 20 +- .../test/write-hashline-header.test.ts | 2 +- packages/hashline/CHANGELOG.md | 9 + packages/hashline/README.md | 14 +- packages/hashline/src/apply.ts | 73 ++- packages/hashline/src/block.ts | 32 +- packages/hashline/src/clipboard.ts | 187 ++++++-- packages/hashline/src/format.ts | 69 +-- packages/hashline/src/grammar.lark | 23 +- packages/hashline/src/input.ts | 24 +- packages/hashline/src/messages.ts | 194 +++++--- packages/hashline/src/parser.ts | 414 +++++++++++++----- packages/hashline/src/patcher.ts | 16 +- packages/hashline/src/prefixes.ts | 16 +- packages/hashline/src/prompt.md | 75 ++-- packages/hashline/src/recovery.ts | 71 ++- packages/hashline/src/tokenizer.ts | 365 +++++++-------- packages/hashline/src/types.ts | 73 +-- packages/hashline/test/block.test.ts | 152 +++---- .../hashline/test/boundary-repair.test.ts | 125 ++++-- packages/hashline/test/clipboard.test.ts | 205 +++------ packages/hashline/test/core-contracts.test.ts | 65 ++- packages/hashline/test/file-ops.test.ts | 4 +- packages/hashline/test/format-v2.test.ts | 56 +-- packages/hashline/test/landing-shift.test.ts | 42 +- packages/hashline/test/leniency.test.ts | 159 ++++--- packages/hashline/test/patcher.test.ts | 60 ++- .../test/recovery-session-chain.test.ts | 22 +- packages/metaharness/adapters/edit/runner.ts | 68 +-- scripts/session-stats/analyze.py | 95 ++-- 46 files changed, 1767 insertions(+), 1243 deletions(-) diff --git a/packages/coding-agent/src/edit/hashline/block-resolver.ts b/packages/coding-agent/src/edit/hashline/block-resolver.ts index 433a0c153..015648393 100644 --- a/packages/coding-agent/src/edit/hashline/block-resolver.ts +++ b/packages/coding-agent/src/edit/hashline/block-resolver.ts @@ -1,9 +1,9 @@ /** - * Tree-sitter-backed {@link BlockResolver} for the hashline block replace - * operator. Bridges the pure hashline seam to the native `blockRangeAt` - * primitive in `@oh-my-pi/pi-natives`, which infers the language from the file - * path and returns the 1-indexed line span of the syntactic block beginning on - * the requested line (or `null` when none can be resolved). + * Tree-sitter-backed {@link BlockResolver} for the hashline `N*` locator. + * Bridges the pure hashline seam to the native `blockRangeAt` primitive in + * `@oh-my-pi/pi-natives`, which infers the language from the file path and + * returns the 1-indexed line span of the syntactic block beginning on the + * requested line (or `null` when none can be resolved). */ import type { BlockResolver } from "@oh-my-pi/hashline"; import { blockRangeAt } from "@oh-my-pi/pi-natives"; diff --git a/packages/coding-agent/src/edit/hashline/diff.ts b/packages/coding-agent/src/edit/hashline/diff.ts index 6d52aafaa..5058f51dd 100644 --- a/packages/coding-agent/src/edit/hashline/diff.ts +++ b/packages/coding-agent/src/edit/hashline/diff.ts @@ -18,6 +18,7 @@ import { computeFileHash, type Edit, Patch as HashlinePatch, + forkClipboard, hasBlockEdit, MismatchError, missingSnapshotTagMessage, @@ -53,10 +54,10 @@ export interface HashlineDiffOptions { skipHashValidation?: boolean; /** * Clipboard register shared across the sections of one patch preview. - * `CUT` in an earlier section feeds `PASTE` in a later one, so the preview - * matches apply. Multi-section previews MUST thread one register through - * sections in patch order; omitted, each section gets a - * private register (same-file cut/paste still previews correctly). + * `CUT` in an earlier section feeds a register-backed `PUT` in a later one, + * so the preview matches apply. Multi-section previews MUST thread one + * register through sections in patch order; omitted, each section gets a + * private register (same-file cut/put still previews correctly). */ clipboard?: Clipboard; } @@ -153,9 +154,11 @@ async function readSectionForPreview( function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { return edits.some(edit => { - if (edit.kind === "delete") return true; - if (edit.kind === "block") return true; - if (edit.kind === "cut") return true; + if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true; + if (edit.kind === "paste") { + if (edit.at.kind === "span") return true; + return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor"; + } return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); } @@ -279,19 +282,23 @@ function buildStreamingSectionDiff( onUnresolved: "drop", }); const fileLines = normalized.split("\n"); - // Expand clipboard ops so a `PASTE` previews the moved rows. Resolve into - // a scratch register and commit it back only on success: a mid-typed - // capture (out-of-range while the range digits are still streaming, - // transient sequencing violations) throws AFTER earlier captures may have - // landed, and leaking those into the shared register would let a later - // section preview a paste that never happened. On failure, fall back to - // filtering the clipboard edits out of this section's frame. - const scratch: Clipboard = { ...clipboard }; + // Expand register-backed PUT ops so moved rows appear in the preview. + // Resolve into a transactional register and publish it only on success: a + // mid-typed capture (out-of-range while the range digits are still + // streaming, transient sequencing violations) throws AFTER earlier + // captures may have landed, and leaking those into the shared register + // would let a later section preview a PUT that never happened. On failure, + // filter the clipboard edits out of this section's frame. + const scratch = forkClipboard(clipboard); let resolved: readonly Edit[]; try { resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" }); if (scratch.lines === undefined) delete clipboard.lines; else clipboard.lines = scratch.lines; + if (scratch.named === undefined) delete clipboard.named; + else clipboard.named = scratch.named; + if (scratch.pendingAnonCuts === undefined) delete clipboard.pendingAnonCuts; + else clipboard.pendingAnonCuts = scratch.pendingAnonCuts; } catch { resolved = blockResolved.filter(edit => edit.kind !== "cut" && edit.kind !== "paste"); } diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index a2cfde969..1ba55a583 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -16,6 +16,7 @@ import { type Clipboard, commitClipboard, forkClipboard, + startClipboardBatch, MismatchError as HashlineMismatchError, Patch, Patcher, @@ -48,7 +49,7 @@ export interface ExecuteHashlineSingleOptions { function noChangeDiagnostic(path: string): string { // The patch parsed and applied cleanly but produced no change — the - // `|literal` body rows matched the file content at the targeted lines + // `+TEXT` body rows matched the file content at the targeted lines // byte-for-byte. The model usually misreads this as "wrong anchor, try // again with a bigger payload" and starts duplicating content; the // message below names the cause directly so the next turn can re-read @@ -103,10 +104,10 @@ interface RenderedSection { } const BLOCK_OP_LABELS: Record = { - replace: "SWAP.BLK", - insert_after: "INS.BLK.POST", - cut: "CUT.BLK", - paste_after: "PASTE.BLK.POST", + replace: "PUT N*:", + insert_after: "PUT >N*:", + cut: "CUT N*", + paste_after: "PUT >N*", }; function formatBlockResolution(resolution: BlockResolution): string { @@ -229,11 +230,11 @@ export async function executeHashlineSingle( const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines"); const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines }); - // The clipboard register is session-persistent: `CUT` in one edit call can - // `PASTE` in a later one. Each batch works on a fork and publishes it back - // only after writes land. + // Named registers persist across edit calls; the anonymous register is + // batch-local. Each batch starts without anonymous state and publishes + // named registers only after writes land. const sessionClipboard = getEditClipboard(options.session); - const clipboard = forkClipboard(sessionClipboard); + const clipboard = startClipboardBatch(sessionClipboard); // Single-section fast path: prepare, commit, render. const inputHash = hashPatchInput(options.input); @@ -254,8 +255,8 @@ export async function executeHashlineSingle( } // Multi-section: prepare every section up front so we fail fast before - // any write hits the filesystem. One clipboard register spans the batch, - // so `CUT` in one section feeds `PASTE` in a later one. + // any write hits the filesystem. One batch-local register spans the batch, + // so `CUT` in one section feeds a register-backed `PUT` in a later one. const prepared: PreparedSection[] = []; // Register state after each section's prepare. Commits are non-atomic: a // mid-batch write failure leaves earlier sections on disk, so the session diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 507b6e4ea..6a9a14061 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -140,7 +140,7 @@ interface HashlineInputEntry { path: string; op?: Operation; rename?: string; - /** A SWAP/CUT/INS line-editing op precedes the file op — keeps a move framed. */ + /** A PUT/CUT line edit precedes the file op — keeps a move framed. */ hasLineEdits?: boolean; } @@ -568,9 +568,9 @@ function parseHashlineInputPreviewHeader(line: string): string | null { return previewPath.length > 0 ? previewPath : null; } -// Line-editing op headers (SWAP/CUT/INS family), distinct from file-level +// Line-editing op headers (PUT/CUT family), distinct from file-level // REM/MV ops. Body rows are `+TEXT`, so this only matches real headers. -const HL_LINE_OP_HEADER = /^(?:SWAP|CUT|INS)\b/; +const HL_LINE_OP_HEADER = /^(?:PUT|CUT)\b/; /** * Walk a (possibly mid-stream) hashline payload into per-section descriptors: diff --git a/packages/coding-agent/src/prompts/system/plan-mode-active.md b/packages/coding-agent/src/prompts/system/plan-mode-active.md index fe1a1b862..f14b4e5ed 100644 --- a/packages/coding-agent/src/prompts/system/plan-mode-active.md +++ b/packages/coding-agent/src/prompts/system/plan-mode-active.md @@ -28,12 +28,12 @@ Choose a short kebab-case `` naming this task and write the plan to `local Use `{{editToolName}}` for incremental edits and `{{writeToolName}}` only to create or fully replace the file. You MUST write findings into the plan as you learn them — you NEVER batch all writing to the end. {{#if isHashlineEditMode}} -Structure the plan as `##`/`###` markdown sections so you can revise it section-by-section: with `{{editToolName}}`, a heading anchors its WHOLE section (through every nested deeper heading, up to the next same-or-higher heading). Rely on the block ops to grow the plan without rewriting the file: -- `SWAP.BLK N:` on a heading line — rewrite that entire section in place. -- `CUT.BLK N` on a heading line — drop the whole section. -- `INS.BLK.POST N:` on a heading line — add a new section AFTER that one (end the inserted body with a blank line so the next heading stays separated). +Structure the plan as `##`/`###` markdown sections so you can revise it section-by-section: with `{{editToolName}}`, the `N*` locator targets a heading's WHOLE section (through every nested deeper heading, up to the next same-or-higher heading). Use composable locators to grow the plan without rewriting the file: +- `PUT N*:` on a heading line — rewrite that entire section in place. +- `CUT N*` on a heading line — drop the whole section. +- `PUT >N*:` on a heading line — add a new section AFTER that one (end the inserted body with a blank line so the next heading stays separated). -Write each section together with its body — block ops need a multi-line section; a bare heading with no body falls back to plain `INS.POST`/`CUT`/`SWAP`. +Write each section together with its body — `N*` needs a multi-line section; a bare heading with no body falls back to plain `PUT >N:`/`CUT N`/`PUT N:`. {{/if}} ## Ground every claim diff --git a/packages/coding-agent/test/core/block-replace.test.ts b/packages/coding-agent/test/core/block-replace.test.ts index c3e35058f..93b85c5f8 100644 --- a/packages/coding-agent/test/core/block-replace.test.ts +++ b/packages/coding-agent/test/core/block-replace.test.ts @@ -65,12 +65,12 @@ async function seedFile( const TS_SOURCE = "function x() {\n if (y) {\n }\n}\n"; const ELISP_SOURCE = ["(ert-deftest ogent-zen-test ()", ' "Doc."', " (should t))", ""].join("\n"); -describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { +describe("PUT N*: — native tree-sitter resolution end-to-end", () => { it("resolves the inner `if` block (line 2) and replaces its full span", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`; + const input = `${header}\nPUT 2*:\n+ if (y || z) {\n+ }`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -82,7 +82,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nSWAP.BLK 1:\n+function x() {\n+ return 42;\n+}`; + const input = `${header}\nPUT 1*:\n+function x() {\n+ return 42;\n+}`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -90,11 +90,11 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { }); }); - it("deletes the resolved `if` block (line 2) end-to-end via `CUT.BLK`", async () => { + it("deletes the resolved `if` block (line 2) end-to-end via `CUT N*`", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nCUT.BLK 2`; + const input = `${header}\nCUT 2*`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -106,7 +106,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "ogent-zen-tests.el", ELISP_SOURCE); - const input = `${header}\nINS.BLK.POST 1:\n+\n+(ert-deftest ogent-zen-second-test ()\n+ (should-not nil))`; + const input = `${header}\nPUT >1*:\n+\n+(ert-deftest ogent-zen-second-test ()\n+ (should-not nil))`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); @@ -122,14 +122,14 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { "", ].join("\n"), ); - expect(text).toContain("INS.BLK.POST 1 → resolved lines 1-3 (3 lines); body lands after line 3"); + expect(text).toContain("PUT >N*: 1 → resolved lines 1-3 (3 lines); body lands after line 3"); }); }); it("inserts after an extensionless .emacs top-level form", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, ".emacs", ELISP_SOURCE); - const input = `${header}\nINS.BLK.POST 1:\n+\n+(message "loaded")`; + const input = `${header}\nPUT >1*:\n+\n+(message "loaded")`; await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -143,7 +143,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`; + const input = `${header}\nPUT 2*:\n+ if (y || z) {\n+ }`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); @@ -152,31 +152,31 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { }); }); - it("echoes the resolved span in the result text for SWAP.BLK", async () => { + it("echoes the resolved span in the result text for PUT N*:", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nSWAP.BLK 1:\n+function x() {\n+ return 42;\n+}`; + const input = `${header}\nPUT 1*:\n+function x() {\n+ return 42;\n+}`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); // `function x() {` opens on line 1; tree-sitter resolves the whole body (lines 1-4). - expect(text).toContain("SWAP.BLK 1 → resolved lines 1-4 (4 lines)"); + expect(text).toContain("PUT N*: 1 → resolved lines 1-4 (4 lines)"); }); }); - it("echoes the resolved span in the result text for CUT.BLK", async () => { + it("echoes the resolved span in the result text for CUT N*", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); - const input = `${header}\nCUT.BLK 2`; + const input = `${header}\nCUT 2*`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); // `if (y) {` opens on line 2; resolves lines 2-3. - expect(text).toContain("CUT.BLK 2 → resolved lines 2-3 (2 lines)"); + expect(text).toContain("CUT 2* → resolved lines 2-3 (2 lines)"); }); }); @@ -185,7 +185,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); // Line 3 is ` }` — a closing delimiter, not a block opener. - const input = `${header}\nSWAP.BLK 3:\n+ }`; + const input = `${header}\nPUT 3*:\n+ }`; // Steers to the concrete form and previews the file around the anchor (`*`-marked). await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( @@ -201,10 +201,10 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { const session = makeSession(tempDir); const source = "\nfunction x() {\n return 1;\n}\n"; const { filePath, header } = await seedFile(tempDir, session, "blank.ts", source); - const input = `${header}\nSWAP.BLK 1:\n+function y() {}`; + const input = `${header}\nPUT 1*:\n+function y() {}`; await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( - "Line 1 is blank; no syntactic block can begin there. The next multi-line block begins at line 2 and ends at line 4. Retry `SWAP.BLK 2:`.", + "Line 1 is blank; no syntactic block can begin there. The next multi-line block begins at line 2 and ends at line 4. Retry `PUT 2*:`.", ); expect(await Bun.file(filePath).text()).toBe(source); }); @@ -215,10 +215,10 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { const session = makeSession(tempDir); const source = "function x() {\n run();\n}\n"; const { filePath, header } = await seedFile(tempDir, session, "statement.ts", source); - const input = `${header}\nSWAP.BLK 2:\n+ stop();`; + const input = `${header}\nPUT 2*:\n+ stop();`; await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( - "For only this statement use `SWAP 2.=2:`. The nearest enclosing multi-line block begins at line 1 and ends at line 3; use `SWAP.BLK 1:` to target it.", + "For only this statement use `PUT 2-2:`. The nearest enclosing multi-line block begins at line 1 and ends at line 3; use `PUT 1*:` to target it.", ); expect(await Bun.file(filePath).text()).toBe(source); }); @@ -229,7 +229,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => { const session = makeSession(tempDir); const source = "alpha\nbeta\ngamma\n"; const { filePath, header } = await seedFile(tempDir, session, "data.unknownext", source); - const input = `${header}\nSWAP.BLK 1:\n+ALPHA`; + const input = `${header}\nPUT 1*:\n+ALPHA`; await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( /could not resolve a syntactic block/, @@ -256,13 +256,13 @@ const MD_PLAN = [ ].join("\n"); describe("block ops on markdown headings — whole-section resolution end-to-end", () => { - it("CUT.BLK at a `## H2` deletes the entire section, including nested subsections", async () => { + it("CUT N* at a `## H2` deletes the entire section, including nested subsections", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); // Line 4 is `## Context`; its section runs through `### Detail` and // the trailing blank, up to `## Approach`. const { filePath, header } = await seedFile(tempDir, session, "plan.md", MD_PLAN); - const input = `${header}\nCUT.BLK 4`; + const input = `${header}\nCUT 4*`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); @@ -270,15 +270,15 @@ describe("block ops on markdown headings — whole-section resolution end-to-end expect(await Bun.file(filePath).text()).toBe( ["# Plan", "intro", "", "## Approach", "step one", ""].join("\n"), ); - expect(text).toContain("CUT.BLK 4 → resolved lines 4-10 (7 lines)"); + expect(text).toContain("CUT 4* → resolved lines 4-10 (7 lines)"); }); }); - it("INS.BLK.POST at a `## H2` lands after the whole section, past nested subsections", async () => { + it("PUT >N*: at a `## H2` lands after the whole section, past nested subsections", async () => { await withTempDir(async tempDir => { const session = makeSession(tempDir); const { filePath, header } = await seedFile(tempDir, session, "plan.md", MD_PLAN); - const input = `${header}\nINS.BLK.POST 4:\n+## Verification\n+run the suite\n+`; + const input = `${header}\nPUT >4*:\n+## Verification\n+run the suite\n+`; const result = await executeHashlineSingle(executeOptions(tempDir, input, session)); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); @@ -305,7 +305,7 @@ describe("block ops on markdown headings — whole-section resolution end-to-end "", ].join("\n"), ); - expect(text).toContain("INS.BLK.POST 4 → resolved lines 4-10 (7 lines); body lands after line 10"); + expect(text).toContain("PUT >N*: 4 → resolved lines 4-10 (7 lines); body lands after line 10"); }); }); }); diff --git a/packages/coding-agent/test/core/hashline-loop-guard.test.ts b/packages/coding-agent/test/core/hashline-loop-guard.test.ts index 34d25920d..55b60ad97 100644 --- a/packages/coding-agent/test/core/hashline-loop-guard.test.ts +++ b/packages/coding-agent/test/core/hashline-loop-guard.test.ts @@ -55,7 +55,7 @@ async function withTempDir(fn: (tempDir: string) => Promise): Promise { @@ -145,7 +145,7 @@ describe("hashline noop loop guard", () => { // from the model's perspective. const source = "aaa\nbbb\nccc\n"; const tag = getFileReadCache(session).record(filePath, source); - const realEdit = `${formatHashlineHeader("a.ts", tag)}\nSWAP 2.=2:\n+BBB\n`; + const realEdit = `${formatHashlineHeader("a.ts", tag)}\nPUT 2-2:\n+BBB\n`; const editResult = await executeHashlineSingle(execOptions(realEdit, session)); expect(editResult.content[0]?.type === "text" ? editResult.content[0].text : "").not.toContain( "byte-identical to the file", @@ -157,7 +157,7 @@ describe("hashline noop loop guard", () => { // soft-hint regime. const newSource = "aaa\nBBB\nccc\n"; const newTag = getFileReadCache(session).record(filePath, newSource); - const newNoop = `${formatHashlineHeader("a.ts", newTag)}\nSWAP 2.=2:\n+BBB\n`; + const newNoop = `${formatHashlineHeader("a.ts", newTag)}\nPUT 2-2:\n+BBB\n`; const result = await executeHashlineSingle(execOptions(newNoop, session)); expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain( "byte-identical to the file", diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 8265640bf..5f762acdb 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -88,7 +88,7 @@ function hashlineExecuteOptions( describe("hashline executor", () => { it("rejects file creation and directs to the write tool", async () => { await withTempDir(async tempDir => { - const input = `[new.ts]\nINS.HEAD:\n${repl("export const x = 1;")}\n`; + const input = `[new.ts]\nPUT <1:\n${repl("export const x = 1;")}\n`; await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/); expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false); }); @@ -101,7 +101,7 @@ describe("hashline executor", () => { await Bun.write(filePath, source); const sourceTag = recordFullSnapshot(getFileReadCache(session), filePath, source); - const input = `${header("a.ts", sourceTag)}\nINS.TAIL:\n${repl("bbb")}\n${repl("ccc")}\n${repl("NEW")}\n`; + const input = `${header("a.ts", sourceTag)}\nPUT >$:\n${repl("bbb")}\n${repl("ccc")}\n${repl("NEW")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session)); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; @@ -256,7 +256,7 @@ describe("hashline executor", () => { repl("L2h"), repl("L2i"), header("a.ts", originalTag), - `INS.POST ${tag(8, "L8")}:`, + `PUT > ${tag(8, "L8")}:`, repl("INSERTED"), ].join("\n"); @@ -318,14 +318,14 @@ describe("hashlineEditParamsSchema — payload shape", () => { it("tolerates provider extra fields without declaring `path`", () => { const result = arkSafeParse(hashlineEditParamsSchema, { path: "x.ts", - input: `[x.ts]\nINS.HEAD:\n${repl("x")}`, + input: `[x.ts]\nPUT <1:\n${repl("x")}`, }); expect(result.success).toBe(true); }); it("rejects `_input` as an alias for `input`", () => { const result = arkSafeParse(hashlineEditParamsSchema, { - _input: `[x.ts]\nINS.HEAD:\n${repl("x")}`, + _input: `[x.ts]\nPUT <1:\n${repl("x")}`, }); expect(result.success).toBe(false); }); diff --git a/packages/coding-agent/test/edit-acp-bridge.test.ts b/packages/coding-agent/test/edit-acp-bridge.test.ts index 83308426f..e822d6033 100644 --- a/packages/coding-agent/test/edit-acp-bridge.test.ts +++ b/packages/coding-agent/test/edit-acp-bridge.test.ts @@ -258,7 +258,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", ( const result = await executeHashlineSingle({ session, - input: `[${relPath}#${realTag}]\nSWAP 2.=2:\n+ const v0 = 100;`, + input: `[${relPath}#${realTag}]\nPUT 2-2:\n+ const v0 = 100;`, writethrough, beginDeferredDiagnosticsForPath: noopBeginDeferred, }); @@ -282,7 +282,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", ( const nextTag = extractTag(text); const followUp = await executeHashlineSingle({ session, - input: `[${relPath}#${nextTag}]\nSWAP 1.=1:\n+function g() {`, + input: `[${relPath}#${nextTag}]\nPUT 1-1:\n+function g() {`, writethrough, beginDeferredDiagnosticsForPath: noopBeginDeferred, }); @@ -303,7 +303,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", ( const result = await executeHashlineSingle({ session, - input: `[${relPath}#${realTag}]\nSWAP 2.=2:\n+earth`, + input: `[${relPath}#${realTag}]\nPUT 2-2:\n+earth`, writethrough, beginDeferredDiagnosticsForPath: noopBeginDeferred, }); @@ -334,7 +334,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", ( const result = await executeHashlineSingle({ session, - input: `[${relPath}#${realTag}]\nSWAP 2.=2:\n+print('new')`, + input: `[${relPath}#${realTag}]\nPUT 2-2:\n+print('new')`, writethrough, beginDeferredDiagnosticsForPath: noopBeginDeferred, }); @@ -344,7 +344,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", ( const nextTag = extractTag(text); const followUp = await executeHashlineSingle({ session, - input: `[${relPath}#${nextTag}]\nSWAP 2.=2:\n+print('newer')`, + input: `[${relPath}#${nextTag}]\nPUT 2-2:\n+print('newer')`, writethrough, beginDeferredDiagnosticsForPath: noopBeginDeferred, }); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 6d9b119a2..91bb573ff 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -242,12 +242,12 @@ describe("computeHashlineDiff", () => { const line = "unchanged content"; await Bun.write(sourcePath, `${line}\n`); - // `SWAP 1.=1:` with the same line in the body is a true no-op: the edit + // `PUT 1-1:` with the same line in the body is a true no-op: the edit // fires through computeHashlineDiff but produces identical content. const text = `${line}\n`; const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); - const input = `${formatHashlineHeader(sourcePath, tag)}\nSWAP 1.=1:\n+${line}\n`; + const input = `${formatHashlineHeader(sourcePath, tag)}\nPUT 1-1:\n+${line}\n`; const result = await computeHashlineDiff({ input }, tempDir, snapshotStore); expect("error" in result).toBe(true); if ("error" in result) { @@ -263,7 +263,7 @@ describe("computeHashlineDiff", () => { const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); const result = await computeHashlineDiff( - { input: `${formatHashlineHeader(sourcePath, tag)}\nINS.TAIL:\n+second` }, + { input: `${formatHashlineHeader(sourcePath, tag)}\nPUT >$:\n+second` }, tempDir, snapshotStore, ); @@ -281,7 +281,7 @@ describe("computeHashlineDiff", () => { const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); const result = await computeHashlineDiff( - { input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 1.=1\nPASTE.TAIL` }, + { input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 1-1\nPUT >$` }, tempDir, snapshotStore, ); @@ -303,7 +303,7 @@ describe("computeHashlineDiff", () => { // once per preview frame; sections then thread the fork in patch order. const sessionRegister = { lines: ["carried"] }; const result = await computeHashlineDiff( - { input: `${formatHashlineHeader(sourcePath, tag)}\nPASTE.TAIL` }, + { input: `${formatHashlineHeader(sourcePath, tag)}\nPUT >$` }, tempDir, snapshotStore, { clipboard: forkClipboard(sessionRegister) }, @@ -323,7 +323,7 @@ describe("computeHashlineDiff", () => { const snapshotStore = new InMemorySnapshotStore(); const tag = snapshotStore.record(sourcePath, text); const result = await computeHashlineDiff( - { input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 2.=2\nPASTE.HEAD` }, + { input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 2-2\nPUT <1` }, tempDir, snapshotStore, { streaming: true, skipHashValidation: true }, @@ -339,12 +339,12 @@ describe("computeHashlineDiff", () => { const relativePath = "source.txt"; await Bun.write(path.join(tempDir, relativePath), "first\n"); - // A tagless `INS.TAIL:` carries no anchored edit, yet the apply path + // A tagless `PUT >$:` carries no anchored edit, yet the apply path // (Patcher.prepare) rejects it for the missing mandatory tag. The // preview/diff path MUST emit the SAME rejection so a successful preview // never precedes a failing apply. const result = await computeHashlineDiff( - { input: `[${relativePath}]\nINS.TAIL:\n+second` }, + { input: `[${relativePath}]\nPUT >$:\n+second` }, tempDir, new InMemorySnapshotStore(), ); @@ -355,7 +355,7 @@ describe("computeHashlineDiff", () => { }); test("returns a handled error when the source path is a local URL", async () => { const result = await computeHashlineDiff( - { input: "[local://PLAN.md]\nINS.TAIL:\n+x" }, + { input: "[local://PLAN.md]\nPUT >$:\n+x" }, tempDir, new InMemorySnapshotStore(), ); @@ -384,7 +384,7 @@ describe("computeHashlineDiff", () => { const tag = snapshotStore.record(sourcePath, SNAPSHOT_TEXT); const result = await computeHashlineDiff( - { input: `${formatHashlineHeader(sourcePath, tag)}\nSWAP 2.=2:\n+edited live` }, + { input: `${formatHashlineHeader(sourcePath, tag)}\nPUT 2-2:\n+edited live` }, tempDir, snapshotStore, ); diff --git a/packages/coding-agent/test/edit-snapshot-details.test.ts b/packages/coding-agent/test/edit-snapshot-details.test.ts index ba0d56056..86f53174e 100644 --- a/packages/coding-agent/test/edit-snapshot-details.test.ts +++ b/packages/coding-agent/test/edit-snapshot-details.test.ts @@ -246,7 +246,7 @@ describe("executeHashlineSingle multi-section aggregate cap", () => { } const sections = tags.map((tag, i) => - [formatHashlineHeader(`f${i}.ts`, tag), "SWAP 1.=1:", `+HEADER${i}`].join("\n"), + [formatHashlineHeader(`f${i}.ts`, tag), "PUT 1-1:", `+HEADER${i}`].join("\n"), ); const input = sections.join("\n"); diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index b7e4ce59b..f0822ddba 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -67,7 +67,7 @@ describe("hashline streaming preview (multi-section)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots }); test("keeps section A's preview when section B's header just arrived", async () => { - const input = [headerA, "INS.HEAD:", "+// new", headerB].join("\n"); + const input = [headerA, "PUT <1:", "+// new", headerB].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -78,7 +78,7 @@ describe("hashline streaming preview (multi-section)", () => { test("ignores parse errors from the trailing in-progress section", async () => { // `7:bad` has invalid payload — the trailing section is still being typed. - const input = [headerA, "INS.HEAD:", "+// new", headerB, "7:bad"].join("\n"); + const input = [headerA, "PUT <1:", "+// new", headerB, "7:bad"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -87,7 +87,7 @@ describe("hashline streaming preview (multi-section)", () => { }); test("renders both sections once each has at least one valid op", async () => { - const input = [headerA, "INS.HEAD:", "+// new a", headerB, "INS.HEAD:", "+// new b"].join("\n"); + const input = [headerA, "PUT <1:", "+// new a", headerB, "PUT <1:", "+// new b"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(2); expect(previews?.map(p => p.path).sort()).toEqual(["a.ts", "b.ts"]); @@ -129,7 +129,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { // The `+` payload has no trailing newline — the common single-op case // the trailing-line trim used to erase, collapsing the preview to a // "No changes" error that rendered as a blank box for the whole stream. - const input = `${header}\nSWAP 2.=2:\n+const b = 22`; + const input = `${header}\nPUT 2-2:\n+const b = 22`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -137,7 +137,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("does not surface stale hash errors while streaming", async () => { - const input = "[a.ts#FFFF]\nSWAP 2.=2:\n+const b = 22"; + const input = "[a.ts#FFFF]\nPUT 2-2:\n+const b = 22"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -146,7 +146,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { test("final preview accepts a live content hash even when the snapshot store has no history", async () => { const liveHeader = formatHashlineHeader("a.ts", computeFileHash(text)); - const input = `${liveHeader}\nSWAP 2.=2:\n+const b = 22\n`; + const input = `${liveHeader}\nPUT 2-2:\n+const b = 22\n`; const previews = await strategy.computeDiffPreview( { input } as never, { @@ -163,7 +163,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { test("final preview recovers a stale tag from snapshot history", async () => { await Bun.write(file, `// external\n${text}`); - const input = `${header}\nSWAP 2.=2:\n+const b = 22\n`; + const input = `${header}\nPUT 2-2:\n+const b = 22\n`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toBeUndefined(); @@ -171,7 +171,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { }); test("surfaces stale hash errors once streaming is complete", async () => { - const input = "[a.ts#FFFF]\nSWAP 2.=2:\n+const b = 22\n"; + const input = "[a.ts#FFFF]\nPUT 2-2:\n+const b = 22\n"; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).toHaveLength(1); expect(previews?.[0]?.error).toContain("not from this session"); @@ -181,7 +181,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => { // Op header typed, payload still empty: applyPartialTo drops the // payload-less op so nothing changes yet. The preview must report null // (preserving any prior frame), never a 'No changes' error that wipes it. - const input = `${header}\nSWAP 2.=2:\n`; + const input = `${header}\nPUT 2-2:\n`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toBeNull(); }); @@ -218,7 +218,7 @@ describe("hashline streaming preview (monotonic growth)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots, isStreaming: true }); // Replace the 20-line body (lines 2..21) with the first `n` payload rows. const buildInput = (n: number) => - `${header}\nSWAP 2.=21:\n${payload + `${header}\nPUT 2-21:\n${payload .slice(0, n) .map(l => `+${l}`) .join("\n")}`; @@ -305,7 +305,7 @@ describe("apply_patch streaming preview (trailing partial line)", () => { describe("matcherDigest", () => { test("hashline: digests stripped `+` body rows only, never headers or op lines", () => { - const input = ["[a.ts#AB12]", "SWAP 1.=2:", "+const x = 1;", "+const y = 2;", "CUT 5", ""].join("\n"); + const input = ["[a.ts#AB12]", "PUT 1-2:", "+const x = 1;", "+const y = 2;", "CUT 5", ""].join("\n"); expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;"); }); @@ -391,7 +391,7 @@ describe("hashline streaming preview (tag-based path recovery)", () => { }); test("streaming: recovers the bare header onto its nested file instead of blanking", async () => { - const input = `${header}\nSWAP 1.=1:\n+const a = 99;`; + const input = `${header}\nPUT 1-1:\n+const a = 99;`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, true) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -400,7 +400,7 @@ describe("hashline streaming preview (tag-based path recovery)", () => { }); test("args-complete: recovers the bare header for the final Myers diff too", async () => { - const input = `${header}\nSWAP 1.=1:\n+const a = 99;`; + const input = `${header}\nPUT 1-1:\n+const a = 99;`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -412,7 +412,7 @@ describe("hashline streaming preview (tag-based path recovery)", () => { // A header whose basename matches no retained snapshot path cannot recover; // the preview must report the failure rather than silently pick a file. const orphan = formatHashlineHeader("absent.ts", snapshots.record(nestedFile, text)); - const input = `${orphan}\nSWAP 1.=1:\n+const a = 99;`; + const input = `${orphan}\nPUT 1-1:\n+const a = 99;`; const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never); expect(previews).not.toBeNull(); expect(previews?.[0]?.error).toBeTruthy(); diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index f387de660..d5a85cea5 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -106,7 +106,7 @@ describe("read → edit seen-line guard", () => { const tag = tagFromOutput(resultText(read)); await expect( - executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 12.=12:\n+EDITED`, session)), + executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 12-12:\n+EDITED`, session)), ).rejects.toThrow(/never displayed \(it showed/); // The reject left the file untouched. expect(await Bun.file(file).text()).toBe(CONTENT); @@ -120,7 +120,7 @@ describe("read → edit seen-line guard", () => { const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); const tag = tagFromOutput(resultText(read)); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 2.=2:\n+EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 2-2:\n+EDITED`, session)); expect(await Bun.file(file).text()).toContain("EDITED"); }); @@ -143,7 +143,7 @@ describe("read → edit seen-line guard", () => { expect(seen?.has(6)).toBe(true); expect(seen?.has(10)).toBe(false); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 5.=5:\n+RAW EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 5-5:\n+RAW EDITED`, session)); const edited = await Bun.file(file).text(); expect(edited).toContain("line 4\nRAW EDITED\nline 6"); expect(edited).not.toContain("line 5"); @@ -169,11 +169,11 @@ describe("read → edit seen-line guard", () => { expect(seen?.has(5)).toBe(false); await expect( - executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 5.=5:\n+OUTSIDE`, session)), + executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 5-5:\n+OUTSIDE`, session)), ).rejects.toThrow(/never displayed \(it showed/); expect(await Bun.file(file).text()).toBe(CONTENT); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 7.=7:\n+RAW RANGE EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 7-7:\n+RAW RANGE EDITED`, session)); const edited = await Bun.file(file).text(); expect(edited).toContain("line 6\nRAW RANGE EDITED\nline 8"); expect(edited).not.toContain("line 7"); @@ -197,7 +197,7 @@ describe("read → edit seen-line guard", () => { expect(seen?.has(4)).toBe(true); await expect( - executeHashlineSingle(execOptions(`[wide-raw.txt#${tag}]\nSWAP 1.=1:\n+REPLACED`, session)), + executeHashlineSingle(execOptions(`[wide-raw.txt#${tag}]\nPUT 1-1:\n+REPLACED`, session)), ).rejects.toThrow(/never displayed \(it showed/); expect(await Bun.file(file).text()).toBe(content); }); @@ -214,7 +214,7 @@ describe("read → edit seen-line guard", () => { const seen = store.byHash(canonicalSnapshotKey(file), tag)?.seenLines; expect(seen?.has(2)).toBe(true); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nINS.POST 2:\n+EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT >2:\n+EDITED`, session)); expect(await Bun.file(file).text()).toContain("line 2\nEDITED"); }); @@ -236,7 +236,7 @@ describe("read → edit seen-line guard", () => { expect(seen?.has(1)).toBe(true); expect(seen?.has(2)).toBe(true); - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 1.=1:\n+RAW BLANK EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 1-1:\n+RAW BLANK EDITED`, session)); expect(await Bun.file(file).text()).toBe("RAW BLANK EDITED"); }); @@ -262,7 +262,7 @@ describe("read → edit seen-line guard", () => { expect(seen?.has(1122)).toBe(true); await executeHashlineSingle( execOptions( - `[src/main.c#${tag}]\nINS.POST 1122:\n+\tbeep_3k8hz_on();\n+\tk_sleep(K_MSEC(300));\n+\tbeep_3k8hz_off();\nCUT 1288.=1291`, + `[src/main.c#${tag}]\nPUT >1122:\n+\tbeep_3k8hz_on();\n+\tk_sleep(K_MSEC(300));\n+\tbeep_3k8hz_off();\nCUT 1288-1291`, session, ), ); @@ -281,7 +281,7 @@ describe("read → edit seen-line guard", () => { let message: string | undefined; try { - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 10.=12:\n+X10\n+X11\n+X12`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 10-12:\n+X10\n+X11\n+X12`, session)); } catch (err) { message = (err as Error).message; } @@ -296,7 +296,7 @@ describe("read → edit seen-line guard", () => { // The revealed lines are now in the snapshot's seen set, so a straight // retry with the same `[path#tag]` header succeeds without a re-read. - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 10.=12:\n+X10\n+X11\n+X12`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 10-12:\n+X10\n+X11\n+X12`, session)); const after = await Bun.file(file).text(); expect(after).toContain("X10\nX11\nX12"); expect(after).not.toContain("line 10"); @@ -348,7 +348,7 @@ describe("read → edit seen-line guard", () => { const seen = getFileSnapshotStore(session).byHash(canonicalSnapshotKey(file), tag)?.seenLines; expect(seen?.has(2)).toBe(true); - await executeHashlineSingle(execOptions(`[wide.txt#${tag}]\nSWAP 2.=2:\n+REPLACED`, session)); + await executeHashlineSingle(execOptions(`[wide.txt#${tag}]\nPUT 2-2:\n+REPLACED`, session)); expect(await Bun.file(file).text()).toBe("head\nREPLACED\nfoot\n"); }); }); @@ -399,7 +399,7 @@ describe("search → edit seen-line guard", () => { expect(seen?.has(8)).toBe(false); // The matched line is in the seen set, so editing it applies. - await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 4.=4:\n+NEEDLE edited`, session)); + await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nPUT 4-4:\n+NEEDLE edited`, session)); expect(await Bun.file(file).text()).toContain("NEEDLE edited"); }); @@ -412,7 +412,7 @@ describe("search → edit seen-line guard", () => { const search = await new GrepTool(session).execute("s1", { pattern: "NEEDLE", path: file }); const tag = tagFromOutput(resultText(search)); - await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nSWAP 8.=8:\n+X`, session))).rejects.toThrow( + await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nPUT 8-8:\n+X`, session))).rejects.toThrow( /never displayed \(it showed/, ); expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`); @@ -442,7 +442,7 @@ describe("seen-line guard disabled by default", () => { const tag = tagFromOutput(resultText(read)); // Line 12 was never displayed, but the guard is disabled, so it applies. - await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nSWAP 12.=12:\n+EDITED`, session)); + await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 12-12:\n+EDITED`, session)); expect(await Bun.file(file).text()).toContain("EDITED"); }); }); diff --git a/packages/coding-agent/test/edit/streaming-matcher-paths.test.ts b/packages/coding-agent/test/edit/streaming-matcher-paths.test.ts index 617069248..9702de306 100644 --- a/packages/coding-agent/test/edit/streaming-matcher-paths.test.ts +++ b/packages/coding-agent/test/edit/streaming-matcher-paths.test.ts @@ -31,20 +31,20 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => { describe("hashline (section-header path)", () => { it("extracts the path from a single section header", () => { - const input = "[demo.ts#ABCD]\nSWAP 1.=1:\n+const x = 1;\n"; + const input = "[demo.ts#ABCD]\nPUT 1-1:\n+const x = 1;\n"; expect(EDIT_MODE_STRATEGIES.hashline.matcherPaths({ input })).toEqual(["demo.ts"]); }); it("extracts paths from multiple section headers in order, deduped", () => { const input = [ "[src/a.ts#ABCD]", - "SWAP 1.=1:", + "PUT 1-1:", "+const a = 1;", "[src/b.ts#EF01]", - "SWAP 1.=1:", + "PUT 1-1:", "+const b = 2;", "[src/a.ts#1234]", - "SWAP 2.=2:", + "PUT 2-2:", "+const c = 3;", "", ].join("\n"); @@ -61,10 +61,10 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => { it("handles paths with spaces and recovers apply_patch-style header noise", () => { const input = [ "[dir with spaces/file.ts#1A2B]", - "SWAP 1.=1:", + "PUT 1-1:", "+after", "[*** Update File: src/recovered.ts#1A2B]", - "SWAP 1.=1:", + "PUT 1-1:", "+after", "", ].join("\n"); @@ -76,7 +76,7 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => { it("returns undefined when input has no section header", () => { expect(EDIT_MODE_STRATEGIES.hashline.matcherPaths({ input: "" })).toBeUndefined(); - expect(EDIT_MODE_STRATEGIES.hashline.matcherPaths({ input: "SWAP 1.=1:\n+x" })).toBeUndefined(); + expect(EDIT_MODE_STRATEGIES.hashline.matcherPaths({ input: "PUT 1-1:\n+x" })).toBeUndefined(); }); }); @@ -122,13 +122,13 @@ describe("EDIT_MODE_STRATEGIES.matcherEntries", () => { it("hashline splits multi-section payloads into one entry per file", () => { const input = [ "[src/a.ts#ABCD]", - "SWAP 1.=1:", + "PUT 1-1:", "+const a = 1;", "[README.md#EF01]", - "SWAP 1.=1:", + "PUT 1-1:", "+# Heading", "[src/a.ts#1234]", - "SWAP 2.=2:", + "PUT 2-2:", "+const c = 3;", "", ].join("\n"); @@ -198,7 +198,7 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => { const ANY = "any"; // Snippet rendered at runtime to avoid tripping the rule on this test file itself. const VIOLATING_LINE = `export const value: ${ANY} = 1;`; - const HASHLINE_PAYLOAD = `[demo.ts#ABCD]\nSWAP 1.=1:\n+${VIOLATING_LINE}\n`; + const HASHLINE_PAYLOAD = `[demo.ts#ABCD]\nPUT 1-1:\n+${VIOLATING_LINE}\n`; async function makeManager(): Promise { const manager = new TtsrManager({ @@ -250,10 +250,10 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => { const manager = await makeManager(); const input = [ "[README.md#ABCD]", - "SWAP 1.=1:", + "PUT 1-1:", `+${VIOLATING_LINE}`, "[src/ok.ts#EF01]", - "SWAP 1.=1:", + "PUT 1-1:", "+export const ok = 1;", "", ].join("\n"); @@ -278,10 +278,10 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => { const manager = await makeManager(); const input = [ "[README.md#ABCD]", - "SWAP 1.=1:", + "PUT 1-1:", "+# Heading", "[src/bad.ts#EF01]", - "SWAP 1.=1:", + "PUT 1-1:", `+${VIOLATING_LINE}`, "", ].join("\n"); diff --git a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts index d4c65d71a..d30ba44e5 100644 --- a/packages/coding-agent/test/read-column-truncation-snapshot.test.ts +++ b/packages/coding-agent/test/read-column-truncation-snapshot.test.ts @@ -176,7 +176,7 @@ describe("read tool column truncation vs hashline snapshot", () => { tmpDir, filePath, header, - patchBody: "SWAP 3.=3:\n+epilogue\n", + patchBody: "PUT 3-3:\n+epilogue\n", }); const after = await fs.readFile(filePath, "utf8"); diff --git a/packages/coding-agent/test/read-edit-out-of-cwd.test.ts b/packages/coding-agent/test/read-edit-out-of-cwd.test.ts index 0cfc7d881..476ff29ac 100644 --- a/packages/coding-agent/test/read-edit-out-of-cwd.test.ts +++ b/packages/coding-agent/test/read-edit-out-of-cwd.test.ts @@ -89,7 +89,7 @@ describe("read → edit round-trip for out-of-cwd files", () => { expect(header).toMatch(/^\[.+settings\.json#[0-9A-F]{4}\]$/); expect(header).toContain(path.basename(outDir)); - const result = await executeHashlineSingle(editOptions(session, `${header}\nSWAP 1.=1:\n+ALPHA\n`)); + const result = await executeHashlineSingle(editOptions(session, `${header}\nPUT 1-1:\n+ALPHA\n`)); const resultText = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); expect(resultText).not.toContain("File not found"); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index bf237828f..cf9b58163 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -111,7 +111,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { - input: "[packages/coding-agent/src/edit/renderer.ts]\nINS.TAIL:\n+// preview", + input: "[packages/coding-agent/src/edit/renderer.ts]\nPUT >$:\n+// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -132,7 +132,7 @@ describe("editToolRenderer", () => { input: [ "*** Begin Patch", "[crates/pi-natives/src/shell.rs]", - "INS.TAIL:", + "PUT >$:", "+pub fn streaming_preview() {", ].join("\n"), }, @@ -143,7 +143,7 @@ describe("editToolRenderer", () => { const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("crates/pi-natives/src/shell.rs"); - expect(rendered).not.toContain("INS.TAIL:"); + expect(rendered).not.toContain("PUT >$:"); expect(rendered).not.toContain("+pub fn streaming_preview() {"); expect(rendered).not.toContain("*** Begin Patch"); }); @@ -152,7 +152,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall( { - input: "[foo bar.ts]\nINS.HEAD:\n+// preview", + input: "[foo bar.ts]\nPUT <1:\n+// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -160,7 +160,7 @@ describe("editToolRenderer", () => { const quotedComponent = editToolRenderer.renderCall( { - input: "['baz qux.ts']\nINS.HEAD:\n+// preview", + input: "['baz qux.ts']\nPUT <1:\n+// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -179,7 +179,7 @@ describe("editToolRenderer", () => { // renderer keeps the title clean. const canonical = editToolRenderer.renderCall( { - input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\nINS.HEAD:\n+// preview", + input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\nPUT <1:\n+// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -187,7 +187,7 @@ describe("editToolRenderer", () => { // While streaming, the closing bracket may not have arrived yet. const partial = editToolRenderer.renderCall( - { input: "[a/b/c.ts\nINS.HEAD:\n+// preview" }, + { input: "[a/b/c.ts\nPUT <1:\n+// preview" }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); @@ -214,7 +214,7 @@ describe("editToolRenderer", () => { { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, uiTheme, { - input: "[packages/coding-agent/src/edit/renderer.ts]\nINS.TAIL:\n+// preview", + input: "[packages/coding-agent/src/edit/renderer.ts]\nPUT >$:\n+// preview", }, ); @@ -301,7 +301,7 @@ describe("editToolRenderer", () => { // The trailing payload line carries no newline — the common shape for a // single-line edit. The streaming pass trims that in-flight line, so the // preview only becomes computable once args are marked complete. - const input = `[memory.ts#${tag}]\nSWAP 2.=2:\n+export const b = 22;`; + const input = `[memory.ts#${tag}]\nPUT 2-2:\n+export const b = 22;`; const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir); component.setArgsComplete(); @@ -328,7 +328,7 @@ describe("editToolRenderer", () => { const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, content); - const input = `[memory.ts#${tag}]\nSWAP 2.=2:\n+export const b = 22;\n`; + const input = `[memory.ts#${tag}]\nPUT 2-2:\n+export const b = 22;\n`; const component = new ToolExecutionComponent( "edit", { __partialJson: input }, diff --git a/packages/coding-agent/test/write-hashline-header.test.ts b/packages/coding-agent/test/write-hashline-header.test.ts index aae4b98ca..48ec7a9ef 100644 --- a/packages/coding-agent/test/write-hashline-header.test.ts +++ b/packages/coding-agent/test/write-hashline-header.test.ts @@ -83,7 +83,7 @@ describe("write tool hashline header", () => { // Apply a hashline patch immediately, using only the tag the write tool // returned — no intervening `read`. - const patchInput = `${headerLine}\nSWAP 1.=1:\n+export const enabled = true;\n`; + const patchInput = `${headerLine}\nPUT 1-1:\n+export const enabled = true;\n`; const patch = Patch.parse(patchInput, { cwd: tmpDir }); expect(patch.sections).toHaveLength(1); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 23879f828..e3a14b0af 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Breaking Changes + +- Replaced legacy `SWAP`, `INS`, and `PASTE` syntax with unified `PUT` and `CUT` hunks + +### Added + +- Added named register support (`@reg`) and span paste capabilities to clipboard operations +- Added auto-shifting for uniformly omitted base indents under brace openers + ## [17.2.0] - 2026-07-30 ### Breaking Changes diff --git a/packages/hashline/README.md b/packages/hashline/README.md index 04f511fd6..6b0379b6c 100644 --- a/packages/hashline/README.md +++ b/packages/hashline/README.md @@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before); const tag = snapshots.record("hello.ts", before); const patcher = new Patcher({ fs, snapshots }); const patch = Patch.parse(String.raw`[hello.ts#${tag}] -SWAP 1.=1: +PUT 1.=1: +const greeting = "hello";`); const result = await patcher.apply(patch); @@ -47,12 +47,12 @@ still matches the recorded content hash, and refusing or attempting session-aware recovery on mismatch. Inside a section: -- `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows. -- `SWAP.BLK A:` — replace the syntactic block beginning on line A. -- `CUT A.=B` / `CUT.BLK A` — delete concrete lines or a resolved block and capture them for `PASTE`. -- `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows. -- `INS.BLK.POST A:` — insert following body rows after the resolved block's last line. -- `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`. +- `PUT A.=B:` — replace lines A through B (inclusive) with following `+TEXT` body rows. +- `PUT A*:` — replace the syntactic block beginning on line A. +- `PUT A:` — insert following body rows before/after line A (`<1` = head, `>$` = tail). +- `PUT >A*:` — insert following body rows after the resolved block's last line. +- `PUT A` / `PUT A.=B @name` — paste a captured register at a gap or over a range (no `:` header, no body rows; `@name` optional at gaps). +- `CUT A.=B` / `CUT A*` — delete concrete lines or a resolved block and capture them (anonymous, or `@name` when given). - `REM` — delete the whole file named by the section header. - `MV DEST` — move/rename the section file to `DEST` (optionally after line edits). - `+TEXT` — literal body row (use `+` alone for a blank line). diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 3eeb589e5..8c52783db 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -14,6 +14,7 @@ import { ambiguousBoundaryEchoMessage, ambiguousCloserSpareMessage, blockInsertLandingShiftWarning, + REPLACEMENT_INDENT_AUTO_SHIFT_WARNING, UNRESOLVED_BLOCK_INTERNAL, } from "./messages"; import { cloneCursor } from "./tokenizer"; @@ -390,6 +391,66 @@ function findReplacementGroup(edits: readonly AppliedEdit[], start: number): Rep }; } +/** + * Restore a uniformly omitted base indent only when the payload would escape + * a surviving `{` opener immediately above the replacement. Matching unchanged + * rows then prove the uniform shift; ordinary indentation-only edits stay exact. + */ +function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly string[]): string[] { + let repaired = false; + for (let start = 0; start < edits.length; ) { + const group = findReplacementGroup(edits, start); + if (group === undefined) { + start++; + continue; + } + const lastDeleteIndex = group.deleteIndices.at(-1); + if (lastDeleteIndex === undefined) continue; + start = lastDeleteIndex + 1; + if (group.payload.length !== group.deleteIndices.length) continue; + const preceding = fileLines[group.startLine - 2] ?? ""; + const sourceFirst = fileLines[group.startLine - 1] ?? ""; + const payloadFirst = group.payload[0] ?? ""; + if ( + !preceding.trimEnd().endsWith("{") || + !isIndentDeeper(leadingIndent(sourceFirst), leadingIndent(preceding)) || + isIndentDeeper(leadingIndent(payloadFirst), leadingIndent(preceding)) + ) { + continue; + } + + let shift: string | undefined; + let matches = 0; + let consistent = true; + for (let offset = 0; offset < group.payload.length; offset++) { + const source = fileLines[group.startLine - 1 + offset] ?? ""; + const payload = group.payload[offset]; + if (source.trim().length === 0 || source.trimStart() !== payload.trimStart()) continue; + const sourceIndent = leadingIndent(source); + const payloadIndent = leadingIndent(payload); + if (!sourceIndent.endsWith(payloadIndent)) { + consistent = false; + break; + } + const candidate = sourceIndent.slice(0, sourceIndent.length - payloadIndent.length); + if (shift === undefined) shift = candidate; + else if (shift !== candidate) { + consistent = false; + break; + } + matches++; + } + if (!consistent || !shift || matches < 2 || matches * 2 <= group.payload.length) continue; + for (const index of group.insertIndices) { + const edit = edits[index]; + if (edit.kind !== "insert" || edit.text.trim().length === 0) continue; + edits[index] = { ...edit, text: `${shift}${edit.text}` }; + } + repaired = true; + } + return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : []; +} + /** * Largest `k` such that the payload's last `k` lines exactly equal the `k` * surviving file lines just below the range AND dropping them zeroes `delta`. @@ -927,6 +988,15 @@ function repairReplacementBoundaries( after: new Map(), }; for (const edit of projected) { + if (edit.kind === "insert" && edit.cursor.kind === "bof") { + const inserted = insertedByLine.get(1); + if (inserted) inserted.push(edit.text); + else insertedByLine.set(1, [edit.text]); + const before = insertedLineMaps.before.get(1); + if (before) before.push(edit.text); + else insertedLineMaps.before.set(1, [edit.text]); + continue; + } if (edit.kind !== "insert") continue; for (const anchor of getCursorAnchors(edit.cursor)) { const lines = insertedByLine.get(anchor.line); @@ -1272,9 +1342,10 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE fileLines, ); validateLineBounds(targetEdits, fileLines); + const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines); const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines); const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines); - const warnings = [...boundaryWarnings, ...landingWarnings]; + const warnings = [...indentationWarnings, ...boundaryWarnings, ...landingWarnings]; // Partition edits into bof, eof, and anchor-targeted buckets. const bofLines: string[] = []; diff --git a/packages/hashline/src/block.ts b/packages/hashline/src/block.ts index 67cdd0728..52ce3fef0 100644 --- a/packages/hashline/src/block.ts +++ b/packages/hashline/src/block.ts @@ -139,7 +139,13 @@ export function resolveBlockEdits( : pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line), ); const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } }; - resolved.push({ kind: "paste", cursor, lineNum: edit.lineNum, index: synthIndex++ }); + resolved.push({ + kind: "paste", + at: { kind: "gap", cursor }, + ...(edit.register === undefined ? {} : { register: edit.register }), + lineNum: edit.lineNum, + index: synthIndex++, + }); continue; } options.onWarning?.( @@ -165,7 +171,9 @@ export function resolveBlockEdits( const suggestions: BlockDiagnosticSuggestions = {}; if (nextBlock) suggestions.nextBlock = nextBlock; if (enclosingBlock) suggestions.enclosingBlock = enclosingBlock; - throw new Error(`line ${edit.lineNum}: ${blockUnresolvedMessage(edit.anchor.line, op, lines, suggestions)}`); + throw new Error( + `line ${edit.lineNum}: ${blockUnresolvedMessage(edit.anchor.line, op, lines, suggestions, edit.register)}`, + ); } if (span.start === span.end) { // A single-line block resolution means line N is a bare statement, not @@ -193,7 +201,8 @@ export function resolveBlockEdits( // claiming a depth inside the block back across its trailing closers. resolved.push({ kind: "paste", - cursor: { kind: "after_anchor", anchor: { line: span.end } }, + at: { kind: "gap", cursor: { kind: "after_anchor", anchor: { line: span.end } } }, + ...(edit.register === undefined ? {} : { register: edit.register }), lineNum: edit.lineNum, index: synthIndex++, blockStart: span.start, @@ -205,6 +214,7 @@ export function resolveBlockEdits( resolved.push({ kind: "cut", range: { start: { line: span.start }, end: { line: span.end } }, + ...(edit.register === undefined ? {} : { register: edit.register }), lineNum: edit.lineNum, index: synthIndex++, }); @@ -232,8 +242,20 @@ export function resolveBlockEdits( } continue; } - // Mirror `SWAP start.=end:`: replacement inserts at `span.start`, then - // one delete per line across the resolved span. + if (edit.register !== undefined) { + // Register-backed block replace (`PUT N* @reg`): expand to a span paste + // over the resolved block range. + resolved.push({ + kind: "paste", + at: { kind: "span", range: { start: { line: span.start }, end: { line: span.end } } }, + register: edit.register, + lineNum: edit.lineNum, + index: synthIndex++, + }); + continue; + } + // Body-backed block replace (`PUT N*:` + body): replacement inserts at + // `span.start`, then one delete per line across the resolved span. for (const payload of edit.payloads) { const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } }; resolved.push({ diff --git a/packages/hashline/src/clipboard.ts b/packages/hashline/src/clipboard.ts index bed55af25..c3ce3c5f9 100644 --- a/packages/hashline/src/clipboard.ts +++ b/packages/hashline/src/clipboard.ts @@ -1,13 +1,14 @@ /** - * Clipboard register support for `CUT` / `PASTE` ops. + * Clipboard register support for `CUT` and register `PUT` ops. * - * `CUT` captures its current source lines before ordinary delete edits apply. - * `PASTE` expands the latest capture into inserts. One register flows through - * patch sections in authored order, so content moves across files; the latest - * cut wins and paste does not consume it. + * `CUT` captures source lines before ordinary delete edits apply. Register + * `PUT`s expand captured lines into inserts (plus per-line deletes when the + * target is a `span`). Named registers (`named: Map`) persist across edit + * batches when host-owned (`PatcherOptions.clipboard`); the anonymous register + * (`lines`) is batch-local and resets between calls. */ -import { HL_CUT_KEYWORD, HL_RANGE_SEP } from "./format"; -import { EMPTY_PASTE } from "./messages"; +import { HL_CUT_KEYWORD, HL_PUT_KEYWORD, HL_RANGE_SEP } from "./format"; +import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, unknownRegisterMessage } from "./messages"; import { cloneCursor } from "./tokenizer"; import type { Clipboard, Edit } from "./types"; @@ -15,29 +16,84 @@ type CutEdit = Extract; function describeCutEdit(edit: CutEdit): string { const { start, end } = edit.range; - const range = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`; - return `${HL_CUT_KEYWORD} ${range}`; + const span = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`; + const reg = edit.register ? ` @${edit.register}` : ""; + return `${HL_CUT_KEYWORD} ${span}${reg}`; } -/** True when at least one edit reads or writes the clipboard register. */ +/** True when at least one edit reads or writes a clipboard register. */ export function hasClipboardEdit(edits: readonly Edit[]): boolean { return edits.some( edit => edit.kind === "cut" || edit.kind === "paste" || - (edit.kind === "block" && (edit.mode === "cut" || edit.mode === "paste_after")), + (edit.kind === "block" && (edit.mode === "cut" || edit.mode === "paste_after" || edit.register !== undefined)), ); } /** Optional knobs for {@link resolveClipboardEdits}. */ export interface ResolveClipboardEditsOptions { - /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */ + /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */ onEmptyPaste?: "throw" | "drop"; } +/** + * Read lines from a register. Throws on missing/ambiguous register unless `onEmptyPaste === "drop"`. + */ +function readRegister( + register: string | undefined, + clipboard: Clipboard, + lineNum: number, + onEmptyPaste: "throw" | "drop", +): 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)}`); + } + + const pending = clipboard.pendingAnonCuts ?? []; + if (pending.length > 1) { + if (onEmptyPaste === "drop") return null; + throw new Error(`line ${lineNum}: ${ambiguousAnonymousPasteMessage(pending)}`); + } + + const lines = clipboard.lines; + if (lines === undefined) { + if (onEmptyPaste === "drop") return null; + throw new Error(`line ${lineNum}: ${EMPTY_PASTE}`); + } + // Successful anonymous read clears the pending ambiguity counter for follow-up pastes. + clipboard.pendingAnonCuts = []; + return lines; +} + +/** + * Write lines into a register (named or anonymous). + */ +function writeRegister(edit: CutEdit, fileLines: readonly string[], clipboard: Clipboard): void { + const { start, end } = edit.range; + if (start.line < 1 || end.line > fileLines.length) { + throw new Error( + `line ${edit.lineNum}: \`${describeCutEdit(edit)}\` is out of range (file has ${fileLines.length} lines).`, + ); + } + const captured = fileLines.slice(start.line - 1, end.line); + if (edit.register !== undefined) { + clipboard.named ??= new Map(); + clipboard.named.set(edit.register, captured); + } else { + clipboard.lines = captured; + clipboard.pendingAnonCuts ??= []; + clipboard.pendingAnonCuts.push(describeCutEdit(edit)); + } +} + /** * Resolve clipboard edits against the original file lines in authored order. - * Cuts fill the register and emit nothing; pastes become plain inserts. + * Cuts fill the register and emit nothing; pastes expand to inserts (+ deletes for span targets). */ export function resolveClipboardEdits( edits: readonly Edit[], @@ -49,32 +105,55 @@ export function resolveClipboardEdits( const onEmptyPaste = options.onEmptyPaste ?? "throw"; const resolved: Edit[] = []; let synthIndex = 0; + for (const edit of edits) { if (edit.kind === "cut") { - const { start, end } = edit.range; - if (start.line < 1 || end.line > fileLines.length) { - throw new Error( - `line ${edit.lineNum}: \`${describeCutEdit(edit)}\` is out of range (file has ${fileLines.length} lines).`, - ); - } - clipboard.lines = fileLines.slice(start.line - 1, end.line); + writeRegister(edit, fileLines, clipboard); continue; } if (edit.kind === "paste") { - const lines = clipboard.lines; - if (lines === undefined) { - if (onEmptyPaste === "drop") continue; - throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`); - } - for (const text of lines) { - resolved.push({ - kind: "insert", - cursor: cloneCursor(edit.cursor), - text, - lineNum: edit.lineNum, - index: synthIndex++, - ...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }), - }); + const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste); + if (lines === null) continue; + + if (edit.at.kind === "gap") { + for (const text of lines) { + resolved.push({ + kind: "insert", + cursor: cloneCursor(edit.at.cursor), + text, + lineNum: edit.lineNum, + index: synthIndex++, + ...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }), + }); + } + } else { + // Span paste: insert replacement lines before start, then delete span lines. + const range = edit.at.range; + if (range.start.line < 1 || range.end.line > fileLines.length) { + const reg = edit.register ? ` @${edit.register}` : ""; + throw new Error( + `line ${edit.lineNum}: \`${HL_PUT_KEYWORD} ${range.start.line}${HL_RANGE_SEP}${range.end.line}${reg}\` is out of range (file has ${fileLines.length} lines).`, + ); + } + const cursor = { kind: "before_anchor" as const, anchor: { line: range.start.line } }; + for (const text of lines) { + resolved.push({ + kind: "insert", + cursor: cloneCursor(cursor), + text, + lineNum: edit.lineNum, + index: synthIndex++, + mode: "replacement", + }); + } + for (let line = range.start.line; line <= range.end.line; line++) { + resolved.push({ + kind: "delete", + anchor: { line }, + lineNum: edit.lineNum, + index: synthIndex++, + }); + } } continue; } @@ -83,15 +162,28 @@ export function resolveClipboardEdits( return resolved; } -/** Create a transactional working copy of a clipboard register. */ -export function forkClipboard(source?: Clipboard): Clipboard { - return source === undefined ? {} : { ...source }; +/** Start a batch with persisted named registers but no anonymous state. */ +export function startClipboardBatch(source?: Clipboard): Clipboard { + if (source?.named === undefined) return {}; + return { named: new Map(source.named) }; } -/** Publish a clipboard fork back to its source register. */ +/** Create a transactional working copy of a clipboard register. */ +export function forkClipboard(source?: Clipboard): Clipboard { + if (source === undefined) return {}; + return { + ...(source.lines === undefined ? {} : { lines: [...source.lines] }), + ...(source.named === undefined ? {} : { named: new Map(source.named) }), + ...(source.pendingAnonCuts === undefined ? {} : { pendingAnonCuts: [...source.pendingAnonCuts] }), + }; +} + +/** Publish a clipboard fork back to its source register (only named registers persist across batches). */ export function commitClipboard(fork: Clipboard, target: Clipboard): void { - if (fork.lines === undefined) delete target.lines; - else target.lines = fork.lines; + if (fork.named !== undefined) { + target.named ??= new Map(); + for (const [k, v] of fork.named) target.named.set(k, v); + } } /** @@ -99,12 +191,19 @@ export function commitClipboard(fork: Clipboard, target: Clipboard): void { * mutating the register or reading file content. */ export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void { - let hasLines = clipboard.lines !== undefined; + const fork = forkClipboard(clipboard); for (const edit of edits) { if (edit.kind === "cut") { - hasLines = true; - } else if (edit.kind === "paste" && !hasLines) { - throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`); + if (edit.register !== undefined) { + fork.named ??= new Map(); + fork.named.set(edit.register, []); + } else { + fork.lines = []; + fork.pendingAnonCuts ??= []; + fork.pendingAnonCuts.push(describeCutEdit(edit)); + } + } else if (edit.kind === "paste") { + readRegister(edit.register, fork, edit.lineNum, "throw"); } } } diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index f30de3106..6c77f590c 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -13,40 +13,31 @@ export const HL_FILE_SUFFIX = "]"; /** Payload sigil for literal body rows. */ export const HL_PAYLOAD_REPLACE = "+"; -/** Hunk-header keyword for concrete line replacement. */ -export const HL_REPLACE_KEYWORD = "SWAP"; -/** Hunk-header keyword for insertion operations. */ -export const HL_INSERT_KEYWORD = "INS"; -/** Insert position keyword for inserting before a concrete line. */ -export const HL_INSERT_BEFORE = "PRE"; -/** Insert position keyword for inserting after a concrete line. */ -export const HL_INSERT_AFTER = "POST"; -/** Insert position keyword for inserting at the start of the file. */ -export const HL_INSERT_HEAD = "HEAD"; -/** Insert position keyword for inserting at the end of the file. */ -export const HL_INSERT_TAIL = "TAIL"; -/** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */ -export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK"; -/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */ -export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST"; -/** Hunk-header keyword: `CUT N.=M` captures lines into the clipboard register and deletes them. */ +/** Hunk-header keyword: `PUT` writes content (body rows) or a register at a span or gap. */ +export const HL_PUT_KEYWORD = "PUT"; +/** Hunk-header keyword: `CUT N.=M` / `CUT N*` deletes lines and captures them (anonymous register, or `@name` when given). */ export const HL_CUT_KEYWORD = "CUT"; -/** Hunk-header keyword: `CUT.BLK N` captures the tree-sitter block at N and deletes its span. */ -export const HL_CUT_BLOCK_KEYWORD = "CUT.BLK"; -/** Hunk-header keyword prefix: `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` inserts the clipboard. */ -export const HL_PASTE_KEYWORD = "PASTE"; -/** Hunk-header keyword: `PASTE.BLK.POST N` inserts the clipboard after the tree-sitter block at N. */ -export const HL_PASTE_AFTER_BLOCK_KEYWORD = "PASTE.BLK.POST"; /** File-level keyword: `REM` deletes the whole file named by the section header. */ export const HL_REM_KEYWORD = "REM"; /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */ export const HL_MOVE_KEYWORD = "MV"; export const HL_HEADER_COLON = ":"; +/** Gap sigil: `N` targets the gap after line N (`>$` = tail). */ +export const HL_GAP_AFTER = ">"; +/** Locator suffix: `N*` extends the anchor to the syntactic block opening at N. */ +export const HL_BLOCK_SUFFIX = "*"; +/** Gap anchor: `$` names the last line, so `>$` is end-of-file. */ +export const HL_EOF_ANCHOR = "$"; +/** Register sigil: `@name` selects a named clipboard register on `PUT`/`CUT`. */ +export const HL_REGISTER_SIGIL = "@"; + /** Separator between a hashline file path and its opaque snapshot tag. */ export const HL_FILE_HASH_SEP = "#"; -/** Separator between two line numbers in a range, e.g. `5.=10`. */ +/** Canonical separator between inclusive range endpoints, e.g. `5.=10`. */ export const HL_RANGE_SEP = ".="; /** Separator between a line number and displayed line content in hashline mode. */ @@ -62,30 +53,40 @@ export const HL_LINE_RE_RAW = `[1-9]\\d*`; /** Capture-group form of {@link HL_LINE_RE_RAW}. */ export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`; -/** Format a concrete replacement hunk header. */ +/** Format a concrete replacement hunk header (`PUT 5.=9:`). */ export function formatReplaceHeader(start: number, end: number): string { - return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`; + return `${HL_PUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`; } -/** Format a concrete cut hunk header. */ +/** Format a concrete cut hunk header (`CUT 5.=9`). */ export function formatCutHeader(start: number, end = start): string { - return start === end ? `${HL_CUT_KEYWORD} ${start}` : `${HL_CUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}`; + return `${HL_CUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}`; } -/** Format an insertion hunk header for a cursor position. */ -export function formatInsertHeader(cursor: Cursor): string { +/** Format a gap locator for a cursor position (`<5`, `>5`, `<1`, `>$`). */ +export function formatGapLocator(cursor: Cursor): string { switch (cursor.kind) { case "before_anchor": - return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`; + return `${HL_GAP_BEFORE}${cursor.anchor.line}`; case "after_anchor": - return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`; + return `${HL_GAP_AFTER}${cursor.anchor.line}`; case "bof": - return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`; + return `${HL_GAP_BEFORE}1`; case "eof": - return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`; + return `${HL_GAP_AFTER}${HL_EOF_ANCHOR}`; } } +/** Format an insertion hunk header for a cursor position (`PUT <5:`, `PUT >$:`). */ +export function formatInsertHeader(cursor: Cursor): string { + return `${HL_PUT_KEYWORD} ${formatGapLocator(cursor)}${HL_HEADER_COLON}`; +} + +/** Format a register reference (`@name`). */ +export function formatRegister(name: string): string { + return `${HL_REGISTER_SIGIL}${name}`; +} + /** Number of hex characters in a content-derived file-hash tag. */ export const HL_FILE_HASH_LENGTH = 4; /** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */ diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index e66c2afa5..355d49c3d 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -7,20 +7,21 @@ file_header: "[" filename "#" file_hash "]" LF file_hash: /[0-9A-F]{4}/ filename: /[^#\r\n]+/ -hunk: swap_hunk | ins_hunk | cut_hunk | paste_hunk | rem_hunk | mv_hunk -swap_hunk: "SWAP " header_range ":" LF body* - | "SWAP.BLK " LID ":" LF body+ -ins_hunk: "INS." pos ":" LF body+ -cut_hunk: "CUT" target LF -paste_hunk: "PASTE." pos LF +hunk: put_hunk | cut_hunk | rem_hunk | mv_hunk +put_hunk: "PUT " put_locator register? ":" LF body* + | "PUT " put_locator register LF + | "PUT " gap_locator LF +cut_hunk: "CUT " cut_locator register? LF rem_hunk: "REM" LF mv_hunk: "MV " filename LF body* -target: " " header_range | ".BLK " LID -pos: "PRE " LID | "POST " LID | "BLK.POST " LID | "HEAD" | "TAIL" +put_locator: range | LID "*" | gap_locator +cut_locator: range | LID "*" +gap_locator: "<" LID | ">" LID | ">" LID "*" | ">$" +register: " @" /[A-Za-z0-9_-]+/ + +range: LID ".=" LID +LID: /[1-9]\d*/ body: "+" /(.*)/ LF -header_range: LID ".=" LID -LID: /[1-9]\d*/ - %import common.LF diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index fc918f113..7d722effd 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -305,11 +305,11 @@ export class PatchSection { */ get hasAnchorScopedEdit(): boolean { return this.edits.some(edit => { - if (edit.kind === "delete") return true; - // A `replace_block N:` edit is anchored to concrete content on line N. - if (edit.kind === "block") return true; - // A `CUT` range reads concrete content. - if (edit.kind === "cut") return true; + if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true; + if (edit.kind === "paste") { + if (edit.at.kind === "span") return true; + return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor"; + } return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); } @@ -318,11 +318,7 @@ export class PatchSection { collectAnchorLines(): readonly number[] { const lines = new Set(); for (const edit of this.edits) { - if (edit.kind === "delete") { - lines.add(edit.anchor.line); - continue; - } - if (edit.kind === "block") { + if (edit.kind === "delete" || edit.kind === "block") { lines.add(edit.anchor.line); continue; } @@ -330,6 +326,14 @@ export class PatchSection { for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line); continue; } + if (edit.kind === "paste") { + if (edit.at.kind === "span") { + for (let line = edit.at.range.start.line; line <= edit.at.range.end.line; line++) lines.add(line); + } else if (edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor") { + lines.add(edit.at.cursor.anchor.line); + } + continue; + } if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") { lines.add(edit.cursor.anchor.line); } diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 673a7d7fa..3b2b7f2e1 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -33,16 +33,34 @@ export function formatAnchoredContext(anchorLines: readonly number[], fileLines: /** Concrete range operation rejected because its absolute end precedes its start. */ export type AbsoluteRangeOp = "replace" | "cut"; -/** Header forms per concrete-range op, used to compose retry suggestions. */ -const RANGE_OP_FORMS: Record = { - replace: { keyword: "SWAP", colon: ":", blockKeyword: "SWAP.BLK" }, - cut: { keyword: "CUT", colon: "", blockKeyword: "CUT.BLK" }, +/** Format a register suffix (` @name` or empty). */ +function regSuffix(register?: string): string { + return register ? ` @${register}` : ""; +} + +/** Header composers per concrete-range op, used in retry suggestions. */ +const RANGE_OP_FORMS: Record< + AbsoluteRangeOp, + { single: (line: number, reg?: string) => string; range: (start: number, end: number, reg?: string) => string } +> = { + replace: { + single: (line, reg) => (reg ? `PUT ${line}${regSuffix(reg)}` : `PUT ${line}:`), + range: (start, end, reg) => + reg ? `PUT ${start}${HL_RANGE_SEP}${end}${regSuffix(reg)}` : `PUT ${start}${HL_RANGE_SEP}${end}:`, + }, + cut: { + single: (line, reg) => `CUT ${line}${regSuffix(reg)}`, + range: (start, end, reg) => `CUT ${start}${HL_RANGE_SEP}${end}${regSuffix(reg)}`, + }, }; -/** `OP.BLK N`-style header for a concrete-range op (`SWAP.BLK 5:` / `CUT.BLK 5`). */ -function blockFormAt(op: AbsoluteRangeOp, line: number): string { - const forms = RANGE_OP_FORMS[op]; - return `${forms.blockKeyword} ${line}${forms.colon}`; +/** Block-locator header for a concrete-range op (`PUT 5*:` / `CUT 5*`). */ +function blockFormAt(op: AbsoluteRangeOp, line: number, reg?: string): string { + return op === "replace" + ? reg + ? `PUT ${line}*${regSuffix(reg)}` + : `PUT ${line}*:` + : `CUT ${line}*${regSuffix(reg)}`; } /** Explain absolute range endpoints and provide safe, non-applying retry forms. */ @@ -52,15 +70,14 @@ export function invalidAbsoluteRangeMessage( end: number, op: AbsoluteRangeOp, block?: BlockSpan, + register?: string, ): string { const forms = RANGE_OP_FORMS[op]; - const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `${forms.keyword} ${start}`; + const single = forms.single(start, register); const countedEnd = start + end - 1; const counted = - Number.isSafeInteger(countedEnd) && countedEnd >= start - ? `${forms.keyword} ${start}${HL_RANGE_SEP}${countedEnd}${forms.colon}` - : null; - const blockForm = blockFormAt(op, start); + Number.isSafeInteger(countedEnd) && countedEnd >= start ? forms.range(start, countedEnd, register) : null; + const blockForm = blockFormAt(op, start, register); let message = `line ${patchLine}: Invalid absolute range: start ${start}, end ${end}. ` + `The value after \`${HL_RANGE_SEP}\` is an absolute source line, not a line count or replacement length. ` + @@ -87,34 +104,49 @@ export const END_PATCH_MARKER = "*** End Patch"; */ export const ABORT_MARKER = "*** Abort"; -/** Two consecutive hunks targeted the exact same concrete range. */ -export const REPLACE_PAIR_COALESCED_WARNING = `Two hunks targeted the same range; kept only the second. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`; +/** Exact-range duplicate hunks were normalized to the final hunk. */ +export const REPLACE_PAIR_COALESCED_WARNING = + "Multiple hunks targeted the same exact range; kept only the last. Issue one `PUT` or `CUT` hunk per range."; -/** Bare bodyless hunk followed by an overlapping concrete hunk. */ -`Dropped a bare hunk overlapped by the concrete hunk after it. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`; +/** Replacement body indentation was aligned from unchanged structural rows. */ +export const REPLACEMENT_INDENT_AUTO_SHIFT_WARNING = + "Auto-indented a replacement body to match unchanged structural rows in its source range."; /** Bare body rows auto-converted to literal `+` rows. */ export const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines."; +/** Top-level read-output rows recovered as single-line replacements. */ +export const SNAPSHOT_ROWS_AUTO_PUT_WARNING = `Recovered top-level \`N:TEXT\` snapshot row(s) as single-line \`PUT N${HL_RANGE_SEP}N:\` replacements. Use explicit \`PUT\` headers for reliable edits.`; +/** Bare range header recovered as an implicit replacement hunk. */ +export const BARE_RANGE_AUTO_PUT_WARNING = `Recovered a bare \`N${HL_RANGE_SEP}M:\` header as \`PUT N${HL_RANGE_SEP}M:\`. Prefix replacement ranges with \`PUT\`.`; + +/** Copied read-output elision rows were ignored rather than written as source. */ +export const READ_METADATA_IGNORED_WARNING = + "Ignored copied read-output elision row(s). Re-read elided ranges before editing them."; + +/** Empty span/block PUT recovered as a delete-only edit. */ +export const EMPTY_PUT_AUTO_CUT_WARNING = `Interpreted an empty \`PUT\` body as deletion. Use \`CUT N${HL_RANGE_SEP}M\` or \`CUT N*\` for bodyless deletes.`; + +/** A bodyless CUT carried a harmless trailing colon. */ +export const CUT_COLON_IGNORED_WARNING = `Ignored a trailing \`:\` on bodyless \`CUT\`. Prefer \`CUT N${HL_RANGE_SEP}M\` / \`CUT N*\` without a colon.`; + /** * Bare `-` body rows accepted as literal Markdown bullets. Only emitted when * the hunk is unambiguously a bullet list: every `-` row is bullet-shaped + * (`- item`) and the body has no unified-diff `+new` counterpart rows. */ export const MINUS_BULLET_AUTO_PIPED_WARNING = "Auto-prefixed bare `- ` bullet row(s) as literal content. `-` rows never remove lines — the range does that; always prefix literal body rows with `+`: `+- item`."; +/** Unified-diff old rows were discarded; explicit `+` rows are final content. */ +export const DIFF_OLD_ROWS_IGNORED_WARNING = + "Ignored unified-diff `-old` row(s); the range already removes old content, so only `+new` rows were kept."; /** Unified-diff-style `-` row in a hunk body. */ export 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`."; -/** Replace hunk with no body. */ -export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`CUT N${HL_RANGE_SEP}M\`.`; - -/** `SWAP.BLK N:` hunk with no body. */ -export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `CUT.BLK N`."; - /** Optional source-aware suggestions appended to block-anchor diagnostics. */ export interface BlockDiagnosticSuggestions { /** Closest following multi-line block that begins after the authored anchor. */ @@ -126,22 +158,29 @@ export interface BlockDiagnosticSuggestions { /** * A block-anchored replace/cut could not resolve to a syntactic block. * Appends a {@link formatAnchoredContext} preview when `fileLines` is given. - * `INS.BLK.POST N:` never reaches this path; it lowers to `INS.POST N:`. + * `PUT >N*` never reaches this path; it lowers to `PUT >N`. */ export function blockUnresolvedMessage( line: number, op: AbsoluteRangeOp = "replace", fileLines?: readonly string[], suggestions: BlockDiagnosticSuggestions = {}, + register?: string, ): string { - const forms = RANGE_OP_FORMS[op]; - const phrase = blockFormAt(op, line); - const fallback = `${forms.keyword} ${line}${HL_RANGE_SEP}M${forms.colon}`; + const phrase = blockFormAt(op, line, register); + const fallback = + op === "replace" + ? register + ? `PUT ${line}${HL_RANGE_SEP}M @${register}` + : `PUT ${line}${HL_RANGE_SEP}M:` + : register + ? `CUT ${line}${HL_RANGE_SEP}M @${register}` + : `CUT ${line}${HL_RANGE_SEP}M`; const anchorText = fileLines?.[line - 1]; const nextBlock = suggestions.nextBlock; let message: string; if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) { - const retry = blockFormAt(op, nextBlock.start); + const retry = blockFormAt(op, nextBlock.start, register); message = `Line ${line} is blank; no syntactic block can begin there. ` + `The next multi-line block begins at line ${nextBlock.start} and ends at line ${nextBlock.end}. ` + @@ -153,7 +192,7 @@ export function blockUnresolvedMessage( } const enclosingBlock = suggestions.enclosingBlock; if (enclosingBlock) { - const retry = blockFormAt(op, enclosingBlock.start); + const retry = blockFormAt(op, enclosingBlock.start, register); message += ` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` + `and ends at line ${enclosingBlock.end}; use \`${retry}\` to target it.`; @@ -167,7 +206,7 @@ export function blockUnresolvedMessage( /** Block-anchored edit reached a path with no {@link BlockResolver} wired in. */ export 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."; + "Block locators (`N*` in `PUT N*:`, `PUT >N*`, `CUT N*`) are not available here (no block resolver configured). Use a concrete line range."; /** * An after-block op anchored on a closing-delimiter line, lowered to its @@ -187,24 +226,24 @@ function unresolvedLoweredWarning(blockForm: string, line: number, plainForm: st return `\`${blockForm}\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`${plainForm}\`. Verify the landing line; anchor on a line that OPENS a construct.`; } -/** `INS.BLK.POST N:` anchored on a closing-delimiter line; applied as `INS.POST N:`. */ +/** `PUT >N*:` anchored on a closing-delimiter line; applied as `PUT >N:`. */ export function insertAfterBlockCloserLoweredWarning(line: number): string { - return closerLoweredWarning(`INS.BLK.POST ${line}:`, `INS.POST ${line}:`); + return closerLoweredWarning(`PUT >${line}*:`, `PUT >${line}:`); } -/** `INS.BLK.POST N:` anchor unresolvable; applied as `INS.POST N:`. */ +/** `PUT >N*:` anchor unresolvable; applied as `PUT >N:`. */ export function insertAfterBlockUnresolvedLoweredWarning(line: number): string { - return unresolvedLoweredWarning(`INS.BLK.POST ${line}:`, line, `INS.POST ${line}:`); + return unresolvedLoweredWarning(`PUT >${line}*:`, line, `PUT >${line}:`); } -/** `PASTE.BLK.POST N` anchored on a closing-delimiter line; applied as `PASTE.POST N`. */ +/** Register `PUT >N*` anchored on a closing-delimiter line; applied as `PUT >N`. */ export function pasteAfterBlockCloserLoweredWarning(line: number): string { - return closerLoweredWarning(`PASTE.BLK.POST ${line}`, `PASTE.POST ${line}`); + return closerLoweredWarning(`PUT >${line}*`, `PUT >${line}`); } -/** `PASTE.BLK.POST N` anchor unresolvable; applied as `PASTE.POST N`. */ +/** Register `PUT >N*` anchor unresolvable; applied as `PUT >N`. */ export function pasteAfterBlockUnresolvedLoweredWarning(line: number): string { - return unresolvedLoweredWarning(`PASTE.BLK.POST ${line}`, line, `PASTE.POST ${line}`); + return unresolvedLoweredWarning(`PUT >${line}*`, line, `PUT >${line}`); } /** * A one-sided boundary echo whose payload is too short to be the widened @@ -224,7 +263,7 @@ export function ambiguousBoundaryEchoMessage( ? `opens by restating the ${count} line(s) just above the range` : `ends by restating the ${count} line(s) just below the range`; return ( - `\`SWAP ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the body ${where}, ` + + `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the body ${where}, ` + `but is too short to be the full final content of the widened range — applying it as-is or ` + `auto-repairing would delete range line(s) the body never restates. ` + `Re-issue with the range covering exactly the lines that change and the body as their complete ` + @@ -248,11 +287,11 @@ export function ambiguousCloserSpareMessage( ): string { const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`; return ( - `\`SWAP ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range deletes the closing-delimiter ` + + `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range deletes the closing-delimiter ` + `${closers} but the body never restates it, and the body claims no position inside that block ` + `(no unmatched opener, indentation not deeper than the closer) — whether the new content belongs ` + `before or after the closer is ambiguous. Restate the closer in the body at the intended position, ` + - `or use \`INS.PRE ${closerLine}:\` / \`INS.POST ${closerLine}:\` instead.` + `or use \`PUT <${closerLine}:\` / \`PUT >${closerLine}:\` instead.` ); } @@ -271,15 +310,40 @@ export const REM_TAKES_NO_BODY = export 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."; -/** `CUT N.=M` hunk received a body row. */ -export const CUT_TAKES_NO_BODY = `\`CUT N${HL_RANGE_SEP}M\` captures + deletes lines and takes no body rows. To replace lines with new content, use \`SWAP N${HL_RANGE_SEP}M:\`.`; +/** `CUT` hunk received a body row. */ +export const CUT_TAKES_NO_BODY = `\`CUT\` deletes (and captures) the named lines and takes no body rows. To write new content, use \`PUT N${HL_RANGE_SEP}M:\` with \`+TEXT\` rows.`; -/** `PASTE` hunk received a body row. */ -export const PASTE_TAKES_NO_BODY = - "`PASTE` inserts the clipboard content and takes no `+` body rows. To insert literal text, use `INS`."; +/** Register `PUT` header carried a `:`. */ +export const COLON_ON_REGISTER_PUT = + "`PUT … @name` pastes the register and never takes `:` — the colon promises body rows. Drop the colon (`PUT >40 @name`), or drop `@name` and write `+TEXT` body rows."; -/** `PASTE` ran with an empty clipboard register. */ -export const EMPTY_PASTE = `\`PASTE\` found nothing in the clipboard. Ops run top-to-bottom across the whole patch (sections included): put \`CUT N${HL_RANGE_SEP}M\` or \`CUT.BLK N\` above the \`PASTE\`.`; +/** Register `PUT` hunk received a body row. */ +export 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 …:` with body rows."; + +/** Colonless `PUT` hunk received a body row. */ +export 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)."; + +/** Colonless anonymous `PUT` on a span target. */ +export const COLONLESS_SPAN_PUT = `Colonless \`PUT\` is clipboard-backed, and span targets need a named register (\`PUT 5${HL_RANGE_SEP}9 @name\`); the anonymous register pastes only at gaps (\`PUT >40\`). To write literal content, add \`:\` and \`+TEXT\` body rows.`; + +/** 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.`; + return known.length === 0 ? base : `${base} Available registers: ${known.map(k => `\`@${k}\``).join(", ")}.`; +} + +/** Unlabeled paste with two or more unlabeled cuts pending. */ +export function ambiguousAnonymousPasteMessage(pending: readonly string[]): string { + return ( + `${pending.length} unlabeled \`CUT\`s are pending (${pending.join(", ")}) — an unlabeled paste cannot tell which one you meant. ` + + "Label the moves (`CUT … @name` → `PUT … @name`), or keep at most one unlabeled `CUT` before each unlabeled paste." + ); +} /** * Clipboard ops inside a same-path section that was merged across another @@ -287,10 +351,11 @@ export const EMPTY_PASTE = `\`PASTE\` found nothing in the clipboard. Ops run to * an interleaved layout would silently reorder the register sequence. */ export 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."; + "`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."; -/** Insert hunk with no body. */ -export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."; +/** Gap `PUT` with `:` but no body. */ +export const EMPTY_INSERT = + "`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)."; /** * `insert after` body indented shallower than the anchor: the landing slid @@ -298,16 +363,16 @@ export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."; * I read instead of after the block" mistake. */ export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string { - return `INS.POST ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`; + return `PUT >${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`; } /** - * `insert_after_block N:` body indented deeper than the block's closer: the - * landing was pulled inside the block — a deeper body almost always means - * "append inside the block's body". + * `PUT >N*:` body indented deeper than the block's closer: the landing was + * pulled inside the block — a deeper body almost always means "append inside + * the block's body". */ export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string { - return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`; + return `PUT >${blockStart}*: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`PUT >N*\` lands AFTER the block at sibling depth — if inside was intended, use plain \`PUT >${closerLine}:\`.`; } /** `Recovery`: an external write matched a cached snapshot. */ @@ -328,7 +393,7 @@ export const RECOVERY_LINE_REMAP_WARNING = * onto live content and warn instead of hard-failing. */ export const HEADTAIL_DRIFT_WARNING = - "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected."; + "Applied the `PUT <1:`/`PUT >$:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected."; /** * The `Filesystem` reported that what actually landed on disk differs from @@ -457,11 +522,11 @@ export function unseenLinesMessage( export type BlockOp = "replace" | "insert_after" | "cut" | "paste_after"; /** Display forms per deferred-block op: block keyword, trailing colon, and single-line plain form. */ -const BLOCK_OP_FORMS: Record string }> = { - replace: { keyword: "SWAP.BLK", colon: ":", plain: line => `SWAP ${line}${HL_RANGE_SEP}${line}:` }, - insert_after: { keyword: "INS.BLK.POST", colon: ":", plain: line => `INS.POST ${line}:` }, - cut: { keyword: "CUT.BLK", colon: "", plain: line => `CUT ${line}` }, - paste_after: { keyword: "PASTE.BLK.POST", colon: "", plain: line => `PASTE.POST ${line}` }, +const BLOCK_OP_FORMS: Record string; plain: (line: number) => string }> = { + replace: { form: line => `PUT ${line}*:`, plain: line => `PUT ${line}:` }, + insert_after: { form: line => `PUT >${line}*:`, plain: line => `PUT >${line}:` }, + cut: { form: line => `CUT ${line}*`, plain: line => `CUT ${line}` }, + paste_after: { form: line => `PUT >${line}*`, plain: line => `PUT >${line}` }, }; /** @@ -473,13 +538,12 @@ export function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock const forms = BLOCK_OP_FORMS[op]; const plainForm = forms.plain(line); let message = - `\`${forms.keyword} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` + + `\`${forms.form(line)}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` + `of a multi-line construct. For only this statement use \`${plainForm}\`.`; if (enclosingBlock) { - const enclosingForm = `${forms.keyword} ${enclosingBlock.start}${forms.colon}`; message += ` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` + - `and ends at line ${enclosingBlock.end}; use \`${enclosingForm}\` to target it.`; + `and ends at line ${enclosingBlock.end}; use \`${forms.form(enclosingBlock.start)}\` to target it.`; } return message; } diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index b6d77a6b6..6e88f5f60 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -7,19 +7,28 @@ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format"; import { type AbsoluteRangeOp, BARE_BODY_AUTO_PIPED_WARNING, + BARE_RANGE_AUTO_PUT_WARNING, + COLON_ON_REGISTER_PUT, + COLONLESS_PUT_TAKES_NO_BODY, + COLONLESS_SPAN_PUT, + CUT_COLON_IGNORED_WARNING, CUT_TAKES_NO_BODY, - EMPTY_BLOCK, + DIFF_OLD_ROWS_IGNORED_WARNING, EMPTY_INSERT, + EMPTY_PUT_AUTO_CUT_WARNING, invalidAbsoluteRangeMessage, MINUS_BULLET_AUTO_PIPED_WARNING, MINUS_ROW_REJECTED, MOVE_TAKES_NO_BODY, - PASTE_TAKES_NO_BODY, + READ_METADATA_IGNORED_WARNING, + REGISTER_PUT_TAKES_NO_BODY, REM_TAKES_NO_BODY, + REPLACE_PAIR_COALESCED_WARNING, + SNAPSHOT_ROWS_AUTO_PUT_WARNING, } from "./messages"; -import { stripOneLeadingHashlinePrefix } from "./prefixes"; +import { isReadMetadataLine, stripOneLeadingHashlinePrefix } from "./prefixes"; import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer"; -import type { Anchor, BlockSpan, Cursor, Edit, FileOp } from "./types"; +import type { Anchor, BlockSpan, Cursor, Edit, FileOp, PasteTarget } from "./types"; /** Bounds parser amplification before the target file's line count is available. */ const MAX_EXPANDED_RANGE_LINES = 100_000; @@ -33,23 +42,32 @@ export class InvalidAbsoluteRangeError extends Error { readonly endLine: number; /** Operation whose range was invalid. */ readonly op: AbsoluteRangeOp; + readonly register?: string; - constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan) { - super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block)); + constructor( + patchLine: number, + startLine: number, + endLine: number, + op: AbsoluteRangeOp, + block?: BlockSpan, + register?: string, + ) { + super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block, register)); this.name = "InvalidAbsoluteRangeError"; this.patchLine = patchLine; this.startLine = startLine; this.endLine = endLine; this.op = op; + this.register = register; } /** Rebuild this error with a proven syntactic-block endpoint suggestion. */ withBlock(block: BlockSpan): InvalidAbsoluteRangeError { - return new InvalidAbsoluteRangeError(this.patchLine, this.startLine, this.endLine, this.op, block); + return new InvalidAbsoluteRangeError(this.patchLine, this.startLine, this.endLine, this.op, block, this.register); } } -function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void { +function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp, register?: string): void { if ( !Number.isSafeInteger(range.start.line) || range.start.line < 1 || @@ -61,7 +79,7 @@ function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp) ); } if (range.end.line < range.start.line) { - throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op); + throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op, undefined, register); } const span = range.end.line - range.start.line + 1; if (span > MAX_EXPANDED_RANGE_LINES) { @@ -76,20 +94,15 @@ function isSkippableCommentLine(line: string): boolean { } /** - * Body-row rejection message for targets that take no `+TEXT` rows, or `null` - * for targets whose header is followed by a body. + * Body-row rejection message for ops that take no `+TEXT` rows, or `null` + * for ops whose header (`:`) promises a body. */ -function bodylessTargetMessage(target: BlockTarget): string | null { - switch (target.kind) { - case "cut": - case "cut_block": - return CUT_TAKES_NO_BODY; - case "paste": - case "paste_after_block": - return PASTE_TAKES_NO_BODY; - default: - return null; - } +function bodylessTargetMessage(target: BlockTarget, hadColon: boolean): string | null { + if (target.kind === "cut" || target.kind === "cut_block") return CUT_TAKES_NO_BODY; + if (target.kind === "rem" || target.kind === "move") return null; + if (target.register !== undefined) return REGISTER_PUT_TAKES_NO_BODY; + if (!hadColon) return COLONLESS_PUT_TAKES_NO_BODY; + return null; } /** @@ -99,6 +112,26 @@ function bodylessTargetMessage(target: BlockTarget): string | null { */ const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/; +const TOP_LEVEL_SNAPSHOT_ROW_RE = /^\s*([1-9]\d*):(.*)$/; + +function parseTopLevelSnapshotRow(text: string): { line: number; text: string } | null { + const match = TOP_LEVEL_SNAPSHOT_ROW_RE.exec(text); + if (match === null) return null; + const line = Number(match[1]); + if (!Number.isSafeInteger(line)) return null; + return { line, text: match[2] }; +} +const TOP_LEVEL_BARE_RANGE_HEADER_RE = /^\s*([1-9]\d*)(?:\s|[-.=…])+([1-9]\d*)\s*:\s*$/; + +function parseTopLevelBareRangeHeader(text: string): ParsedRange | null { + const match = TOP_LEVEL_BARE_RANGE_HEADER_RE.exec(text); + if (match === null) return null; + const start = Number(match[1]); + const end = Number(match[2]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return null; + return { start: { line: start }, end: { line: end } }; +} + /** * Markdown-bullet shape: optional indent, `-`, exactly one space, then * content. Unified-diff `-` rows almost never match — code lines get the `-` @@ -119,36 +152,30 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri return ( `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` + "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " + - `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` + `Use \`PUT N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`PUT N:\` ops.` ); } if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) { return ( "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " + - `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.` + `Use \`PUT N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`PUT N:\` ops.` ); } if (trimmed.startsWith("@@")) { const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed; return ( `\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` + - `Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.` + `Drop the \`@@ ... @@\` brackets and write a header such as \`PUT N${HL_RANGE_SEP}M:\`.` ); } - // Bare `PASTE` (optionally `PASTE 5` / `PASTE:`) — the op requires an - // explicit position suffix; a bare form would otherwise surface as a - // confusing body-row rejection under the preceding hunk. - if (/^PASTE(?:\s+[1-9]\d*)?\s*:?\s*$/.test(trimmed)) { - return "`PASTE` needs a position: use `PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N`."; - } if (/^[1-9]\d*\s*$/.test(trimmed)) { - return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`CUT ${trimmed}\` to delete.`; + return `hunk headers need a verb and both endpoints. Use \`PUT ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`CUT ${trimmed}${HL_RANGE_SEP}${trimmed}\` to delete.`; } - const bareRange = /^([1-9]\d*)\s*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed); + const bareRange = /^([1-9]\d*)\s+(?:[1-9]\d*)\s*:?$/.exec(trimmed); if (bareRange !== null) { return ( `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` + - `Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`CUT ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.` + `Hunk headers need a verb: use \`PUT N${HL_RANGE_SEP}M:\` or \`CUT N${HL_RANGE_SEP}M\`.` ); } return null; @@ -165,6 +192,8 @@ interface Pending { target: BlockTarget; lineNum: number; payloads: PayloadRow[]; + /** Whether the header carried `:` — the promise that body rows follow. */ + hadColon: boolean; /** * Blank rows seen after the body started. Interior blanks are committed to * the payload when the next non-blank row arrives; trailing blanks before @@ -225,27 +254,45 @@ export class Executor { this.#consumePendingSkippableComments(); this.#handleRaw(token.text, token.lineNum); return; - case "op-block": + case "op-block": { this.#discardPendingSkippableComments(); - if (token.target.kind === "replace") { - validateRange(token.target.range, token.lineNum, "replace"); + const target = token.target; + if (target.kind === "replace") { + validateRange(target.range, token.lineNum, "replace", target.register); } - if (token.target.kind === "cut") { - validateRange(token.target.range, token.lineNum, "cut"); + if (target.kind === "cut") { + validateRange(target.range, token.lineNum, "cut", target.register); } - if (token.target.kind === "rem") { + // `:` exclusively promises body rows; ops that never take a body + // reject it outright so the sigil keeps one meaning. + if (token.hadColon && (target.kind === "cut" || target.kind === "cut_block")) { + if (!this.#warnings.includes(CUT_COLON_IGNORED_WARNING)) { + this.#warnings.push(CUT_COLON_IGNORED_WARNING); + } + } + if (token.hadColon && target.kind !== "rem" && target.kind !== "move" && target.register !== undefined) { + throw new Error(`line ${token.lineNum}: ${COLON_ON_REGISTER_PUT}`); + } + if (target.kind === "rem") { this.#flushPending(); this.#setFileOp({ kind: "rem" }, token.lineNum); return; } - if (token.target.kind === "move") { + if (target.kind === "move") { this.#flushPending(); - this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum); + this.#setFileOp({ kind: "move", dest: target.dest }, token.lineNum); return; } this.#flushPending(); - this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] }; + this.#pending = { + target, + lineNum: token.lineNum, + payloads: [], + hadColon: token.hadColon, + deferredBlanks: [], + }; return; + } } } @@ -253,7 +300,7 @@ export class Executor { this.#consumePendingSkippableComments(); this.#flushPending(); this.#validateFileOp(); - this.#validateNoOverlappingDeletes(); + this.#normalizeOverlappingRanges(); return { edits: this.#edits, ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }), @@ -263,11 +310,11 @@ export class Executor { endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { this.#consumePendingSkippableComments(); - if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending(); - else if (this.#pending && bodylessTargetMessage(this.#pending.target) !== null) this.#flushPending(); + const pending = this.#pending; + if (pending && (pending.payloads.length > 0 || this.#isCompleteBodylessOp(pending))) this.#flushPending(); else this.#pending = undefined; this.#validateFileOp(); - this.#validateNoOverlappingDeletes(); + this.#normalizeOverlappingRanges(); return { edits: this.#edits, ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }), @@ -275,6 +322,27 @@ export class Executor { }; } + /** + * True when a payload-less pending op is already a complete, valid op — + * safe to flush at the end of a streaming parse. A `:`-op still awaiting + * body rows and the invalid colonless-span shape (possibly a truncated + * `PUT 5-9 @reg` line) are dropped instead. + */ + #isCompleteBodylessOp(pending: Pending): boolean { + const { target, hadColon } = pending; + if (target.kind === "cut" || target.kind === "cut_block") return true; + if (target.kind === "rem" || target.kind === "move") return false; + if (target.register !== undefined) return true; + if (hadColon) return false; + return ( + target.kind === "insert_before" || + target.kind === "insert_after" || + target.kind === "insert_after_block" || + target.kind === "bof" || + target.kind === "eof" + ); + } + reset(): void { this.#edits = []; this.#warnings = []; @@ -304,25 +372,78 @@ export class Executor { } } - #validateNoOverlappingDeletes(): void { - const sourceLinesByAnchor = new Map(); - for (const edit of this.#edits) { - if (edit.kind !== "delete") continue; - let sourceLines = sourceLinesByAnchor.get(edit.anchor.line); - if (sourceLines === undefined) { - sourceLines = []; - sourceLinesByAnchor.set(edit.anchor.line, sourceLines); + #normalizeOverlappingRanges(): void { + type ConcreteHunk = { + lineNum: number; + sourceLines: Set; + clipboardDependent: boolean; + }; + const hunks = new Map(); + const hunkFor = (lineNum: number): ConcreteHunk => { + let hunk = hunks.get(lineNum); + if (hunk === undefined) { + hunk = { lineNum, sourceLines: new Set(), clipboardDependent: false }; + hunks.set(lineNum, hunk); } - if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum); + return hunk; + }; + for (const edit of this.#edits) { + if (edit.kind === "cut") { + hunkFor(edit.lineNum).clipboardDependent = true; + continue; + } + if (edit.kind === "paste" && edit.at.kind === "span") { + const hunk = hunkFor(edit.lineNum); + hunk.clipboardDependent = true; + for (let line = edit.at.range.start.line; line <= edit.at.range.end.line; line++) { + hunk.sourceLines.add(line); + } + continue; + } + if (edit.kind === "delete") hunkFor(edit.lineNum).sourceLines.add(edit.anchor.line); } - for (const [anchorLine, sourceLines] of sourceLinesByAnchor) { - if (sourceLines.length < 2) continue; - const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b); + + const ownerByLine = new Map(); + const dropped = new Set(); + const claim = (hunk: ConcreteHunk): void => { + for (const line of hunk.sourceLines) ownerByLine.set(line, hunk); + }; + for (const hunk of hunks.values()) { + if (hunk.sourceLines.size === 0) continue; + const overlaps = new Set(); + let firstOverlap: number | undefined; + for (const line of hunk.sourceLines) { + const owner = ownerByLine.get(line); + if (owner === undefined) continue; + overlaps.add(owner); + firstOverlap ??= line; + } + if (overlaps.size === 0) { + claim(hunk); + continue; + } + const previous = overlaps.size === 1 ? overlaps.values().next().value : undefined; + const exact = + previous !== undefined && + previous.sourceLines.size === hunk.sourceLines.size && + [...hunk.sourceLines].every(line => previous.sourceLines.has(line)); + if (exact && !previous.clipboardDependent) { + dropped.add(previous.lineNum); + for (const line of previous.sourceLines) { + if (ownerByLine.get(line) === previous) ownerByLine.delete(line); + } + claim(hunk); + if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) { + this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING); + } + continue; + } throw new Error( - `line ${secondBlock}: anchor line ${anchorLine} is already targeted by another hunk on line ${firstBlock}. ` + + `line ${hunk.lineNum}: anchor line ${firstOverlap} is already targeted by another hunk on line ${previous?.lineNum ?? "an earlier line"}. ` + "Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.", ); } + if (dropped.size > 0) this.#edits = this.#edits.filter(edit => !dropped.has(edit.lineNum)); } #handleLiteralPayload(text: string, lineNum: number): void { @@ -334,13 +455,19 @@ export class Executor { `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`, ); } - const noBodyOnLiteral = bodylessTargetMessage(pending.target); + const noBodyOnLiteral = bodylessTargetMessage(pending.target, pending.hadColon); if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`); this.#commitDeferredBlanks(pending); pending.payloads.push({ kind: "literal", text, lineNum }); } #handleRaw(text: string, lineNum: number): void { + if (this.#pending === undefined && isReadMetadataLine(text)) { + if (!this.#warnings.includes(READ_METADATA_IGNORED_WARNING)) { + this.#warnings.push(READ_METADATA_IGNORED_WARNING); + } + return; + } const contamination = detectApplyPatchContamination(text, this.#pending !== undefined); if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`); if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`); @@ -349,7 +476,7 @@ export class Executor { this.#handleBlank(text, lineNum); return; } - const noBodyOnRaw = bodylessTargetMessage(this.#pending.target); + const noBodyOnRaw = bodylessTargetMessage(this.#pending.target, this.#pending.hadColon); if (noBodyOnRaw !== null) throw new Error(`line ${lineNum}: ${noBodyOnRaw}`); const row: PayloadRow = { kind: "literal", text, lineNum, bare: true }; // `-` rows are held and judged at flush time by #resolveMinusRows, @@ -369,9 +496,40 @@ export class Executor { return; } if (text.trim().length === 0) return; + const bareRange = parseTopLevelBareRangeHeader(text); + if (bareRange !== null) { + validateRange(bareRange, lineNum, "replace"); + this.#pending = { + target: { kind: "replace", range: bareRange }, + lineNum, + payloads: [], + hadColon: true, + deferredBlanks: [], + }; + if (!this.#warnings.includes(BARE_RANGE_AUTO_PUT_WARNING)) { + this.#warnings.push(BARE_RANGE_AUTO_PUT_WARNING); + } + return; + } + const snapshotRow = parseTopLevelSnapshotRow(text); + if (snapshotRow !== null) { + const range = { start: { line: snapshotRow.line }, end: { line: snapshotRow.line } }; + validateRange(range, lineNum, "replace"); + this.#pushInsert( + { kind: "before_anchor", anchor: { line: snapshotRow.line } }, + snapshotRow.text, + lineNum, + "replacement", + ); + this.#pushDeleteRange(range, lineNum); + if (!this.#warnings.includes(SNAPSHOT_ROWS_AUTO_PUT_WARNING)) { + this.#warnings.push(SNAPSHOT_ROWS_AUTO_PUT_WARNING); + } + return; + } throw new Error( `line ${lineNum}: payload line has no preceding hunk header. ` + - `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`, + `Use \`PUT N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`PUT N:\` above the body. Got ${JSON.stringify(text)}.`, ); } @@ -385,7 +543,7 @@ export class Executor { #handleBlank(text: string, lineNum: number): void { const pending = this.#pending; if (!pending) return; - if (bodylessTargetMessage(pending.target) !== null) return; + if (bodylessTargetMessage(pending.target, pending.hadColon) !== null) return; if (pending.payloads.length === 0) return; pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true }); } @@ -398,15 +556,13 @@ export class Executor { } /** - * Judge bare `-` body rows once the whole hunk body is known. They are - * usually unified-diff contamination (`-old` next to `+new`) and inserting - * them would corrupt the file, so they are rejected — EXCEPT when the body - * is unambiguously a Markdown bullet list: every `-` row is bullet-shaped - * (`- item`) and the body is either fully bare or already contains an - * explicit `+- item` sibling. Those rows are kept as literal content with a - * warning instead of failing the patch. + * Judge bare `-` body rows once the whole hunk body is known. Non-bullet + * rows paired with explicit `+new` rows are unified-diff contamination, so + * discard the redundant old rows. Unambiguously literal Markdown bullets + * are kept. Other `-` rows remain rejected rather than silently corrupting + * source. */ - #resolveMinusRows(payloads: readonly PayloadRow[]): void { + #resolveMinusRows(payloads: PayloadRow[]): void { let firstMinus: PayloadRow | undefined; let allBulletShaped = true; let hasExplicit = false; @@ -426,6 +582,15 @@ export class Executor { this.#warnings.push(MINUS_BULLET_AUTO_PIPED_WARNING); return; } + if (hasExplicit && !allBulletShaped) { + for (let i = payloads.length - 1; i >= 0; i--) { + if (payloads[i].minus) payloads.splice(i, 1); + } + if (!this.#warnings.includes(DIFF_OLD_ROWS_IGNORED_WARNING)) { + this.#warnings.push(DIFF_OLD_ROWS_IGNORED_WARNING); + } + return; + } throw new Error(`line ${firstMinus.lineNum}: ${MINUS_ROW_REJECTED}`); } @@ -476,10 +641,11 @@ export class Executor { for (let line = range.start.line; line <= range.end.line; line++) this.#pushDelete({ line }, lineNum); } - #pushCut(range: ParsedRange, lineNum: number): void { + #pushCut(range: ParsedRange, lineNum: number, register: string | undefined): void { this.#edits.push({ kind: "cut", range: { start: { ...range.start }, end: { ...range.end } }, + ...(register === undefined ? {} : { register }), lineNum, index: this.#editIndex++, }); @@ -488,17 +654,29 @@ export class Executor { this.#pushDeleteRange(range, lineNum); } + #pushPaste(at: PasteTarget, register: string | undefined, lineNum: number): void { + this.#edits.push({ + kind: "paste", + at, + ...(register === undefined ? {} : { register }), + lineNum, + index: this.#editIndex++, + }); + } + #pushBlock( anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after" | "cut" | "paste_after", + register?: string, ): void { this.#edits.push({ kind: "block", anchor: { ...anchor }, payloads: payloads.map(payload => payload.text), ...(mode === undefined ? {} : { mode }), + ...(register === undefined ? {} : { register }), lineNum, index: this.#editIndex++, }); @@ -511,58 +689,84 @@ export class Executor { #flushPending(): void { const pending = this.#pending; if (!pending) return; - const { target, lineNum, payloads } = pending; + const { target, lineNum, payloads, hadColon } = pending; this.#resolveMinusRows(payloads); this.#stripBarePrefixesIfUniform(payloads); this.#pending = undefined; + if (target.kind === "rem" || target.kind === "move") return; if (target.kind === "cut") { - this.#pushCut(target.range, lineNum); + this.#pushCut(target.range, lineNum, target.register); return; } if (target.kind === "cut_block") { - this.#pushBlock(target.anchor, [], lineNum, "cut"); + this.#pushBlock(target.anchor, [], lineNum, "cut", target.register); return; } - if (target.kind === "paste") { - this.#edits.push({ kind: "paste", cursor: cloneCursor(target.cursor), lineNum, index: this.#editIndex++ }); - return; - } - if (target.kind === "paste_after_block") { - this.#pushBlock(target.anchor, [], lineNum, "paste_after"); - return; - } - if (target.kind === "block") { - if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`); - this.#pushBlock(target.anchor, payloads, lineNum); - return; - } - if (target.kind === "insert_after_block") { - if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`); - this.#pushBlock(target.anchor, payloads, lineNum, "insert_after"); - return; - } - if (payloads.length === 0) { - if (target.kind === "replace") { - this.#pushDeleteRange(target.range, lineNum); + // Span targets: body writes, register pastes over the span; the + // anonymous register never pastes over a span (too easy to fire by + // forgetting `:` + body on a replace). + if (target.kind === "replace") { + if (target.register !== undefined) { + this.#pushPaste( + { kind: "span", range: { start: { ...target.range.start }, end: { ...target.range.end } } }, + target.register, + lineNum, + ); + return; + } + if (payloads.length === 0) { + if (!hadColon) throw new Error(`line ${lineNum}: ${COLONLESS_SPAN_PUT}`); + this.#pushDeleteRange(target.range, lineNum); + if (!this.#warnings.includes(EMPTY_PUT_AUTO_CUT_WARNING)) { + this.#warnings.push(EMPTY_PUT_AUTO_CUT_WARNING); + } return; } - throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`); - } - if (target.kind === "replace") { const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } }; this.#emitPayloadRows(cursor, payloads, lineNum, "replacement"); this.#pushDeleteRange(target.range, lineNum); return; } - if (target.kind === "insert_before") { - this.#emitPayloadRows({ kind: "before_anchor", anchor: { ...target.anchor } }, payloads, lineNum); + if (target.kind === "block") { + if (target.register !== undefined) { + this.#pushBlock(target.anchor, [], lineNum, undefined, target.register); + return; + } + if (payloads.length === 0) { + if (!hadColon) throw new Error(`line ${lineNum}: ${COLONLESS_SPAN_PUT}`); + this.#pushBlock(target.anchor, [], lineNum); + if (!this.#warnings.includes(EMPTY_PUT_AUTO_CUT_WARNING)) { + this.#warnings.push(EMPTY_PUT_AUTO_CUT_WARNING); + } + return; + } + this.#pushBlock(target.anchor, payloads, lineNum); return; } - if (target.kind === "insert_after") { - this.#emitPayloadRows({ kind: "after_anchor", anchor: { ...target.anchor } }, payloads, lineNum); + // Gap targets: body inserts, register pastes, and the colonless + // bodyless form is an anonymous paste. + if (target.kind === "insert_after_block") { + if (target.register !== undefined || (!hadColon && payloads.length === 0)) { + this.#pushBlock(target.anchor, [], lineNum, "paste_after", target.register); + return; + } + if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`); + this.#pushBlock(target.anchor, payloads, lineNum, "insert_after"); return; } - const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" }; + const cursor: Cursor = + target.kind === "insert_before" + ? { kind: "before_anchor", anchor: { ...target.anchor } } + : target.kind === "insert_after" + ? { kind: "after_anchor", anchor: { ...target.anchor } } + : target.kind === "bof" + ? { kind: "bof" } + : { kind: "eof" }; + if (target.register !== undefined || (!hadColon && payloads.length === 0)) { + this.#pushPaste({ kind: "gap", cursor }, target.register, lineNum); + return; + } + if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`); this.#emitPayloadRows(cursor, payloads, lineNum); } } diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index f391809bc..e8323714a 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -25,7 +25,7 @@ import * as path from "node:path"; import { applyEdits } from "./apply"; import { hasBlockEdit, resolveBlockEdits } from "./block"; -import { commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard"; +import { commitClipboard, forkClipboard, startClipboardBatch, validateClipboardSequence } from "./clipboard"; import { computeFileHash, formatHashlineHeader } from "./format"; import type { Filesystem, WriteResult } from "./fs"; import { isNotFound } from "./fs"; @@ -161,11 +161,11 @@ export class PreparedSection { function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { return edits.some(edit => { - if (edit.kind === "delete") return true; - // A `replace_block N:` edit anchors to concrete content on line N. - if (edit.kind === "block") return true; - // A `CUT` range reads concrete content. - if (edit.kind === "cut") return true; + if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true; + if (edit.kind === "paste") { + if (edit.at.kind === "span") return true; + return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor"; + } return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; }); } @@ -247,7 +247,7 @@ export class Patcher { // work on a fork and publish it per landed section, so a failed batch // never poisons the persistent register and a mid-batch failure still // preserves content already cut from disk. - const clipboard = forkClipboard(this.clipboard); + const clipboard = startClipboardBatch(this.clipboard); // Single-section fast path. if (patch.sections.length === 1) { @@ -305,7 +305,7 @@ export class Patcher { */ async preflight(patch: Patch): Promise { // Dry run: fork the register and never publish it back. - const clipboard = forkClipboard(this.clipboard); + const clipboard = startClipboardBatch(this.clipboard); const prepared: PreparedSection[] = []; for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard)); assertUniqueCanonicalPaths(prepared); diff --git a/packages/hashline/src/prefixes.ts b/packages/hashline/src/prefixes.ts index 1199b02d0..cde86b2b3 100644 --- a/packages/hashline/src/prefixes.ts +++ b/packages/hashline/src/prefixes.ts @@ -20,7 +20,15 @@ const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/; const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/; const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`); const DIFF_PLUS_RE = /^[+](?![+])/; -const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/; +const READ_TRUNCATION_NOTICE_RE = + /^\s*\[(?:(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+|(?:…|\.\.\.)?\d+\s*ln elided;\s*re-read needed ranges with .+)\]\s*$/; +const READ_RANGE_ELISION_RE = /^\s*[1-9]\d*\s*-\s*[1-9]\d*:.*(?:…|\.\.\.).*$/; +const READ_SINGLE_ELISION_RE = /^\s*(?:…|\.\.\.)\s*$/; + +/** Whether a row is display-only metadata emitted by `read`, never source. */ +export function isReadMetadataLine(line: string): boolean { + return READ_TRUNCATION_NOTICE_RE.test(line) || READ_RANGE_ELISION_RE.test(line) || READ_SINGLE_ELISION_RE.test(line); +} function stripLeadingHashlinePrefixes(line: string): string { let result = line; @@ -63,7 +71,7 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats { for (const line of lines) { if (line.length === 0) continue; - if (READ_TRUNCATION_NOTICE_RE.test(line)) { + if (isReadMetadataLine(line)) { stats.truncationNoticeCount++; continue; } @@ -103,7 +111,7 @@ export function stripNewLinePrefixes(lines: string[]): string[] { if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines; return lines - .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !(stripHash && HL_HEADER_RE.test(line))) + .filter(line => !isReadMetadataLine(line) && !(stripHash && HL_HEADER_RE.test(line))) .map(line => { if (stripHash) return stripLeadingHashlinePrefixes(line); if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); @@ -124,7 +132,7 @@ export function stripHashlinePrefixes(lines: string[]): string[] { const contentLineCount = stats.nonEmpty - stats.headerCount; if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines; return lines - .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !HL_HEADER_RE.test(line)) + .filter(line => !isReadMetadataLine(line) && !HL_HEADER_RE.test(line)) .map(line => stripLeadingHashlinePrefixes(line)); } diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index a24c7fd8a..63f226b76 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -1,19 +1,18 @@ -Line-anchored patch language: name original lines to replace, cut, or insert at, then list new content. A header ending in `:` takes `+` body rows; `CUT`, `PASTE`, `REM`, `MV` take none. +Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste, then list new content. A header ending in `:` takes `+` body rows; colonless `PUT` (paste), `CUT`, `REM`, `MV` take none. Every file section starts `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search` — REQUIRED on every section. Create new files with `write`; hashline only edits existing files. -`SWAP N.=M:` — replace original lines N.=M (INCLUSIVE). -`SWAP.BLK N:` — replace the whole syntactic block BEGINNING on line N; its closing line is resolved for you. -`CUT N.=M` / `CUT.BLK N` — delete lines N.=M / the block beginning at N, and capture them for `PASTE`. -`INS.PRE N:` / `INS.POST N:` — insert immediately before / after line N. -`INS.BLK.POST N:` — insert after the END of the block beginning at N, outside it at sibling depth. Append inside a block → `INS.POST`. -`INS.HEAD:` / `INS.TAIL:` — insert at the very start / end of the file. -`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N` — insert the clipboard at the position (the clipboard IS the body). +`PUT N.=M:` — replace original lines N through M (INCLUSIVE) with body rows. +`PUT N*:` — replace the syntactic block BEGINNING on line N; its closing line is resolved for you. +`PUT N:` — insert body rows before / after line N (`PUT <1:` = file head, `PUT >$:` = file tail). +`PUT >N*:` — insert body rows after the END of the block beginning at N (at sibling depth). Append inside a block → `PUT >M:`. +`PUT N` / `PUT N.=M @name` — paste a captured register at a gap or over a range (no `:` header, no body rows). Unlabeled `PUT` pastes the anonymous register; `@name` selects a named register. +`CUT N.=M` / `CUT N*` — delete lines N through M / block N and capture them (anonymous, or `@name` when given). `REM` — delete the whole section file. `MV DEST` — move/rename to `DEST` (quote paths with spaces); edits above `MV` land on the source first, final content written at `DEST`. -Single line: `SWAP N.=N:` / `CUT N`. Range = ORIGINAL lines touched; body length irrelevant (1 line → 10 is still `SWAP N.=N:`). +Single line: `PUT N.=N:` / `CUT N.=N`. Range = ORIGINAL lines touched (`N.=M`, inclusive); body length irrelevant. @@ -27,12 +26,12 @@ Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept - Elided regions are UNSEEN (`…`/`..` markers, collapsed `N-M:` summary rows) — NEVER place or span a hunk inside one; `read` the range first. - NEVER start or end a range mid-expression or mid-block. - Ranges cover ONLY changed lines — never widen over keepers. Non-adjacent changes = separate hunks. -- Whole construct → `SWAP.BLK N`; lines inside one → `SWAP N.=M`. -- `SWAP.BLK` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes — point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `SWAP N.=M`). -- Block ops anchor the OPENING line of a MULTI-LINE construct — never the closer, last line, or a bare inner statement; one statement → plain op (`SWAP N.=N:` / `CUT N` / `INS.POST N:`). Saw the closer? `INS.POST M:`. -- Markdown: a heading IS a block opener — block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `INS.BLK.POST` after a section: end the body with a blank line to keep the next heading separated. -- Pure additions → `INS.PRE`/`INS.POST`/`INS.HEAD`/`INS.TAIL`, never a widened `SWAP`. -- Move code with `CUT`+`PASTE`, never retype. Clipboard: top-to-bottom across the whole patch (cross-file moves), persists across edit calls, latest `CUT` wins, and `PASTE` repeats freely. Pasted indentation is verbatim; re-indent via `SWAP`. +- Whole construct → `PUT N*:`; lines inside one → `PUT N.=M:`. +- `PUT N*:` resolves EXACTLY the node at N: leading decorators/attributes/doc-comments are separate nodes — point N at the FIRST decorator to sweep both; standalone line-comments are never swept (use `PUT N.=M:`). +- Block ops anchor the OPENING line of a MULTI-LINE construct — never the closer, last line, or a bare inner statement; one statement → plain op (`PUT N.=N:` / `CUT N.=N` / `PUT >N:`). Saw the closer? `PUT >M:`. +- Markdown: a heading IS a block opener — block ops on `##`/`###` resolve the WHOLE section (through deeper nested headings, up to the next same-or-higher heading). `PUT >N*:` after a section: end the body with a blank line to keep the next heading separated. +- Pure additions → `PUT N:`, never a widened `PUT N.=M:`. +- Move code with `CUT`+`PUT`: `CUT 5.=9 @fn` captures into `@fn`; `PUT >40 @fn` pastes it. Unlabeled `CUT` + `PUT >40` works for a single call-local move. Named registers persist across edit calls. - NEVER format/restyle code with this tool; run the project formatter. @@ -49,7 +48,7 @@ Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept Edit, then move: ``` [greet.py#A1B2] -SWAP 1.=3: +PUT 1.=3: +def greet(name): + print(f"Hi, {name}") MV lib/greet.py @@ -58,23 +57,23 @@ MV lib/greet.py Markdown bullets — the file receives `- task`: ``` [PLAN.md#A1B2] -INS.POST 2: +PUT >2: +- task + - nested task ``` -Move `greet` to a sibling file — clipboard flows across sections: +Move `greet` to a sibling file using a named register — flows across sections: ``` [greet.py#A1B2] -CUT.BLK 1 +CUT 1* @fn [other.py#3C4D] -PASTE.HEAD +PUT <1 @fn ``` -`SWAP.BLK 1:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays: +`PUT 1*:` resolves lines 1–3 (`def` header through `print(msg)`); line 4 is a separate statement and stays: ``` [greet.py#A1B2] -SWAP.BLK 1: +PUT 1*: +def greet(name): + print(f"Hello, {name}") ``` @@ -82,7 +81,7 @@ SWAP.BLK 1: Decorator/doc-comment = SEPARATE block — point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`: ``` [svc.py#C3D4] -SWAP.BLK 1: +PUT 1*: +@cache +def load(key): + return store[key] @@ -90,45 +89,45 @@ SWAP.BLK 1: -# WRONG — empty `SWAP` to delete. RIGHT: CUT 4 -SWAP 4.=4: +# WRONG — empty `PUT` to delete. RIGHT: `CUT 4.=4` +PUT 4.=4: -# WRONG — range sized to the post-edit content. RIGHT: SWAP 1.=1: (body length irrelevant) -SWAP 1.=2: +# WRONG — range sized to the post-edit content. RIGHT: `PUT 1.=1:` (body length irrelevant) +PUT 1.=2: +def greet(name): # WRONG — `-` rows / bare context lines do not exist; the range deletes, the body is only new content. -SWAP 3.=3: +PUT 3.=3: msg = "Hello, " + name - print(msg) + return msg # RIGHT -SWAP 3.=3: +PUT 3.=3: + return msg -# WRONG — pure insertion as a widened `SWAP`: retyped keepers get dropped (here line 4). -SWAP 2.=4: +# WRONG — pure insertion as a widened `PUT`: retyped keepers get dropped (here line 4). +PUT 2.=4: + msg = "Hello, " + name + extra = compute(name) + print(msg) # RIGHT — touch nothing you keep. -INS.POST 2: +PUT >2: + extra = compute(name) -# WRONG — `INS.BLK.POST` anchored on the closing delimiter / last visible line. RIGHT: plain `INS.POST M:` -INS.BLK.POST 3: +# WRONG — `PUT >N*:` anchored on the closing delimiter / last visible line. RIGHT: plain `PUT >M:` +PUT >3*: +after() # RIGHT -INS.POST 3: +PUT >3: +after() -# WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20`. -PASTE.POST 20: +# WRONG — body rows under register PUT; register pastes take no body. RIGHT: bodyless `PUT >20 @fn`. +PUT >20 @fn: +function f() {} 1. RE-GROUND AFTER EVERY EDIT — applied edits renumber the file and change the `#TAG`; take next numbers from the edit response or a fresh `read`. Stale tag or surprise? STOP, re-`read`. -2. RANGES ARE TIGHT — cover only lines that change. Whole construct → `SWAP.BLK N`. +2. RANGES ARE TIGHT — cover only lines that change. Whole construct → `PUT N*:`. 3. BODY = FINAL CONTENT — every body row starts with `+`; Markdown bullets use `+- item`, not `- item`. diff --git a/packages/hashline/src/recovery.ts b/packages/hashline/src/recovery.ts index 239c48695..d54eb3d49 100644 --- a/packages/hashline/src/recovery.ts +++ b/packages/hashline/src/recovery.ts @@ -49,6 +49,15 @@ function getEditAnchors(edit: Edit): Anchor[] { for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line }); return anchors; } + if (edit.kind === "paste") { + if (edit.at.kind === "span") { + const anchors: Anchor[] = []; + for (let line = edit.at.range.start.line; line <= edit.at.range.end.line; line++) anchors.push({ line }); + return anchors; + } + const cursor = edit.at.cursor; + return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : []; + } return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : []; } @@ -236,23 +245,55 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } }); continue; } - - let blockStart = edit.blockStart; - if (blockStart !== undefined) { - const mappedBlockStart = mapLine(blockStart); - if (mappedBlockStart === null) return null; - blockStart = mappedBlockStart; - } - - const cursor = edit.cursor; - if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") { - remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart }); + if (edit.kind === "paste") { + let blockStart = edit.blockStart; + if (blockStart !== undefined) { + const mappedBlockStart = mapLine(blockStart); + if (mappedBlockStart === null) return null; + blockStart = mappedBlockStart; + } + if (edit.at.kind === "span") { + const start = mapLine(edit.at.range.start.line); + if (start === null) return null; + let end = start; + for (let line = edit.at.range.start.line + 1; line <= edit.at.range.end.line; line++) { + const mapped = mapLine(line); + if (mapped === null) return null; + end = mapped; + } + remapped.push({ + ...edit, + at: { kind: "span", range: { start: { line: start }, end: { line: end } } }, + blockStart, + }); + continue; + } + const cursor = edit.at.cursor; + if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") { + remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart }); + continue; + } + const anchor = mapAnchor(cursor.anchor); + if (anchor === null) return null; + remapped.push({ ...edit, at: { kind: "gap", cursor: { kind: cursor.kind, anchor } }, blockStart }); continue; } - - const anchor = mapAnchor(cursor.anchor); - if (anchor === null) return null; - remapped.push({ ...edit, cursor: { kind: cursor.kind, anchor }, blockStart }); + if (edit.kind === "insert") { + let blockStart = edit.blockStart; + if (blockStart !== undefined) { + const mappedBlockStart = mapLine(blockStart); + if (mappedBlockStart === null) return null; + blockStart = mappedBlockStart; + } + const cursor = edit.cursor; + if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") { + remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart }); + continue; + } + const anchor = mapAnchor(cursor.anchor); + if (anchor === null) return null; + remapped.push({ ...edit, cursor: { kind: cursor.kind, anchor }, blockStart }); + } } if (offsets.length === 0) return null; diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index 616d7a1e9..954ab6f19 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -10,26 +10,16 @@ */ import { describeAnchorExamples, - HL_CUT_BLOCK_KEYWORD, HL_CUT_KEYWORD, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_HEADER_COLON, - HL_INSERT_AFTER, - HL_INSERT_AFTER_BLOCK_KEYWORD, - HL_INSERT_BEFORE, - HL_INSERT_HEAD, - HL_INSERT_KEYWORD, - HL_INSERT_TAIL, HL_MOVE_KEYWORD, - HL_PASTE_AFTER_BLOCK_KEYWORD, - HL_PASTE_KEYWORD, HL_PAYLOAD_REPLACE, + HL_PUT_KEYWORD, HL_REM_KEYWORD, - HL_REPLACE_BLOCK_KEYWORD, - HL_REPLACE_KEYWORD, } from "./format"; import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages"; import type { Anchor, Cursor, ParsedRange } from "./types"; @@ -41,11 +31,16 @@ const CHAR_NINE = 57; const CHAR_HASH = 35; const CHAR_TAB = 9; const CHAR_SPACE = 32; -const CHAR_DOT = 46; -const CHAR_COMMA = 44; const CHAR_HYPHEN = 45; -const CHAR_ELLIPSIS = 0x2026; +const CHAR_DOT = 46; const CHAR_EQUALS = 61; +const CHAR_ELLIPSIS = 0x2026; +const CHAR_LESS_THAN = 60; +const CHAR_GREATER_THAN = 62; +const CHAR_STAR = 42; +const CHAR_DOLLAR = 36; +const CHAR_AT = 64; +const CHAR_UNDERSCORE = 95; const CHAR_UPPER_A = 65; const CHAR_UPPER_F = 70; @@ -157,40 +152,33 @@ export function parseLid(raw: string, lineNum: number): Anchor { interface RangeScan { range: ParsedRange; nextIndex: number; + hadSeparator: boolean; } +/** + * Range separator scanner. Canonical input is `.=`, while parsing remains + * deliberately lenient for model output: `-`, `=`, `.`, `..`, `…`, mixed + * runs, and whitespace-only separators all recover to the same range. + */ function scanRangeSeparator(line: string, index: number, end: number): number | null { let cursor = index; let consumedSeparator = false; while (cursor < end) { const code = line.charCodeAt(cursor); - if (isWhitespaceCode(code)) { + if ( + isWhitespaceCode(code) || + code === CHAR_HYPHEN || + code === CHAR_DOT || + code === CHAR_EQUALS || + code === CHAR_ELLIPSIS + ) { cursor++; consumedSeparator = true; continue; } - if (code === CHAR_COMMA || code === CHAR_HYPHEN || code === CHAR_ELLIPSIS) { - cursor++; - consumedSeparator = true; - continue; - } - if (code === CHAR_DOT && cursor + 1 < end) { - const next = line.charCodeAt(cursor + 1); - if (next === CHAR_DOT || next === CHAR_EQUALS) { - cursor += 2; - consumedSeparator = true; - continue; - } - if (isNonZeroDigitCode(next)) { - cursor++; - consumedSeparator = true; - continue; - } - } break; } - if (!consumedSeparator) return null; - if (cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null; + if (!consumedSeparator || cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null; return cursor; } @@ -204,6 +192,7 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo return { range: { start: { line: start.line }, end: { line: start.line } }, nextIndex: skipWhitespace(line, start.nextIndex, end), + hadSeparator: false, }; } const endNumber = scanLineNumber(line, afterFirst, end); @@ -211,27 +200,35 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo return { range: { start: { line: start.line }, end: { line: endNumber.line } }, nextIndex: skipWhitespace(line, endNumber.nextIndex, end), + hadSeparator: true, }; } export type BlockTarget = - | { kind: "replace"; range: ParsedRange } - | { kind: "block"; anchor: Anchor } - | { kind: "insert_before"; anchor: Anchor } - | { kind: "insert_after"; anchor: Anchor } - | { kind: "insert_after_block"; anchor: Anchor } - | { kind: "cut"; range: ParsedRange } - | { kind: "cut_block"; anchor: Anchor } - | { kind: "paste"; cursor: Cursor } - | { kind: "paste_after_block"; anchor: Anchor } + | { kind: "replace"; range: ParsedRange; register?: string } + | { kind: "block"; anchor: Anchor; register?: string } + | { kind: "insert_before"; anchor: Anchor; register?: string } + | { kind: "insert_after"; anchor: Anchor; register?: string } + | { kind: "insert_after_block"; anchor: Anchor; register?: string } + | { kind: "cut"; range: ParsedRange; register?: string } + | { kind: "cut_block"; anchor: Anchor; register?: string } + | { kind: "bof"; register?: string } + | { kind: "eof"; register?: string } | { kind: "rem" } - | { kind: "move"; dest: string } - | { kind: "bof" } - | { kind: "eof" }; + | { kind: "move"; dest: string }; + +/** Targets that may carry a `@register` suffix (everything but the file-level ops). */ +type RegisterableTarget = Exclude; interface TargetScan { target: BlockTarget; nextIndex: number; + /** + * Whether the header carried a trailing `:`. The parser uses it to tell a + * literal insertion awaiting body rows (`PUT >40:`) from a bodyless + * anonymous paste (`PUT >40`). + */ + hadColon: boolean; } function scanKeyword(line: string, index: number, end: number, keyword: string): number | null { @@ -239,85 +236,128 @@ function scanKeyword(line: string, index: number, end: number, keyword: string): const next = index + keyword.length; if (next < end) { const code = line.charCodeAt(next); - if (!isWhitespaceCode(code) && code !== CHAR_COLON && code !== CHAR_DOT) return null; + if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null; } return next; } -/** - * GLM 5.2 inserts a stray `.` between the line number/range and the trailing - * `:` (e.g. `SWAP 2.=3.:`, `INS.POST 2.:`). A `.` is never valid syntax at - * this position, so skip it when it precedes an optional `:` or end-of-line. - */ -function skipStrayDot(line: string, index: number, end: number): number { - if (index < end && line.charCodeAt(index) === CHAR_DOT) { - const after = skipWhitespace(line, index + 1, end); - if (after === end || line.charCodeAt(after) === CHAR_COLON) return after; - } - return index; -} - -function consumeOptionalColon(line: string, index: number, end: number): number { - let cursor = skipWhitespace(line, index, end); - cursor = skipStrayDot(line, cursor, end); - return cursor < end && line.charCodeAt(cursor) === CHAR_COLON ? skipWhitespace(line, cursor + 1, end) : cursor; -} -/** - * Recover local-model replace trailers that permute `:` and `=` as `:=:` or - * `=:`. The range has already been parsed, so these suffixes are unambiguous. - */ -function consumeReplaceColon(line: string, index: number, end: number): number { - const canonical = consumeOptionalColon(line, index, end); - if (canonical >= end || line.charCodeAt(canonical) !== CHAR_EQUALS) return canonical; - const afterEquals = skipWhitespace(line, canonical + 1, end); - if (afterEquals >= end || line.charCodeAt(afterEquals) !== CHAR_COLON) return canonical; - return skipWhitespace(line, afterEquals + 1, end); -} - -interface PositionScan { - cursor: Cursor; +interface ColonScan { nextIndex: number; + hadColon: boolean; } -/** Scan the `.PRE N` / `.POST N` / `.HEAD` / `.TAIL` positional suffix shared by `INS` and `PASTE`. */ -function scanPositionalSuffix(line: string, index: number, end: number): PositionScan | null { - if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null; - const probe = skipWhitespace(line, index + 1, end); - const beforeEnd = scanKeyword(line, probe, end, HL_INSERT_BEFORE); - if (beforeEnd !== null) { - const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end); - if (anchor === null) return null; - const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end); - return { cursor: { kind: "before_anchor", anchor: { line: anchor.line } }, nextIndex }; +function consumeOptionalColon(line: string, index: number, end: number): ColonScan { + const cursor = skipWhitespace(line, index, end); + if (cursor < end && line.charCodeAt(cursor) === CHAR_COLON) { + return { nextIndex: skipWhitespace(line, cursor + 1, end), hadColon: true }; } - const afterEnd = scanKeyword(line, probe, end, HL_INSERT_AFTER); - if (afterEnd !== null) { - const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end); - if (anchor === null) return null; - const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end); - return { cursor: { kind: "after_anchor", anchor: { line: anchor.line } }, nextIndex }; - } - const headEnd = scanKeyword(line, probe, end, HL_INSERT_HEAD); - if (headEnd !== null) return { cursor: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) }; - const tailEnd = scanKeyword(line, probe, end, HL_INSERT_TAIL); - if (tailEnd !== null) return { cursor: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) }; - return null; + return { nextIndex: cursor, hadColon: false }; } -function scanInsertTarget(line: string, index: number, end: number): TargetScan | null { - const scan = scanPositionalSuffix(line, index, end); - if (scan === null) return null; - const { cursor, nextIndex } = scan; - switch (cursor.kind) { - case "before_anchor": - return { target: { kind: "insert_before", anchor: cursor.anchor }, nextIndex }; - case "after_anchor": - return { target: { kind: "insert_after", anchor: cursor.anchor }, nextIndex }; - case "bof": - return { target: { kind: "bof" }, nextIndex }; - case "eof": - return { target: { kind: "eof" }, nextIndex }; +/** Maximum accepted register-name length; anything longer fails the header parse. */ +const REGISTER_NAME_MAX = 64; + +function isRegisterNameCode(code: number): boolean { + return ( + isDigitCode(code) || + (code >= CHAR_UPPER_A && code <= 90) || + (code >= CHAR_LOWER_A && code <= 122) || + code === CHAR_UNDERSCORE || + code === CHAR_HYPHEN + ); +} + +/** Scan a `@name` register reference. */ +function scanRegister(line: string, index: number, end: number): { name: string; nextIndex: number } | null { + if (index >= end || line.charCodeAt(index) !== CHAR_AT) return null; + const start = index + 1; + let cursor = start; + while (cursor < end && isRegisterNameCode(line.charCodeAt(cursor))) cursor++; + if (cursor === start || cursor - start > REGISTER_NAME_MAX) return null; + return { name: line.slice(start, cursor), nextIndex: cursor }; +} + +/** + * Finish a `PUT`/`CUT` header: optional `@register`, optional trailing `:`. + * The parser decides whether a body is required; the tokenizer only records + * the shape. + */ +function finishTargetScan(line: string, index: number, end: number, target: RegisterableTarget): TargetScan { + let cursor = skipWhitespace(line, index, end); + const register = scanRegister(line, cursor, end); + if (register !== null) { + target = { ...target, register: register.name }; + cursor = register.nextIndex; } + const colon = consumeOptionalColon(line, cursor, end); + return { target, nextIndex: colon.nextIndex, hadColon: colon.hadColon }; +} + +/** + * Scan the locator of a `PUT` header: + * span — `5` / `5-9` (replace lines), `5*` (replace the block opening at 5) + * gap — `<5` / `>5` (insert), `>5*` (after the block's end), `<1` (head), `>$` (tail) + */ +function scanPutTarget(line: string, index: number, end: number): TargetScan | null { + const cursor = skipWhitespace(line, index, end); + if (cursor >= end) return null; + const sigil = line.charCodeAt(cursor); + if (sigil === CHAR_LESS_THAN || sigil === CHAR_GREATER_THAN) { + const isAfter = sigil === CHAR_GREATER_THAN; + const probe = skipWhitespace(line, cursor + 1, end); + if (isAfter && probe < end && line.charCodeAt(probe) === CHAR_DOLLAR) { + return finishTargetScan(line, probe + 1, end, { kind: "eof" }); + } + const anchor = scanLineNumber(line, probe, end); + if (anchor === null) return null; + let next = anchor.nextIndex; + let block = false; + if (next < end && line.charCodeAt(next) === CHAR_STAR) { + block = true; + next++; + } + if (isAfter) { + return finishTargetScan( + line, + next, + end, + block + ? { kind: "insert_after_block", anchor: { line: anchor.line } } + : { kind: "insert_after", anchor: { line: anchor.line } }, + ); + } + // `N @r` / + * `PUT N-M @r`, their `>N*` block-resolved forms, or the register-less + * anonymous equivalents). Expanded by the clipboard pre-pass into one + * plain insert per captured line; `span` targets additionally expand + * into per-line deletes there — only after the register read + * succeeds, so a dropped paste (streaming preview with an empty + * register) never leaves destructive orphan deletes. `blockStart` + * mirrors the insert variant's field for block-lowered gap pastes so + * landing correction can slide the body across trailing closer lines. */ kind: "paste"; - cursor: Cursor; + at: PasteTarget; + register?: string; lineNum: number; index: number; blockStart?: number; } | { /** - * Deferred block edit (`SWAP.BLK N:`, `INS.BLK.POST N:`, - * `CUT.BLK N`, or `PASTE.BLK.POST N`). The exact line span is - * unknown at parse time; {@link resolveBlockEdits} computes it once - * file text and language are available, then expands it into - * concrete edits. `mode: "insert_after"` becomes plain - * `after_anchor` inserts, `"cut"` becomes a clipboard cut plus - * per-line deletes, and `"paste_after"` becomes a paste after the - * resolved block. No mode denotes a block replacement. + * Deferred block edit (`PUT N*:`, `PUT >N*:`, `CUT N*`, or their + * `@register` forms). The exact line span is unknown at parse time; + * {@link resolveBlockEdits} computes it once file text and language + * are available, then expands it into concrete edits. + * `mode: "insert_after"` becomes plain `after_anchor` inserts, + * `"cut"` becomes a clipboard cut plus per-line deletes, and + * `"paste_after"` becomes a gap paste after the resolved block. No + * mode denotes a block replacement — from body payloads, or from + * `register` when one is named (a span paste over the resolved block). */ kind: "block"; anchor: Anchor; payloads: string[]; mode?: "insert_after" | "cut" | "paste_after"; + register?: string; lineNum: number; index: number; }; +/** Where a `paste` edit lands: an insertion gap, or a span it replaces. */ +export type PasteTarget = { kind: "gap"; cursor: Cursor } | { kind: "span"; range: ParsedRange }; + /** File-level operation parsed from a section body (`REM` / `MV`). */ export type FileOp = { kind: "rem" } | { kind: "move"; dest: string }; @@ -105,7 +116,7 @@ export interface ApplyResult { blockResolutions?: BlockResolution[]; } -/** A parsed `[A.=B]` line range. */ +/** A parsed `A-B` inclusive line range. */ export interface ParsedRange { start: Anchor; end: Anchor; @@ -190,13 +201,25 @@ export interface BlockResolverRequest { export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null; /** - * Mutable clipboard register threaded through one patch application. Filled - * by `CUT` edits and read by `PASTE` edits in patch source order, across - * sections, so content can move between files. Create one per batch (`{}`) - * and hand it to every {@link Patcher.prepare} / `applyTo` call in that batch; - * callers that omit it get a private per-call register. + * Mutable clipboard registers threaded through one patch application. Filled + * by `CUT` edits and read by register `PUT`s in patch source order, across + * sections, so content can move between files. + * + * The anonymous register (`lines`) is batch-local: {@link forkClipboard} + * never copies it in, so it exists only between a `CUT` and a paste inside + * one patch. Named registers (`named`) persist across batches when the host + * owns the Clipboard (`PatcherOptions.clipboard`) — the sanctioned way to + * move content across separate edit calls. */ export interface Clipboard { - /** Lines captured by the most recent `CUT`, or unset. */ + /** Anonymous register: lines captured by the latest unlabeled `CUT` in this batch. */ lines?: readonly string[]; + /** Named registers captured by `CUT … @name`. */ + named?: Map; + /** + * Headers of unlabeled `CUT`s seen since the last unlabeled paste in this + * batch. Two or more make the next unlabeled paste ambiguous (which cut + * did the author mean?) and fail it with a labeling hint. + */ + pendingAnonCuts?: string[]; } diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts index 8d988322f..4e03f30dd 100644 --- a/packages/hashline/test/block.test.ts +++ b/packages/hashline/test/block.test.ts @@ -31,9 +31,9 @@ function normalizeEdits(edits: readonly Edit[]): unknown[] { }); } -describe("SWAP.BLK parsing", () => { - it("parses `SWAP.BLK N:` into a single deferred block edit", () => { - const { edits } = parsePatch("SWAP.BLK 2:\n+A\n+B"); +describe("PUT N*: parsing", () => { + it("parses `PUT N*: N:` into a single deferred block edit", () => { + const { edits } = parsePatch("PUT 2*:\n+A\n+B"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -43,52 +43,54 @@ describe("SWAP.BLK parsing", () => { expect(edit.payloads).toEqual(["A", "B"]); }); - it("still parses a literal `SWAP N.=M:` range (distinct from `SWAP.BLK`)", () => { - const { edits } = parsePatch("SWAP 2.=3:\n+A"); + it("still parses a literal `SWAP N.=M:` range (distinct from `PUT N*:`)", () => { + const { edits } = parsePatch("PUT 2-3:\n+A"); expect(edits.some(edit => edit.kind === "block")).toBe(false); expect(edits.some(edit => edit.kind === "delete")).toBe(true); }); - it("rejects a `SWAP.BLK N:` hunk with no body row", () => { - expect(() => parsePatch("SWAP.BLK 2:")).toThrow("`SWAP.BLK N:` needs at least one"); + it("treats a `PUT N*:` hunk with no body row as a delete-only block edit", () => { + const result = parsePatch("PUT 2*:"); + expect(result.edits).toEqual([{ kind: "block", anchor: { line: 2 }, payloads: [], lineNum: 1, index: 0 }]); + expect(result.warnings.some(w => /empty `PUT` body as deletion/.test(w))).toBe(true); }); }); describe("resolveBlockEdits", () => { it("expands a block edit exactly like the equivalent `SWAP start.=end:`", () => { - const blockEdits = parsePatch("SWAP.BLK 2:\n+A\n+B").edits; + const blockEdits = parsePatch("PUT 2*:\n+A\n+B").edits; const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); - const replaceEdits = parsePatch("SWAP 2.=3:\n+A\n+B").edits; + const replaceEdits = parsePatch("PUT 2-3:\n+A\n+B").edits; expect(resolved.some(edit => edit.kind === "block")).toBe(false); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(replaceEdits)); }); it("returns the input untouched when there are no block edits (fast path)", () => { - const edits = parsePatch("SWAP 1.=1:\n+X").edits; + const edits = parsePatch("PUT 1-1:\n+X").edits; expect(resolveBlockEdits(edits, "ignored", PATH, stubResolver)).toBe(edits); }); it("throws (default) when no resolver is wired", () => { - const edits = parsePatch("SWAP.BLK 2:\n+X").edits; + const edits = parsePatch("PUT 2*:\n+X").edits; expect(() => resolveBlockEdits(edits, "ignored", PATH, undefined)).toThrow("not available here"); }); it("drops an unresolvable block edit in `drop` mode", () => { - const edits = parsePatch("SWAP.BLK 2:\n+X").edits; + const edits = parsePatch("PUT 2*:\n+X").edits; const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { onUnresolved: "drop" }); expect(resolved).toHaveLength(0); }); it("throws a block-unresolved error in `throw` mode when the resolver returns null", () => { - const edits = parsePatch("SWAP.BLK 7:\n+X").edits; + const edits = parsePatch("PUT 7*:\n+X").edits; expect(() => resolveBlockEdits(edits, "ignored", PATH, () => null)).toThrow( "could not resolve a syntactic block beginning on line 7", ); }); it("includes a nearby-context preview in the block-unresolved error", () => { - const edits = parsePatch("SWAP.BLK 3:\n+X").edits; + const edits = parsePatch("PUT 3*:\n+X").edits; const text = "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot"; let error: Error | undefined; try { @@ -107,10 +109,10 @@ describe("resolveBlockEdits", () => { it("suggests the next multi-line opener for a blank anchor without applying it", () => { const text = "alpha\n\nfunction x() {\n return 1;\n}"; const resolver: BlockResolver = ({ line }) => (line === 3 ? { start: 3, end: 5 } : null); - const edits = parsePatch("SWAP.BLK 2:\n+function y() {}").edits; + const edits = parsePatch("PUT 2*:\n+function y() {}").edits; expect(() => resolveBlockEdits(edits, text, PATH, resolver)).toThrow( - "Line 2 is blank; no syntactic block can begin there. The next multi-line block begins at line 3 and ends at line 5. Retry `SWAP.BLK 3:`.", + "Line 2 is blank; no syntactic block can begin there. The next multi-line block begins at line 3 and ends at line 5. Retry `PUT 3*:`.", ); }); @@ -121,15 +123,15 @@ describe("resolveBlockEdits", () => { if (line === 1) return { start: 1, end: 3 }; return null; }; - const edits = parsePatch("SWAP.BLK 2:\n+ stop();").edits; + const edits = parsePatch("PUT 2*:\n+ stop();").edits; expect(() => resolveBlockEdits(edits, text, PATH, resolver)).toThrow( - "For only this statement use `SWAP 2.=2:`. The nearest enclosing multi-line block begins at line 1 and ends at line 3; use `SWAP.BLK 1:` to target it.", + "For only this statement use `PUT 2:`. The nearest enclosing multi-line block begins at line 1 and ends at line 3; use `PUT 1*:` to target it.", ); }); it("omits the context preview when the anchor line is out of range", () => { - const edits = parsePatch("SWAP.BLK 9:\n+X").edits; + const edits = parsePatch("PUT 9*:\n+X").edits; let error: Error | undefined; try { resolveBlockEdits(edits, "only\ntwo", PATH, () => null); @@ -143,10 +145,10 @@ describe("resolveBlockEdits", () => { it("fires onResolved with the resolved span for replace and cut blocks", () => { const seen: BlockResolution[] = []; // stubResolver maps line N → span [N, N+1]. - resolveBlockEdits(parsePatch("SWAP.BLK 2:\n+A\n+B").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("PUT 2*:\n+A\n+B").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); - resolveBlockEdits(parsePatch("CUT.BLK 5").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("CUT 5*").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); @@ -158,7 +160,7 @@ describe("resolveBlockEdits", () => { it("does not fire onResolved for a dropped unresolvable block", () => { const seen: BlockResolution[] = []; - resolveBlockEdits(parsePatch("SWAP.BLK 2:\n+X").edits, "ignored", PATH, () => null, { + resolveBlockEdits(parsePatch("PUT 2*:\n+X").edits, "ignored", PATH, () => null, { onUnresolved: "drop", onResolved: resolution => seen.push(resolution), }); @@ -171,20 +173,20 @@ describe("resolveBlockEdits", () => { // plain form rather than silently landing a body in the wrong scope. const singleLineResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line }); - it("rejects a `SWAP.BLK` that resolves to a single line", () => { - const edits = parsePatch("SWAP.BLK 2:\n+X").edits; + it("rejects a `PUT N*:` that resolves to a single line", () => { + const edits = parsePatch("PUT 2*:\n+X").edits; expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow( - /resolved a single-line block/, + "For only this statement use `PUT 2:`.", ); }); - it("rejects an `INS.BLK.POST` that resolves to a single line", () => { - const edits = parsePatch("INS.BLK.POST 2:\n+X").edits; + it("rejects an `PUT >N*:` that resolves to a single line", () => { + const edits = parsePatch("PUT >2*:\n+X").edits; expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow(/single-line block/); }); it("drops a single-line block resolution on the lenient preview path", () => { - const edits = parsePatch("SWAP.BLK 2:\n+X").edits; + const edits = parsePatch("PUT 2*:\n+X").edits; const resolved = resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver, { onUnresolved: "drop" }); expect(resolved).toHaveLength(0); }); @@ -194,8 +196,8 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { const text = "function x() {\n if (y) {\n }\n}\n"; it("applyTo resolves a block edit and matches the equivalent `replace`", () => { - const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`); - const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP 2.=3:\n+ if (y || z) {\n+ }`); + const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nPUT 2*:\n+ if (y || z) {\n+ }`); + const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nPUT 2-3:\n+ if (y || z) {\n+ }`); const blockResult = blockSection.applyTo(text, stubResolver); const replaceResult = replaceSection.applyTo(text); @@ -205,12 +207,12 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { }); it("applyTo throws when a block edit has no resolver", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.BLK 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT 2*:\n+X`); expect(() => section.applyTo(text)).toThrow("no block resolver configured"); }); it("applyPartialTo drops an unresolvable block edit instead of throwing", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP.BLK 2:\n+X`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT 2*:\n+X`); // No resolver → drop. The lone block edit vanishes, so the text is unchanged. const result = section.applyPartialTo(text); expect(result.text).toBe(text); @@ -226,7 +228,7 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2*:\n+ if (y || z) {\n+ }`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n"); @@ -238,7 +240,7 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.BLK 2:\n+ if (y || z) {\n+ }`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2*:\n+ if (y || z) {\n+ }`)); expect(result.sections[0]?.blockResolutions).toEqual([{ anchorLine: 2, start: 2, end: 3, op: "replace" }]); }); @@ -251,8 +253,8 @@ describe("Patcher with a block resolver", () => { const resolver: BlockResolver = ({ line }) => (line === 195 ? { start: 195, end: 255 } : null); const patcher = new Patcher({ fs, snapshots, blockResolver: resolver }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 195.=61:\n+replacement`))).rejects.toThrow( - "Invalid absolute range: start 195, end 61. The value after `.=` is an absolute source line, not a line count or replacement length. For one line use `SWAP 195.=195:`. For 61 lines starting at 195, use `SWAP 195.=255:`. The syntactic block beginning at 195 ends at 255, so `SWAP.BLK 195:` is also valid.", + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 195-61:\n+replacement`))).rejects.toThrow( + "Invalid absolute range: start 195, end 61. The value after `.=` is an absolute source line, not a line count or replacement length. For one line use `PUT 195:`. For 61 lines starting at 195, use `PUT 195.=255:`. The syntactic block beginning at 195 ends at 255, so `PUT 195*:` is also valid.", ); expect(fs.get(PATH)).toBe(source); }); @@ -268,7 +270,7 @@ describe("Patcher with a block resolver", () => { // `block 2` resolves against the SNAPSHOT → span [2,3] → replace // "line1","line2"; recovery maps that unchanged span onto the live file. - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.BLK 2:\n+NEW`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2*:\n+NEW`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n"); @@ -286,7 +288,7 @@ describe("Patcher with a block resolver", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nSWAP.BLK 2:\n+NEW`))).rejects.toBeInstanceOf( + await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nPUT 2*:\n+NEW`))).rejects.toBeInstanceOf( MismatchError, ); expect(fs.get(PATH)).toBe(liveText); @@ -298,18 +300,18 @@ describe("Patcher with a block resolver", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: () => null }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP.BLK 2:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2*:\n+X`))).rejects.toThrow( "could not resolve a syntactic block", ); expect(fs.get(PATH)).toBe(text); }); }); -describe("CUT.BLK", () => { +describe("CUT N*", () => { const text = "function x() {\n if (y) {\n }\n}\n"; - it("parses `CUT.BLK N` into a cut block edit", () => { - const { edits } = parsePatch("CUT.BLK 2"); + it("parses `CUT N* N` into a cut block edit", () => { + const { edits } = parsePatch("CUT 2*"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -320,25 +322,25 @@ describe("CUT.BLK", () => { expect(edit.mode).toBe("cut"); }); - it("rejects body rows under `CUT.BLK N`", () => { - expect(() => parsePatch("CUT.BLK 2\n+X")).toThrow("`CUT N.=M` captures + deletes lines"); + it("rejects body rows under `CUT N* N`", () => { + expect(() => parsePatch("CUT 2*\n+X")).toThrow("`CUT` deletes (and captures) the named lines"); }); it("resolveBlockEdits expands a cut block into capture and deletes", () => { - const resolved = resolveBlockEdits(parsePatch("CUT.BLK 2").edits, "ignored", PATH, stubResolver); + const resolved = resolveBlockEdits(parsePatch("CUT 2*").edits, "ignored", PATH, stubResolver); expect(resolved.map(edit => edit.kind)).toEqual(["cut", "delete", "delete"]); expect(resolved.flatMap(edit => (edit.kind === "delete" ? [edit.anchor.line] : []))).toEqual([2, 3]); }); it("applyTo deletes the resolved block span", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT.BLK 2`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2*`); // stub span [2,3] → drop " if (y) {" and " }". expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n"); }); it("applyPartialTo drops an unresolvable cut-block edit", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT.BLK 2`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2*`); expect(section.applyPartialTo(text).text).toBe(text); }); @@ -348,18 +350,18 @@ describe("CUT.BLK", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nCUT.BLK 2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nCUT 2*`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n}\n"); }); }); -describe("INS.BLK.POST", () => { +describe("PUT >N*:", () => { const text = "function x() {\n if (y) {\n }\n}\n"; - it("parses `INS.BLK.POST N:` into a deferred block edit with insert mode", () => { - const { edits } = parsePatch("INS.BLK.POST 2:\n+A\n+B"); + it("parses `PUT >N*: N:` into a deferred block edit with insert mode", () => { + const { edits } = parsePatch("PUT >2*:\n+A\n+B"); expect(edits).toHaveLength(1); const edit = edits[0]; @@ -370,20 +372,20 @@ describe("INS.BLK.POST", () => { expect(edit.mode).toBe("insert_after"); }); - it("still parses a literal `INS.POST N:` anchor (distinct from `INS.BLK.POST`)", () => { - const { edits } = parsePatch("INS.POST 2:\n+A"); + it("still parses a literal `PUT > N:` anchor (distinct from `PUT >N*:`)", () => { + const { edits } = parsePatch("PUT >2:\n+A"); expect(edits.some(edit => edit.kind === "block")).toBe(false); }); - it("rejects an `INS.BLK.POST N:` hunk with no body row", () => { - expect(() => parsePatch("INS.BLK.POST 2:")).toThrow("`INS` needs at least one"); + it("rejects an `PUT >N*: N:` hunk with no body row", () => { + expect(() => parsePatch("PUT >2*:")).toThrow("promises body rows"); }); it("resolveBlockEdits expands to the equivalent `insert after end:` lowering", () => { - const blockEdits = parsePatch("INS.BLK.POST 2:\n+A\n+B").edits; + const blockEdits = parsePatch("PUT >2*:\n+A\n+B").edits; // stub span [2,3] → after_anchor inserts at line 3. const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); - const insertEdits = parsePatch("INS.POST 3:\n+A\n+B").edits; + const insertEdits = parsePatch("PUT >3:\n+A\n+B").edits; expect(resolved.some(edit => edit.kind === "block")).toBe(false); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(insertEdits)); @@ -391,39 +393,39 @@ describe("INS.BLK.POST", () => { it("fires onResolved with op insert_after", () => { const seen: BlockResolution[] = []; - resolveBlockEdits(parsePatch("INS.BLK.POST 2:\n+A").edits, "ignored", PATH, stubResolver, { + resolveBlockEdits(parsePatch("PUT >2*:\n+A").edits, "ignored", PATH, stubResolver, { onResolved: resolution => seen.push(resolution), }); expect(seen).toEqual([{ anchorLine: 2, start: 2, end: 3, op: "insert_after" }]); }); - it("lowers an unresolvable anchor to plain `INS.POST N:` with a warning", () => { - const edits = parsePatch("INS.BLK.POST 7:\n+X").edits; + it("lowers an unresolvable anchor to plain `PUT > N:` with a warning", () => { + const edits = parsePatch("PUT >7*:\n+X").edits; const warnings: string[] = []; const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { onWarning: warning => warnings.push(warning), }); - expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("INS.POST 7:\n+X").edits)); + expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("PUT >7:\n+X").edits)); expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("applied as plain `INS.POST 7:`"); + expect(warnings[0]).toContain("applied as plain `PUT >7:`"); }); - it("lowers `INS.BLK.POST` even when no resolver is wired", () => { - const edits = parsePatch("INS.BLK.POST 2:\n+X").edits; + it("lowers `PUT >N*:` even when no resolver is wired", () => { + const edits = parsePatch("PUT >2*:\n+X").edits; const warnings: string[] = []; const resolved = resolveBlockEdits(edits, "ignored", PATH, undefined, { onWarning: warning => warnings.push(warning), }); - expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("INS.POST 2:\n+X").edits)); + expect(normalizeEdits(resolved)).toEqual(normalizeEdits(parsePatch("PUT >2:\n+X").edits)); expect(warnings).toHaveLength(1); }); - it("lowers a closing-delimiter anchor to plain `INS.POST N:` with a warning", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nINS.BLK.POST 3:\n+ done();`); + it("lowers a closing-delimiter anchor to plain `PUT > N:` with a warning", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >3*:\n+ done();`); const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null); const result = section.applyTo(text, resolver); @@ -431,17 +433,17 @@ describe("INS.BLK.POST", () => { // line 3 is ` }` — no block begins there, but it ends one; the body // lands after it, exactly where `insert_after_block` would have put it. expect(result.text).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); - expect(result.warnings?.some(w => /applied as plain `INS.POST 3:`/.test(w))).toBe(true); + expect(result.warnings?.some(w => /applied as plain `PUT >3:`/.test(w))).toBe(true); }); - it("lowers an unresolvable blank-line anchor to plain `INS.POST N:` instead of failing", () => { - const blankAnchored = Patch.parseSingle(`[notes.md#1A2B]\nINS.BLK.POST 2:\n+- new entry`); + it("lowers an unresolvable blank-line anchor to plain `PUT > N:` instead of failing", () => { + const blankAnchored = Patch.parseSingle(`[notes.md#1A2B]\nPUT >2*:\n+- new entry`); const result = blankAnchored.applyTo("### Changed\n\n- old entry\n", () => null); expect(result.text).toBe("### Changed\n\n- new entry\n- old entry\n"); expect( - result.warnings?.some(w => /could not resolve a syntactic block.*applied as plain `INS.POST 2:`/.test(w)), + result.warnings?.some(w => /could not resolve a syntactic block.*applied as plain `PUT >2:`/.test(w)), ).toBe(true); }); @@ -452,14 +454,14 @@ describe("INS.BLK.POST", () => { const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null); const patcher = new Patcher({ fs, snapshots, blockResolver: resolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nINS.BLK.POST 3:\n+ done();`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT >3*:\n+ done();`)); expect(fs.get(PATH)).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); - expect(result.sections[0]?.warnings.some(w => /applied as plain `INS.POST 3:`/.test(w))).toBe(true); + expect(result.sections[0]?.warnings.some(w => /applied as plain `PUT >3:`/.test(w))).toBe(true); }); it("applyTo inserts the body after the resolved block's last line", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nINS.BLK.POST 2:\n+ done();`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >2*:\n+ done();`); // stub span [2,3] → body lands after " }" (line 3), before the final "}". expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); }); @@ -470,7 +472,7 @@ describe("INS.BLK.POST", () => { const tag = snapshots.record(PATH, text); const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nINS.BLK.POST 2:\n+ done();`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT >2*:\n+ done();`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index f5d043203..d6eb35577 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -7,6 +7,45 @@ function apply(text: string, diff: string): { text: string; warnings: string[] } } describe("boundary-balance repair", () => { + it("restores a uniformly omitted base indent from unchanged structural rows", () => { + const file = [ + " if (value > 90) {", + " result = error;", + " } else if (value > 70) {", + " result = plain;", + " } else {", + " result = warning;", + " }", + ].join("\n"); + const diff = [ + "PUT 2.=6:", + "+ result = error;", + "+} else if (value > 70) {", + "+ result = warning;", + "+} else {", + "+ result = plain;", + ].join("\n"); + const { text, warnings } = apply(file, diff); + expect(text).toBe( + [ + " if (value > 90) {", + " result = error;", + " } else if (value > 70) {", + " result = warning;", + " } else {", + " result = plain;", + " }", + ].join("\n"), + ); + expect(warnings.some(w => /Auto-indented a replacement body/.test(w))).toBe(true); + }); + it("preserves intentional indentation-only replacements", () => { + const file = [" first();", " second();"].join("\n"); + const { text, warnings } = apply(file, "PUT 1.=2:\n+first();\n+second();"); + expect(text).toBe("first();\nsecond();"); + expect(warnings.some(w => /Auto-indented a replacement body/.test(w))).toBe(false); + }); + // The canonical incident: a range-replace whose payload restates the // fragment + paren close that still live just below the range, doubling // `` and `);`. `replace 11.=31:` covers `const …` through the second `/>`. @@ -35,7 +74,7 @@ describe("boundary-balance repair", () => { // Range 7..16 = `const …` through the first `/>`; payload restates the // `` + `);` that survive at lines 17-18. const diff = [ - "SWAP 7.=16:", + "PUT 7-16:", "+\treturn (", "+\t\t<>", "+\t\t\t { const file = ["it('a', () => {", "\tsetup();", "\trun();", "});", "after();"].join("\n"); // `replace 2.=3:` replaces the two body lines but the payload also restates the // `});` at line 4, which survives — a duplicate close. - const diff = ["SWAP 2.=3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); + const diff = ["PUT 2-3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["it('a', () => {", "\tsetup2();", "\trun2();", "});", "after();"].join("\n")); expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); @@ -86,7 +125,7 @@ describe("boundary-balance repair", () => { // `replace 4.=6:` covers the params + return-type line, but the payload also // restates the `planRender(` at line 3, which survives — a duplicate open. const diff = [ - "SWAP 4.=6:", + "PUT 4-6:", "+\tplanRender(", "+\t\ta: string[],", "+\t\tb: boolean,", @@ -117,7 +156,7 @@ describe("boundary-balance repair", () => { const file = ["if (a) {", "\tfoo();", "}", "bar();"].join("\n"); // Payload duplicates `if (a) {` but is net +2 braces; dropping the one // opener cannot zero the delta, so nothing is repaired. - const diff = ["SWAP 2.=2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); + const diff = ["PUT 2-2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "if (a) {", "\tif (b) {", "\t\tfoo();", "}", "bar();"].join("\n")); expect(warnings).toHaveLength(0); @@ -128,7 +167,7 @@ describe("boundary-balance repair", () => { const file = ["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "};"].join("\n"); // `replace 5.=5:` is the final `};`. Model inserts a new method but forgets to // restate `};`; sparing it keeps the object literal balanced. - const diff = ["SWAP 5.=5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); + const diff = ["PUT 5-5:", "+\tb() {", "+\t\treturn 2;", "+\t},"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( ["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "\tb() {", "\t\treturn 2;", "\t},", "};"].join( @@ -143,7 +182,7 @@ describe("boundary-balance repair", () => { // keeping the deleted suffix would duplicate the closer outside the payload. it("does not spare a deleted closing line that the payload already restates", () => { const file = ["class Foo {", "\tok();", "\t}", "}"].join("\n"); - const diff = ["SWAP 1.=4:", "+class Foo {", "+\tok();", "+}"].join("\n"); + const diff = ["PUT 1-4:", "+class Foo {", "+\tok();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class Foo {", "\tok();", "}"].join("\n")); @@ -159,7 +198,7 @@ describe("boundary-balance repair", () => { "\tprint_status()", ].join("\n"); const diff = [ - "SWAP 2.=3:", + "PUT 2-3:", "+func _cmd_travel_homeworld():", "+\tvar destination = find_homeworld()", "+\ttravel_to(destination)", @@ -183,7 +222,7 @@ describe("boundary-balance repair", () => { it("preserves payloads where multi-line boundary echoes cover every line", () => { const file = ["A", "B", "old", "C", "D"].join("\n"); - const diff = ["SWAP 3.=3:", "+A", "+B", "+C", "+D"].join("\n"); + const diff = ["PUT 3-3:", "+A", "+B", "+C", "+D"].join("\n"); const { text, warnings } = apply(file, diff); @@ -193,7 +232,7 @@ describe("boundary-balance repair", () => { it("preserves payloads made only of lines matching both replacement neighbors", () => { const file = ["a", "old", "c"].join("\n"); - const diff = ["SWAP 2.=2:", "+a", "+c"].join("\n"); + const diff = ["PUT 2-2:", "+a", "+c"].join("\n"); const { text, warnings } = apply(file, diff); @@ -209,7 +248,7 @@ describe("boundary-balance repair", () => { // Payload deliberately opens with the same bare `}` that sits above the // range and closes with the same `}` that sits below it; the payload is // internally balanced (delta 0) while the dropped edges sum to -2 braces. - const diff = ["SWAP 2.=2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); + const diff = ["PUT 2-2:", "+}", "+if (a) {", "+if (b) {", "+x();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); @@ -221,7 +260,7 @@ describe("boundary-balance repair", () => { // (opener + closer) that duplicate the surviving neighbors are dropped. it("still drops a balance-neutral wrapper echo", () => { const file = ["function f() {", "old();", "}"].join("\n"); - const diff = ["SWAP 2.=2:", "+function f() {", "+fresh();", "+}"].join("\n"); + const diff = ["PUT 2-2:", "+function f() {", "+fresh();", "+}"].join("\n"); const { text, warnings } = apply(file, diff); @@ -235,7 +274,7 @@ describe("boundary-balance repair", () => { const file = ["foo();", "bar();", "bar();", "baz();"].join("\n"); // Replace line 2 with two balanced statements; the tail `bar();` equals // the surviving line 3 but the payload is balanced — must NOT be dropped. - const diff = ["SWAP 2.=2:", "+qux();", "+bar();"].join("\n"); + const diff = ["PUT 2-2:", "+qux();", "+bar();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["foo();", "qux();", "bar();", "bar();", "baz();"].join("\n")); expect(warnings).toHaveLength(0); @@ -245,7 +284,7 @@ describe("boundary-balance repair", () => { // could discard intended content, and it does not break syntax. it("does not drop a balance-neutral duplicated statement", () => { const file = ["a = 1;", "b = 2;", "c = 3;"].join("\n"); - const diff = ["SWAP 1.=1:", "+a = 1;", "+b = 2;"].join("\n"); + const diff = ["PUT 1-1:", "+a = 1;", "+b = 2;"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["a = 1;", "b = 2;", "b = 2;", "c = 3;"].join("\n")); expect(warnings).toHaveLength(0); @@ -254,7 +293,7 @@ describe("boundary-balance repair", () => { // Brackets inside strings must not trigger a spurious balance mismatch. it("ignores brackets inside string literals", () => { const file = ['const a = "}";', 'const b = "x";', 'const c = "y";'].join("\n"); - const diff = ["SWAP 2.=2:", '+const b = "}}}";'].join("\n"); + const diff = ["PUT 2-2:", '+const b = "}}}";'].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['const a = "}";', 'const b = "}}}";', 'const c = "y";'].join("\n")); expect(warnings).toHaveLength(0); @@ -265,7 +304,7 @@ describe("boundary-balance repair", () => { // the range was one line short of the `const changedFiles` it retyped. it("drops a one-sided trailing keeper echo in a multi-line rewrite", () => { const file = ["function f() {", " a();", " b();", " const out = [];", " return out;", "}"].join("\n"); - const diff = ["SWAP 2.=3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); + const diff = ["PUT 2-3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n")); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); @@ -273,7 +312,7 @@ describe("boundary-balance repair", () => { it("drops a one-sided JSX closer echo in a single-line expansion", () => { const file = ["const view = (", "
", " ", "
", ");"].join("\n"); - const diff = ["SWAP 3.=3:", "+ ", "+ "].join("\n"); + const diff = ["PUT 3-3:", "+ ", "+ "].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["const view = (", "
", " ", "
", ");"].join("\n")); @@ -283,7 +322,7 @@ describe("boundary-balance repair", () => { it("drops a JSX closer echo after a self-closing tag with a greater-than prop expression", () => { const file = ["const view = (", "", "old text", "", ");"].join("\n"); - const diff = ["SWAP 3.=3:", "+ b} />", "+"].join("\n"); + const diff = ["PUT 3-3:", "+ b} />", "+"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["const view = (", "", " b} />", "", ");"].join("\n")); @@ -293,7 +332,7 @@ describe("boundary-balance repair", () => { it("preserves a nested JSX closer that matches the surviving parent closer", () => { const file = ["const view = (", '
', "old text", "
", ");"].join("\n"); - const diff = ["SWAP 3.=3:", "+
", "+new text", "+
"].join("\n"); + const diff = ["PUT 3-3:", "+
", "+new text", "+
"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( @@ -313,7 +352,7 @@ describe("boundary-balance repair", () => { it("preserves a nested JSX closer when the opener spans payload lines", () => { const file = ["const view = (", '
', "old text", "
", ");"].join("\n"); - const diff = ["SWAP 3.=3:", "+", "+new text", "+"].join("\n"); + const diff = ["PUT 3-3:", "+", "+new text", "+"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( @@ -337,7 +376,7 @@ describe("boundary-balance repair", () => { // the multi-line range (range one line low instead of one short). it("drops a one-sided leading keeper echo in a multi-line rewrite", () => { const file = ["setup();", "a();", "b();", "c();"].join("\n"); - const diff = ["SWAP 3.=4:", "+a();", "+B();", "+C();"].join("\n"); + const diff = ["PUT 3-4:", "+a();", "+B();", "+C();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n")); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); @@ -356,7 +395,7 @@ describe("boundary-balance repair", () => { "}", ].join("\n"); const diff = [ - "SWAP 3.=4:", + "PUT 3-4:", "+ auto* handle = payloadFor(self);", "+ if (!handle || !handle->isStarted())", ].join("\n"); @@ -368,7 +407,7 @@ describe("boundary-balance repair", () => { // mentions it. it("rejects a trailing keeper echo when the payload cannot fill the widened range", () => { const file = ["a();", "b();", "c();", "keep();"].join("\n"); - const diff = ["SWAP 2.=3:", "+B();", "+keep();"].join("\n"); + const diff = ["PUT 2-3:", "+B();", "+keep();"].join("\n"); expect(() => apply(file, diff)).toThrow(/rejected: the body ends by restating/); }); @@ -384,7 +423,7 @@ describe("boundary-balance repair", () => { " }", " handle->setIdent(currentIdent());", ].join("\n"); - const diff = ["SWAP 4.=4:", "+ after();"].join("\n"); + const diff = ["PUT 4-4:", "+ after();"].join("\n"); expect(() => apply(file, diff)).toThrow(/before or after the closer is ambiguous/); }); @@ -392,7 +431,7 @@ describe("boundary-balance repair", () => { // spared closer claims the inside of the block, so the spare still fires. it("still spares a closer when the payload indentation claims the block interior", () => { const file = ["if (!global) {", " setDone();", " return;", "}", "after();"].join("\n"); - const diff = ["SWAP 4.=4:", "+ setIdent();"].join("\n"); + const diff = ["PUT 4-4:", "+ setIdent();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( ["if (!global) {", " setDone();", " return;", " setIdent();", "}", "after();"].join("\n"), @@ -404,7 +443,7 @@ describe("boundary-balance repair", () => { // the per-group repair wrongly kept it, leaving a stray `}`. it("does not keep a deleted closer when another hunk removes its opener (#3142)", () => { const file = ["if enabled {", '\tText("Old")', "}", '\tText("Tail")'].join("\n"); - const diff = ["CUT 1", "SWAP 2.=3:", '+Text("New")'].join("\n"); + const diff = ["CUT 1", "PUT 2-3:", '+Text("New")'].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['Text("New")', '\tText("Tail")'].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); @@ -414,7 +453,7 @@ describe("boundary-balance repair", () => { // residual must be spent on the genuine hunk, not the wrapper-removed one. it("spends the missing-closer residual on the genuine hunk, not an earlier wrapper removal", () => { const file = ["if enabled {", '\tText("Old")', "}", "const config = {", "\ta: 1,", "};"].join("\n"); - const diff = ["CUT 1", "SWAP 2.=3:", '+Text("New")', "SWAP 6.=6:", "+\tb: 2,"].join("\n"); + const diff = ["CUT 1", "PUT 2-3:", '+Text("New")', "PUT 6-6:", "+\tb: 2,"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['Text("New")', "const config = {", "\ta: 1,", "\tb: 2,", "};"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -424,7 +463,7 @@ describe("boundary-balance repair", () => { // the net deleted-prefix balance is zero, so the closer is correctly kept. it("keeps the closer when the matching opener is replaced rather than removed", () => { const file = ["if (a) {", "\told();", "}"].join("\n"); - const diff = ["SWAP 1.=1:", "+if (b) {", "SWAP 2.=3:", "+\tnew();"].join("\n"); + const diff = ["PUT 1-1:", "+if (b) {", "PUT 2-3:", "+\tnew();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (b) {", "\tnew();", "}"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -440,7 +479,7 @@ describe("boundary-balance repair", () => { "}", ].join("\n"); const diff = [ - "SWAP 3.=6:", + "PUT 3-6:", "+function supportsDevinThinking(config: ClientModelConfig): boolean {", "+\tif (NO_REASONING_LABEL_PATTERN.test(config.label)) return false;", "+\treturn config.supportsThinking === true;", @@ -462,7 +501,7 @@ describe("boundary-balance repair", () => { it("keeps only the non-restated outer closer for a nested deleted suffix", () => { const file = ["class C {", "\told();", "\t}", "}"].join("\n"); - const diff = ["SWAP 2.=4:", "+\tnewMethod() {", "+\t\treturn 1;", "+\t}"].join("\n"); + const diff = ["PUT 2-4:", "+\tnewMethod() {", "+\t\treturn 1;", "+\t}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class C {", "\tnewMethod() {", "\t\treturn 1;", "\t}", "}"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -470,7 +509,7 @@ describe("boundary-balance repair", () => { it("ignores non-contiguously deleted openers when choosing which closer to keep", () => { const file = ["if (a) {", "\told();", "\tmore();", "}", "const obj = {", "\ta: 1,", "};"].join("\n"); - const diff = ["CUT 1", "SWAP 3.=4:", "+\tnew();", "SWAP 7.=7:", "+\tb: 2,"].join("\n"); + const diff = ["CUT 1", "PUT 3-4:", "+\tnew();", "PUT 7-7:", "+\tb: 2,"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["\told();", "\tnew();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -486,9 +525,9 @@ describe("boundary-balance repair", () => { "\t}", ].join("\n"); const diff = [ - "SWAP 2.=3:", + "PUT 2-3:", "+\tnew();", - "SWAP 4.=6:", + "PUT 4-6:", "+function supportsDevinThinking(config: ClientModelConfig): boolean {", "+\treturn config.supportsThinking === true;", "+}", @@ -509,7 +548,7 @@ describe("boundary-balance repair", () => { it("does not let an earlier kept closer cover a later orphan closer", () => { const file = ["if (a) {", "\told();", "}", "}"].join("\n"); - const diff = ["SWAP 2.=3:", "+\tnew();", "SWAP 4.=4:", "+after();"].join("\n"); + const diff = ["PUT 2-3:", "+\tnew();", "PUT 4-4:", "+after();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "\tnew();", "}", "after();"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -517,7 +556,7 @@ describe("boundary-balance repair", () => { it("does not keep a deleted outer closer when one survives below the range", () => { const file = ["class C {", "\tmethod() {", "\t\told();", "\t}", "}", "}"].join("\n"); - const diff = ["SWAP 2.=5:", "+\tmethod() {", "+\t\tnew();", "+\t}"].join("\n"); + const diff = ["PUT 2-5:", "+\tmethod() {", "+\t\tnew();", "+\t}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class C {", "\tmethod() {", "\t\tnew();", "\t}", "}"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); @@ -525,23 +564,23 @@ describe("boundary-balance repair", () => { it("keeps an omitted inner closer when the outer closer survives below", () => { const file = ["class C {", "\tmethod() {", "\t\told();", "\t}", "}", "}"].join("\n"); - const diff = ["SWAP 2.=5:", "+\tmethod() {", "+\t\tnew();"].join("\n"); + const diff = ["PUT 2-5:", "+\tmethod() {", "+\t\tnew();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class C {", "\tmethod() {", "\t\tnew();", "\t}", "}"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); }); - it("counts same-line inserted prefixes before replacement payload", () => { + it("counts head insertions before replacement payloads in original coordinates", () => { const file = ["\told();", "}"].join("\n"); - const diff = ["INS.PRE 1:", "+if (a) {", "SWAP 1.=2:", "+\tnew();"].join("\n"); + const diff = ["PUT <1:", "+if (a) {", "PUT 1-2:", "+\tnew();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "\tnew();", "}"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(warnings.some(warning => /kept 1 structural closing line/.test(warning))).toBe(true); }); it("counts a separately inserted closer immediately below the range", () => { const file = ["class C {", "\told();", "}", "after();", "const obj = {", "\ta: 1,", "};"].join("\n"); - const diff = ["SWAP 2.=3:", "+\tnew();", "INS.PRE 4:", "+}", "SWAP 7.=7:", "+\tb: 2,"].join("\n"); + const diff = ["PUT 2-3:", "+\tnew();", "PUT <4:", "+}", "PUT 7-7:", "+\tb: 2,"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( ["class C {", "\tnew();", "}", "after();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n"), @@ -551,7 +590,7 @@ describe("boundary-balance repair", () => { it("keeps an omitted outer closer even when the payload restates an inner closer", () => { const file = ["if (a) {", "\tif (b) {", "\t\told();", "\t}", "}", "after();"].join("\n"); - const diff = ["SWAP 1.=5:", "+if (a) {", "+\tif (c) {", "+\t\tnew();", "+\t}"].join("\n"); + const diff = ["PUT 1-5:", "+if (a) {", "+\tif (c) {", "+\t\tnew();", "+\t}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "\tif (c) {", "\t\tnew();", "\t}", "}", "after();"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -570,7 +609,7 @@ describe("boundary-balance repair", () => { "\ta: 1,", "};", ].join("\n"); - const diff = ["SWAP 2.=3:", "+\tsetup();", "+\tfoo();", "+\tbar();", "+});", "SWAP 8.=8:", "+\tb: 2,"].join("\n"); + const diff = ["PUT 2-3:", "+\tsetup();", "+\tfoo();", "+\tbar();", "+});", "PUT 8-8:", "+\tb: 2,"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe( [ @@ -594,7 +633,7 @@ describe("boundary-balance repair", () => { // bleed across into another hunk's delimiter count and mask its missing closer. it("does not let an unterminated template in one hunk mask a missing closer in another", () => { const file = ["const log = makeLog(`", "prefix", "`);", "const obj = {", "\ta: 1", "};"].join("\n"); - const diff = ["SWAP 1.=1:", "+const log = createLog(`", "SWAP 5.=6:", "+\ta: 2"].join("\n"); + const diff = ["PUT 1-1:", "+const log = createLog(`", "PUT 5-6:", "+\ta: 2"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["const log = createLog(`", "prefix", "`);", "const obj = {", "\ta: 2", "};"].join("\n")); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); @@ -636,7 +675,7 @@ describe("boundary-balance repair through stale-snapshot recovery", () => { // `replace 4.=5:` replaces the body lines but the payload also restates the `});` // that survives at line 6 — the duplicate-closer mistake. - const { edits } = parsePatch(["SWAP 4.=5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); + const { edits } = parsePatch(["PUT 4-5:", "+\tsetup2();", "+\trun2();", "+});"].join("\n")); const recovered = new Recovery(store).tryRecover({ path: PATH, currentText, fileHash, edits }); expect(recovered).not.toBeNull(); diff --git a/packages/hashline/test/clipboard.test.ts b/packages/hashline/test/clipboard.test.ts index 7b755b6bf..5a3216913 100644 --- a/packages/hashline/test/clipboard.test.ts +++ b/packages/hashline/test/clipboard.test.ts @@ -4,7 +4,6 @@ import { type BlockResolver, type BlockSpan, type Clipboard, - computeFileHash, InMemoryFilesystem, InMemorySnapshotStore, Patch, @@ -33,15 +32,15 @@ function taggedPatcher(files: Array<[string, string]>): { } describe("clipboard parsing", () => { - it("lowers `CUT N.=M` to a capture plus per-line deletes", () => { - const cut = parsePatch("CUT 2.=3").edits; + it("lowers `CUT N-M` to a capture plus per-line deletes", () => { + const cut = parsePatch("CUT 2-3").edits; expect(cut.map(edit => edit.kind)).toEqual(["cut", "delete", "delete"]); expect(cut[0]).toMatchObject({ kind: "cut", range: { start: { line: 2 }, end: { line: 3 } } }); }); - it("parses every PASTE position, tolerating a trailing colon", () => { - const cursors = parsePatch("CUT 1\nPASTE.PRE 2\nPASTE.POST 3:\nPASTE.HEAD\nPASTE.TAIL:").edits.flatMap(edit => - edit.kind === "paste" ? [edit.cursor] : [], + it("parses every PUT paste locator, rejecting colons on register PUTs", () => { + const cursors = parsePatch("CUT 1\nPUT <2\nPUT >3\nPUT <1\nPUT >$").edits.flatMap(edit => + edit.kind === "paste" && edit.at.kind === "gap" ? [edit.at.cursor] : [], ); expect(cursors).toEqual([ { kind: "before_anchor", anchor: { line: 2 } }, @@ -51,114 +50,118 @@ describe("clipboard parsing", () => { ]); }); - it("rejects bare `PASTE` without a position", () => { - expect(() => parsePatch("PASTE")).toThrow(/`PASTE` needs a position/); + it("rejects colon on register PUT", () => { + expect(() => parsePatch("PUT >2 @name:")).toThrow(/never takes `:`/); }); - it("rejects body rows under clipboard ops", () => { - expect(() => parsePatch("CUT 1.=2\n+x")).toThrow(/`CUT N.=M` captures \+ deletes/); - expect(() => parsePatch("CUT 1\nPASTE.POST 1\n+x")).toThrow(/`PASTE` inserts the clipboard content/); + it("rejects body rows under CUT", () => { + expect(() => parsePatch("CUT 1-2\n+x")).toThrow(/`CUT` deletes/); }); it("rejects a CUT range overlapping another hunk's range", () => { - expect(() => parsePatch("CUT 2.=4\nSWAP 3.=3:\n+x")).toThrow(/already targeted by another hunk/); + expect(() => parsePatch("CUT 2-4\nPUT 3:\n+x")).toThrow(/already targeted by another hunk/); }); it("reports inverted CUT ranges with op-specific retry forms", () => { - expect(() => parsePatch("CUT 5.=2")).toThrow(/`CUT 5`.*`CUT 5.=6`/); + expect(() => parsePatch("CUT 5-2")).toThrow(/`CUT 5`.*`CUT 5\.=6`/); }); it("flushes a trailing bodyless clipboard op in streaming mode", () => { - const { edits } = parsePatchStreaming("CUT 1\nPASTE.TAIL"); + const { edits } = parsePatchStreaming("CUT 1\nPUT >$"); expect(edits.map(edit => edit.kind)).toEqual(["cut", "delete", "paste"]); }); }); describe("clipboard apply semantics", () => { - it("moves a range within a file (CUT + PASTE)", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=3\nPASTE.POST 5`); + it("moves a range within a file (CUT + PUT)", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2-3\nPUT >5`); expect(section.applyTo("l1\nl2\nl3\nl4\nl5\n").text).toBe("l1\nl4\nl5\nl2\nl3\n"); }); it("repeats CUT content without consuming the clipboard", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=2\nPASTE.HEAD\nPASTE.TAIL`); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2\nPUT <1\nPUT >$`); expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l2\nl1\nl3\nl2\n"); }); - it("swaps two regions with sequential CUT/PASTE pairs on original coordinates", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=2\nPASTE.POST 4\nCUT 3.=4\nPASTE.PRE 1`); + it("swaps two regions with named registers", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1-2 @a\nCUT 3-4 @b\nPUT <1 @b\nPUT >$ @a`); expect(section.applyTo("a1\na2\nb1\nb2").text).toBe("b1\nb2\na1\na2"); }); - it("rejects PASTE with an empty register", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`); - expect(() => section.applyTo("l1\nl2\n")).toThrow(/found nothing in the clipboard/); + it("rejects PUT with an empty register", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >1`); + expect(() => section.applyTo("l1\nl2\n")).toThrow(/Nothing to paste/); }); - it("drops an empty-register PASTE on the streaming-tolerant path", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`); + 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"); }); - it("allows a CUT without a following PASTE", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=2`); + it("allows a CUT without a following PUT", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2`); expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l1\nl3\n"); }); - it("allows consecutive CUTs and pastes the latest capture", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=1\nCUT 3.=3\nPASTE.TAIL`); + it("rejects multiple unlabeled CUTs before an unlabeled paste", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1\nCUT 3\nPUT >$`); + expect(() => section.applyTo("l1\nl2\nl3\n")).toThrow(/unlabeled `CUT`s are pending/); + }); + + it("allows consecutive CUTs into named registers", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1 @first\nCUT 3 @second\nPUT >$ @second`); expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l2\nl3\n"); }); it("rejects an out-of-range capture", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 8.=9\nPASTE.HEAD`); - expect(() => section.applyTo("l1\nl2\n")).toThrow(/out of range \(file has 3 lines\)/); + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 8-9\nPUT <1`); + expect(() => section.applyTo("l1\nl2\n")).toThrow(/out of range/); }); - it("threads a caller-owned register across applyEdits calls", () => { + it("threads host-owned named registers across applyEdits calls", () => { const clipboard: Clipboard = {}; - const cut = parsePatch("CUT 1.=1").edits; - const paste = parsePatch("PASTE.TAIL").edits; + const cut = parsePatch("CUT 1 @r").edits; + const paste = parsePatch("PUT >$ @r").edits; expect(applyEdits("a\nb", cut, { clipboard }).text).toBe("b"); expect(applyEdits("x\ny", paste, { clipboard }).text).toBe("x\ny\na"); }); }); describe("clipboard block ops", () => { - it("expands CUT.BLK to a span capture plus per-line deletes", () => { - const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits; + it("expands CUT N* to a span capture plus per-line deletes", () => { + const edits = parsePatch("CUT 2*\nPUT >$").edits; const resolved = resolveBlockEdits(edits, "l1\nl2\nl3\nl4", PATH, stubResolver); expect(resolved.map(edit => edit.kind)).toEqual(["cut", "delete", "delete", "paste"]); expect(resolved[0]).toMatchObject({ kind: "cut", range: { start: { line: 2 }, end: { line: 3 } } }); }); - it("moves a block after another block via PASTE.BLK.POST", () => { - const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT.BLK 1\nPASTE.BLK.POST 3`); + it("moves a block after another block via PUT >N*", () => { + const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1*\nPUT >3*`); // stub blocks: [1,2] and [3,4]. expect(section.applyTo("a1\na2\nb1\nb2\nrest", stubResolver).text).toBe("b1\nb2\na1\na2\nrest"); }); it("echoes clipboard block resolutions with their op", () => { const seen: string[] = []; - resolveBlockEdits(parsePatch("CUT.BLK 2\nPASTE.TAIL").edits, "l1\nl2\nl3", PATH, stubResolver, { + resolveBlockEdits(parsePatch("CUT 2*\nPUT >$").edits, "l1\nl2\nl3", PATH, stubResolver, { onResolved: resolution => seen.push(resolution.op), }); expect(seen).toEqual(["cut"]); }); - it("rejects a single-line CUT.BLK resolution with the plain-op retry", () => { + it("rejects a single-line CUT N* resolution with the plain-op retry", () => { const single: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line }); - const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits; + const edits = parsePatch("CUT 2*\nPUT >$").edits; expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, single)).toThrow(/use `CUT 2`/); }); - it("lowers an unresolvable PASTE.BLK.POST to a plain paste with a warning", () => { + it("lowers an unresolvable PUT >N* to a plain paste with a warning", () => { const warnings: string[] = []; - const resolved = resolveBlockEdits(parsePatch("CUT 1\nPASTE.BLK.POST 2").edits, "a\nb\nc", PATH, () => null, { + const resolved = resolveBlockEdits(parsePatch("CUT 1\nPUT >2*").edits, "a\nb\nc", PATH, () => null, { onWarning: warning => warnings.push(warning), }); expect(resolved.map(edit => edit.kind)).toEqual(["cut", "delete", "paste"]); - expect(warnings.some(warning => warning.includes("`PASTE.BLK.POST 2`"))).toBe(true); + expect(warnings.some(warning => warning.includes("`PUT >2*`"))).toBe(true); }); }); @@ -171,118 +174,12 @@ describe("clipboard across sections and batches", () => { ["b.ts", b], ]); - const patch = Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCUT 2.=3\n[b.ts#${tags.get("b.ts")}]\nPASTE.TAIL`); + const input = [`[a.ts#${tags.get("a.ts")}]`, "CUT 2-3", `[b.ts#${tags.get("b.ts")}]`, "PUT >$"].join("\n"); + + const patch = Patch.parse(input); await patcher.apply(patch); - expect(fs.get("a.ts")).toBe("keep\n"); - expect(fs.get("b.ts")).toBe("b1\nmove1\nmove2\n"); - }); - - it("applies a batch-local CUT without requiring PASTE", async () => { - const a = "l1\nl2\n"; - const { fs, patcher, tags } = taggedPatcher([["a.ts", a]]); - - await patcher.apply(Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCUT 1.=1`)); - expect(fs.get("a.ts")).toBe("l2\n"); - }); - - it("persists a host-owned register across apply calls", async () => { - const a = "l1\nl2\n"; - const b = "b1\n"; - const fs = new InMemoryFilesystem([ - ["a.ts", a], - ["b.ts", b], - ]); - const snapshots = new InMemorySnapshotStore(); - const tagA = snapshots.record("a.ts", a); - const tagB = snapshots.record("b.ts", b); - const clipboard: Clipboard = {}; - const patcher = new Patcher({ fs, snapshots, clipboard }); - - const first = await patcher.apply(Patch.parse(`[a.ts#${tagA}]\nCUT 1.=1`)); - expect(fs.get("a.ts")).toBe("l2\n"); - expect(first.sections[0]?.warnings).toEqual([]); - expect(clipboard).toEqual({ lines: ["l1"] }); - - await patcher.apply(Patch.parse(`[b.ts#${tagB}]\nPASTE.TAIL`)); - expect(fs.get("b.ts")).toBe("b1\nl1\n"); - expect(clipboard).toEqual({ lines: ["l1"] }); - }); - - it("does not publish register changes from a failed batch", async () => { - const a = "l1\nl2\n"; - const b = "b1\n"; - const fs = new InMemoryFilesystem([ - ["a.ts", a], - ["b.ts", b], - ]); - const snapshots = new InMemorySnapshotStore(); - const tagA = snapshots.record("a.ts", a); - snapshots.record("b.ts", b); - const clipboard: Clipboard = {}; - const patcher = new Patcher({ fs, snapshots, clipboard }); - - // Second section carries a bogus tag, so the batch fails in prepare. - const patch = Patch.parse(`[a.ts#${tagA}]\nCUT 1.=1\n[b.ts#0000]\nSWAP 1.=1:\n+x`); - await expect(patcher.apply(patch)).rejects.toThrow(); - - expect(fs.get("a.ts")).toBe(a); - expect(clipboard.lines).toBeUndefined(); - }); - - it("recovers clipboard anchors when the file drifted by a uniform offset", async () => { - const original = "x1\nx2\nx3\n"; - const live = "new\nx1\nx2\nx3\n"; - const fs = new InMemoryFilesystem([["a.ts", live]]); - const snapshots = new InMemorySnapshotStore(); - const tag = snapshots.record("a.ts", original); - const patcher = new Patcher({ fs, snapshots }); - - const result = await patcher.apply(Patch.parse(`[a.ts#${tag}]\nCUT 2.=2\nPASTE.TAIL`)); - - expect(fs.get("a.ts")).toBe("new\nx1\nx3\nx2\n"); - expect(result.sections[0]?.warnings.length).toBeGreaterThan(0); - }); - - it("rejects clipboard ops in same-path sections interleaved across another file", () => { - const patch = Patch.parse(`[a.ts#1A2B]\nCUT 1.=1\n[b.ts#3C4D]\nSWAP 1.=1:\n+x\n[a.ts#1A2B]\nPASTE.TAIL`); - expect(() => patch.sections[0]?.edits).toThrow(/interleaved with another file/); - }); - - it("still merges interleaved same-path sections without clipboard ops", () => { - const patch = Patch.parse( - `[a.ts#1A2B]\nSWAP 1.=1:\n+a1\n[b.ts#3C4D]\nSWAP 1.=1:\n+b1\n[a.ts#1A2B]\nSWAP 3.=3:\n+a3`, - ); - expect(patch.sections).toHaveLength(2); - expect(patch.sections[0]?.edits.map(edit => edit.kind)).toEqual(["insert", "delete", "insert", "delete"]); - }); - - it("surfaces a targeted sequencing error instead of a mismatch on the drift path", async () => { - const original = "x1\nx2\nx3\n"; - const live = "new\nx1\nx2\nx3\n"; - const fs = new InMemoryFilesystem([["a.ts", live]]); - const snapshots = new InMemorySnapshotStore(); - const tag = snapshots.record("a.ts", original); - const patcher = new Patcher({ fs, snapshots }); - - // PASTE with nothing captured, against a drifted tag: the recovery path - // swallows apply errors, so the sequencing check must fire first. - await expect(patcher.apply(Patch.parse(`[a.ts#${tag}]\nPASTE.POST 2\nCUT 2.=2`))).rejects.toThrow( - /found nothing in the clipboard/, - ); - }); -}); - -describe("clipboard header hash interplay", () => { - it("applies a paste-only section on the clean-tag fast path", async () => { - const text = "l1\nl2\n"; - const fs = new InMemoryFilesystem([["a.ts", text]]); - const snapshots = new InMemorySnapshotStore(); - expect(computeFileHash(text)).toBe(snapshots.record("a.ts", text)); - const clipboard: Clipboard = { lines: ["from-before"] }; - const patcher = new Patcher({ fs, snapshots, clipboard }); - - await patcher.apply(Patch.parse(`[a.ts#${computeFileHash(text)}]\nPASTE.POST 1`)); - expect(fs.get("a.ts")).toBe("l1\nfrom-before\nl2\n"); + expect(await fs.readText("a.ts")).toBe("keep\n"); + expect(await fs.readText("b.ts")).toBe("b1\nmove1\nmove2\n"); }); }); diff --git a/packages/hashline/test/core-contracts.test.ts b/packages/hashline/test/core-contracts.test.ts index 12b92bd48..817469925 100644 --- a/packages/hashline/test/core-contracts.test.ts +++ b/packages/hashline/test/core-contracts.test.ts @@ -21,7 +21,7 @@ function tag(line: number): string { } function sameLineRange(anchor: string): string { - return `SWAP ${anchor}..${anchor}:`; + return `PUT ${anchor}:`; } function applyDiff(content: string, diff: string): string { @@ -87,50 +87,50 @@ describe("hashline parser — range-anchor contracts", () => { const content = "aaa\nbbb\nccc"; it("keeps parsed sections reusable across target snapshots", () => { - const section = Patch.parseSingle(["[a.ts]", `INS.POST ${tag(2)}:`, repl("tail")].join("\n")); + const section = Patch.parseSingle(["[a.ts]", `PUT >${tag(2)}:`, repl("tail")].join("\n")); expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); }); - it("applies replace/cut/insert operations against concrete anchors", () => { + it("applies canonical PUT/CUT operations against concrete anchors", () => { const diff = [ - `INS.PRE ${tag(2)}:`, + `PUT <${tag(2)}:`, repl("before b"), - `INS.POST ${tag(2)}:`, + `PUT >${tag(2)}:`, repl("after b"), - "INS.HEAD:", + "PUT <1:", repl("top"), - "INS.TAIL:", + "PUT >$:", repl("tail"), ].join("\n"); expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); expect(applyDiff(content, `CUT ${tag(2)}`)).toBe("aaa\nccc"); - expect(applyDiff(content, `CUT ${tag(2)}..${tag(3)}`)).toBe("aaa"); + expect(applyDiff(content, `CUT ${tag(2)}-${tag(3)}`)).toBe("aaa"); expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc"); }); it("inserts after the final line without falling off the file", () => { - expect(applyDiff(content, `INS.POST ${tag(3)}:\n${repl("tail")}`)).toBe("aaa\nbbb\nccc\ntail"); + expect(applyDiff(content, `PUT >${tag(3)}:\n${repl("tail")}`)).toBe("aaa\nbbb\nccc\ntail"); }); it("preserves whitespace-bearing and sigil-leading payload exactly", () => { const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; - expect(applyDiff(content, `INS.POST ${tag(2)}:\n${repl(payload)}`)).toBe(`aaa\nbbb\n${payload}\nccc`); + expect(applyDiff(content, `PUT >${tag(2)}:\n${repl(payload)}`)).toBe(`aaa\nbbb\n${payload}\nccc`); expect( applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`), ).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); }); it("strips copied read-output prefixes only inside pasted bare body rows", () => { - const diff = `SWAP ${tag(2)}..${tag(4)}:\n${repl("line one")}\n${tag(3)}:line two`; + const diff = `PUT ${tag(2)}-${tag(4)}:\n${repl("line one")}\n${tag(3)}:line two`; const { edits, warnings } = parsePatch(diff); expect(applyEdits("aaa\nbbb\nccc\nddd\neee", edits).text).toBe("aaa\nline one\nline two\neee"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("rejects overlapping replacement ranges", () => { - const diff = `SWAP ${tag(2)}..${tag(4)}:\n${repl("NEW1")}\nSWAP ${tag(3)}..${tag(5)}:\n${repl("NEW2")}`; + const diff = `PUT ${tag(2)}-${tag(4)}:\n${repl("NEW1")}\nPUT ${tag(3)}-${tag(5)}:\n${repl("NEW2")}`; expect(() => parsePatch(diff).edits).toThrow(/anchor line 3 is already targeted by another hunk on line 1/); }); @@ -151,34 +151,34 @@ describe("hashline input splitter", () => { }); it("normalizes leading blanks, cwd-relative paths, and explicit fallback paths", () => { - expect(splitHashlineInput(`\n[foo.ts]\nINS.HEAD:\n${repl("x")}`)).toEqual({ + expect(splitHashlineInput(`\n[foo.ts]\nPUT <1:\n${repl("x")}`)).toEqual({ path: "foo.ts", - diff: `INS.HEAD:\n${repl("x")}`, + diff: `PUT <1:\n${repl("x")}`, }); const cwd = process.cwd(); const absolute = `${cwd}/src/foo.ts`; - expect(splitHashlineInput(`[${absolute}]\nINS.HEAD:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); - expect(splitHashlineInput(`INS.HEAD:\n${repl("x")}`, { path: "a.ts" })).toEqual({ + expect(splitHashlineInput(`[${absolute}]\nPUT <1:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); + expect(splitHashlineInput(`PUT <1:\n${repl("x")}`, { path: "a.ts" })).toEqual({ path: "a.ts", - diff: `INS.HEAD:\n${repl("x")}`, + diff: `PUT <1:\n${repl("x")}`, }); expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); }); it("splits multiple sections and drops a trailing header without operations", () => { - const input = ["[a.ts]", "INS.HEAD:", repl("a"), "[b.ts]", "INS.TAIL:", repl("b")].join("\n"); + const input = ["[a.ts]", "PUT <1:", repl("a"), "[b.ts]", "PUT >$:", repl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `INS.HEAD:\n${repl("a")}` }, - { path: "b.ts", diff: `INS.TAIL:\n${repl("b")}` }, + { path: "a.ts", diff: `PUT <1:\n${repl("a")}` }, + { path: "b.ts", diff: `PUT >$:\n${repl("b")}` }, ]); - expect(splitHashlineInputs(["[a.ts]", "INS.HEAD:", repl("a"), "[b.ts]"].join("\n"))).toEqual([ - { path: "a.ts", diff: `INS.HEAD:\n${repl("a")}` }, + expect(splitHashlineInputs(["[a.ts]", "PUT <1:", repl("a"), "[b.ts]"].join("\n"))).toEqual([ + { path: "a.ts", diff: `PUT <1:\n${repl("a")}` }, ]); }); it("rejects unified-diff hunk headers on the first line", () => { - const input = ["@@ -1,3 +1,3 @@", "INS.HEAD:", repl("x")].join("\n"); + const input = ["@@ -1,3 +1,3 @@", "PUT <1:", repl("x")].join("\n"); expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/); }); }); @@ -247,7 +247,7 @@ describe("Recovery", () => { path: filePath, currentText, tag: v0Tag, - edits: parsePatch(`SWAP 10.=10:\n${repl("L10-EDITED")}`).edits, + edits: parsePatch(`PUT 10-10:\n${repl("L10-EDITED")}`).edits, }); expect(recovered).not.toBeNull(); @@ -259,7 +259,7 @@ describe("hashline abort sentinel", () => { const sentinel = "*** Abort"; it("terminates parsing without surfacing a warning", () => { - const diff = [`INS.POST ${tag(1)}:`, repl("HELLO"), sentinel, `INS.POST ${tag(99)}:`, repl("never")].join("\n"); + const diff = [`PUT >${tag(1)}:`, repl("HELLO"), sentinel, `PUT >${tag(99)}:`, repl("never")].join("\n"); const { edits, warnings } = parsePatch(diff); expect(edits).toHaveLength(1); expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); @@ -269,11 +269,11 @@ describe("hashline abort sentinel", () => { it("stops the input splitter before later sections", () => { const input = [ "[a.ts]", - `INS.POST ${tag(1)}:`, + `PUT >${tag(1)}:`, repl("a-payload"), sentinel, "[b.ts]", - `INS.POST ${tag(1)}:`, + `PUT >${tag(1)}:`, repl("never"), ].join("\n"); const sections = splitHashlineInputs(input); @@ -284,14 +284,11 @@ describe("hashline abort sentinel", () => { }); describe("hashline parser — cut and blank payload semantics", () => { - it("applies inline cut and empty replace operations", () => { + it("applies inline cut operations", () => { expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nCUT 2\n").diff)).toBe("line1\nline3\n"); - expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\nCUT 2.=3\n").diff)).toBe( + expect(applyDiff("line1\nline2\nline3\nline4\n", splitHashlineInput("[a.ts]\nCUT 2-3\n").diff)).toBe( "line1\nline4\n", ); - expect(applyDiff("line1\nline2\nline3\n", splitHashlineInput("[a.ts]\nSWAP 2.=2:\n").diff)).toBe( - "line1\nline3\n", - ); }); it("treats old inline replacement syntax as orphan body", () => { @@ -301,10 +298,10 @@ describe("hashline parser — cut and blank payload semantics", () => { it("preserves explicit blank replacement rows", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `[a.ts]\nSWAP 2.=2:\n${repl("")}\n${repl("")}\nSWAP 4.=4:\n${repl("D")}\n`; + const ops = `[a.ts]\nPUT 2-2:\n${repl("")}\n${repl("")}\nPUT 4-4:\n${repl("D")}\n`; expect(applyDiff(text, splitHashlineInput(ops).diff)).toBe("a\n\n\nc\nD\ne\n"); - const embedded = `[a.ts]\nSWAP 2.=2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + const embedded = `[a.ts]\nPUT 2-2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; expect(applyDiff("a\nb\nc\n", splitHashlineInput(embedded).diff)).toBe("a\nfirst\n\nsecond\nc\n"); }); }); diff --git a/packages/hashline/test/file-ops.test.ts b/packages/hashline/test/file-ops.test.ts index 174ee211b..f7298e896 100644 --- a/packages/hashline/test/file-ops.test.ts +++ b/packages/hashline/test/file-ops.test.ts @@ -15,7 +15,7 @@ const CONTENT = "one\ntwo\nthree\n"; describe("hashline file ops", () => { it("parses REM and rejects line ops in the same section", () => { expect(parsePatch("REM").fileOp).toEqual({ kind: "rem" }); - expect(() => parsePatch(`SWAP 1.=1:\n+one\nREM`)).toThrow(/REM.*line ops/); + expect(() => parsePatch(`PUT 1-1:\n+one\nREM`)).toThrow(/REM.*line ops/); }); it("parses MV with a normalized destination path", () => { @@ -59,7 +59,7 @@ describe("hashline file ops", () => { const tag = snapshots.record(PATH, CONTENT); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+TWO\nMV ${DEST}`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2-2:\n+TWO\nMV ${DEST}`)); expect(result.sections[0]?.moveDest).toBe(DEST); expect(fs.get(PATH)).toBeUndefined(); diff --git a/packages/hashline/test/format-v2.test.ts b/packages/hashline/test/format-v2.test.ts index 007b68b40..b41f203f4 100644 --- a/packages/hashline/test/format-v2.test.ts +++ b/packages/hashline/test/format-v2.test.ts @@ -8,14 +8,14 @@ function applyPatch(text: string, diff: string): string { describe("hashline format v4", () => { it("replaces a concrete range with literal body rows in textual order", () => { const text = "a\nb\nc"; - const diff = ["SWAP 2.=2:", "+before", "+after"].join("\n"); + const diff = ["PUT 2.=2:", "+before", "+after"].join("\n"); expect(applyPatch(text, diff)).toBe("a\nbefore\nafter\nc"); }); it("deletes a single source line", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "CUT 2")).toBe("a\nc"); + expect(applyPatch(text, "CUT 2.=2")).toBe("a\nc"); }); it("deletes a concrete range", () => { @@ -23,28 +23,31 @@ describe("hashline format v4", () => { expect(applyPatch(text, "CUT 2.=3")).toBe("a\nd"); }); - it("accepts a single dot between integer range endpoints", () => { + it("leniently accepts common range separator variants", () => { const text = "a\nb\nc\nd"; - expect(applyPatch(text, "CUT 2.3")).toBe("a\nd"); - expect(applyPatch(text, "SWAP 2.3:\n+middle")).toBe("a\nmiddle\nd"); + for (const separator of ["-", ".", "=", "..", "…", " "]) { + expect(applyPatch(text, `CUT 2${separator}3`)).toBe("a\nd"); + expect(applyPatch(text, `PUT 2${separator}3:\n+middle`)).toBe("a\nmiddle\nd"); + } }); it("inserts before and after concrete anchors", () => { const text = "a\nb\nc"; - const diff = ["INS.PRE 2:", "+before", "INS.POST 2:", "+after"].join("\n"); + const diff = ["PUT <2:", "+before", "PUT >2:", "+after"].join("\n"); expect(applyPatch(text, diff)).toBe("a\nbefore\nb\nafter\nc"); }); it("inserts at head and tail", () => { const text = "a\nb"; - expect(applyPatch(text, "INS.HEAD:\n+HEAD")).toBe("HEAD\na\nb"); - expect(applyPatch(text, "INS.TAIL:\n+TAIL")).toBe("a\nb\nTAIL"); + expect(applyPatch(text, "PUT <1:\n+HEAD")).toBe("HEAD\na\nb"); + expect(applyPatch(text, "PUT >$:\n+TAIL")).toBe("a\nb\nTAIL"); }); - it("treats an empty replace hunk as a delete and still rejects empty inserts", () => { - const text = "a\nb\nc"; - expect(applyPatch(text, "SWAP 2.=2:")).toBe("a\nc"); - expect(() => parsePatch("INS.HEAD:")).toThrow(/needs at least one/); + it("treats an empty replace as deletion while still rejecting an empty insert", () => { + const result = parsePatch("PUT 2-3:"); + expect(applyEdits("a\nb\nc\nd", result.edits).text).toBe("a\nd"); + expect(result.warnings.some(w => /empty `PUT` body as deletion/.test(w))).toBe(true); + expect(() => parsePatch("PUT <1:")).toThrow(/promises body rows/); }); it("rejects body rows under cut", () => { @@ -59,8 +62,8 @@ describe("hashline format v4", () => { it("auto-pipes bare body rows as literal text", () => { const text = "a\nb\nc"; - expect(applyPatch(text, "SWAP 2.=2:\nraw")).toBe("a\nraw\nc"); - const { warnings } = parsePatch("SWAP 2.=2:\nraw"); + expect(applyPatch(text, "PUT 2-2:\nraw")).toBe("a\nraw\nc"); + const { warnings } = parsePatch("PUT 2-2:\nraw"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); @@ -68,13 +71,13 @@ describe("hashline format v4", () => { const text = "a\nb\nc"; // Without this fix, "3:text" becomes literal "3:text" in the file. // With the fix, the "3:" prefix is stripped, yielding just "text". - const { edits, warnings } = parsePatch("SWAP 2.=2:\n3:replaced"); + const { edits, warnings } = parsePatch("PUT 2-2:\n3:replaced"); expect(applyEdits(text, edits).text).toBe("a\nreplaced\nc"); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("validates insert anchors against file bounds", () => { - const edits = parsePatch("INS.PRE 4:\n+x").edits; + const edits = parsePatch("PUT <4:\n+x").edits; expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/); }); @@ -86,9 +89,7 @@ describe("hashline format v4", () => { }); it("rejects safe-integer ranges above the expansion limit", () => { - expect(() => parsePatch("SWAP 1.=100001:\n+x")).toThrow( - /replace range spans 100001 lines; the maximum is 100000/, - ); + expect(() => parsePatch("PUT 1-100001:\n+x")).toThrow(/replace range spans 100001 lines; the maximum is 100000/); }); it("ignores cutting the trailing blank sentinel of a newline-terminated file", () => { @@ -98,17 +99,17 @@ describe("hashline format v4", () => { }); it("treats a cut range ending at the trailing sentinel as ending at the last real line", () => { - const edits = parsePatch("CUT 2.=3").edits; + const edits = parsePatch("CUT 2-3").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\n"); }); it("treats a replace range ending at the trailing sentinel as ending at the last real line", () => { - const edits = parsePatch("SWAP 2.=3:\n+B").edits; + const edits = parsePatch("PUT 2-3:\n+B").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n"); }); it("still allows inserts anchored on the trailing blank sentinel", () => { - const edits = parsePatch("INS.POST 3:\n+tail").edits; + const edits = parsePatch("PUT >3:\n+tail").edits; expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n\ntail"); }); @@ -119,12 +120,15 @@ describe("hashline format v4", () => { }); it("does not flush a trailing streaming pending empty replace hunk", () => { - const result = parsePatchStreaming("SWAP 5.=5:\n"); + const result = parsePatchStreaming("PUT 5-5:\n"); expect(result.edits).toEqual([]); }); - it("flushes a streaming empty replace hunk when another hunk starts", () => { - const result = parsePatchStreaming("SWAP 2.=2:\nINS.TAIL:\n"); - expect(result.edits).toEqual([{ kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }]); + it("flushes a streaming CUT hunk when another hunk starts", () => { + const result = parsePatchStreaming("CUT 2\nPUT >$:\n"); + expect(result.edits).toEqual([ + { kind: "cut", range: { start: { line: 2 }, end: { line: 2 } }, lineNum: 1, index: 0 }, + { kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 1 }, + ]); }); }); diff --git a/packages/hashline/test/landing-shift.test.ts b/packages/hashline/test/landing-shift.test.ts index 99e0fcac0..0202fbde2 100644 --- a/packages/hashline/test/landing-shift.test.ts +++ b/packages/hashline/test/landing-shift.test.ts @@ -27,13 +27,13 @@ function apply(text: string, patch: string): { text: string; warnings: string[] describe("after-insert landing shift", () => { it("slides a shallower body past the closing line and warns", () => { - const { text, warnings } = apply(FILE, "INS.POST 3:\n+ c();"); + const { text, warnings } = apply(FILE, "PUT >3:\n+ c();"); expect(text).toBe( ["function f() {", " if (x) {", " a();", " }", " c();", " b();", "}", ""].join("\n"), ); expect(warnings).toHaveLength(1); - expect(warnings[0]).toMatch(/INS.POST 3: .*moved past 1 closing line to after line 4/); + expect(warnings[0]).toMatch(/PUT >3: .*moved past 1 closing line to after line 4/); }); it("crosses multiple closer levels and stops when depth returns to the body's", () => { @@ -50,42 +50,42 @@ describe("after-insert landing shift", () => { ].join("\n"); // Body at depth 4 escapes both the `for` and the `if`. - const outer = apply(nested, "INS.POST 4:\n+ c();"); + const outer = apply(nested, "PUT >4:\n+ c();"); expect(outer.text.split("\n")[6]).toBe(" c();"); expect(outer.warnings[0]).toMatch(/moved past 2 closing lines to after line 6/); // Body at depth 8 escapes only the `for`, staying inside the `if`. - const inner = apply(nested, "INS.POST 4:\n+ c();"); + const inner = apply(nested, "PUT >4:\n+ c();"); expect(inner.text.split("\n")[5]).toBe(" c();"); expect(inner.warnings[0]).toMatch(/moved past 1 closing line to after line 5/); }); it("does not shift when the body matches the anchor's depth", () => { - const { text } = apply(FILE, "INS.POST 3:\n+ c();"); + const { text } = apply(FILE, "PUT >3:\n+ c();"); expect(text.split("\n")[3]).toBe(" c();"); }); it("never crosses content lines (indentation-only languages stay put)", () => { const py = ["def f():", " if x:", " a()", " b()", ""].join("\n"); - const { text, warnings } = apply(py, "INS.POST 3:\n+ c()"); + const { text, warnings } = apply(py, "PUT >3:\n+ c()"); expect(text).toBe(["def f():", " if x:", " a()", " c()", " b()", ""].join("\n")); expect(warnings).toHaveLength(0); }); it("treats a body of pure closers as depth-neutral", () => { - const { text, warnings } = apply(FILE, "INS.POST 3:\n+ }"); + const { text, warnings } = apply(FILE, "PUT >3:\n+ }"); expect(text.split("\n")[3]).toBe(" }"); expect(warnings).toHaveLength(0); }); it("skips incomparable indentation styles (tabs file, spaces body)", () => { const tabs = ["function f() {", "\tif (x) {", "\t\ta();", "\t}", "\tb();", "}", ""].join("\n"); - const { text } = apply(tabs, "INS.POST 3:\n+ c();"); + const { text } = apply(tabs, "PUT >3:\n+ c();"); expect(text.split("\n")[3]).toBe(" c();"); }); it("refuses to cross a line targeted by another hunk", () => { - const { text, warnings } = apply(FILE, "INS.POST 3:\n+ c();\nCUT 4"); + const { text, warnings } = apply(FILE, "PUT >3:\n+ c();\nCUT 4"); // The closer on line 4 is owned by the cut; the insert stays put. expect(text).toBe(["function f() {", " if (x) {", " a();", " c();", " b();", "}", ""].join("\n")); expect(warnings).toHaveLength(0); @@ -93,24 +93,24 @@ describe("after-insert landing shift", () => { it("looks past blank lines between the anchor and the closer", () => { const gapped = ["function f() {", " if (x) {", " a();", "", " }", " b();", "}", ""].join("\n"); - const { text, warnings } = apply(gapped, "INS.POST 3:\n+ c();"); + const { text, warnings } = apply(gapped, "PUT >3:\n+ c();"); expect(text).toBe( ["function f() {", " if (x) {", " a();", "", " }", " c();", " b();", "}", ""].join("\n"), ); expect(warnings[0]).toMatch(/after line 5/); }); - it("leaves `INS.PRE N:` untouched", () => { - const { text, warnings } = apply(FILE, "INS.PRE 4:\n+ c();"); + it("leaves `PUT < N:` untouched", () => { + const { text, warnings } = apply(FILE, "PUT <4:\n+ c();"); expect(text.split("\n")[3]).toBe(" c();"); expect(warnings).toHaveLength(0); }); - it("composes with `INS.BLK.POST N:` to escape enclosing closers", () => { + it("composes with `PUT >N*: N:` to escape enclosing closers", () => { // stub: block beginning on N spans [N, N+1] → `block 2` ends on line 3. const stubResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 }); const text = ["function f() {", " const t = mk({", " });", "}", "x();", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ref = t;"); + const section = Patch.parseSingle("[x.ts#1A2B]\nPUT >2*:\n+ref = t;"); const result = section.applyTo(text, stubResolver); @@ -144,7 +144,7 @@ describe("insert-after-block inward landing shift", () => { it("pulls a deeper body inside the block, after its last content line", () => { const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 2 }); - const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ setup();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nPUT >2*:\n+ setup();"); const result = section.applyTo(BLOCK_FILE, resolver); expect(result.text).toBe( @@ -152,13 +152,13 @@ describe("insert-after-block inward landing shift", () => { "\n", ), ); - expect(result.warnings?.some(w => /INS.BLK.POST 2: .*placed inside the block, after line 3/.test(w))).toBe(true); + expect(result.warnings?.some(w => /PUT >2\*: .*placed inside the block, after line 3/.test(w))).toBe(true); }); it("lands right after the opener of an empty block", () => { const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 }); const text = ["function f() {", " afterEach(() => {", " });", "}", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ setup();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nPUT >2*:\n+ setup();"); const result = section.applyTo(text, resolver); @@ -171,7 +171,7 @@ describe("insert-after-block inward landing shift", () => { it("crosses nested trailing closers and stops at the body's claimed depth", () => { const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 1:\n+ baz();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nPUT >1*:\n+ baz();"); const result = section.applyTo(text, resolver); @@ -185,7 +185,7 @@ describe("insert-after-block inward landing shift", () => { it("leaves a sibling-depth body after the block (the literal contract)", () => { const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 2 }); - const section = Patch.parseSingle("[x.ts#1A2B]\nINS.BLK.POST 2:\n+ cleanup();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nPUT >2*:\n+ cleanup();"); const result = section.applyTo(BLOCK_FILE, resolver); @@ -198,7 +198,7 @@ describe("insert-after-block inward landing shift", () => { }); it("never shifts a plain `insert after M:` anchored on a closer", () => { - const { text, warnings } = apply(BLOCK_FILE, "INS.POST 4:\n+ leak();"); + const { text, warnings } = apply(BLOCK_FILE, "PUT >4:\n+ leak();"); expect(text.split("\n")[4]).toBe(" leak();"); expect(warnings).toHaveLength(0); @@ -207,7 +207,7 @@ describe("insert-after-block inward landing shift", () => { it("refuses to cross a closer targeted by another hunk", () => { const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); - const section = Patch.parseSingle("[x.ts#1A2B]\nSWAP 4.=4:\n+ }); // bar\nINS.BLK.POST 1:\n+ y();"); + const section = Patch.parseSingle("[x.ts#1A2B]\nPUT 4-4:\n+ }); // bar\nPUT >1*:\n+ y();"); const result = section.applyTo(text, resolver); diff --git a/packages/hashline/test/leniency.test.ts b/packages/hashline/test/leniency.test.ts index 3f009fa9b..193947a60 100644 --- a/packages/hashline/test/leniency.test.ts +++ b/packages/hashline/test/leniency.test.ts @@ -9,7 +9,7 @@ const FILE = "a\nb\nc\nd\ne"; describe("hashline section headers", () => { it("accepts paths with spaces in anchored section headers", () => { - const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nSWAP 1.=1:\n+after"); + const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nPUT 1-1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -17,7 +17,7 @@ describe("hashline section headers", () => { }); it("recovers apply_patch-contaminated headers whose paths contain spaces", () => { - const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nSWAP 1.=1:\n+after"); + const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nPUT 1-1:\n+after"); expect(section.path).toBe("dir with spaces/file.ts"); expect(section.fileHash).toBe("1A2B"); @@ -25,30 +25,30 @@ describe("hashline section headers", () => { }); it("rejects trailing junk after a snapshot tag", () => { - expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2B:812]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B:812]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); }); it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => { - expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nSWAP 1.=1:\n+after")).toThrow( + expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nPUT 1-1:\n+after")).toThrow( /Input header must be/, ); - expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags", () => { - expect(() => Patch.parse("[src/a.ts#1A2]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2G]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); - expect(() => Patch.parse("[src/a.ts#1A2B5]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2G]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[src/a.ts#1A2B5]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); }); it("rejects malformed snapshot tags even with apply_patch noise", () => { - expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nSWAP 1.=1:\n+after")).toThrow(/Input header must be/); + expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nPUT 1-1:\n+after")).toThrow(/Input header must be/); }); it("reports bracket syntax with a 4-hex example when the header is missing", () => { try { - Patch.parse("CUT 38.=40"); + Patch.parse("CUT 38-40"); throw new Error("expected missing-header error"); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -68,138 +68,149 @@ describe("hashline core — verb header forms", () => { expect(() => parsePatch("2 3\n+X")).toThrow(/Hunk headers need a verb/); }); - it("accepts canonical replace/cut/insert forms", () => { - expect(applyPatch(FILE, "SWAP 2.=3:\n+X")).toBe("a\nX\nd\ne"); + it("accepts canonical dot-equals replace/cut and gap forms", () => { + expect(applyPatch(FILE, "PUT 2.=3:\n+X")).toBe("a\nX\nd\ne"); expect(applyPatch(FILE, "CUT 2.=3")).toBe("a\nd\ne"); - expect(applyPatch(FILE, "INS.PRE 2:\n+X")).toBe("a\nX\nb\nc\nd\ne"); - expect(applyPatch(FILE, "INS.POST 2:\n+X")).toBe("a\nb\nX\nc\nd\ne"); - expect(applyPatch(FILE, "INS.HEAD:\n+X")).toBe("X\na\nb\nc\nd\ne"); - expect(applyPatch(FILE, "INS.TAIL:\n+X")).toBe("a\nb\nc\nd\ne\nX"); + expect(applyPatch(FILE, "PUT <2:\n+X")).toBe("a\nX\nb\nc\nd\ne"); + expect(applyPatch(FILE, "PUT >2:\n+X")).toBe("a\nb\nX\nc\nd\ne"); + expect(applyPatch(FILE, "PUT <1:\n+X")).toBe("X\na\nb\nc\nd\ne"); + expect(applyPatch(FILE, "PUT >$:\n+X")).toBe("a\nb\nc\nd\ne\nX"); }); - it("accepts single-number replace and cut shorthand", () => { - expect(applyPatch(FILE, "SWAP 2:\n+X")).toBe("a\nX\nc\nd\ne"); + it("leniently accepts single-number replace and cut shorthand", () => { + expect(applyPatch(FILE, "PUT 2:\n+X")).toBe("a\nX\nc\nd\ne"); expect(applyPatch(FILE, "CUT 2")).toBe("a\nc\nd\ne"); }); - it("accepts alternate replace range separators and missing colon", () => { - expect(applyPatch(FILE, "SWAP 2-3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "SWAP 2\u20263:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "SWAP 2 3:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "SWAP 2..3:\n+X")).toBe("a\nX\nd\ne"); // legacy `..` still accepted - expect(applyPatch(FILE, "SWAP 2.=3\n+X")).toBe("a\nX\nd\ne"); // missing colon + it("recovers top-level numbered snapshot rows as single-line replacements", () => { + const result = parsePatch("2:B\n4:D"); + expect(applyEdits(FILE, result.edits).text).toBe("a\nB\nc\nD\ne"); + expect(result.warnings.some(w => /snapshot row.*single-line `PUT N\.=N:`/i.test(w))).toBe(true); }); - it("accepts missing colon on insert headers", () => { - expect(applyPatch(FILE, "INS.PRE 2\n+X")).toBe("a\nX\nb\nc\nd\ne"); - expect(applyPatch(FILE, "INS.HEAD\n+X")).toBe("X\na\nb\nc\nd\ne"); + it("recovers a bare range header as an implicit PUT", () => { + const result = parsePatch("2.=3:\n+X"); + expect(applyEdits(FILE, result.edits).text).toBe("a\nX\nd\ne"); + expect(result.warnings.some(w => /bare `N\.=M:` header/.test(w))).toBe(true); }); - it("tolerates GLM 5.2 stray dot before the trailing colon", () => { - // GLM 5.2 inserts a `.` between the line number/range and `:`, - // e.g. `SWAP 2.=3.:` instead of `SWAP 2.=3:`. - expect(applyPatch(FILE, "SWAP 2.=3.:\n+X")).toBe("a\nX\nd\ne"); - expect(applyPatch(FILE, "SWAP 2.=2.:\n+X")).toBe("a\nX\nc\nd\ne"); - // `INS.POST 2.:` instead of `INS.POST 2:` - expect(applyPatch(FILE, "INS.POST 2.:\n+X")).toBe("a\nb\nX\nc\nd\ne"); - expect(applyPatch(FILE, "INS.PRE 2.:\n+X")).toBe("a\nX\nb\nc\nd\ne"); - // `CUT 2.=3.` instead of `CUT 2.=3` - expect(applyPatch(FILE, "CUT 2.=3.")).toBe("a\nd\ne"); - // `INS.HEAD.:` and `INS.TAIL.:` with stray dot - expect(applyPatch(FILE, "INS.HEAD.:\n+X")).toBe("X\na\nb\nc\nd\ne"); - expect(applyPatch(FILE, "INS.TAIL.:\n+X")).toBe("a\nb\nc\nd\ne\nX"); + it("ignores copied read elisions instead of writing them", () => { + const result = parsePatch( + ["1:a", "2-3: omitted() { … }", "4:d", "[…2ln elided; re-read needed ranges with a.ts:2-3]"].join("\n"), + ); + expect(applyEdits(FILE, result.edits).text).toBe(FILE); + expect(result.warnings.some(w => /Ignored copied read-output elision/.test(w))).toBe(true); + }); + + it("accepts a harmless trailing colon on bodyless CUT", () => { + const result = parsePatch("CUT 2-3:"); + expect(applyEdits(FILE, result.edits).text).toBe("a\nd\ne"); + expect(result.warnings.some(w => /Ignored a trailing `:`/.test(w))).toBe(true); + }); + + it("keeps the final hunk when numbered context targets the same exact line", () => { + const result = parsePatch("2:b\nPUT 2:\n+B"); + expect(applyEdits(FILE, result.edits).text).toBe("a\nB\nc\nd\ne"); + expect(result.warnings.some(w => /kept only the last/.test(w))).toBe(true); + }); + + it("lets a CUT supersede a placeholder PUT over the same exact range", () => { + const result = parsePatch("PUT 2-3:\n+// moved block removed\nCUT 2-3 @block"); + expect(result.edits.some(edit => edit.lineNum === 1)).toBe(false); + expect(result.edits.some(edit => edit.kind === "cut" && edit.register === "block")).toBe(true); + }); + + it("rejects missing colon on body-bearing insert headers", () => { + expect(() => parsePatch("PUT < 2\n+X")).toThrow(/`PUT` without `:` is clipboard-backed/); + expect(() => parsePatch("PUT <1\n+X")).toThrow(/`PUT` without `:` is clipboard-backed/); }); }); describe("hashline body contracts", () => { it("auto-pipes a bare body row while warning", () => { - const result = parsePatch("SWAP 2.=2:\n hello"); + const result = parsePatch("PUT 2-2:\n hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\n hello\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("strips read-output line number prefix from auto-piped bare body rows", () => { - const result = parsePatch("SWAP 2.=2:\n2:hello"); + const result = parsePatch("PUT 2-2:\n2:hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\nhello\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); }); it("preserves `+N:` literal payloads without stripping", () => { - const result = parsePatch("SWAP 2.=2:\n+3:keep"); + const result = parsePatch("PUT 2-2:\n+3:keep"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nc\nd\ne"); expect(result.warnings.some(w => /Auto-prefixed/.test(w))).toBe(false); }); it("strips only one N: prefix from bare body rows (preserves nested digits:colon)", () => { // "2:42:hello" → should yield "42:hello", NOT "hello" (recursive would over-strip) - const result = parsePatch("SWAP 2.=2:\n2:42:hello"); + const result = parsePatch("PUT 2-2:\n2:42:hello"); expect(applyEdits(FILE, result.edits).text).toBe("a\n42:hello\nc\nd\ne"); }); it("strips N: prefixes only when every bare body row carries one", () => { - const result = parsePatch("SWAP 2.=3:\n2:foo\n3:bar"); + const result = parsePatch("PUT 2-3:\n2:foo\n3:bar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nbar\nd\ne"); }); it("leaves bare body rows untouched when only some carry an N: prefix", () => { // "3:keep" looks like a snapshot prefix but "plain" does not, so the body // is genuine content (not a pasted snapshot) — strip nothing. - const result = parsePatch("SWAP 2.=3:\n3:keep\nplain"); + const result = parsePatch("PUT 2-3:\n3:keep\nplain"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nplain\nd\ne"); }); it("keeps interior blank rows in a bare replace body", () => { - const result = parsePatch("SWAP 2.=3:\nfoo\n\nbar"); + const result = parsePatch("PUT 2-3:\nfoo\n\nbar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne"); }); it("drops trailing blank rows between a bare body and the next hunk", () => { - const result = parsePatch("SWAP 2.=2:\nfoo\n\nSWAP 4.=4:\nbaz"); + const result = parsePatch("PUT 2-2:\nfoo\n\nPUT 4-4:\nbaz"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nc\nbaz\ne"); }); it("skips blank rows when checking N: prefix uniformity", () => { - const result = parsePatch("SWAP 2.=3:\n2:foo\n\n3:bar"); + const result = parsePatch("PUT 2-3:\n2:foo\n\n3:bar"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne"); }); it("leaves numeric-keyed literal bodies untouched (dict/YAML shape)", () => { - const result = parsePatch('SWAP 2.=3:\n1: "one",\n2: "two",'); + const result = parsePatch('PUT 2-3:\n1: "one",\n2: "two",'); expect(applyEdits(FILE, result.edits).text).toBe('a\n1: "one",\n2: "two",\nd\ne'); }); - it("rejects `-` body rows with Markdown bullet escape guidance", () => { - expect(() => parsePatch("SWAP 2.=2:\n-old\n+new")).toThrow( - /Markdown bullets or other literal `-` lines.*`\+- item`/, - ); + it("rejects ambiguous standalone `-` body rows with Markdown bullet guidance", () => { + expect(() => parsePatch("PUT 2-2:\n-old")).toThrow(/Markdown bullets or other literal `-` lines.*`\+- item`/); }); it("auto-pipes a fully bare Markdown bullet body with a warning", () => { - const result = parsePatch("SWAP 2.=2:\n- item\n - nested"); + const result = parsePatch("PUT 2-2:\n- item\n - nested"); expect(applyEdits(FILE, result.edits).text).toBe("a\n- item\n - nested\nc\nd\ne"); expect(result.warnings.some(w => /bullet row/.test(w))).toBe(true); }); it("auto-pipes a bare bullet row next to explicit `+- item` siblings", () => { - const result = parsePatch("SWAP 2.=2:\n+### Fixed\n+- one\n- two"); + const result = parsePatch("PUT 2-2:\n+### Fixed\n+- one\n- two"); expect(applyEdits(FILE, result.edits).text).toBe("a\n### Fixed\n- one\n- two\nc\nd\ne"); expect(result.warnings.some(w => /bullet row/.test(w))).toBe(true); }); it("still rejects non-bullet bare `-` rows even in a fully bare body", () => { - expect(() => parsePatch("SWAP 2.=2:\n-old()")).toThrow(/`-` rows are not valid/); + expect(() => parsePatch("PUT 2-2:\n-old()")).toThrow(/`-` rows are not valid/); }); it("still rejects bullet-shaped `-` rows beside a plain `+new` row (diff paste)", () => { - expect(() => parsePatch("SWAP 2.=2:\n- x\n+new()")).toThrow(/`-` rows are not valid/); + expect(() => parsePatch("PUT 2-2:\n- x\n+new()")).toThrow(/`-` rows are not valid/); }); it("allows literal Markdown bullets and plus-prefixed text when prefixed with `+`", () => { - expect(applyPatch(FILE, "SWAP 2.=2:\n+- item\n+ - nested\n++plus")).toBe( - "a\n- item\n - nested\n+plus\nc\nd\ne", - ); + expect(applyPatch(FILE, "PUT 2-2:\n+- item\n+ - nested\n++plus")).toBe("a\n- item\n - nested\n+plus\nc\nd\ne"); }); - it("treats empty replace as delete and still rejects empty insert", () => { - expect(applyPatch(FILE, "SWAP 2.=2:")).toBe("a\nc\nd\ne"); - expect(() => parsePatch("INS.TAIL:")).toThrow(/`INS` needs/); + it("treats an empty replace as deletion and still rejects an empty insert", () => { + expect(applyPatch(FILE, "PUT 2-2:")).toBe("a\nc\nd\ne"); + expect(() => parsePatch("PUT >$:")).toThrow(/promises body rows/); }); it("rejects cut with a body", () => { @@ -209,29 +220,35 @@ describe("hashline body contracts", () => { describe("hashline — apply_patch / unified-diff contamination", () => { it("rejects apply_patch sentinels as contamination", () => { - expect(() => parsePatch("*** Update File: a.ts\nSWAP 2.=2:\n+X")).toThrow(/apply_patch sentinel/); - expect(() => parsePatch("*** Add File: a.ts\nSWAP 2.=2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Update File: a.ts\nPUT 2-2:\n+X")).toThrow(/apply_patch sentinel/); + expect(() => parsePatch("*** Add File: a.ts\nPUT 2-2:\n+X")).toThrow(/apply_patch sentinel/); }); it("rejects unified-diff hunk headers as contamination", () => { - expect(() => parsePatch("@@ -1,3 +1,3 @@\nSWAP 2.=2:\n+X")).toThrow(/unified-diff hunk header/); + expect(() => parsePatch("@@ -1,3 +1,3 @@\nPUT 2-2:\n+X")).toThrow(/unified-diff hunk header/); + }); + + it("discards unified-diff old rows when explicit new rows follow", () => { + const result = parsePatch("PUT 2:\n-b\n+B"); + expect(applyEdits(FILE, result.edits).text).toBe("a\nB\nc\nd\ne"); + expect(result.warnings.some(w => /Ignored unified-diff `-old`/.test(w))).toBe(true); }); it("treats top-level `+TEXT` as an orphan literal payload", () => { - expect(() => parsePatch("+const X = 1;\nSWAP 2.=2:")).toThrow(/payload line has no preceding hunk header/); + expect(() => parsePatch("+const X = 1;\nPUT 2-2:")).toThrow(/payload line has no preceding hunk header/); }); }); describe("hashline apply — duplicate boundary payloads", () => { it("keeps replacement boundary echoes literal unless balance repair applies", () => { const text = ["// one", "// two", "old();"].join("\n"); - const diff = "SWAP 3.=3:\n+// one\n+// two\n+new();"; + const diff = "PUT 3-3:\n+// one\n+// two\n+new();"; expect(applyPatch(text, diff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n")); }); it("keeps pure-insert context echoes literal", () => { const text = ["aaa", "bbb", "ccc"].join("\n"); - const diff = "INS.TAIL:\n+bbb\n+ccc\n+NEW"; + const diff = "PUT >$:\n+bbb\n+ccc\n+NEW"; expect(applyPatch(text, diff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW"); }); }); diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 4d9c6d3b3..67b8478d4 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -31,7 +31,7 @@ describe("Patcher snapshot tag integrity", () => { const tag = snapshots.record(PATH, "before\n"); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1.=1:\n+after`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 1-1:\n+after`)); expect(result.sections[0]?.op).toBe("update"); expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/); @@ -47,7 +47,7 @@ describe("Patcher snapshot tag integrity", () => { await Bun.write(filePath, new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(source)])); const snapshots = new InMemorySnapshotStore(); const tag = snapshots.record(filePath, source); - const patch = Patch.parse([formatHashlineHeader(filePath, tag), "SWAP 1.=1:", "+using B;"].join("\n")); + const patch = Patch.parse([formatHashlineHeader(filePath, tag), "PUT 1-1:", "+using B;"].join("\n")); await new Patcher({ fs: new NodeFilesystem(), snapshots }).apply(patch); @@ -71,14 +71,14 @@ describe("Patcher snapshot tag integrity", () => { expect(snapshots.byHash(PATH, tag)).toBeNull(); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 3.=3:\n+L3`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 3-3:\n+L3`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n"); }); it("normalizes lowercase section tags while parsing", () => { - const section = Patch.parseSingle(`[${PATH}#1a2b]\nSWAP 1.=1:\n+after`); + const section = Patch.parseSingle(`[${PATH}#1a2b]\nPUT 1-1:\n+after`); expect(section.fileHash).toBe("1A2B"); }); @@ -91,7 +91,7 @@ describe("Patcher snapshot tag integrity", () => { const patcher = new Patcher({ fs, snapshots }); try { - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 1.=1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 1-1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -115,7 +115,7 @@ describe("Patcher snapshot tag integrity", () => { const bogus = live === "FFFF" ? "0000" : "FFFF"; try { - await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nSWAP 1.=1:\n+after`)); + await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nPUT 1-1:\n+after`)); throw new Error("expected MismatchError"); } catch (error) { expect(error).toBeInstanceOf(MismatchError); @@ -144,7 +144,7 @@ describe("Patcher snapshot tag integrity", () => { const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+edited live`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2-2:\n+edited live`)); expect(fs.get(PATH)).toBe("line one 410\nedited live\n"); }); }); @@ -173,7 +173,7 @@ describe("Patcher snapshot tag stays honest across a write-time content transfor const tag = snapshots.record(PATH, original); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+ return 2;`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2-2:\n+ return 2;`)); const section = result.sections[0]; if (!section) throw new Error("expected one section result"); @@ -194,7 +194,7 @@ describe("Patcher snapshot tag stays honest across a write-time content transfor // A follow-up edit anchored on the returned tag must succeed against // the real (drifted) file instead of failing a stale-tag mismatch. - await patcher.apply(Patch.parse(`[${PATH}#${section.fileHash}]\nSWAP 1.=1:\n+function g() {`)); + await patcher.apply(Patch.parse(`[${PATH}#${section.fileHash}]\nPUT 1-1:\n+function g() {`)); expect(fs.get(PATH)).toBe("function g() {\n\treturn 2;\n}\n"); }); }); @@ -205,7 +205,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}]\nINS.TAIL:\n+c`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nPUT >$:\n+c`))).rejects.toThrow( /Missing hashline snapshot tag.*use the write tool/s, ); expect(fs.get(PATH)).toBe("a\nb\n"); @@ -216,7 +216,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}]\nSWAP 1.=1:\n+X`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}]\nPUT 1-1:\n+X`))).rejects.toThrow( /Missing hashline snapshot tag/, ); }); @@ -226,7 +226,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\nINS.TAIL:\n+c`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\nPUT >$:\n+c`))).rejects.toThrow( /File not found.*use the write tool/is, ); }); @@ -239,7 +239,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const stale = live === "0000" ? "FFFF" : "0000"; const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\nINS.TAIL:\n+c`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\nPUT >$:\n+c`)); const section = result.sections[0]; expect(section?.op).toBe("update"); @@ -254,7 +254,7 @@ describe("Patcher mandatory snapshot tag policy", () => { const tag = snapshots.record(PATH, content); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nINS.TAIL:\n+c`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT >$:\n+c`)); const section = result.sections[0]; expect(section?.op).toBe("update"); @@ -272,7 +272,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`))).rejects.toThrow( + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`))).rejects.toThrow( /never displayed \(it showed/, ); expect(fs.get(PATH)).toBe(CONTENT); @@ -284,7 +284,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+L2`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2-2:\n+L2`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nL2\nl3\nl4\nl5\n"); @@ -298,7 +298,7 @@ describe("Patcher seen-line provenance", () => { snapshots.record(PATH, CONTENT, [4, 5]); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n"); @@ -313,7 +313,7 @@ describe("Patcher seen-line provenance", () => { let message: string | undefined; try { - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`)); } catch (err) { message = (err as Error).message; } @@ -324,7 +324,7 @@ describe("Patcher seen-line provenance", () => { // The revealed line joins the snapshot's seen set, so a straight retry // with the same [path#tag] header applies — no extra read required. - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n"); }); @@ -382,7 +382,7 @@ describe("Patcher seen-line provenance", () => { let message: string | undefined; try { - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=3:\n+X\n+Y`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2-3:\n+X\n+Y`)); } catch (err) { message = (err as Error).message; } @@ -402,7 +402,7 @@ describe("Patcher seen-line provenance", () => { // having only seen the first 512 chars of a >4KB line. let retryMessage: string | undefined; try { - await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=3:\n+X\n+Y`)); + await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 2-3:\n+X\n+Y`)); } catch (err) { retryMessage = (err as Error).message; } @@ -418,7 +418,7 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 4.=4:\n+L4`)); + const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`)); expect(result.sections[0]?.op).toBe("update"); }); @@ -435,7 +435,7 @@ describe("Patcher tag-based path recovery", () => { const patcher = new Patcher({ fs, snapshots }); // The header carries only the basename — the model dropped the directory. - const result = await patcher.apply(Patch.parse(`[file.ts#${tag}]\nSWAP 2.=2:\n+TWO`)); + const result = await patcher.apply(Patch.parse(`[file.ts#${tag}]\nPUT 2-2:\n+TWO`)); const section = result.sections[0]; expect(section?.op).toBe("update"); @@ -453,7 +453,7 @@ describe("Patcher tag-based path recovery", () => { const tag = snapshots.record(NESTED, CONTENT); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[other.ts#${tag}]\nSWAP 2.=2:\n+TWO`))).rejects.toThrow(/File not found/); + await expect(patcher.apply(Patch.parse(`[other.ts#${tag}]\nPUT 2-2:\n+TWO`))).rejects.toThrow(/File not found/); expect(fs.get(NESTED)).toBe(CONTENT); }); @@ -464,9 +464,7 @@ describe("Patcher tag-based path recovery", () => { const bogus = tag === "FFFF" ? "0000" : "FFFF"; const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[file.ts#${bogus}]\nSWAP 2.=2:\n+TWO`))).rejects.toThrow( - /File not found/, - ); + await expect(patcher.apply(Patch.parse(`[file.ts#${bogus}]\nPUT 2-2:\n+TWO`))).rejects.toThrow(/File not found/); }); it("declines recovery when two retained files share the filename and tag", async () => { @@ -479,7 +477,7 @@ describe("Patcher tag-based path recovery", () => { snapshots.record("b/file.ts", CONTENT); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[file.ts#${tag}]\nSWAP 2.=2:\n+TWO`))).rejects.toThrow(/File not found/); + await expect(patcher.apply(Patch.parse(`[file.ts#${tag}]\nPUT 2-2:\n+TWO`))).rejects.toThrow(/File not found/); expect(fs.get("a/file.ts")).toBe(CONTENT); expect(fs.get("b/file.ts")).toBe(CONTENT); }); @@ -495,7 +493,7 @@ describe("Patcher tag-based path recovery", () => { const tag = snapshots.record(NESTED, CONTENT); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[file.ts#${tag}]\nSWAP 2.=2:\n+TWO`))).rejects.toThrow(/File not found/); + await expect(patcher.apply(Patch.parse(`[file.ts#${tag}]\nPUT 2-2:\n+TWO`))).rejects.toThrow(/File not found/); expect(fs.get(NESTED)).toBe(CONTENT); }); @@ -513,7 +511,7 @@ describe("Patcher tag-based path recovery", () => { const tag = snapshots.record(NESTED, CONTENT); const patcher = new Patcher({ fs, snapshots }); - const result = await patcher.apply(Patch.parse(`[file.ts#${tag}]\nSWAP 2.=2:\n+TWO`)); + const result = await patcher.apply(Patch.parse(`[file.ts#${tag}]\nPUT 2-2:\n+TWO`)); expect(result.sections[0]?.path).toBe(NESTED); expect(fs.get(NESTED)).toBe("one\nTWO\nthree\n"); }); @@ -530,6 +528,6 @@ describe("Patcher tag-based path recovery", () => { const snapshots = new InMemorySnapshotStore(); const patcher = new Patcher({ fs, snapshots }); - await expect(patcher.apply(Patch.parse(`[file.ts#ABCD]\nSWAP 1.=1:\n+X`))).rejects.toThrow(/write gate/); + await expect(patcher.apply(Patch.parse(`[file.ts#ABCD]\nPUT 1-1:\n+X`))).rejects.toThrow(/write gate/); }); }); diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 9957ced76..ab559e1cd 100644 --- a/packages/hashline/test/recovery-session-chain.test.ts +++ b/packages/hashline/test/recovery-session-chain.test.ts @@ -44,7 +44,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // rewrote. Replaying onto current would overwrite "L5-CHANGED" with // payload the model authored against the stale "L5". That is // corruption, not recovery. - const { edits } = parsePatch("SWAP 5.=5:\n|L5-MODEL"); + const { edits } = parsePatch("PUT 5-5:\n|L5-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -61,7 +61,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // Edit anchored at line 3 — unchanged between v0 and v1. Recovery // proves that the target and its surrounding context still map to the // same live lines before replaying the edit. - const { edits } = parsePatch("SWAP 3.=3:\n|L3-MODEL"); + const { edits } = parsePatch("PUT 3-3:\n|L3-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -87,7 +87,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { const h0 = store.record(PATH, v0Text); const v1Text = lines("L1", "L2", "INSERTED", "L3", "L4", "L5", "L6"); store.record(PATH, v1Text); - const { edits } = parsePatch("SWAP 5.=5:\n+L5-MODEL"); + const { edits } = parsePatch("PUT 5-5:\n+L5-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -107,7 +107,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { const h0 = store.record(PATH, v0Text); const v1Text = lines("L1", "L3", "L4", "L5", "L6"); store.record(PATH, v1Text); - const { edits } = parsePatch("SWAP 5.=5:\n+L5-MODEL"); + const { edits } = parsePatch("PUT 5-5:\n+L5-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -127,7 +127,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { const h0 = store.record(PATH, v0Text); const v1Text = lines("start", "mid", "DUP", "CHANGED", "tail"); store.record(PATH, v1Text); - const { edits } = parsePatch("SWAP 4.=4:\n+MODEL"); + const { edits } = parsePatch("PUT 4-4:\n+MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -145,7 +145,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { const v0Text = lines(...block, "middle", ...block, "tail"); const hash = store.record(PATH, v0Text); const currentText = lines("head", "CHANGED_A", "CHANGED_B", "ctx1", "ctx2", "ctx3", "middle", ...block, "tail"); - const { edits } = parsePatch("SWAP 2.=3:\n+MODEL_A\n+MODEL_B"); + const { edits } = parsePatch("PUT 2-3:\n+MODEL_A\n+MODEL_B"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -164,7 +164,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { const h0 = store.record(PATH, v0Text); const v1Text = lines("X", "L1", "L2", "L3", "L4", "BEFORE", "T", "AFTER", "L6"); store.record(PATH, v1Text); - const { edits } = parsePatch("SWAP 5.=5:\n+MODEL"); + const { edits } = parsePatch("PUT 5-5:\n+MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -186,7 +186,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { const h0 = store.record(PATH, v0Text); const v1Text = lines("alpha", "INSERTED", "DUP", "beta", "DUP", "omega"); store.record(PATH, v1Text); - const { edits } = parsePatch("SWAP 3.=4:\n+B-MODEL\n+MODEL"); + const { edits } = parsePatch("PUT 3-4:\n+B-MODEL\n+MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -238,7 +238,7 @@ describe("Recovery — colliding snapshot tags", () => { path: PATH, currentText, fileHash: tag, - edits: parsePatch("SWAP 2.=2:\n+model payload").edits, + edits: parsePatch("PUT 2-2:\n+model payload").edits, }); expect(recovered?.text).toBe(lines("shared head", "model payload", "shared tail", "drifted trailer")); @@ -255,7 +255,7 @@ describe("Recovery — colliding snapshot tags", () => { path: PATH, currentText, fileHash: tag, - edits: parsePatch("SWAP 2.=2:\n+model payload").edits, + edits: parsePatch("PUT 2-2:\n+model payload").edits, }); expect(recovered).not.toBeNull(); @@ -277,7 +277,7 @@ describe("Recovery — ill-formed UTF-16 content", () => { path: PATH, currentText, fileHash: hash, - edits: parsePatch("SWAP 3.=3:\n+model payload").edits, + edits: parsePatch("PUT 3-3:\n+model payload").edits, }); expect(recovered).not.toBeNull(); diff --git a/packages/metaharness/adapters/edit/runner.ts b/packages/metaharness/adapters/edit/runner.ts index ef7e962ed..6bb33c0e1 100644 --- a/packages/metaharness/adapters/edit/runner.ts +++ b/packages/metaharness/adapters/edit/runner.ts @@ -281,40 +281,42 @@ function isMutationTool(toolName: unknown): boolean { // Pure classification — single shared tokenizer is safe. const HASHLINE_OP_TOKENIZER = new HashlineTokenizer(); -/** Display label for a hashline op header, e.g. `SWAP.BLK` or `PASTE.POST`. */ -function hashlineOpLabel(target: HashlineBlockTarget): string { +function hashlinePutSuffix(register: string | undefined, hadColon: boolean, allowsAnonymousPaste: boolean): string { + if (register !== undefined) return hadColon ? " @reg: (invalid)" : " @reg"; + if (hadColon) return ":"; + return allowsAnonymousPaste ? "" : " (invalid)"; +} + +function hashlineCutSuffix(register: string | undefined, hadColon: boolean): string { + if (register !== undefined) return hadColon ? " @reg: (invalid)" : " @reg"; + return hadColon ? ": (invalid)" : ""; +} + +/** Display the canonical S3 header shape for a parsed hashline target. */ +function hashlineOpLabel(target: HashlineBlockTarget, hadColon: boolean): string { switch (target.kind) { - case "replace": - return "SWAP"; + case "replace": { + const locator = target.range.start.line === target.range.end.line ? "N.=N" : "N.=M"; + return `PUT ${locator}${hashlinePutSuffix(target.register, hadColon, false)}`; + } case "block": - return "SWAP.BLK"; + return `PUT N*${hashlinePutSuffix(target.register, hadColon, false)}`; case "insert_before": - return "INS.PRE"; + return `PUT N${hashlinePutSuffix(target.register, hadColon, true)}`; case "bof": - return "INS.HEAD"; + return `PUT <1${hashlinePutSuffix(target.register, hadColon, true)}`; case "eof": - return "INS.TAIL"; + return `PUT >$${hashlinePutSuffix(target.register, hadColon, true)}`; case "insert_after_block": - return "INS.BLK.POST"; - case "cut": - return "CUT"; + return `PUT >N*${hashlinePutSuffix(target.register, hadColon, true)}`; + case "cut": { + const locator = target.range.start.line === target.range.end.line ? "N.=N" : "N.=M"; + return `CUT ${locator}${hashlineCutSuffix(target.register, hadColon)}`; + } case "cut_block": - return "CUT.BLK"; - case "paste": - switch (target.cursor.kind) { - case "before_anchor": - return "PASTE.PRE"; - case "after_anchor": - return "PASTE.POST"; - case "bof": - return "PASTE.HEAD"; - case "eof": - return "PASTE.TAIL"; - } - case "paste_after_block": - return "PASTE.BLK.POST"; + return `CUT N*${hashlineCutSuffix(target.register, hadColon)}`; case "rem": return "REM"; case "move": @@ -323,9 +325,9 @@ function hashlineOpLabel(target: HashlineBlockTarget): string { } /** - * Count hashline op headers (`SWAP`, `CUT.BLK`, `PASTE.POST`, …) in an - * edit call's patch input. Returns `null` when the args carry no hashline - * patch — non-hashline edit variants and malformed calls contribute nothing. + * Count canonical hashline op header shapes (`PUT N.=M:`, `CUT N*`, + * `PUT >N @reg`, …) in an edit call's patch input. Returns `null` when + * the args carry no hashline patch; non-hashline variants contribute nothing. */ function countHashlineOps(args: unknown): Record | null { if (!args || typeof args !== "object" || !("input" in args)) return null; @@ -334,7 +336,7 @@ function countHashlineOps(args: unknown): Record | null { const counts: Record = {}; for (const token of HASHLINE_OP_TOKENIZER.tokenizeAll(input)) { if (token.kind !== "op-block") continue; - const label = hashlineOpLabel(token.target); + const label = hashlineOpLabel(token.target, token.hadColon); counts[label] = (counts[label] ?? 0) + 1; } return Object.keys(counts).length > 0 ? counts : null; @@ -878,7 +880,7 @@ export interface TaskRunResult { editFailures: EditFailure[]; editWarnings: string[]; editAutocorrectCount: number; - /** Hashline op counts (`SWAP`, `CUT.BLK`, `PASTE.POST`, …) — present when edit calls carried hashline patches. */ + /** Canonical hashline op-shape counts (`PUT N.=M:`, `CUT N*`, `PUT >N @reg`, …). */ hashlineEditSubtypes?: Record; mutationIntentMatched?: boolean; mutationIntentReason?: string; @@ -994,7 +996,7 @@ export interface BenchmarkSummary { mutationIntentMatchRate?: number; /** Edit failure categories across all runs. */ editFailureCategories: Record; - /** Hashline op totals across all runs — present when any run's edit calls carried hashline patches. */ + /** Canonical hashline op totals across all runs when edit calls carried hashline patches. */ hashlineEditSubtypes?: Record; } @@ -1988,7 +1990,7 @@ export function buildBenchmarkResult(params: { // Op counts aggregate every attempted edit call across ALL runs (retries and // failed calls included) — deliberately: the mix shows what the model reaches // for, not just what landed. Best-run-only would hide exactly the flailing - // (failed SWAPs before a successful retry) this table exists to expose. + // (failed operations before a successful retry) this table exists to expose. const hashlineEditSubtypeTotals: Record = {}; for (const run of allRuns) { const runOps = run.hashlineEditSubtypes; diff --git a/scripts/session-stats/analyze.py b/scripts/session-stats/analyze.py index 94e793d52..e81b6855b 100644 --- a/scripts/session-stats/analyze.py +++ b/scripts/session-stats/analyze.py @@ -569,43 +569,88 @@ def classify_edit_result(text: str, is_error: int | None = None) -> str: _ANCHOR_BARE = re.compile(r"^[a-zA-Z]?[0-9]+[a-z]{2}$") +_LINE_LOCATOR = r"[1-9]\d*" +_RANGE_SEPARATOR = r"(?:\.=|-|\.|…|\s+)" +_RANGE_LOCATOR = rf"{_LINE_LOCATOR}(?:{_RANGE_SEPARATOR}{_LINE_LOCATOR})?" +_REGISTER = r"@[A-Za-z0-9_-]{1,64}" + +# Current S3 headers. Match the whole row: prefix-only matching used to count +# recovered body text such as `replace this value` as an edit operation. _HASHLINE_OP = re.compile( - r"^(SWAP\.BLK|SWAP|XCHG\.BLK|XCHG|DEL\.BLK|DEL|CUT\.BLK|CUT|" - r"PASTE\.BLK\.POST|PASTE\.PRE|PASTE\.POST|PASTE\.HEAD|PASTE\.TAIL|" - r"INS\.BLK\.POST|INS\.PRE|INS\.POST|INS\.HEAD|INS\.TAIL|REM|MV|" - r"replace_block|replace block|replace|delete_block|delete block|delete|" - r"insert_after_block|insert after block|insert before|insert after|insert head|insert tail)\b", - re.I, + rf""" + ^\s*(?: + (?PPUT)\s+ + (?: + {_RANGE_LOCATOR} + |{_LINE_LOCATOR}\* + |[<>]\s*{_LINE_LOCATOR}\*? + |>\s*\$ + ) + (?:\s+{_REGISTER})?\s*:? + |(?PCUT)\s+(?:{_RANGE_LOCATOR}|{_LINE_LOCATOR}\*)(?:\s+{_REGISTER})?\s*:? + |(?PMV)\s+\S(?:.*\S)?\s* + |(?PREM)\s* + )$ + """, + re.I | re.X, +) + +# Retain syntactically shaped pre-S3 rows so stored sessions remain useful for +# historical reporting. These labels intentionally remain historical rather +# than being folded into PUT/CUT. +_HISTORICAL_HASHLINE_OP = re.compile( + rf""" + ^\s*(?P + SWAP\.BLK|SWAP|XCHG\.BLK|XCHG|DEL\.BLK|DEL|CUT\.BLK + |PASTE\.BLK\.POST|PASTE\.PRE|PASTE\.POST|PASTE\.HEAD|PASTE\.TAIL + |INS\.BLK\.POST|INS\.PRE|INS\.POST|INS\.HEAD|INS\.TAIL + |replace_block|replace\ block|replace + |delete_block|delete\ block|delete + |insert_after_block|insert\ after\ block|insert\ before|insert\ after + |insert\ head|insert\ tail + ) + (?: + \s+(?:{_RANGE_LOCATOR}|{_LINE_LOCATOR}\.={_LINE_LOCATOR}|{_LINE_LOCATOR}) + \s*:? + )? + \s*$ + """, + re.I | re.X, ) _HASHLINE_OP_ALIASES = { - "replace block": "SWAP.BLK", - "replace_block": "SWAP.BLK", - "replace": "SWAP", - "xchg.blk": "SWAP.BLK", - "xchg": "SWAP", - "delete block": "DEL.BLK", - "delete_block": "DEL.BLK", - "delete": "DEL", - "insert before": "INS.PRE", - "insert after": "INS.POST", - "insert head": "INS.HEAD", - "insert tail": "INS.TAIL", - "insert after block": "INS.BLK.POST", - "insert_after_block": "INS.BLK.POST", + "REPLACE BLOCK": "SWAP.BLK", + "REPLACE_BLOCK": "SWAP.BLK", + "REPLACE": "SWAP", + "XCHG.BLK": "SWAP.BLK", + "XCHG": "SWAP", + "DELETE BLOCK": "DEL.BLK", + "DELETE_BLOCK": "DEL.BLK", + "DELETE": "DEL", + "INSERT BEFORE": "INS.PRE", + "INSERT AFTER": "INS.POST", + "INSERT HEAD": "INS.HEAD", + "INSERT TAIL": "INS.TAIL", + "INSERT AFTER BLOCK": "INS.BLK.POST", + "INSERT_AFTER_BLOCK": "INS.BLK.POST", } def _hashline_ops(inp: str) -> list[str]: - """Op kinds in a hashline edit `input` payload (skips `+` body rows).""" + """Op kinds in a hashline edit `input`, excluding body/recovery rows.""" ops: list[str] = [] for line in inp.splitlines(): - if not line or line[0] == "+": + stripped = line.lstrip() + if not stripped or stripped.startswith("+"): continue - m = _HASHLINE_OP.match(line.lstrip()) - if m: - op = m.group(1).lower() + match = _HASHLINE_OP.fullmatch(line) + if match: + ops.append(next(name.upper() for name, value in match.groupdict().items() if value)) + continue + match = _HISTORICAL_HASHLINE_OP.fullmatch(line) + if match: + op = match.group("op").upper() ops.append(_HASHLINE_OP_ALIASES.get(op, op)) return ops