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.
This commit is contained in:
can1357
2026-07-31 00:19:52 +02:00
parent 55b23864e2
commit 5ea583e413
46 changed files with 1767 additions and 1243 deletions
@@ -1,9 +1,9 @@
/** /**
* Tree-sitter-backed {@link BlockResolver} for the hashline block replace * Tree-sitter-backed {@link BlockResolver} for the hashline `N*` locator.
* operator. Bridges the pure hashline seam to the native `blockRangeAt` * Bridges the pure hashline seam to the native `blockRangeAt` primitive in
* primitive in `@oh-my-pi/pi-natives`, which infers the language from the file * `@oh-my-pi/pi-natives`, which infers the language from the file path and
* path and returns the 1-indexed line span of the syntactic block beginning on * returns the 1-indexed line span of the syntactic block beginning on the
* the requested line (or `null` when none can be resolved). * requested line (or `null` when none can be resolved).
*/ */
import type { BlockResolver } from "@oh-my-pi/hashline"; import type { BlockResolver } from "@oh-my-pi/hashline";
import { blockRangeAt } from "@oh-my-pi/pi-natives"; import { blockRangeAt } from "@oh-my-pi/pi-natives";
+22 -15
View File
@@ -18,6 +18,7 @@ import {
computeFileHash, computeFileHash,
type Edit, type Edit,
Patch as HashlinePatch, Patch as HashlinePatch,
forkClipboard,
hasBlockEdit, hasBlockEdit,
MismatchError, MismatchError,
missingSnapshotTagMessage, missingSnapshotTagMessage,
@@ -53,10 +54,10 @@ export interface HashlineDiffOptions {
skipHashValidation?: boolean; skipHashValidation?: boolean;
/** /**
* Clipboard register shared across the sections of one patch preview. * Clipboard register shared across the sections of one patch preview.
* `CUT` in an earlier section feeds `PASTE` in a later one, so the preview * `CUT` in an earlier section feeds a register-backed `PUT` in a later one,
* matches apply. Multi-section previews MUST thread one register through * so the preview matches apply. Multi-section previews MUST thread one
* sections in patch order; omitted, each section gets a * register through sections in patch order; omitted, each section gets a
* private register (same-file cut/paste still previews correctly). * private register (same-file cut/put still previews correctly).
*/ */
clipboard?: Clipboard; clipboard?: Clipboard;
} }
@@ -153,9 +154,11 @@ async function readSectionForPreview(
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
return edits.some(edit => { return edits.some(edit => {
if (edit.kind === "delete") return true; if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true;
if (edit.kind === "block") return true; if (edit.kind === "paste") {
if (edit.kind === "cut") return true; 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"; return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
}); });
} }
@@ -279,19 +282,23 @@ function buildStreamingSectionDiff(
onUnresolved: "drop", onUnresolved: "drop",
}); });
const fileLines = normalized.split("\n"); const fileLines = normalized.split("\n");
// Expand clipboard ops so a `PASTE` previews the moved rows. Resolve into // Expand register-backed PUT ops so moved rows appear in the preview.
// a scratch register and commit it back only on success: a mid-typed // Resolve into a transactional register and publish it only on success: a
// capture (out-of-range while the range digits are still streaming, // mid-typed capture (out-of-range while the range digits are still
// transient sequencing violations) throws AFTER earlier captures may have // streaming, transient sequencing violations) throws AFTER earlier
// landed, and leaking those into the shared register would let a later // captures may have landed, and leaking those into the shared register
// section preview a paste that never happened. On failure, fall back to // would let a later section preview a PUT that never happened. On failure,
// filtering the clipboard edits out of this section's frame. // filter the clipboard edits out of this section's frame.
const scratch: Clipboard = { ...clipboard }; const scratch = forkClipboard(clipboard);
let resolved: readonly Edit[]; let resolved: readonly Edit[];
try { try {
resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" }); resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" });
if (scratch.lines === undefined) delete clipboard.lines; if (scratch.lines === undefined) delete clipboard.lines;
else clipboard.lines = scratch.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 { } catch {
resolved = blockResolved.filter(edit => edit.kind !== "cut" && edit.kind !== "paste"); resolved = blockResolved.filter(edit => edit.kind !== "cut" && edit.kind !== "paste");
} }
@@ -16,6 +16,7 @@ import {
type Clipboard, type Clipboard,
commitClipboard, commitClipboard,
forkClipboard, forkClipboard,
startClipboardBatch,
MismatchError as HashlineMismatchError, MismatchError as HashlineMismatchError,
Patch, Patch,
Patcher, Patcher,
@@ -48,7 +49,7 @@ export interface ExecuteHashlineSingleOptions {
function noChangeDiagnostic(path: string): string { function noChangeDiagnostic(path: string): string {
// The patch parsed and applied cleanly but produced no change — the // 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 // byte-for-byte. The model usually misreads this as "wrong anchor, try
// again with a bigger payload" and starts duplicating content; the // again with a bigger payload" and starts duplicating content; the
// message below names the cause directly so the next turn can re-read // message below names the cause directly so the next turn can re-read
@@ -103,10 +104,10 @@ interface RenderedSection {
} }
const BLOCK_OP_LABELS: Record<BlockResolution["op"], string> = { const BLOCK_OP_LABELS: Record<BlockResolution["op"], string> = {
replace: "SWAP.BLK", replace: "PUT N*:",
insert_after: "INS.BLK.POST", insert_after: "PUT >N*:",
cut: "CUT.BLK", cut: "CUT N*",
paste_after: "PASTE.BLK.POST", paste_after: "PUT >N*",
}; };
function formatBlockResolution(resolution: BlockResolution): string { function formatBlockResolution(resolution: BlockResolution): string {
@@ -229,11 +230,11 @@ export async function executeHashlineSingle(
const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines"); const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines");
const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines }); const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines });
// The clipboard register is session-persistent: `CUT` in one edit call can // Named registers persist across edit calls; the anonymous register is
// `PASTE` in a later one. Each batch works on a fork and publishes it back // batch-local. Each batch starts without anonymous state and publishes
// only after writes land. // named registers only after writes land.
const sessionClipboard = getEditClipboard(options.session); const sessionClipboard = getEditClipboard(options.session);
const clipboard = forkClipboard(sessionClipboard); const clipboard = startClipboardBatch(sessionClipboard);
// Single-section fast path: prepare, commit, render. // Single-section fast path: prepare, commit, render.
const inputHash = hashPatchInput(options.input); 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 // Multi-section: prepare every section up front so we fail fast before
// any write hits the filesystem. One clipboard register spans the batch, // any write hits the filesystem. One batch-local register spans the batch,
// so `CUT` in one section feeds `PASTE` in a later one. // so `CUT` in one section feeds a register-backed `PUT` in a later one.
const prepared: PreparedSection[] = []; const prepared: PreparedSection[] = [];
// Register state after each section's prepare. Commits are non-atomic: a // Register state after each section's prepare. Commits are non-atomic: a
// mid-batch write failure leaves earlier sections on disk, so the session // mid-batch write failure leaves earlier sections on disk, so the session
+3 -3
View File
@@ -140,7 +140,7 @@ interface HashlineInputEntry {
path: string; path: string;
op?: Operation; op?: Operation;
rename?: string; 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; hasLineEdits?: boolean;
} }
@@ -568,9 +568,9 @@ function parseHashlineInputPreviewHeader(line: string): string | null {
return previewPath.length > 0 ? previewPath : 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. // 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: * Walk a (possibly mid-stream) hashline payload into per-section descriptors:
@@ -28,12 +28,12 @@ Choose a short kebab-case `<slug>` 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. 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}} {{#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: 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:
- `SWAP.BLK N:` on a heading line — rewrite that entire section in place. - `PUT N*:` on a heading line — rewrite that entire section in place.
- `CUT.BLK N` on a heading line — drop the whole section. - `CUT 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). - `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}} {{/if}}
## Ground every claim ## Ground every claim
@@ -65,12 +65,12 @@ async function seedFile(
const TS_SOURCE = "function x() {\n if (y) {\n }\n}\n"; const TS_SOURCE = "function x() {\n if (y) {\n }\n}\n";
const ELISP_SOURCE = ["(ert-deftest ogent-zen-test ()", ' "Doc."', " (should t))", ""].join("\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 () => { it("resolves the inner `if` block (line 2) and replaces its full span", async () => {
await withTempDir(async tempDir => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); 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)); await executeHashlineSingle(executeOptions(tempDir, input, session));
@@ -82,7 +82,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => {
await withTempDir(async tempDir => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); 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)); 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 => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); 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)); await executeHashlineSingle(executeOptions(tempDir, input, session));
@@ -106,7 +106,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => {
await withTempDir(async tempDir => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, "ogent-zen-tests.el", ELISP_SOURCE); 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 result = await executeHashlineSingle(executeOptions(tempDir, input, session));
const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); 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"), ].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 () => { it("inserts after an extensionless .emacs top-level form", async () => {
await withTempDir(async tempDir => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, ".emacs", ELISP_SOURCE); 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)); await executeHashlineSingle(executeOptions(tempDir, input, session));
@@ -143,7 +143,7 @@ describe("SWAP.BLK — native tree-sitter resolution end-to-end", () => {
await withTempDir(async tempDir => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); 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)); 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 => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); 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 result = await executeHashlineSingle(executeOptions(tempDir, input, session));
const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); 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). // `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 => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); 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 result = await executeHashlineSingle(executeOptions(tempDir, input, session));
const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
// `if (y) {` opens on line 2; resolves lines 2-3. // `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 session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE); const { filePath, header } = await seedFile(tempDir, session, "x.ts", TS_SOURCE);
// Line 3 is ` }` — a closing delimiter, not a block opener. // 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). // Steers to the concrete form and previews the file around the anchor (`*`-marked).
await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow( 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 session = makeSession(tempDir);
const source = "\nfunction x() {\n return 1;\n}\n"; const source = "\nfunction x() {\n return 1;\n}\n";
const { filePath, header } = await seedFile(tempDir, session, "blank.ts", source); 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( 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); 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 session = makeSession(tempDir);
const source = "function x() {\n run();\n}\n"; const source = "function x() {\n run();\n}\n";
const { filePath, header } = await seedFile(tempDir, session, "statement.ts", source); 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( 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); 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 session = makeSession(tempDir);
const source = "alpha\nbeta\ngamma\n"; const source = "alpha\nbeta\ngamma\n";
const { filePath, header } = await seedFile(tempDir, session, "data.unknownext", source); 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( await expect(executeHashlineSingle(executeOptions(tempDir, input, session))).rejects.toThrow(
/could not resolve a syntactic block/, /could not resolve a syntactic block/,
@@ -256,13 +256,13 @@ const MD_PLAN = [
].join("\n"); ].join("\n");
describe("block ops on markdown headings — whole-section resolution end-to-end", () => { 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 => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
// Line 4 is `## Context`; its section runs through `### Detail` and // Line 4 is `## Context`; its section runs through `### Detail` and
// the trailing blank, up to `## Approach`. // the trailing blank, up to `## Approach`.
const { filePath, header } = await seedFile(tempDir, session, "plan.md", MD_PLAN); 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 result = await executeHashlineSingle(executeOptions(tempDir, input, session));
const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); 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( expect(await Bun.file(filePath).text()).toBe(
["# Plan", "intro", "", "## Approach", "step one", ""].join("\n"), ["# 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 => { await withTempDir(async tempDir => {
const session = makeSession(tempDir); const session = makeSession(tempDir);
const { filePath, header } = await seedFile(tempDir, session, "plan.md", MD_PLAN); 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 result = await executeHashlineSingle(executeOptions(tempDir, input, session));
const text = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n"); 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"), ].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");
}); });
}); });
}); });
@@ -55,7 +55,7 @@ async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void
function buildNoopInput(filePath: string, displayPath: string, session: ToolSession): string { function buildNoopInput(filePath: string, displayPath: string, session: ToolSession): string {
const source = "aaa\nbbb\nccc\n"; const source = "aaa\nbbb\nccc\n";
const tag = getFileReadCache(session).record(filePath, source); const tag = getFileReadCache(session).record(filePath, source);
return `${formatHashlineHeader(displayPath, tag)}\nSWAP 2.=2:\n+bbb\n`; return `${formatHashlineHeader(displayPath, tag)}\nPUT 2-2:\n+bbb\n`;
} }
describe("hashline noop loop guard", () => { describe("hashline noop loop guard", () => {
@@ -145,7 +145,7 @@ describe("hashline noop loop guard", () => {
// from the model's perspective. // from the model's perspective.
const source = "aaa\nbbb\nccc\n"; const source = "aaa\nbbb\nccc\n";
const tag = getFileReadCache(session).record(filePath, source); 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)); const editResult = await executeHashlineSingle(execOptions(realEdit, session));
expect(editResult.content[0]?.type === "text" ? editResult.content[0].text : "").not.toContain( expect(editResult.content[0]?.type === "text" ? editResult.content[0].text : "").not.toContain(
"byte-identical to the file", "byte-identical to the file",
@@ -157,7 +157,7 @@ describe("hashline noop loop guard", () => {
// soft-hint regime. // soft-hint regime.
const newSource = "aaa\nBBB\nccc\n"; const newSource = "aaa\nBBB\nccc\n";
const newTag = getFileReadCache(session).record(filePath, newSource); 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)); const result = await executeHashlineSingle(execOptions(newNoop, session));
expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain( expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain(
"byte-identical to the file", "byte-identical to the file",
@@ -88,7 +88,7 @@ function hashlineExecuteOptions(
describe("hashline executor", () => { describe("hashline executor", () => {
it("rejects file creation and directs to the write tool", async () => { it("rejects file creation and directs to the write tool", async () => {
await withTempDir(async tempDir => { 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/); await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/);
expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false); expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false);
}); });
@@ -101,7 +101,7 @@ describe("hashline executor", () => {
await Bun.write(filePath, source); await Bun.write(filePath, source);
const sourceTag = recordFullSnapshot(getFileReadCache(session), 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 result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, undefined, session));
const text = result.content[0]?.type === "text" ? result.content[0].text : ""; const text = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -256,7 +256,7 @@ describe("hashline executor", () => {
repl("L2h"), repl("L2h"),
repl("L2i"), repl("L2i"),
header("a.ts", originalTag), header("a.ts", originalTag),
`INS.POST ${tag(8, "L8")}:`, `PUT > ${tag(8, "L8")}:`,
repl("INSERTED"), repl("INSERTED"),
].join("\n"); ].join("\n");
@@ -318,14 +318,14 @@ describe("hashlineEditParamsSchema — payload shape", () => {
it("tolerates provider extra fields without declaring `path`", () => { it("tolerates provider extra fields without declaring `path`", () => {
const result = arkSafeParse(hashlineEditParamsSchema, { const result = arkSafeParse(hashlineEditParamsSchema, {
path: "x.ts", path: "x.ts",
input: `[x.ts]\nINS.HEAD:\n${repl("x")}`, input: `[x.ts]\nPUT <1:\n${repl("x")}`,
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("rejects `_input` as an alias for `input`", () => { it("rejects `_input` as an alias for `input`", () => {
const result = arkSafeParse(hashlineEditParamsSchema, { 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); expect(result.success).toBe(false);
}); });
@@ -258,7 +258,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", (
const result = await executeHashlineSingle({ const result = await executeHashlineSingle({
session, session,
input: `[${relPath}#${realTag}]\nSWAP 2.=2:\n+ const v0 = 100;`, input: `[${relPath}#${realTag}]\nPUT 2-2:\n+ const v0 = 100;`,
writethrough, writethrough,
beginDeferredDiagnosticsForPath: noopBeginDeferred, beginDeferredDiagnosticsForPath: noopBeginDeferred,
}); });
@@ -282,7 +282,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", (
const nextTag = extractTag(text); const nextTag = extractTag(text);
const followUp = await executeHashlineSingle({ const followUp = await executeHashlineSingle({
session, session,
input: `[${relPath}#${nextTag}]\nSWAP 1.=1:\n+function g() {`, input: `[${relPath}#${nextTag}]\nPUT 1-1:\n+function g() {`,
writethrough, writethrough,
beginDeferredDiagnosticsForPath: noopBeginDeferred, beginDeferredDiagnosticsForPath: noopBeginDeferred,
}); });
@@ -303,7 +303,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", (
const result = await executeHashlineSingle({ const result = await executeHashlineSingle({
session, session,
input: `[${relPath}#${realTag}]\nSWAP 2.=2:\n+earth`, input: `[${relPath}#${realTag}]\nPUT 2-2:\n+earth`,
writethrough, writethrough,
beginDeferredDiagnosticsForPath: noopBeginDeferred, beginDeferredDiagnosticsForPath: noopBeginDeferred,
}); });
@@ -334,7 +334,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", (
const result = await executeHashlineSingle({ const result = await executeHashlineSingle({
session, session,
input: `[${relPath}#${realTag}]\nSWAP 2.=2:\n+print('new')`, input: `[${relPath}#${realTag}]\nPUT 2-2:\n+print('new')`,
writethrough, writethrough,
beginDeferredDiagnosticsForPath: noopBeginDeferred, beginDeferredDiagnosticsForPath: noopBeginDeferred,
}); });
@@ -344,7 +344,7 @@ describe("executeHashlineSingle model-visible payload under write-time drift", (
const nextTag = extractTag(text); const nextTag = extractTag(text);
const followUp = await executeHashlineSingle({ const followUp = await executeHashlineSingle({
session, session,
input: `[${relPath}#${nextTag}]\nSWAP 2.=2:\n+print('newer')`, input: `[${relPath}#${nextTag}]\nPUT 2-2:\n+print('newer')`,
writethrough, writethrough,
beginDeferredDiagnosticsForPath: noopBeginDeferred, beginDeferredDiagnosticsForPath: noopBeginDeferred,
}); });
+10 -10
View File
@@ -242,12 +242,12 @@ describe("computeHashlineDiff", () => {
const line = "unchanged content"; const line = "unchanged content";
await Bun.write(sourcePath, `${line}\n`); 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. // fires through computeHashlineDiff but produces identical content.
const text = `${line}\n`; const text = `${line}\n`;
const snapshotStore = new InMemorySnapshotStore(); const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text); 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); const result = await computeHashlineDiff({ input }, tempDir, snapshotStore);
expect("error" in result).toBe(true); expect("error" in result).toBe(true);
if ("error" in result) { if ("error" in result) {
@@ -263,7 +263,7 @@ describe("computeHashlineDiff", () => {
const snapshotStore = new InMemorySnapshotStore(); const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text); const tag = snapshotStore.record(sourcePath, text);
const result = await computeHashlineDiff( const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nINS.TAIL:\n+second` }, { input: `${formatHashlineHeader(sourcePath, tag)}\nPUT >$:\n+second` },
tempDir, tempDir,
snapshotStore, snapshotStore,
); );
@@ -281,7 +281,7 @@ describe("computeHashlineDiff", () => {
const snapshotStore = new InMemorySnapshotStore(); const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text); const tag = snapshotStore.record(sourcePath, text);
const result = await computeHashlineDiff( const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 1.=1\nPASTE.TAIL` }, { input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 1-1\nPUT >$` },
tempDir, tempDir,
snapshotStore, snapshotStore,
); );
@@ -303,7 +303,7 @@ describe("computeHashlineDiff", () => {
// once per preview frame; sections then thread the fork in patch order. // once per preview frame; sections then thread the fork in patch order.
const sessionRegister = { lines: ["carried"] }; const sessionRegister = { lines: ["carried"] };
const result = await computeHashlineDiff( const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nPASTE.TAIL` }, { input: `${formatHashlineHeader(sourcePath, tag)}\nPUT >$` },
tempDir, tempDir,
snapshotStore, snapshotStore,
{ clipboard: forkClipboard(sessionRegister) }, { clipboard: forkClipboard(sessionRegister) },
@@ -323,7 +323,7 @@ describe("computeHashlineDiff", () => {
const snapshotStore = new InMemorySnapshotStore(); const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text); const tag = snapshotStore.record(sourcePath, text);
const result = await computeHashlineDiff( const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 2.=2\nPASTE.HEAD` }, { input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 2-2\nPUT <1` },
tempDir, tempDir,
snapshotStore, snapshotStore,
{ streaming: true, skipHashValidation: true }, { streaming: true, skipHashValidation: true },
@@ -339,12 +339,12 @@ describe("computeHashlineDiff", () => {
const relativePath = "source.txt"; const relativePath = "source.txt";
await Bun.write(path.join(tempDir, relativePath), "first\n"); 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 // (Patcher.prepare) rejects it for the missing mandatory tag. The
// preview/diff path MUST emit the SAME rejection so a successful preview // preview/diff path MUST emit the SAME rejection so a successful preview
// never precedes a failing apply. // never precedes a failing apply.
const result = await computeHashlineDiff( const result = await computeHashlineDiff(
{ input: `[${relativePath}]\nINS.TAIL:\n+second` }, { input: `[${relativePath}]\nPUT >$:\n+second` },
tempDir, tempDir,
new InMemorySnapshotStore(), new InMemorySnapshotStore(),
); );
@@ -355,7 +355,7 @@ describe("computeHashlineDiff", () => {
}); });
test("returns a handled error when the source path is a local URL", async () => { test("returns a handled error when the source path is a local URL", async () => {
const result = await computeHashlineDiff( const result = await computeHashlineDiff(
{ input: "[local://PLAN.md]\nINS.TAIL:\n+x" }, { input: "[local://PLAN.md]\nPUT >$:\n+x" },
tempDir, tempDir,
new InMemorySnapshotStore(), new InMemorySnapshotStore(),
); );
@@ -384,7 +384,7 @@ describe("computeHashlineDiff", () => {
const tag = snapshotStore.record(sourcePath, SNAPSHOT_TEXT); const tag = snapshotStore.record(sourcePath, SNAPSHOT_TEXT);
const result = await computeHashlineDiff( 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, tempDir,
snapshotStore, snapshotStore,
); );
@@ -246,7 +246,7 @@ describe("executeHashlineSingle multi-section aggregate cap", () => {
} }
const sections = tags.map((tag, i) => 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"); const input = sections.join("\n");
@@ -67,7 +67,7 @@ describe("hashline streaming preview (multi-section)", () => {
const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots }); const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal, snapshots });
test("keeps section A's preview when section B's header just arrived", async () => { 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).not.toBeNull(); expect(previews).not.toBeNull();
expect(previews).toHaveLength(1); 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 () => { test("ignores parse errors from the trailing in-progress section", async () => {
// `7:bad` has invalid payload — the trailing section is still being typed. // `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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).not.toBeNull(); expect(previews).not.toBeNull();
expect(previews).toHaveLength(1); 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 () => { 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).toHaveLength(2); expect(previews).toHaveLength(2);
expect(previews?.map(p => p.path).sort()).toEqual(["a.ts", "b.ts"]); 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 `+` payload has no trailing newline — the common single-op case
// the trailing-line trim used to erase, collapsing the preview to a // 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. // "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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).toHaveLength(1); expect(previews).toHaveLength(1);
expect(previews?.[0]?.error).toBeUndefined(); 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 () => { 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).toHaveLength(1); expect(previews).toHaveLength(1);
expect(previews?.[0]?.error).toBeUndefined(); 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 () => { 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 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( const previews = await strategy.computeDiffPreview(
{ input } as never, { 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 () => { test("final preview recovers a stale tag from snapshot history", async () => {
await Bun.write(file, `// external\n${text}`); 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never);
expect(previews).toHaveLength(1); expect(previews).toHaveLength(1);
expect(previews?.[0]?.error).toBeUndefined(); 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 () => { 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never);
expect(previews).toHaveLength(1); expect(previews).toHaveLength(1);
expect(previews?.[0]?.error).toContain("not from this session"); 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 // Op header typed, payload still empty: applyPartialTo drops the
// payload-less op so nothing changes yet. The preview must report null // payload-less op so nothing changes yet. The preview must report null
// (preserving any prior frame), never a 'No changes' error that wipes it. // (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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
expect(previews).toBeNull(); 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 }); 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. // Replace the 20-line body (lines 2..21) with the first `n` payload rows.
const buildInput = (n: number) => const buildInput = (n: number) =>
`${header}\nSWAP 2.=21:\n${payload `${header}\nPUT 2-21:\n${payload
.slice(0, n) .slice(0, n)
.map(l => `+${l}`) .map(l => `+${l}`)
.join("\n")}`; .join("\n")}`;
@@ -305,7 +305,7 @@ describe("apply_patch streaming preview (trailing partial line)", () => {
describe("matcherDigest", () => { describe("matcherDigest", () => {
test("hashline: digests stripped `+` body rows only, never headers or op lines", () => { 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;"); 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 () => { 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, true) as never);
expect(previews).not.toBeNull(); expect(previews).not.toBeNull();
expect(previews).toHaveLength(1); 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 () => { 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never);
expect(previews).not.toBeNull(); expect(previews).not.toBeNull();
expect(previews).toHaveLength(1); 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; // A header whose basename matches no retained snapshot path cannot recover;
// the preview must report the failure rather than silently pick a file. // the preview must report the failure rather than silently pick a file.
const orphan = formatHashlineHeader("absent.ts", snapshots.record(nestedFile, text)); 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); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never);
expect(previews).not.toBeNull(); expect(previews).not.toBeNull();
expect(previews?.[0]?.error).toBeTruthy(); expect(previews?.[0]?.error).toBeTruthy();
@@ -106,7 +106,7 @@ describe("read → edit seen-line guard", () => {
const tag = tagFromOutput(resultText(read)); const tag = tagFromOutput(resultText(read));
await expect( 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/); ).rejects.toThrow(/never displayed \(it showed/);
// The reject left the file untouched. // The reject left the file untouched.
expect(await Bun.file(file).text()).toBe(CONTENT); 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 read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` });
const tag = tagFromOutput(resultText(read)); 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"); 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(6)).toBe(true);
expect(seen?.has(10)).toBe(false); 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(); const edited = await Bun.file(file).text();
expect(edited).toContain("line 4\nRAW EDITED\nline 6"); expect(edited).toContain("line 4\nRAW EDITED\nline 6");
expect(edited).not.toContain("line 5"); expect(edited).not.toContain("line 5");
@@ -169,11 +169,11 @@ describe("read → edit seen-line guard", () => {
expect(seen?.has(5)).toBe(false); expect(seen?.has(5)).toBe(false);
await expect( 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/); ).rejects.toThrow(/never displayed \(it showed/);
expect(await Bun.file(file).text()).toBe(CONTENT); 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(); const edited = await Bun.file(file).text();
expect(edited).toContain("line 6\nRAW RANGE EDITED\nline 8"); expect(edited).toContain("line 6\nRAW RANGE EDITED\nline 8");
expect(edited).not.toContain("line 7"); expect(edited).not.toContain("line 7");
@@ -197,7 +197,7 @@ describe("read → edit seen-line guard", () => {
expect(seen?.has(4)).toBe(true); expect(seen?.has(4)).toBe(true);
await expect( 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/); ).rejects.toThrow(/never displayed \(it showed/);
expect(await Bun.file(file).text()).toBe(content); 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; const seen = store.byHash(canonicalSnapshotKey(file), tag)?.seenLines;
expect(seen?.has(2)).toBe(true); 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"); 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(1)).toBe(true);
expect(seen?.has(2)).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"); 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); expect(seen?.has(1122)).toBe(true);
await executeHashlineSingle( await executeHashlineSingle(
execOptions( 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, session,
), ),
); );
@@ -281,7 +281,7 @@ describe("read → edit seen-line guard", () => {
let message: string | undefined; let message: string | undefined;
try { 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) { } catch (err) {
message = (err as Error).message; 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 // 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. // 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(); const after = await Bun.file(file).text();
expect(after).toContain("X10\nX11\nX12"); expect(after).toContain("X10\nX11\nX12");
expect(after).not.toContain("line 10"); 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; const seen = getFileSnapshotStore(session).byHash(canonicalSnapshotKey(file), tag)?.seenLines;
expect(seen?.has(2)).toBe(true); 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"); 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); expect(seen?.has(8)).toBe(false);
// The matched line is in the seen set, so editing it applies. // 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"); 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 search = await new GrepTool(session).execute("s1", { pattern: "NEEDLE", path: file });
const tag = tagFromOutput(resultText(search)); 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/, /never displayed \(it showed/,
); );
expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`); 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)); const tag = tagFromOutput(resultText(read));
// Line 12 was never displayed, but the guard is disabled, so it applies. // 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"); expect(await Bun.file(file).text()).toContain("EDITED");
}); });
}); });
@@ -31,20 +31,20 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => {
describe("hashline (section-header path)", () => { describe("hashline (section-header path)", () => {
it("extracts the path from a single section header", () => { 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"]); expect(EDIT_MODE_STRATEGIES.hashline.matcherPaths({ input })).toEqual(["demo.ts"]);
}); });
it("extracts paths from multiple section headers in order, deduped", () => { it("extracts paths from multiple section headers in order, deduped", () => {
const input = [ const input = [
"[src/a.ts#ABCD]", "[src/a.ts#ABCD]",
"SWAP 1.=1:", "PUT 1-1:",
"+const a = 1;", "+const a = 1;",
"[src/b.ts#EF01]", "[src/b.ts#EF01]",
"SWAP 1.=1:", "PUT 1-1:",
"+const b = 2;", "+const b = 2;",
"[src/a.ts#1234]", "[src/a.ts#1234]",
"SWAP 2.=2:", "PUT 2-2:",
"+const c = 3;", "+const c = 3;",
"", "",
].join("\n"); ].join("\n");
@@ -61,10 +61,10 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => {
it("handles paths with spaces and recovers apply_patch-style header noise", () => { it("handles paths with spaces and recovers apply_patch-style header noise", () => {
const input = [ const input = [
"[dir with spaces/file.ts#1A2B]", "[dir with spaces/file.ts#1A2B]",
"SWAP 1.=1:", "PUT 1-1:",
"+after", "+after",
"[*** Update File: src/recovered.ts#1A2B]", "[*** Update File: src/recovered.ts#1A2B]",
"SWAP 1.=1:", "PUT 1-1:",
"+after", "+after",
"", "",
].join("\n"); ].join("\n");
@@ -76,7 +76,7 @@ describe("EDIT_MODE_STRATEGIES.matcherPaths", () => {
it("returns undefined when input has no section header", () => { it("returns undefined when input has no section header", () => {
expect(EDIT_MODE_STRATEGIES.hashline.matcherPaths({ input: "" })).toBeUndefined(); 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", () => { it("hashline splits multi-section payloads into one entry per file", () => {
const input = [ const input = [
"[src/a.ts#ABCD]", "[src/a.ts#ABCD]",
"SWAP 1.=1:", "PUT 1-1:",
"+const a = 1;", "+const a = 1;",
"[README.md#EF01]", "[README.md#EF01]",
"SWAP 1.=1:", "PUT 1-1:",
"+# Heading", "+# Heading",
"[src/a.ts#1234]", "[src/a.ts#1234]",
"SWAP 2.=2:", "PUT 2-2:",
"+const c = 3;", "+const c = 3;",
"", "",
].join("\n"); ].join("\n");
@@ -198,7 +198,7 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => {
const ANY = "any"; const ANY = "any";
// Snippet rendered at runtime to avoid tripping the rule on this test file itself. // Snippet rendered at runtime to avoid tripping the rule on this test file itself.
const VIOLATING_LINE = `export const value: ${ANY} = 1;`; 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<TtsrManager> { async function makeManager(): Promise<TtsrManager> {
const manager = new TtsrManager({ const manager = new TtsrManager({
@@ -250,10 +250,10 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => {
const manager = await makeManager(); const manager = await makeManager();
const input = [ const input = [
"[README.md#ABCD]", "[README.md#ABCD]",
"SWAP 1.=1:", "PUT 1-1:",
`+${VIOLATING_LINE}`, `+${VIOLATING_LINE}`,
"[src/ok.ts#EF01]", "[src/ok.ts#EF01]",
"SWAP 1.=1:", "PUT 1-1:",
"+export const ok = 1;", "+export const ok = 1;",
"", "",
].join("\n"); ].join("\n");
@@ -278,10 +278,10 @@ describe("hashline edit + path-scoped TTSR (regression: #3646)", () => {
const manager = await makeManager(); const manager = await makeManager();
const input = [ const input = [
"[README.md#ABCD]", "[README.md#ABCD]",
"SWAP 1.=1:", "PUT 1-1:",
"+# Heading", "+# Heading",
"[src/bad.ts#EF01]", "[src/bad.ts#EF01]",
"SWAP 1.=1:", "PUT 1-1:",
`+${VIOLATING_LINE}`, `+${VIOLATING_LINE}`,
"", "",
].join("\n"); ].join("\n");
@@ -176,7 +176,7 @@ describe("read tool column truncation vs hashline snapshot", () => {
tmpDir, tmpDir,
filePath, filePath,
header, header,
patchBody: "SWAP 3.=3:\n+epilogue\n", patchBody: "PUT 3-3:\n+epilogue\n",
}); });
const after = await fs.readFile(filePath, "utf8"); const after = await fs.readFile(filePath, "utf8");
@@ -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).toMatch(/^\[.+settings\.json#[0-9A-F]{4}\]$/);
expect(header).toContain(path.basename(outDir)); 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"); const resultText = result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
expect(resultText).not.toContain("File not found"); expect(resultText).not.toContain("File not found");
@@ -111,7 +111,7 @@ describe("editToolRenderer", () => {
const uiTheme = await getUiTheme(); const uiTheme = await getUiTheme();
const component = editToolRenderer.renderCall( 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" } }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme, uiTheme,
@@ -132,7 +132,7 @@ describe("editToolRenderer", () => {
input: [ input: [
"*** Begin Patch", "*** Begin Patch",
"[crates/pi-natives/src/shell.rs]", "[crates/pi-natives/src/shell.rs]",
"INS.TAIL:", "PUT >$:",
"+pub fn streaming_preview() {", "+pub fn streaming_preview() {",
].join("\n"), ].join("\n"),
}, },
@@ -143,7 +143,7 @@ describe("editToolRenderer", () => {
const rendered = Bun.stripANSI(component.render(160).join("\n")); const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("crates/pi-natives/src/shell.rs"); 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("+pub fn streaming_preview() {");
expect(rendered).not.toContain("*** Begin Patch"); expect(rendered).not.toContain("*** Begin Patch");
}); });
@@ -152,7 +152,7 @@ describe("editToolRenderer", () => {
const uiTheme = await getUiTheme(); const uiTheme = await getUiTheme();
const compactComponent = editToolRenderer.renderCall( 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" } }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme, uiTheme,
@@ -160,7 +160,7 @@ describe("editToolRenderer", () => {
const quotedComponent = editToolRenderer.renderCall( 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" } }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme, uiTheme,
@@ -179,7 +179,7 @@ describe("editToolRenderer", () => {
// renderer keeps the title clean. // renderer keeps the title clean.
const canonical = editToolRenderer.renderCall( 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" } }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme, uiTheme,
@@ -187,7 +187,7 @@ describe("editToolRenderer", () => {
// While streaming, the closing bracket may not have arrived yet. // While streaming, the closing bracket may not have arrived yet.
const partial = editToolRenderer.renderCall( 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" } }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
uiTheme, uiTheme,
); );
@@ -214,7 +214,7 @@ describe("editToolRenderer", () => {
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
uiTheme, 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 // 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 // single-line edit. The streaming pass trims that in-flight line, so the
// preview only becomes computable once args are marked complete. // 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); const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir);
component.setArgsComplete(); component.setArgsComplete();
@@ -328,7 +328,7 @@ describe("editToolRenderer", () => {
const snapshots = new InMemorySnapshotStore(); const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(filePath, content); 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( const component = new ToolExecutionComponent(
"edit", "edit",
{ __partialJson: input }, { __partialJson: input },
@@ -83,7 +83,7 @@ describe("write tool hashline header", () => {
// Apply a hashline patch immediately, using only the tag the write tool // Apply a hashline patch immediately, using only the tag the write tool
// returned — no intervening `read`. // 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 }); const patch = Patch.parse(patchInput, { cwd: tmpDir });
expect(patch.sections).toHaveLength(1); expect(patch.sections).toHaveLength(1);
+9
View File
@@ -2,6 +2,15 @@
## [Unreleased] ## [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 ## [17.2.0] - 2026-07-30
### Breaking Changes ### Breaking Changes
+7 -7
View File
@@ -26,7 +26,7 @@ await fs.writeText("hello.ts", before);
const tag = snapshots.record("hello.ts", before); const tag = snapshots.record("hello.ts", before);
const patcher = new Patcher({ fs, snapshots }); const patcher = new Patcher({ fs, snapshots });
const patch = Patch.parse(String.raw`[hello.ts#${tag}] const patch = Patch.parse(String.raw`[hello.ts#${tag}]
SWAP 1.=1: PUT 1.=1:
+const greeting = "hello";`); +const greeting = "hello";`);
const result = await patcher.apply(patch); 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. session-aware recovery on mismatch.
Inside a section: Inside a section:
- `SWAP A.=B:` — replace lines A.=B with following `+TEXT` body rows. - `PUT A.=B:` — replace lines A through B (inclusive) with following `+TEXT` body rows.
- `SWAP.BLK A:` — replace the syntactic block beginning on line A. - `PUT 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`. - `PUT <A:` / `PUT >A:` — insert following body rows before/after line A (`<1` = head, `>$` = tail).
- `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows. - `PUT >A*:` — insert following body rows after the resolved block's last line.
- `INS.BLK.POST A:` — insert following body rows after the resolved block's last line. - `PUT <A` / `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).
- `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`. - `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. - `REM` — delete the whole file named by the section header.
- `MV DEST` — move/rename the section file to `DEST` (optionally after line edits). - `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
- `+TEXT` — literal body row (use `+` alone for a blank line). - `+TEXT` — literal body row (use `+` alone for a blank line).
+72 -1
View File
@@ -14,6 +14,7 @@ import {
ambiguousBoundaryEchoMessage, ambiguousBoundaryEchoMessage,
ambiguousCloserSpareMessage, ambiguousCloserSpareMessage,
blockInsertLandingShiftWarning, blockInsertLandingShiftWarning,
REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
UNRESOLVED_BLOCK_INTERNAL, UNRESOLVED_BLOCK_INTERNAL,
} from "./messages"; } from "./messages";
import { cloneCursor } from "./tokenizer"; 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` * 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`. * surviving file lines just below the range AND dropping them zeroes `delta`.
@@ -927,6 +988,15 @@ function repairReplacementBoundaries(
after: new Map(), after: new Map(),
}; };
for (const edit of projected) { 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; if (edit.kind !== "insert") continue;
for (const anchor of getCursorAnchors(edit.cursor)) { for (const anchor of getCursorAnchors(edit.cursor)) {
const lines = insertedByLine.get(anchor.line); const lines = insertedByLine.get(anchor.line);
@@ -1272,9 +1342,10 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
fileLines, fileLines,
); );
validateLineBounds(targetEdits, fileLines); validateLineBounds(targetEdits, fileLines);
const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines); const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, 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. // Partition edits into bof, eof, and anchor-targeted buckets.
const bofLines: string[] = []; const bofLines: string[] = [];
+27 -5
View File
@@ -139,7 +139,13 @@ export function resolveBlockEdits(
: pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line), : pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
); );
const cursor: Cursor = { kind: "after_anchor", anchor: { line: 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; continue;
} }
options.onWarning?.( options.onWarning?.(
@@ -165,7 +171,9 @@ export function resolveBlockEdits(
const suggestions: BlockDiagnosticSuggestions = {}; const suggestions: BlockDiagnosticSuggestions = {};
if (nextBlock) suggestions.nextBlock = nextBlock; if (nextBlock) suggestions.nextBlock = nextBlock;
if (enclosingBlock) suggestions.enclosingBlock = enclosingBlock; 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) { if (span.start === span.end) {
// A single-line block resolution means line N is a bare statement, not // 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. // claiming a depth inside the block back across its trailing closers.
resolved.push({ resolved.push({
kind: "paste", 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, lineNum: edit.lineNum,
index: synthIndex++, index: synthIndex++,
blockStart: span.start, blockStart: span.start,
@@ -205,6 +214,7 @@ export function resolveBlockEdits(
resolved.push({ resolved.push({
kind: "cut", kind: "cut",
range: { start: { line: span.start }, end: { line: span.end } }, range: { start: { line: span.start }, end: { line: span.end } },
...(edit.register === undefined ? {} : { register: edit.register }),
lineNum: edit.lineNum, lineNum: edit.lineNum,
index: synthIndex++, index: synthIndex++,
}); });
@@ -232,8 +242,20 @@ export function resolveBlockEdits(
} }
continue; continue;
} }
// Mirror `SWAP start.=end:`: replacement inserts at `span.start`, then if (edit.register !== undefined) {
// one delete per line across the resolved span. // 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) { for (const payload of edit.payloads) {
const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } }; const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
resolved.push({ resolved.push({
+143 -44
View File
@@ -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. * `CUT` captures source lines before ordinary delete edits apply. Register
* `PASTE` expands the latest capture into inserts. One register flows through * `PUT`s expand captured lines into inserts (plus per-line deletes when the
* patch sections in authored order, so content moves across files; the latest * target is a `span`). Named registers (`named: Map`) persist across edit
* cut wins and paste does not consume it. * 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 { HL_CUT_KEYWORD, HL_PUT_KEYWORD, HL_RANGE_SEP } from "./format";
import { EMPTY_PASTE } from "./messages"; import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, unknownRegisterMessage } from "./messages";
import { cloneCursor } from "./tokenizer"; import { cloneCursor } from "./tokenizer";
import type { Clipboard, Edit } from "./types"; import type { Clipboard, Edit } from "./types";
@@ -15,29 +16,84 @@ type CutEdit = Extract<Edit, { kind: "cut" }>;
function describeCutEdit(edit: CutEdit): string { function describeCutEdit(edit: CutEdit): string {
const { start, end } = edit.range; const { start, end } = edit.range;
const range = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`; const span = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`;
return `${HL_CUT_KEYWORD} ${range}`; 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 { export function hasClipboardEdit(edits: readonly Edit[]): boolean {
return edits.some( return edits.some(
edit => edit =>
edit.kind === "cut" || edit.kind === "cut" ||
edit.kind === "paste" || 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}. */ /** Optional knobs for {@link resolveClipboardEdits}. */
export interface ResolveClipboardEditsOptions { 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"; 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. * 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( export function resolveClipboardEdits(
edits: readonly Edit[], edits: readonly Edit[],
@@ -49,32 +105,55 @@ export function resolveClipboardEdits(
const onEmptyPaste = options.onEmptyPaste ?? "throw"; const onEmptyPaste = options.onEmptyPaste ?? "throw";
const resolved: Edit[] = []; const resolved: Edit[] = [];
let synthIndex = 0; let synthIndex = 0;
for (const edit of edits) { for (const edit of edits) {
if (edit.kind === "cut") { if (edit.kind === "cut") {
const { start, end } = edit.range; writeRegister(edit, fileLines, clipboard);
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);
continue; continue;
} }
if (edit.kind === "paste") { if (edit.kind === "paste") {
const lines = clipboard.lines; const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste);
if (lines === undefined) { if (lines === null) continue;
if (onEmptyPaste === "drop") continue;
throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`); if (edit.at.kind === "gap") {
} for (const text of lines) {
for (const text of lines) { resolved.push({
resolved.push({ kind: "insert",
kind: "insert", cursor: cloneCursor(edit.at.cursor),
cursor: cloneCursor(edit.cursor), text,
text, lineNum: edit.lineNum,
lineNum: edit.lineNum, index: synthIndex++,
index: synthIndex++, ...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }),
...(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; continue;
} }
@@ -83,15 +162,28 @@ export function resolveClipboardEdits(
return resolved; return resolved;
} }
/** Create a transactional working copy of a clipboard register. */ /** Start a batch with persisted named registers but no anonymous state. */
export function forkClipboard(source?: Clipboard): Clipboard { export function startClipboardBatch(source?: Clipboard): Clipboard {
return source === undefined ? {} : { ...source }; 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 { export function commitClipboard(fork: Clipboard, target: Clipboard): void {
if (fork.lines === undefined) delete target.lines; if (fork.named !== undefined) {
else target.lines = fork.lines; 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. * mutating the register or reading file content.
*/ */
export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void { export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
let hasLines = clipboard.lines !== undefined; const fork = forkClipboard(clipboard);
for (const edit of edits) { for (const edit of edits) {
if (edit.kind === "cut") { if (edit.kind === "cut") {
hasLines = true; if (edit.register !== undefined) {
} else if (edit.kind === "paste" && !hasLines) { fork.named ??= new Map();
throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`); 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");
} }
} }
} }
+35 -34
View File
@@ -13,40 +13,31 @@ export const HL_FILE_SUFFIX = "]";
/** Payload sigil for literal body rows. */ /** Payload sigil for literal body rows. */
export const HL_PAYLOAD_REPLACE = "+"; export const HL_PAYLOAD_REPLACE = "+";
/** Hunk-header keyword for concrete line replacement. */ /** Hunk-header keyword: `PUT` writes content (body rows) or a register at a span or gap. */
export const HL_REPLACE_KEYWORD = "SWAP"; export const HL_PUT_KEYWORD = "PUT";
/** Hunk-header keyword for insertion operations. */ /** Hunk-header keyword: `CUT N.=M` / `CUT N*` deletes lines and captures them (anonymous register, or `@name` when given). */
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. */
export const HL_CUT_KEYWORD = "CUT"; 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. */ /** File-level keyword: `REM` deletes the whole file named by the section header. */
export const HL_REM_KEYWORD = "REM"; export const HL_REM_KEYWORD = "REM";
/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */ /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
export const HL_MOVE_KEYWORD = "MV"; export const HL_MOVE_KEYWORD = "MV";
export const HL_HEADER_COLON = ":"; export const HL_HEADER_COLON = ":";
/** Gap sigil: `<N` targets the gap before line N (`<1` = head). */
export const HL_GAP_BEFORE = "<";
/** 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. */ /** Separator between a hashline file path and its opaque snapshot tag. */
export const HL_FILE_HASH_SEP = "#"; 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 = ".="; export const HL_RANGE_SEP = ".=";
/** Separator between a line number and displayed line content in hashline mode. */ /** 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}. */ /** Capture-group form of {@link HL_LINE_RE_RAW}. */
export const HL_LINE_CAPTURE_RE_RAW = `(${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 { 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 { 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. */ /** Format a gap locator for a cursor position (`<5`, `>5`, `<1`, `>$`). */
export function formatInsertHeader(cursor: Cursor): string { export function formatGapLocator(cursor: Cursor): string {
switch (cursor.kind) { switch (cursor.kind) {
case "before_anchor": 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": 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": case "bof":
return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`; return `${HL_GAP_BEFORE}1`;
case "eof": 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. */ /** Number of hex characters in a content-derived file-hash tag. */
export const HL_FILE_HASH_LENGTH = 4; export const HL_FILE_HASH_LENGTH = 4;
/** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */ /** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
+12 -11
View File
@@ -7,20 +7,21 @@ file_header: "[" filename "#" file_hash "]" LF
file_hash: /[0-9A-F]{4}/ file_hash: /[0-9A-F]{4}/
filename: /[^#\r\n]+/ filename: /[^#\r\n]+/
hunk: swap_hunk | ins_hunk | cut_hunk | paste_hunk | rem_hunk | mv_hunk hunk: put_hunk | cut_hunk | rem_hunk | mv_hunk
swap_hunk: "SWAP " header_range ":" LF body* put_hunk: "PUT " put_locator register? ":" LF body*
| "SWAP.BLK " LID ":" LF body+ | "PUT " put_locator register LF
ins_hunk: "INS." pos ":" LF body+ | "PUT " gap_locator LF
cut_hunk: "CUT" target LF cut_hunk: "CUT " cut_locator register? LF
paste_hunk: "PASTE." pos LF
rem_hunk: "REM" LF rem_hunk: "REM" LF
mv_hunk: "MV " filename LF body* mv_hunk: "MV " filename LF body*
target: " " header_range | ".BLK " LID put_locator: range | LID "*" | gap_locator
pos: "PRE " LID | "POST " LID | "BLK.POST " LID | "HEAD" | "TAIL" cut_locator: range | LID "*"
gap_locator: "<" LID | ">" LID | ">" LID "*" | ">$"
register: " @" /[A-Za-z0-9_-]+/
range: LID ".=" LID
LID: /[1-9]\d*/
body: "+" /(.*)/ LF body: "+" /(.*)/ LF
header_range: LID ".=" LID
LID: /[1-9]\d*/
%import common.LF %import common.LF
+14 -10
View File
@@ -305,11 +305,11 @@ export class PatchSection {
*/ */
get hasAnchorScopedEdit(): boolean { get hasAnchorScopedEdit(): boolean {
return this.edits.some(edit => { return this.edits.some(edit => {
if (edit.kind === "delete") return true; if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true;
// A `replace_block N:` edit is anchored to concrete content on line N. if (edit.kind === "paste") {
if (edit.kind === "block") return true; if (edit.at.kind === "span") return true;
// A `CUT` range reads concrete content. return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor";
if (edit.kind === "cut") return true; }
return edit.cursor.kind === "before_anchor" || edit.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[] { collectAnchorLines(): readonly number[] {
const lines = new Set<number>(); const lines = new Set<number>();
for (const edit of this.edits) { for (const edit of this.edits) {
if (edit.kind === "delete") { if (edit.kind === "delete" || edit.kind === "block") {
lines.add(edit.anchor.line);
continue;
}
if (edit.kind === "block") {
lines.add(edit.anchor.line); lines.add(edit.anchor.line);
continue; continue;
} }
@@ -330,6 +326,14 @@ export class PatchSection {
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line); for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line);
continue; 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") { if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
lines.add(edit.cursor.anchor.line); lines.add(edit.cursor.anchor.line);
} }
+129 -65
View File
@@ -33,16 +33,34 @@ export function formatAnchoredContext(anchorLines: readonly number[], fileLines:
/** Concrete range operation rejected because its absolute end precedes its start. */ /** Concrete range operation rejected because its absolute end precedes its start. */
export type AbsoluteRangeOp = "replace" | "cut"; export type AbsoluteRangeOp = "replace" | "cut";
/** Header forms per concrete-range op, used to compose retry suggestions. */ /** Format a register suffix (` @name` or empty). */
const RANGE_OP_FORMS: Record<AbsoluteRangeOp, { keyword: string; colon: string; blockKeyword: string }> = { function regSuffix(register?: string): string {
replace: { keyword: "SWAP", colon: ":", blockKeyword: "SWAP.BLK" }, return register ? ` @${register}` : "";
cut: { keyword: "CUT", colon: "", blockKeyword: "CUT.BLK" }, }
/** 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`). */ /** Block-locator header for a concrete-range op (`PUT 5*:` / `CUT 5*`). */
function blockFormAt(op: AbsoluteRangeOp, line: number): string { function blockFormAt(op: AbsoluteRangeOp, line: number, reg?: string): string {
const forms = RANGE_OP_FORMS[op]; return op === "replace"
return `${forms.blockKeyword} ${line}${forms.colon}`; ? reg
? `PUT ${line}*${regSuffix(reg)}`
: `PUT ${line}*:`
: `CUT ${line}*${regSuffix(reg)}`;
} }
/** Explain absolute range endpoints and provide safe, non-applying retry forms. */ /** Explain absolute range endpoints and provide safe, non-applying retry forms. */
@@ -52,15 +70,14 @@ export function invalidAbsoluteRangeMessage(
end: number, end: number,
op: AbsoluteRangeOp, op: AbsoluteRangeOp,
block?: BlockSpan, block?: BlockSpan,
register?: string,
): string { ): string {
const forms = RANGE_OP_FORMS[op]; 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 countedEnd = start + end - 1;
const counted = const counted =
Number.isSafeInteger(countedEnd) && countedEnd >= start Number.isSafeInteger(countedEnd) && countedEnd >= start ? forms.range(start, countedEnd, register) : null;
? `${forms.keyword} ${start}${HL_RANGE_SEP}${countedEnd}${forms.colon}` const blockForm = blockFormAt(op, start, register);
: null;
const blockForm = blockFormAt(op, start);
let message = let message =
`line ${patchLine}: Invalid absolute range: start ${start}, end ${end}. ` + `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. ` + `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"; export const ABORT_MARKER = "*** Abort";
/** Two consecutive hunks targeted the exact same concrete range. */ /** Exact-range duplicate hunks were normalized to the final hunk. */
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.`; 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. */ /** Replacement body indentation was aligned from unchanged structural rows. */
`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.`; 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. */ /** Bare body rows auto-converted to literal `+` rows. */
export const BARE_BODY_AUTO_PIPED_WARNING = export const BARE_BODY_AUTO_PIPED_WARNING =
"Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines."; "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 * Bare `-` body rows accepted as literal Markdown bullets. Only emitted when
* the hunk is unambiguously a bullet list: every `-` row is bullet-shaped * the hunk is unambiguously a bullet list: every `-` row is bullet-shaped
* (`- item`) and the body has no unified-diff `+new` counterpart rows. * (`- item`) and the body has no unified-diff `+new` counterpart rows.
*/ */
export const MINUS_BULLET_AUTO_PIPED_WARNING = 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`."; "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. */ /** Unified-diff-style `-` row in a hunk body. */
export const MINUS_ROW_REJECTED = 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`."; "`-` 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. */ /** Optional source-aware suggestions appended to block-anchor diagnostics. */
export interface BlockDiagnosticSuggestions { export interface BlockDiagnosticSuggestions {
/** Closest following multi-line block that begins after the authored anchor. */ /** 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. * A block-anchored replace/cut could not resolve to a syntactic block.
* Appends a {@link formatAnchoredContext} preview when `fileLines` is given. * 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( export function blockUnresolvedMessage(
line: number, line: number,
op: AbsoluteRangeOp = "replace", op: AbsoluteRangeOp = "replace",
fileLines?: readonly string[], fileLines?: readonly string[],
suggestions: BlockDiagnosticSuggestions = {}, suggestions: BlockDiagnosticSuggestions = {},
register?: string,
): string { ): string {
const forms = RANGE_OP_FORMS[op]; const phrase = blockFormAt(op, line, register);
const phrase = blockFormAt(op, line); const fallback =
const fallback = `${forms.keyword} ${line}${HL_RANGE_SEP}M${forms.colon}`; 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 anchorText = fileLines?.[line - 1];
const nextBlock = suggestions.nextBlock; const nextBlock = suggestions.nextBlock;
let message: string; let message: string;
if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) { if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) {
const retry = blockFormAt(op, nextBlock.start); const retry = blockFormAt(op, nextBlock.start, register);
message = message =
`Line ${line} is blank; no syntactic block can begin there. ` + `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}. ` + `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; const enclosingBlock = suggestions.enclosingBlock;
if (enclosingBlock) { if (enclosingBlock) {
const retry = blockFormAt(op, enclosingBlock.start); const retry = blockFormAt(op, enclosingBlock.start, register);
message += message +=
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` + ` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
`and ends at line ${enclosingBlock.end}; use \`${retry}\` to target it.`; `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. */ /** Block-anchored edit reached a path with no {@link BlockResolver} wired in. */
export const BLOCK_RESOLVER_UNAVAILABLE = 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 * 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.`; 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 { 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 { 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 { 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 { 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 * 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` ? `opens by restating the ${count} line(s) just above the range`
: `ends by restating the ${count} line(s) just below the range`; : `ends by restating the ${count} line(s) just below the range`;
return ( 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 ` + `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. ` + `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 ` + `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 { ): string {
const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`; const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`;
return ( 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 ` + `${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 ` + `(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, ` + `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 = 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."; "`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. */ /** `CUT` 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:\`.`; 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. */ /** Register `PUT` header carried a `:`. */
export const PASTE_TAKES_NO_BODY = export const COLON_ON_REGISTER_PUT =
"`PASTE` inserts the clipboard content and takes no `+` body rows. To insert literal text, use `INS`."; "`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. */ /** Register `PUT` hunk received a body row. */
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\`.`; 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 * 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. * an interleaved layout would silently reorder the register sequence.
*/ */
export const CLIPBOARD_INTERLEAVED_SECTIONS = 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. */ /** Gap `PUT` with `:` but no body. */
export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row."; export const EMPTY_INSERT =
"`PUT <N:` / `PUT >N:` promises body rows and got none. Write `+TEXT` rows, or drop the `:` to paste a register (`PUT >N` = anonymous, `PUT >N @name` = named).";
/** /**
* `insert after` body indented shallower than the anchor: the landing slid * `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. * I read instead of after the block" mistake.
*/ */
export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string { 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 * `PUT >N*:` body indented deeper than the block's closer: the landing was
* landing was pulled inside the block — a deeper body almost always means * pulled inside the block — a deeper body almost always means "append inside
* "append inside the block's body". * the block's body".
*/ */
export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string { 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. */ /** `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. * onto live content and warn instead of hard-failing.
*/ */
export const HEADTAIL_DRIFT_WARNING = 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 * 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"; export type BlockOp = "replace" | "insert_after" | "cut" | "paste_after";
/** Display forms per deferred-block op: block keyword, trailing colon, and single-line plain form. */ /** Display forms per deferred-block op: block keyword, trailing colon, and single-line plain form. */
const BLOCK_OP_FORMS: Record<BlockOp, { keyword: string; colon: string; plain: (line: number) => string }> = { const BLOCK_OP_FORMS: Record<BlockOp, { form: (line: number) => string; plain: (line: number) => string }> = {
replace: { keyword: "SWAP.BLK", colon: ":", plain: line => `SWAP ${line}${HL_RANGE_SEP}${line}:` }, replace: { form: line => `PUT ${line}*:`, plain: line => `PUT ${line}:` },
insert_after: { keyword: "INS.BLK.POST", colon: ":", plain: line => `INS.POST ${line}:` }, insert_after: { form: line => `PUT >${line}*:`, plain: line => `PUT >${line}:` },
cut: { keyword: "CUT.BLK", colon: "", plain: line => `CUT ${line}` }, cut: { form: line => `CUT ${line}*`, plain: line => `CUT ${line}` },
paste_after: { keyword: "PASTE.BLK.POST", colon: "", plain: line => `PASTE.POST ${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 forms = BLOCK_OP_FORMS[op];
const plainForm = forms.plain(line); const plainForm = forms.plain(line);
let message = 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}\`.`; `of a multi-line construct. For only this statement use \`${plainForm}\`.`;
if (enclosingBlock) { if (enclosingBlock) {
const enclosingForm = `${forms.keyword} ${enclosingBlock.start}${forms.colon}`;
message += message +=
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` + ` 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; return message;
} }
+309 -105
View File
@@ -7,19 +7,28 @@ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
import { import {
type AbsoluteRangeOp, type AbsoluteRangeOp,
BARE_BODY_AUTO_PIPED_WARNING, 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, CUT_TAKES_NO_BODY,
EMPTY_BLOCK, DIFF_OLD_ROWS_IGNORED_WARNING,
EMPTY_INSERT, EMPTY_INSERT,
EMPTY_PUT_AUTO_CUT_WARNING,
invalidAbsoluteRangeMessage, invalidAbsoluteRangeMessage,
MINUS_BULLET_AUTO_PIPED_WARNING, MINUS_BULLET_AUTO_PIPED_WARNING,
MINUS_ROW_REJECTED, MINUS_ROW_REJECTED,
MOVE_TAKES_NO_BODY, MOVE_TAKES_NO_BODY,
PASTE_TAKES_NO_BODY, READ_METADATA_IGNORED_WARNING,
REGISTER_PUT_TAKES_NO_BODY,
REM_TAKES_NO_BODY, REM_TAKES_NO_BODY,
REPLACE_PAIR_COALESCED_WARNING,
SNAPSHOT_ROWS_AUTO_PUT_WARNING,
} from "./messages"; } from "./messages";
import { stripOneLeadingHashlinePrefix } from "./prefixes"; import { isReadMetadataLine, stripOneLeadingHashlinePrefix } from "./prefixes";
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer"; 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. */ /** Bounds parser amplification before the target file's line count is available. */
const MAX_EXPANDED_RANGE_LINES = 100_000; const MAX_EXPANDED_RANGE_LINES = 100_000;
@@ -33,23 +42,32 @@ export class InvalidAbsoluteRangeError extends Error {
readonly endLine: number; readonly endLine: number;
/** Operation whose range was invalid. */ /** Operation whose range was invalid. */
readonly op: AbsoluteRangeOp; readonly op: AbsoluteRangeOp;
readonly register?: string;
constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan) { constructor(
super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block)); patchLine: number,
startLine: number,
endLine: number,
op: AbsoluteRangeOp,
block?: BlockSpan,
register?: string,
) {
super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block, register));
this.name = "InvalidAbsoluteRangeError"; this.name = "InvalidAbsoluteRangeError";
this.patchLine = patchLine; this.patchLine = patchLine;
this.startLine = startLine; this.startLine = startLine;
this.endLine = endLine; this.endLine = endLine;
this.op = op; this.op = op;
this.register = register;
} }
/** Rebuild this error with a proven syntactic-block endpoint suggestion. */ /** Rebuild this error with a proven syntactic-block endpoint suggestion. */
withBlock(block: BlockSpan): InvalidAbsoluteRangeError { 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 ( if (
!Number.isSafeInteger(range.start.line) || !Number.isSafeInteger(range.start.line) ||
range.start.line < 1 || range.start.line < 1 ||
@@ -61,7 +79,7 @@ function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp)
); );
} }
if (range.end.line < range.start.line) { 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; const span = range.end.line - range.start.line + 1;
if (span > MAX_EXPANDED_RANGE_LINES) { 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` * Body-row rejection message for ops that take no `+TEXT` rows, or `null`
* for targets whose header is followed by a body. * for ops whose header (`:`) promises a body.
*/ */
function bodylessTargetMessage(target: BlockTarget): string | null { function bodylessTargetMessage(target: BlockTarget, hadColon: boolean): string | null {
switch (target.kind) { if (target.kind === "cut" || target.kind === "cut_block") return CUT_TAKES_NO_BODY;
case "cut": if (target.kind === "rem" || target.kind === "move") return null;
case "cut_block": if (target.register !== undefined) return REGISTER_PUT_TAKES_NO_BODY;
return CUT_TAKES_NO_BODY; if (!hadColon) return COLONLESS_PUT_TAKES_NO_BODY;
case "paste": return null;
case "paste_after_block":
return PASTE_TAKES_NO_BODY;
default:
return null;
}
} }
/** /**
@@ -99,6 +112,26 @@ function bodylessTargetMessage(target: BlockTarget): string | null {
*/ */
const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/; 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 * Markdown-bullet shape: optional indent, `-`, exactly one space, then
* content. Unified-diff `-` rows almost never match — code lines get the `-` * content. Unified-diff `-` rows almost never match — code lines get the `-`
@@ -119,36 +152,30 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
return ( return (
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` + `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " + "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:\`/\`PUT >N:\` ops.`
); );
} }
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) { if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
return ( return (
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " + "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:\`/\`PUT >N:\` ops.`
); );
} }
if (trimmed.startsWith("@@")) { if (trimmed.startsWith("@@")) {
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed; const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
return ( return (
`\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` + `\`@@\`-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)) { 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) { if (bareRange !== null) {
return ( return (
`bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` + `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; return null;
@@ -165,6 +192,8 @@ interface Pending {
target: BlockTarget; target: BlockTarget;
lineNum: number; lineNum: number;
payloads: PayloadRow[]; 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 * Blank rows seen after the body started. Interior blanks are committed to
* the payload when the next non-blank row arrives; trailing blanks before * the payload when the next non-blank row arrives; trailing blanks before
@@ -225,27 +254,45 @@ export class Executor {
this.#consumePendingSkippableComments(); this.#consumePendingSkippableComments();
this.#handleRaw(token.text, token.lineNum); this.#handleRaw(token.text, token.lineNum);
return; return;
case "op-block": case "op-block": {
this.#discardPendingSkippableComments(); this.#discardPendingSkippableComments();
if (token.target.kind === "replace") { const target = token.target;
validateRange(token.target.range, token.lineNum, "replace"); if (target.kind === "replace") {
validateRange(target.range, token.lineNum, "replace", target.register);
} }
if (token.target.kind === "cut") { if (target.kind === "cut") {
validateRange(token.target.range, token.lineNum, "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.#flushPending();
this.#setFileOp({ kind: "rem" }, token.lineNum); this.#setFileOp({ kind: "rem" }, token.lineNum);
return; return;
} }
if (token.target.kind === "move") { if (target.kind === "move") {
this.#flushPending(); this.#flushPending();
this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum); this.#setFileOp({ kind: "move", dest: target.dest }, token.lineNum);
return; return;
} }
this.#flushPending(); this.#flushPending();
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] }; this.#pending = {
target,
lineNum: token.lineNum,
payloads: [],
hadColon: token.hadColon,
deferredBlanks: [],
};
return; return;
}
} }
} }
@@ -253,7 +300,7 @@ export class Executor {
this.#consumePendingSkippableComments(); this.#consumePendingSkippableComments();
this.#flushPending(); this.#flushPending();
this.#validateFileOp(); this.#validateFileOp();
this.#validateNoOverlappingDeletes(); this.#normalizeOverlappingRanges();
return { return {
edits: this.#edits, edits: this.#edits,
...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }), ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
@@ -263,11 +310,11 @@ export class Executor {
endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } { endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
this.#consumePendingSkippableComments(); this.#consumePendingSkippableComments();
if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending(); const pending = this.#pending;
else if (this.#pending && bodylessTargetMessage(this.#pending.target) !== null) this.#flushPending(); if (pending && (pending.payloads.length > 0 || this.#isCompleteBodylessOp(pending))) this.#flushPending();
else this.#pending = undefined; else this.#pending = undefined;
this.#validateFileOp(); this.#validateFileOp();
this.#validateNoOverlappingDeletes(); this.#normalizeOverlappingRanges();
return { return {
edits: this.#edits, edits: this.#edits,
...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }), ...(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 { reset(): void {
this.#edits = []; this.#edits = [];
this.#warnings = []; this.#warnings = [];
@@ -304,25 +372,78 @@ export class Executor {
} }
} }
#validateNoOverlappingDeletes(): void { #normalizeOverlappingRanges(): void {
const sourceLinesByAnchor = new Map<number, number[]>(); type ConcreteHunk = {
for (const edit of this.#edits) { lineNum: number;
if (edit.kind !== "delete") continue; sourceLines: Set<number>;
let sourceLines = sourceLinesByAnchor.get(edit.anchor.line); clipboardDependent: boolean;
if (sourceLines === undefined) { };
sourceLines = []; const hunks = new Map<number, ConcreteHunk>();
sourceLinesByAnchor.set(edit.anchor.line, sourceLines); 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 ownerByLine = new Map<number, ConcreteHunk>();
const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b); const dropped = new Set<number>();
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<ConcreteHunk>();
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( 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.", "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 { #handleLiteralPayload(text: string, lineNum: number): void {
@@ -334,13 +455,19 @@ export class Executor {
`Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`, `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}`); if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`);
this.#commitDeferredBlanks(pending); this.#commitDeferredBlanks(pending);
pending.payloads.push({ kind: "literal", text, lineNum }); pending.payloads.push({ kind: "literal", text, lineNum });
} }
#handleRaw(text: string, lineNum: number): void { #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); const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`); if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`); if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
@@ -349,7 +476,7 @@ export class Executor {
this.#handleBlank(text, lineNum); this.#handleBlank(text, lineNum);
return; 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}`); if (noBodyOnRaw !== null) throw new Error(`line ${lineNum}: ${noBodyOnRaw}`);
const row: PayloadRow = { kind: "literal", text, lineNum, bare: true }; const row: PayloadRow = { kind: "literal", text, lineNum, bare: true };
// `-` rows are held and judged at flush time by #resolveMinusRows, // `-` rows are held and judged at flush time by #resolveMinusRows,
@@ -369,9 +496,40 @@ export class Executor {
return; return;
} }
if (text.trim().length === 0) 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( throw new Error(
`line ${lineNum}: payload line has no preceding hunk header. ` + `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:\`/\`PUT >N:\` above the body. Got ${JSON.stringify(text)}.`,
); );
} }
@@ -385,7 +543,7 @@ export class Executor {
#handleBlank(text: string, lineNum: number): void { #handleBlank(text: string, lineNum: number): void {
const pending = this.#pending; const pending = this.#pending;
if (!pending) return; if (!pending) return;
if (bodylessTargetMessage(pending.target) !== null) return; if (bodylessTargetMessage(pending.target, pending.hadColon) !== null) return;
if (pending.payloads.length === 0) return; if (pending.payloads.length === 0) return;
pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true }); 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 * Judge bare `-` body rows once the whole hunk body is known. Non-bullet
* usually unified-diff contamination (`-old` next to `+new`) and inserting * rows paired with explicit `+new` rows are unified-diff contamination, so
* them would corrupt the file, so they are rejected — EXCEPT when the body * discard the redundant old rows. Unambiguously literal Markdown bullets
* is unambiguously a Markdown bullet list: every `-` row is bullet-shaped * are kept. Other `-` rows remain rejected rather than silently corrupting
* (`- item`) and the body is either fully bare or already contains an * source.
* explicit `+- item` sibling. Those rows are kept as literal content with a
* warning instead of failing the patch.
*/ */
#resolveMinusRows(payloads: readonly PayloadRow[]): void { #resolveMinusRows(payloads: PayloadRow[]): void {
let firstMinus: PayloadRow | undefined; let firstMinus: PayloadRow | undefined;
let allBulletShaped = true; let allBulletShaped = true;
let hasExplicit = false; let hasExplicit = false;
@@ -426,6 +582,15 @@ export class Executor {
this.#warnings.push(MINUS_BULLET_AUTO_PIPED_WARNING); this.#warnings.push(MINUS_BULLET_AUTO_PIPED_WARNING);
return; 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}`); 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); 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({ this.#edits.push({
kind: "cut", kind: "cut",
range: { start: { ...range.start }, end: { ...range.end } }, range: { start: { ...range.start }, end: { ...range.end } },
...(register === undefined ? {} : { register }),
lineNum, lineNum,
index: this.#editIndex++, index: this.#editIndex++,
}); });
@@ -488,17 +654,29 @@ export class Executor {
this.#pushDeleteRange(range, lineNum); 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( #pushBlock(
anchor: Anchor, anchor: Anchor,
payloads: readonly PayloadRow[], payloads: readonly PayloadRow[],
lineNum: number, lineNum: number,
mode?: "insert_after" | "cut" | "paste_after", mode?: "insert_after" | "cut" | "paste_after",
register?: string,
): void { ): void {
this.#edits.push({ this.#edits.push({
kind: "block", kind: "block",
anchor: { ...anchor }, anchor: { ...anchor },
payloads: payloads.map(payload => payload.text), payloads: payloads.map(payload => payload.text),
...(mode === undefined ? {} : { mode }), ...(mode === undefined ? {} : { mode }),
...(register === undefined ? {} : { register }),
lineNum, lineNum,
index: this.#editIndex++, index: this.#editIndex++,
}); });
@@ -511,58 +689,84 @@ export class Executor {
#flushPending(): void { #flushPending(): void {
const pending = this.#pending; const pending = this.#pending;
if (!pending) return; if (!pending) return;
const { target, lineNum, payloads } = pending; const { target, lineNum, payloads, hadColon } = pending;
this.#resolveMinusRows(payloads); this.#resolveMinusRows(payloads);
this.#stripBarePrefixesIfUniform(payloads); this.#stripBarePrefixesIfUniform(payloads);
this.#pending = undefined; this.#pending = undefined;
if (target.kind === "rem" || target.kind === "move") return;
if (target.kind === "cut") { if (target.kind === "cut") {
this.#pushCut(target.range, lineNum); this.#pushCut(target.range, lineNum, target.register);
return; return;
} }
if (target.kind === "cut_block") { if (target.kind === "cut_block") {
this.#pushBlock(target.anchor, [], lineNum, "cut"); this.#pushBlock(target.anchor, [], lineNum, "cut", target.register);
return; return;
} }
if (target.kind === "paste") { // Span targets: body writes, register pastes over the span; the
this.#edits.push({ kind: "paste", cursor: cloneCursor(target.cursor), lineNum, index: this.#editIndex++ }); // anonymous register never pastes over a span (too easy to fire by
return; // forgetting `:` + body on a replace).
} if (target.kind === "replace") {
if (target.kind === "paste_after_block") { if (target.register !== undefined) {
this.#pushBlock(target.anchor, [], lineNum, "paste_after"); this.#pushPaste(
return; { kind: "span", range: { start: { ...target.range.start }, end: { ...target.range.end } } },
} target.register,
if (target.kind === "block") { lineNum,
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`); );
this.#pushBlock(target.anchor, payloads, lineNum); return;
return; }
} if (payloads.length === 0) {
if (target.kind === "insert_after_block") { if (!hadColon) throw new Error(`line ${lineNum}: ${COLONLESS_SPAN_PUT}`);
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`); this.#pushDeleteRange(target.range, lineNum);
this.#pushBlock(target.anchor, payloads, lineNum, "insert_after"); if (!this.#warnings.includes(EMPTY_PUT_AUTO_CUT_WARNING)) {
return; this.#warnings.push(EMPTY_PUT_AUTO_CUT_WARNING);
} }
if (payloads.length === 0) {
if (target.kind === "replace") {
this.#pushDeleteRange(target.range, lineNum);
return; return;
} }
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
}
if (target.kind === "replace") {
const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } }; const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
this.#emitPayloadRows(cursor, payloads, lineNum, "replacement"); this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
this.#pushDeleteRange(target.range, lineNum); this.#pushDeleteRange(target.range, lineNum);
return; return;
} }
if (target.kind === "insert_before") { if (target.kind === "block") {
this.#emitPayloadRows({ kind: "before_anchor", anchor: { ...target.anchor } }, payloads, lineNum); 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; return;
} }
if (target.kind === "insert_after") { // Gap targets: body inserts, register pastes, and the colonless
this.#emitPayloadRows({ kind: "after_anchor", anchor: { ...target.anchor } }, payloads, lineNum); // 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; 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); this.#emitPayloadRows(cursor, payloads, lineNum);
} }
} }
+8 -8
View File
@@ -25,7 +25,7 @@
import * as path from "node:path"; import * as path from "node:path";
import { applyEdits } from "./apply"; import { applyEdits } from "./apply";
import { hasBlockEdit, resolveBlockEdits } from "./block"; import { hasBlockEdit, resolveBlockEdits } from "./block";
import { commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard"; import { commitClipboard, forkClipboard, startClipboardBatch, validateClipboardSequence } from "./clipboard";
import { computeFileHash, formatHashlineHeader } from "./format"; import { computeFileHash, formatHashlineHeader } from "./format";
import type { Filesystem, WriteResult } from "./fs"; import type { Filesystem, WriteResult } from "./fs";
import { isNotFound } from "./fs"; import { isNotFound } from "./fs";
@@ -161,11 +161,11 @@ export class PreparedSection {
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
return edits.some(edit => { return edits.some(edit => {
if (edit.kind === "delete") return true; if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true;
// A `replace_block N:` edit anchors to concrete content on line N. if (edit.kind === "paste") {
if (edit.kind === "block") return true; if (edit.at.kind === "span") return true;
// A `CUT` range reads concrete content. return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor";
if (edit.kind === "cut") return true; }
return edit.cursor.kind === "before_anchor" || edit.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 // 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 // never poisons the persistent register and a mid-batch failure still
// preserves content already cut from disk. // preserves content already cut from disk.
const clipboard = forkClipboard(this.clipboard); const clipboard = startClipboardBatch(this.clipboard);
// Single-section fast path. // Single-section fast path.
if (patch.sections.length === 1) { if (patch.sections.length === 1) {
@@ -305,7 +305,7 @@ export class Patcher {
*/ */
async preflight(patch: Patch): Promise<void> { async preflight(patch: Patch): Promise<void> {
// Dry run: fork the register and never publish it back. // Dry run: fork the register and never publish it back.
const clipboard = forkClipboard(this.clipboard); const clipboard = startClipboardBatch(this.clipboard);
const prepared: PreparedSection[] = []; const prepared: PreparedSection[] = [];
for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard)); for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard));
assertUniqueCanonicalPaths(prepared); assertUniqueCanonicalPaths(prepared);
+12 -4
View File
@@ -20,7 +20,15 @@ const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
const HL_PREFIX_PLUS_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 HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
const DIFF_PLUS_RE = /^[+](?![+])/; 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 { function stripLeadingHashlinePrefixes(line: string): string {
let result = line; let result = line;
@@ -63,7 +71,7 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
for (const line of lines) { for (const line of lines) {
if (line.length === 0) continue; if (line.length === 0) continue;
if (READ_TRUNCATION_NOTICE_RE.test(line)) { if (isReadMetadataLine(line)) {
stats.truncationNoticeCount++; stats.truncationNoticeCount++;
continue; continue;
} }
@@ -103,7 +111,7 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines; if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
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 => { .map(line => {
if (stripHash) return stripLeadingHashlinePrefixes(line); if (stripHash) return stripLeadingHashlinePrefixes(line);
if (stripPlus) return line.replace(DIFF_PLUS_RE, ""); if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
@@ -124,7 +132,7 @@ export function stripHashlinePrefixes(lines: string[]): string[] {
const contentLineCount = stats.nonEmpty - stats.headerCount; const contentLineCount = stats.nonEmpty - stats.headerCount;
if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines; if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines;
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)); .map(line => stripLeadingHashlinePrefixes(line));
} }
+37 -38
View File
@@ -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.
<headers> <headers>
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. 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.
</headers> </headers>
<ops> <ops>
`SWAP N.=M:` — replace original lines N.=M (INCLUSIVE). `PUT N.=M:` — replace original lines N through M (INCLUSIVE) with body rows.
`SWAP.BLK N:` — replace the whole syntactic block BEGINNING on line N; its closing line is resolved for you. `PUT N*:` — replace the 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`. `PUT <N:` / `PUT >N:` — insert body rows before / after line N (`PUT <1:` = file head, `PUT >$:` = file tail).
`INS.PRE N:` / `INS.POST N:` — insert immediately before / after line N. `PUT >N*:` — insert body rows after the END of the block beginning at N (at sibling depth). Append inside a block → `PUT >M:`.
`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`. `PUT <N` / `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.
`INS.HEAD:` / `INS.TAIL:` — insert at the very start / end of the file. `CUT N.=M` / `CUT N*` — delete lines N through M / block N and capture them (anonymous, or `@name` when given).
`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).
`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`. `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.
</ops> </ops>
<body-rows> <body-rows>
@@ -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. - 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. - 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. - 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`. - Whole construct → `PUT N*:`; lines inside one → `PUT 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`). - `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 (`SWAP N.=N:` / `CUT N` / `INS.POST N:`). Saw the closer? `INS.POST 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). `INS.BLK.POST` after a section: end the body with a blank line to keep the next heading separated. - 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 → `INS.PRE`/`INS.POST`/`INS.HEAD`/`INS.TAIL`, never a widened `SWAP`. - Pure additions → `PUT <N:` / `PUT >N:`, never a widened `PUT N.=M:`.
- 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`. - 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. - NEVER format/restyle code with this tool; run the project formatter.
</rules> </rules>
@@ -49,7 +48,7 @@ Only under a `:` header. Every row is `+TEXT`, verbatim (leading whitespace kept
Edit, then move: Edit, then move:
``` ```
[greet.py#A1B2] [greet.py#A1B2]
SWAP 1.=3: PUT 1.=3:
+def greet(name): +def greet(name):
+ print(f"Hi, {name}") + print(f"Hi, {name}")
MV lib/greet.py MV lib/greet.py
@@ -58,23 +57,23 @@ MV lib/greet.py
Markdown bullets — the file receives `- task`: Markdown bullets — the file receives `- task`:
``` ```
[PLAN.md#A1B2] [PLAN.md#A1B2]
INS.POST 2: PUT >2:
+- task +- task
+ - nested 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] [greet.py#A1B2]
CUT.BLK 1 CUT 1* @fn
[other.py#3C4D] [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] [greet.py#A1B2]
SWAP.BLK 1: PUT 1*:
+def greet(name): +def greet(name):
+ print(f"Hello, {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`: Decorator/doc-comment = SEPARATE block — point N at the decorator to take both; anchoring the `def` (line 2) would orphan `@cache`:
``` ```
[svc.py#C3D4] [svc.py#C3D4]
SWAP.BLK 1: PUT 1*:
+@cache +@cache
+def load(key): +def load(key):
+ return store[key] + return store[key]
@@ -90,45 +89,45 @@ SWAP.BLK 1:
</example> </example>
<anti-patterns> <anti-patterns>
# WRONG — empty `SWAP` to delete. RIGHT: CUT 4 # WRONG — empty `PUT` to delete. RIGHT: `CUT 4.=4`
SWAP 4.=4: PUT 4.=4:
# WRONG — range sized to the post-edit content. RIGHT: SWAP 1.=1: (body length irrelevant) # WRONG — range sized to the post-edit content. RIGHT: `PUT 1.=1:` (body length irrelevant)
SWAP 1.=2: PUT 1.=2:
+def greet(name): +def greet(name):
# WRONG — `-` rows / bare context lines do not exist; the range deletes, the body is only new content. # 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 msg = "Hello, " + name
- print(msg) - print(msg)
+ return msg + return msg
# RIGHT # RIGHT
SWAP 3.=3: PUT 3.=3:
+ return msg + return msg
# WRONG — pure insertion as a widened `SWAP`: retyped keepers get dropped (here line 4). # WRONG — pure insertion as a widened `PUT`: retyped keepers get dropped (here line 4).
SWAP 2.=4: PUT 2.=4:
+ msg = "Hello, " + name + msg = "Hello, " + name
+ extra = compute(name) + extra = compute(name)
+ print(msg) + print(msg)
# RIGHT — touch nothing you keep. # RIGHT — touch nothing you keep.
INS.POST 2: PUT >2:
+ extra = compute(name) + extra = compute(name)
# WRONG — `INS.BLK.POST` anchored on the closing delimiter / last visible line. RIGHT: plain `INS.POST M:` # WRONG — `PUT >N*:` anchored on the closing delimiter / last visible line. RIGHT: plain `PUT >M:`
INS.BLK.POST 3: PUT >3*:
+after() +after()
# RIGHT # RIGHT
INS.POST 3: PUT >3:
+after() +after()
# WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20`. # WRONG — body rows under register PUT; register pastes take no body. RIGHT: bodyless `PUT >20 @fn`.
PASTE.POST 20: PUT >20 @fn:
+function f() {} +function f() {}
</anti-patterns> </anti-patterns>
<critical> <critical>
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`. 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`. 3. BODY = FINAL CONTENT — every body row starts with `+`; Markdown bullets use `+- item`, not `- item`.
</critical> </critical>
+56 -15
View File
@@ -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 }); for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line });
return anchors; 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] : []; 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 } } }); remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } });
continue; continue;
} }
if (edit.kind === "paste") {
let blockStart = edit.blockStart; let blockStart = edit.blockStart;
if (blockStart !== undefined) { if (blockStart !== undefined) {
const mappedBlockStart = mapLine(blockStart); const mappedBlockStart = mapLine(blockStart);
if (mappedBlockStart === null) return null; if (mappedBlockStart === null) return null;
blockStart = mappedBlockStart; blockStart = mappedBlockStart;
} }
if (edit.at.kind === "span") {
const cursor = edit.cursor; const start = mapLine(edit.at.range.start.line);
if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") { if (start === null) return null;
remapped.push(blockStart === edit.blockStart ? edit : { ...edit, blockStart }); 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; continue;
} }
if (edit.kind === "insert") {
const anchor = mapAnchor(cursor.anchor); let blockStart = edit.blockStart;
if (anchor === null) return null; if (blockStart !== undefined) {
remapped.push({ ...edit, cursor: { kind: cursor.kind, anchor }, blockStart }); 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; if (offsets.length === 0) return null;
+167 -198
View File
@@ -10,26 +10,16 @@
*/ */
import { import {
describeAnchorExamples, describeAnchorExamples,
HL_CUT_BLOCK_KEYWORD,
HL_CUT_KEYWORD, HL_CUT_KEYWORD,
HL_FILE_HASH_LENGTH, HL_FILE_HASH_LENGTH,
HL_FILE_HASH_SEP, HL_FILE_HASH_SEP,
HL_FILE_PREFIX, HL_FILE_PREFIX,
HL_FILE_SUFFIX, HL_FILE_SUFFIX,
HL_HEADER_COLON, 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_MOVE_KEYWORD,
HL_PASTE_AFTER_BLOCK_KEYWORD,
HL_PASTE_KEYWORD,
HL_PAYLOAD_REPLACE, HL_PAYLOAD_REPLACE,
HL_PUT_KEYWORD,
HL_REM_KEYWORD, HL_REM_KEYWORD,
HL_REPLACE_BLOCK_KEYWORD,
HL_REPLACE_KEYWORD,
} from "./format"; } from "./format";
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages"; import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
import type { Anchor, Cursor, ParsedRange } from "./types"; import type { Anchor, Cursor, ParsedRange } from "./types";
@@ -41,11 +31,16 @@ const CHAR_NINE = 57;
const CHAR_HASH = 35; const CHAR_HASH = 35;
const CHAR_TAB = 9; const CHAR_TAB = 9;
const CHAR_SPACE = 32; const CHAR_SPACE = 32;
const CHAR_DOT = 46;
const CHAR_COMMA = 44;
const CHAR_HYPHEN = 45; const CHAR_HYPHEN = 45;
const CHAR_ELLIPSIS = 0x2026; const CHAR_DOT = 46;
const CHAR_EQUALS = 61; 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_A = 65;
const CHAR_UPPER_F = 70; const CHAR_UPPER_F = 70;
@@ -157,40 +152,33 @@ export function parseLid(raw: string, lineNum: number): Anchor {
interface RangeScan { interface RangeScan {
range: ParsedRange; range: ParsedRange;
nextIndex: number; 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 { function scanRangeSeparator(line: string, index: number, end: number): number | null {
let cursor = index; let cursor = index;
let consumedSeparator = false; let consumedSeparator = false;
while (cursor < end) { while (cursor < end) {
const code = line.charCodeAt(cursor); const code = line.charCodeAt(cursor);
if (isWhitespaceCode(code)) { if (
isWhitespaceCode(code) ||
code === CHAR_HYPHEN ||
code === CHAR_DOT ||
code === CHAR_EQUALS ||
code === CHAR_ELLIPSIS
) {
cursor++; cursor++;
consumedSeparator = true; consumedSeparator = true;
continue; 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; break;
} }
if (!consumedSeparator) return null; if (!consumedSeparator || cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null;
if (cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null;
return cursor; return cursor;
} }
@@ -204,6 +192,7 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
return { return {
range: { start: { line: start.line }, end: { line: start.line } }, range: { start: { line: start.line }, end: { line: start.line } },
nextIndex: skipWhitespace(line, start.nextIndex, end), nextIndex: skipWhitespace(line, start.nextIndex, end),
hadSeparator: false,
}; };
} }
const endNumber = scanLineNumber(line, afterFirst, end); const endNumber = scanLineNumber(line, afterFirst, end);
@@ -211,27 +200,35 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
return { return {
range: { start: { line: start.line }, end: { line: endNumber.line } }, range: { start: { line: start.line }, end: { line: endNumber.line } },
nextIndex: skipWhitespace(line, endNumber.nextIndex, end), nextIndex: skipWhitespace(line, endNumber.nextIndex, end),
hadSeparator: true,
}; };
} }
export type BlockTarget = export type BlockTarget =
| { kind: "replace"; range: ParsedRange } | { kind: "replace"; range: ParsedRange; register?: string }
| { kind: "block"; anchor: Anchor } | { kind: "block"; anchor: Anchor; register?: string }
| { kind: "insert_before"; anchor: Anchor } | { kind: "insert_before"; anchor: Anchor; register?: string }
| { kind: "insert_after"; anchor: Anchor } | { kind: "insert_after"; anchor: Anchor; register?: string }
| { kind: "insert_after_block"; anchor: Anchor } | { kind: "insert_after_block"; anchor: Anchor; register?: string }
| { kind: "cut"; range: ParsedRange } | { kind: "cut"; range: ParsedRange; register?: string }
| { kind: "cut_block"; anchor: Anchor } | { kind: "cut_block"; anchor: Anchor; register?: string }
| { kind: "paste"; cursor: Cursor } | { kind: "bof"; register?: string }
| { kind: "paste_after_block"; anchor: Anchor } | { kind: "eof"; register?: string }
| { kind: "rem" } | { kind: "rem" }
| { kind: "move"; dest: string } | { kind: "move"; dest: string };
| { kind: "bof" }
| { kind: "eof" }; /** Targets that may carry a `@register` suffix (everything but the file-level ops). */
type RegisterableTarget = Exclude<BlockTarget, { kind: "rem" } | { kind: "move" }>;
interface TargetScan { interface TargetScan {
target: BlockTarget; target: BlockTarget;
nextIndex: number; 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 { 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; const next = index + keyword.length;
if (next < end) { if (next < end) {
const code = line.charCodeAt(next); 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; return next;
} }
/** interface ColonScan {
* 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;
nextIndex: number; nextIndex: number;
hadColon: boolean;
} }
/** Scan the `.PRE N` / `.POST N` / `.HEAD` / `.TAIL` positional suffix shared by `INS` and `PASTE`. */ function consumeOptionalColon(line: string, index: number, end: number): ColonScan {
function scanPositionalSuffix(line: string, index: number, end: number): PositionScan | null { const cursor = skipWhitespace(line, index, end);
if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null; if (cursor < end && line.charCodeAt(cursor) === CHAR_COLON) {
const probe = skipWhitespace(line, index + 1, end); return { nextIndex: skipWhitespace(line, cursor + 1, end), hadColon: true };
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 };
} }
const afterEnd = scanKeyword(line, probe, end, HL_INSERT_AFTER); return { nextIndex: cursor, hadColon: false };
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;
} }
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null { /** Maximum accepted register-name length; anything longer fails the header parse. */
const scan = scanPositionalSuffix(line, index, end); const REGISTER_NAME_MAX = 64;
if (scan === null) return null;
const { cursor, nextIndex } = scan; function isRegisterNameCode(code: number): boolean {
switch (cursor.kind) { return (
case "before_anchor": isDigitCode(code) ||
return { target: { kind: "insert_before", anchor: cursor.anchor }, nextIndex }; (code >= CHAR_UPPER_A && code <= 90) ||
case "after_anchor": (code >= CHAR_LOWER_A && code <= 122) ||
return { target: { kind: "insert_after", anchor: cursor.anchor }, nextIndex }; code === CHAR_UNDERSCORE ||
case "bof": code === CHAR_HYPHEN
return { target: { kind: "bof" }, nextIndex }; );
case "eof": }
return { target: { kind: "eof" }, nextIndex };
/** 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*` is the same gap as `<N`: a block anchored at N begins on line N,
// so "before the block" is "before line N". The star is dropped.
// `<1` is head — mapped to `bof` so it stays position-stable (never
// anchor-scoped) and works when creating empty files.
return finishTargetScan(
line,
next,
end,
anchor.line === 1 ? { kind: "bof" } : { kind: "insert_before", anchor: { line: anchor.line } },
);
}
const range = scanHeaderRange(line, cursor, end, true);
if (range === null) return null;
const next = range.nextIndex;
if (next < end && line.charCodeAt(next) === CHAR_STAR) {
// Block locators are single opening lines (`N*`), never ranges.
if (range.hadSeparator) return null;
return finishTargetScan(line, next + 1, end, { kind: "block", anchor: { line: range.range.start.line } });
}
return finishTargetScan(line, next, end, { kind: "replace", range: range.range });
}
/** Scan the locator of a `CUT` header: `N.=M` or `N*` (block). */
function scanCutTarget(line: string, index: number, end: number): TargetScan | null {
const range = scanHeaderRange(line, index, end, true);
if (range === null) return null;
const next = range.nextIndex;
if (next < end && line.charCodeAt(next) === CHAR_STAR) {
if (range.hadSeparator) return null;
return finishTargetScan(line, next + 1, end, { kind: "cut_block", anchor: { line: range.range.start.line } });
}
return finishTargetScan(line, next, end, { kind: "cut", range: range.range });
} }
function unquotePath(pathText: string): string { function unquotePath(pathText: string): string {
@@ -359,93 +399,24 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
if (remEnd !== null) { if (remEnd !== null) {
const next = skipWhitespace(line, remEnd, end); const next = skipWhitespace(line, remEnd, end);
if (next !== end) return null; if (next !== end) return null;
return { target: { kind: "rem" }, nextIndex: next }; return { target: { kind: "rem" }, nextIndex: next, hadColon: false };
} }
const moveEnd = scanKeyword(line, cursor, end, HL_MOVE_KEYWORD); const moveEnd = scanKeyword(line, cursor, end, HL_MOVE_KEYWORD);
if (moveEnd !== null) { if (moveEnd !== null) {
const dest = scanMoveDest(line, moveEnd, end); const dest = scanMoveDest(line, moveEnd, end);
if (dest === null || dest.length === 0) return null; if (dest === null || dest.length === 0) return null;
return { target: { kind: "move", dest }, nextIndex: end }; return { target: { kind: "move", dest }, nextIndex: end, hadColon: false };
} }
const putEnd = scanKeyword(line, cursor, end, HL_PUT_KEYWORD);
// `replace_block N:` — resolve N to a tree-sitter block range at apply time. if (putEnd !== null) return scanPutTarget(line, putEnd, end);
const replaceBlockEnd = scanKeyword(line, cursor, end, HL_REPLACE_BLOCK_KEYWORD);
if (replaceBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, replaceBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "block", anchor: { line: anchor.line } },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
if (replaceEnd !== null) {
const range = scanHeaderRange(line, replaceEnd, end, true);
if (range === null) return null;
return {
target: { kind: "replace", range: range.range },
nextIndex: consumeReplaceColon(line, range.nextIndex, end),
};
}
// `insert_after_block N:` — insert after the last line of the tree-sitter
// block at N.
const insertAfterBlockEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER_BLOCK_KEYWORD);
if (insertAfterBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, insertAfterBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "insert_after_block", anchor: { line: anchor.line } },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
// `PASTE.BLK.POST N` — insert the clipboard after the tree-sitter block
// at N. Like all clipboard ops, takes no body rows.
const pasteAfterBlockEnd = scanKeyword(line, cursor, end, HL_PASTE_AFTER_BLOCK_KEYWORD);
if (pasteAfterBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, pasteAfterBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "paste_after_block", anchor: { line: anchor.line } },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
// `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` — insert the clipboard at the position.
const pasteEnd = scanKeyword(line, cursor, end, HL_PASTE_KEYWORD);
if (pasteEnd !== null) {
const scan = scanPositionalSuffix(line, pasteEnd, end);
if (scan === null) return null;
return { target: { kind: "paste", cursor: scan.cursor }, nextIndex: scan.nextIndex };
}
// `CUT.BLK N` captures and deletes the tree-sitter block beginning at N.
// Scan it before the plain form so `.BLK` is not parsed as a range.
const cutBlockEnd = scanKeyword(line, cursor, end, HL_CUT_BLOCK_KEYWORD);
if (cutBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, cutBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "cut_block", anchor: { line: anchor.line } },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
// `CUT N.=M` captures and deletes concrete lines. A trailing colon is
// tolerated and ignored; body rows are rejected by the parser.
const cutEnd = scanKeyword(line, cursor, end, HL_CUT_KEYWORD); const cutEnd = scanKeyword(line, cursor, end, HL_CUT_KEYWORD);
if (cutEnd !== null) { if (cutEnd !== null) return scanCutTarget(line, cutEnd, end);
const range = scanHeaderRange(line, cutEnd, end, true);
if (range === null) return null;
const next = skipStrayDot(line, range.nextIndex, end);
return {
target: { kind: "cut", range: range.range },
nextIndex: consumeOptionalColon(line, next, end),
};
}
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
return null; return null;
} }
interface ParsedHunkHeader { interface ParsedHunkHeader {
target: BlockTarget; target: BlockTarget;
hadColon: boolean;
} }
function tryParseHunkHeader(line: string): ParsedHunkHeader | null { function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
@@ -455,7 +426,7 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
const scan = scanHunkAnchor(line, start, end); const scan = scanHunkAnchor(line, start, end);
if (scan === null) return null; if (scan === null) return null;
if (scan.nextIndex !== end) return null; if (scan.nextIndex !== end) return null;
return { target: scan.target }; return { target: scan.target, hadColon: scan.hadColon };
} }
function tryParseHeader(line: string): { path: string; fileHash?: string } | null { function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
@@ -512,7 +483,7 @@ export type Token =
| (TokenBase & { kind: "envelope-end" }) | (TokenBase & { kind: "envelope-end" })
| (TokenBase & { kind: "abort" }) | (TokenBase & { kind: "abort" })
| (TokenBase & { kind: "header"; path: string; fileHash?: string }) | (TokenBase & { kind: "header"; path: string; fileHash?: string })
| (TokenBase & { kind: "op-block"; target: BlockTarget }) | (TokenBase & { kind: "op-block"; target: BlockTarget; hadColon: boolean })
| (TokenBase & { kind: "payload-literal"; text: string }) | (TokenBase & { kind: "payload-literal"; text: string })
| (TokenBase & { kind: "raw"; text: string }); | (TokenBase & { kind: "raw"; text: string });
@@ -532,15 +503,13 @@ function classifyLine(line: string, lineNum: number): Token {
} }
const lead = skipWhitespace(line, 0); const lead = skipWhitespace(line, 0);
const isHunkLead = const isHunkLead =
line.startsWith(HL_REPLACE_KEYWORD, lead) || line.startsWith(HL_PUT_KEYWORD, lead) ||
line.startsWith(HL_INSERT_KEYWORD, lead) ||
line.startsWith(HL_REM_KEYWORD, lead) ||
line.startsWith(HL_MOVE_KEYWORD, lead) ||
line.startsWith(HL_CUT_KEYWORD, lead) || line.startsWith(HL_CUT_KEYWORD, lead) ||
line.startsWith(HL_PASTE_KEYWORD, lead); line.startsWith(HL_REM_KEYWORD, lead) ||
line.startsWith(HL_MOVE_KEYWORD, lead);
if (isHunkLead) { if (isHunkLead) {
const hunk = tryParseHunkHeader(line); const hunk = tryParseHunkHeader(line);
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target }; if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target, hadColon: hunk.hadColon };
} }
if (firstCode === CHAR_PAYLOAD_REPLACE) return { kind: "payload-literal", lineNum, text: line.slice(1) }; if (firstCode === CHAR_PAYLOAD_REPLACE) return { kind: "payload-literal", lineNum, text: line.slice(1) };
return { kind: "raw", lineNum, text: line }; return { kind: "raw", lineNum, text: line };
+48 -25
View File
@@ -42,50 +42,61 @@ export type Edit =
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string } | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
| { | {
/** /**
* Clipboard cut (`CUT N.=M`, or the resolved form of `CUT.BLK N`). * Clipboard cut (`CUT N-M @r`, or the resolved form of `CUT N* @r`).
* Captures the range's current lines into the {@link Clipboard} * Captures the range's current lines into a {@link Clipboard}
* register during the applier's clipboard pre-pass and lowers to one * register during the applier's clipboard pre-pass and lowers to one
* `delete` per range line at parse/resolve time. * `delete` per range line at parse/resolve time. `register` names the
* target slot; absent means the batch-local anonymous register.
*/ */
kind: "cut"; kind: "cut";
range: ParsedRange; range: ParsedRange;
register?: string;
lineNum: number; lineNum: number;
index: number; index: number;
} }
| { | {
/** /**
* Clipboard insertion (`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / * Clipboard insertion or replacement (`PUT <N @r` / `PUT >N @r` /
* `PASTE.TAIL`, or the resolved form of `PASTE.BLK.POST N`). Expanded * `PUT N-M @r`, their `>N*` block-resolved forms, or the register-less
* by the clipboard pre-pass into one plain insert per captured line. * anonymous equivalents). Expanded by the clipboard pre-pass into one
* `blockStart` mirrors the insert variant's field for block-lowered * plain insert per captured line; `span` targets additionally expand
* pastes so landing correction can slide the body across trailing * into per-line deletes there — only after the register read
* closer lines. * 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"; kind: "paste";
cursor: Cursor; at: PasteTarget;
register?: string;
lineNum: number; lineNum: number;
index: number; index: number;
blockStart?: number; blockStart?: number;
} }
| { | {
/** /**
* Deferred block edit (`SWAP.BLK N:`, `INS.BLK.POST N:`, * Deferred block edit (`PUT N*:`, `PUT >N*:`, `CUT N*`, or their
* `CUT.BLK N`, or `PASTE.BLK.POST N`). The exact line span is * `@register` forms). The exact line span is unknown at parse time;
* unknown at parse time; {@link resolveBlockEdits} computes it once * {@link resolveBlockEdits} computes it once file text and language
* file text and language are available, then expands it into * are available, then expands it into concrete edits.
* concrete edits. `mode: "insert_after"` becomes plain * `mode: "insert_after"` becomes plain `after_anchor` inserts,
* `after_anchor` inserts, `"cut"` becomes a clipboard cut plus * `"cut"` becomes a clipboard cut plus per-line deletes, and
* per-line deletes, and `"paste_after"` becomes a paste after the * `"paste_after"` becomes a gap paste after the resolved block. No
* resolved block. No mode denotes a block replacement. * mode denotes a block replacement — from body payloads, or from
* `register` when one is named (a span paste over the resolved block).
*/ */
kind: "block"; kind: "block";
anchor: Anchor; anchor: Anchor;
payloads: string[]; payloads: string[];
mode?: "insert_after" | "cut" | "paste_after"; mode?: "insert_after" | "cut" | "paste_after";
register?: string;
lineNum: number; lineNum: number;
index: 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`). */ /** File-level operation parsed from a section body (`REM` / `MV`). */
export type FileOp = { kind: "rem" } | { kind: "move"; dest: string }; export type FileOp = { kind: "rem" } | { kind: "move"; dest: string };
@@ -105,7 +116,7 @@ export interface ApplyResult {
blockResolutions?: BlockResolution[]; blockResolutions?: BlockResolution[];
} }
/** A parsed `[A.=B]` line range. */ /** A parsed `A-B` inclusive line range. */
export interface ParsedRange { export interface ParsedRange {
start: Anchor; start: Anchor;
end: Anchor; end: Anchor;
@@ -190,13 +201,25 @@ export interface BlockResolverRequest {
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null; export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
/** /**
* Mutable clipboard register threaded through one patch application. Filled * Mutable clipboard registers threaded through one patch application. Filled
* by `CUT` edits and read by `PASTE` edits in patch source order, across * by `CUT` edits and read by register `PUT`s in patch source order, across
* sections, so content can move between files. Create one per batch (`{}`) * sections, so content can move between files.
* and hand it to every {@link Patcher.prepare} / `applyTo` call in that batch; *
* callers that omit it get a private per-call register. * 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 { 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[]; lines?: readonly string[];
/** Named registers captured by `CUT … @name`. */
named?: Map<string, readonly string[]>;
/**
* 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[];
} }
+77 -75
View File
@@ -31,9 +31,9 @@ function normalizeEdits(edits: readonly Edit[]): unknown[] {
}); });
} }
describe("SWAP.BLK parsing", () => { describe("PUT N*: parsing", () => {
it("parses `SWAP.BLK N:` into a single deferred block edit", () => { it("parses `PUT N*: N:` into a single deferred block edit", () => {
const { edits } = parsePatch("SWAP.BLK 2:\n+A\n+B"); const { edits } = parsePatch("PUT 2*:\n+A\n+B");
expect(edits).toHaveLength(1); expect(edits).toHaveLength(1);
const edit = edits[0]; const edit = edits[0];
@@ -43,52 +43,54 @@ describe("SWAP.BLK parsing", () => {
expect(edit.payloads).toEqual(["A", "B"]); expect(edit.payloads).toEqual(["A", "B"]);
}); });
it("still parses a literal `SWAP N.=M:` range (distinct from `SWAP.BLK`)", () => { it("still parses a literal `SWAP N.=M:` range (distinct from `PUT N*:`)", () => {
const { edits } = parsePatch("SWAP 2.=3:\n+A"); const { edits } = parsePatch("PUT 2-3:\n+A");
expect(edits.some(edit => edit.kind === "block")).toBe(false); expect(edits.some(edit => edit.kind === "block")).toBe(false);
expect(edits.some(edit => edit.kind === "delete")).toBe(true); expect(edits.some(edit => edit.kind === "delete")).toBe(true);
}); });
it("rejects a `SWAP.BLK N:` hunk with no body row", () => { it("treats a `PUT N*:` hunk with no body row as a delete-only block edit", () => {
expect(() => parsePatch("SWAP.BLK 2:")).toThrow("`SWAP.BLK N:` needs at least one"); 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", () => { describe("resolveBlockEdits", () => {
it("expands a block edit exactly like the equivalent `SWAP start.=end:`", () => { 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 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(resolved.some(edit => edit.kind === "block")).toBe(false);
expect(normalizeEdits(resolved)).toEqual(normalizeEdits(replaceEdits)); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(replaceEdits));
}); });
it("returns the input untouched when there are no block edits (fast path)", () => { 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); expect(resolveBlockEdits(edits, "ignored", PATH, stubResolver)).toBe(edits);
}); });
it("throws (default) when no resolver is wired", () => { 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"); expect(() => resolveBlockEdits(edits, "ignored", PATH, undefined)).toThrow("not available here");
}); });
it("drops an unresolvable block edit in `drop` mode", () => { 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" }); const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { onUnresolved: "drop" });
expect(resolved).toHaveLength(0); expect(resolved).toHaveLength(0);
}); });
it("throws a block-unresolved error in `throw` mode when the resolver returns null", () => { 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( expect(() => resolveBlockEdits(edits, "ignored", PATH, () => null)).toThrow(
"could not resolve a syntactic block beginning on line 7", "could not resolve a syntactic block beginning on line 7",
); );
}); });
it("includes a nearby-context preview in the block-unresolved error", () => { 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"; const text = "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot";
let error: Error | undefined; let error: Error | undefined;
try { try {
@@ -107,10 +109,10 @@ describe("resolveBlockEdits", () => {
it("suggests the next multi-line opener for a blank anchor without applying it", () => { 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 text = "alpha\n\nfunction x() {\n return 1;\n}";
const resolver: BlockResolver = ({ line }) => (line === 3 ? { start: 3, end: 5 } : null); 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( 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 }; if (line === 1) return { start: 1, end: 3 };
return null; 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( 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", () => { 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; let error: Error | undefined;
try { try {
resolveBlockEdits(edits, "only\ntwo", PATH, () => null); resolveBlockEdits(edits, "only\ntwo", PATH, () => null);
@@ -143,10 +145,10 @@ describe("resolveBlockEdits", () => {
it("fires onResolved with the resolved span for replace and cut blocks", () => { it("fires onResolved with the resolved span for replace and cut blocks", () => {
const seen: BlockResolution[] = []; const seen: BlockResolution[] = [];
// stubResolver maps line N → span [N, N+1]. // 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), 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), onResolved: resolution => seen.push(resolution),
}); });
@@ -158,7 +160,7 @@ describe("resolveBlockEdits", () => {
it("does not fire onResolved for a dropped unresolvable block", () => { it("does not fire onResolved for a dropped unresolvable block", () => {
const seen: BlockResolution[] = []; 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", onUnresolved: "drop",
onResolved: resolution => seen.push(resolution), onResolved: resolution => seen.push(resolution),
}); });
@@ -171,20 +173,20 @@ describe("resolveBlockEdits", () => {
// plain form rather than silently landing a body in the wrong scope. // plain form rather than silently landing a body in the wrong scope.
const singleLineResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line }); const singleLineResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line });
it("rejects a `SWAP.BLK` that resolves to a single line", () => { it("rejects a `PUT N*:` that resolves to a single line", () => {
const edits = parsePatch("SWAP.BLK 2:\n+X").edits; const edits = parsePatch("PUT 2*:\n+X").edits;
expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow( 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", () => { it("rejects an `PUT >N*:` that resolves to a single line", () => {
const edits = parsePatch("INS.BLK.POST 2:\n+X").edits; const edits = parsePatch("PUT >2*:\n+X").edits;
expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow(/single-line block/); expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow(/single-line block/);
}); });
it("drops a single-line block resolution on the lenient preview path", () => { 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" }); const resolved = resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver, { onUnresolved: "drop" });
expect(resolved).toHaveLength(0); 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"; const text = "function x() {\n if (y) {\n }\n}\n";
it("applyTo resolves a block edit and matches the equivalent `replace`", () => { 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 blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nPUT 2*:\n+ if (y || z) {\n+ }`);
const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nSWAP 2.=3:\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 blockResult = blockSection.applyTo(text, stubResolver);
const replaceResult = replaceSection.applyTo(text); 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", () => { 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"); expect(() => section.applyTo(text)).toThrow("no block resolver configured");
}); });
it("applyPartialTo drops an unresolvable block edit instead of throwing", () => { 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. // No resolver → drop. The lone block edit vanishes, so the text is unchanged.
const result = section.applyPartialTo(text); const result = section.applyPartialTo(text);
expect(result.text).toBe(text); expect(result.text).toBe(text);
@@ -226,7 +228,7 @@ describe("Patcher with a block resolver", () => {
const tag = snapshots.record(PATH, text); const tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n"); 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 tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); 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" }]); 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 resolver: BlockResolver = ({ line }) => (line === 195 ? { start: 195, end: 255 } : null);
const patcher = new Patcher({ fs, snapshots, blockResolver: resolver }); const patcher = new Patcher({ fs, snapshots, blockResolver: resolver });
await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 195.=61:\n+replacement`))).rejects.toThrow( 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 `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.", "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); 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 // `block 2` resolves against the SNAPSHOT → span [2,3] → replace
// "line1","line2"; recovery maps that unchanged span onto the live file. // "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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n"); 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 bogus = live === "FFFF" ? "0000" : "FFFF";
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); 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, MismatchError,
); );
expect(fs.get(PATH)).toBe(liveText); expect(fs.get(PATH)).toBe(liveText);
@@ -298,18 +300,18 @@ describe("Patcher with a block resolver", () => {
const tag = snapshots.record(PATH, text); const tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: () => null }); 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", "could not resolve a syntactic block",
); );
expect(fs.get(PATH)).toBe(text); expect(fs.get(PATH)).toBe(text);
}); });
}); });
describe("CUT.BLK", () => { describe("CUT N*", () => {
const text = "function x() {\n if (y) {\n }\n}\n"; const text = "function x() {\n if (y) {\n }\n}\n";
it("parses `CUT.BLK N` into a cut block edit", () => { it("parses `CUT N* N` into a cut block edit", () => {
const { edits } = parsePatch("CUT.BLK 2"); const { edits } = parsePatch("CUT 2*");
expect(edits).toHaveLength(1); expect(edits).toHaveLength(1);
const edit = edits[0]; const edit = edits[0];
@@ -320,25 +322,25 @@ describe("CUT.BLK", () => {
expect(edit.mode).toBe("cut"); expect(edit.mode).toBe("cut");
}); });
it("rejects body rows under `CUT.BLK N`", () => { it("rejects body rows under `CUT N* N`", () => {
expect(() => parsePatch("CUT.BLK 2\n+X")).toThrow("`CUT N.=M` captures + deletes lines"); expect(() => parsePatch("CUT 2*\n+X")).toThrow("`CUT` deletes (and captures) the named lines");
}); });
it("resolveBlockEdits expands a cut block into capture and deletes", () => { 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.map(edit => edit.kind)).toEqual(["cut", "delete", "delete"]);
expect(resolved.flatMap(edit => (edit.kind === "delete" ? [edit.anchor.line] : []))).toEqual([2, 3]); expect(resolved.flatMap(edit => (edit.kind === "delete" ? [edit.anchor.line] : []))).toEqual([2, 3]);
}); });
it("applyTo deletes the resolved block span", () => { 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 " }". // stub span [2,3] → drop " if (y) {" and " }".
expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n"); expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n");
}); });
it("applyPartialTo drops an unresolvable cut-block edit", () => { 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); expect(section.applyPartialTo(text).text).toBe(text);
}); });
@@ -348,18 +350,18 @@ describe("CUT.BLK", () => {
const tag = snapshots.record(PATH, text); const tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("function x() {\n}\n"); 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"; const text = "function x() {\n if (y) {\n }\n}\n";
it("parses `INS.BLK.POST N:` into a deferred block edit with insert mode", () => { it("parses `PUT >N*: N:` into a deferred block edit with insert mode", () => {
const { edits } = parsePatch("INS.BLK.POST 2:\n+A\n+B"); const { edits } = parsePatch("PUT >2*:\n+A\n+B");
expect(edits).toHaveLength(1); expect(edits).toHaveLength(1);
const edit = edits[0]; const edit = edits[0];
@@ -370,20 +372,20 @@ describe("INS.BLK.POST", () => {
expect(edit.mode).toBe("insert_after"); expect(edit.mode).toBe("insert_after");
}); });
it("still parses a literal `INS.POST N:` anchor (distinct from `INS.BLK.POST`)", () => { it("still parses a literal `PUT > N:` anchor (distinct from `PUT >N*:`)", () => {
const { edits } = parsePatch("INS.POST 2:\n+A"); const { edits } = parsePatch("PUT >2:\n+A");
expect(edits.some(edit => edit.kind === "block")).toBe(false); expect(edits.some(edit => edit.kind === "block")).toBe(false);
}); });
it("rejects an `INS.BLK.POST N:` hunk with no body row", () => { it("rejects an `PUT >N*: N:` hunk with no body row", () => {
expect(() => parsePatch("INS.BLK.POST 2:")).toThrow("`INS` needs at least one"); expect(() => parsePatch("PUT >2*:")).toThrow("promises body rows");
}); });
it("resolveBlockEdits expands to the equivalent `insert after end:` lowering", () => { 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. // stub span [2,3] → after_anchor inserts at line 3.
const resolved = resolveBlockEdits(blockEdits, "ignored", PATH, stubResolver); 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(resolved.some(edit => edit.kind === "block")).toBe(false);
expect(normalizeEdits(resolved)).toEqual(normalizeEdits(insertEdits)); expect(normalizeEdits(resolved)).toEqual(normalizeEdits(insertEdits));
@@ -391,39 +393,39 @@ describe("INS.BLK.POST", () => {
it("fires onResolved with op insert_after", () => { it("fires onResolved with op insert_after", () => {
const seen: BlockResolution[] = []; 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), onResolved: resolution => seen.push(resolution),
}); });
expect(seen).toEqual([{ anchorLine: 2, start: 2, end: 3, op: "insert_after" }]); 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", () => { it("lowers an unresolvable anchor to plain `PUT > N:` with a warning", () => {
const edits = parsePatch("INS.BLK.POST 7:\n+X").edits; const edits = parsePatch("PUT >7*:\n+X").edits;
const warnings: string[] = []; const warnings: string[] = [];
const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, { const resolved = resolveBlockEdits(edits, "ignored", PATH, () => null, {
onWarning: warning => warnings.push(warning), 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).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", () => { it("lowers `PUT >N*:` even when no resolver is wired", () => {
const edits = parsePatch("INS.BLK.POST 2:\n+X").edits; const edits = parsePatch("PUT >2*:\n+X").edits;
const warnings: string[] = []; const warnings: string[] = [];
const resolved = resolveBlockEdits(edits, "ignored", PATH, undefined, { const resolved = resolveBlockEdits(edits, "ignored", PATH, undefined, {
onWarning: warning => warnings.push(warning), 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); expect(warnings).toHaveLength(1);
}); });
it("lowers a closing-delimiter anchor to plain `INS.POST N:` with a warning", () => { it("lowers a closing-delimiter anchor to plain `PUT > N:` with a warning", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nINS.BLK.POST 3:\n+ done();`); const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >3*:\n+ done();`);
const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null); const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null);
const result = section.applyTo(text, resolver); 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 // 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. // 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.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", () => { it("lowers an unresolvable blank-line anchor to plain `PUT > N:` instead of failing", () => {
const blankAnchored = Patch.parseSingle(`[notes.md#1A2B]\nINS.BLK.POST 2:\n+- new entry`); const blankAnchored = Patch.parseSingle(`[notes.md#1A2B]\nPUT >2*:\n+- new entry`);
const result = blankAnchored.applyTo("### Changed\n\n- old entry\n", () => null); 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.text).toBe("### Changed\n\n- new entry\n- old entry\n");
expect( 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); ).toBe(true);
}); });
@@ -452,14 +454,14 @@ describe("INS.BLK.POST", () => {
const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null); const resolver: BlockResolver = ({ line }) => (line === 2 ? { start: 2, end: 3 } : null);
const patcher = new Patcher({ fs, snapshots, blockResolver: resolver }); 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(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", () => { 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 "}". // 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"); 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 tag = snapshots.record(PATH, text);
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver }); 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("function x() {\n if (y) {\n }\n done();\n}\n"); expect(fs.get(PATH)).toBe("function x() {\n if (y) {\n }\n done();\n}\n");
+82 -43
View File
@@ -7,6 +7,45 @@ function apply(text: string, diff: string): { text: string; warnings: string[] }
} }
describe("boundary-balance repair", () => { 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 // The canonical incident: a range-replace whose payload restates the
// fragment + paren close that still live just below the range, doubling // fragment + paren close that still live just below the range, doubling
// `</>` and `);`. `replace 11.=31:` covers `const …` through the second `/>`. // `</>` 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 // Range 7..16 = `const …` through the first `/>`; payload restates the
// `</>` + `);` that survive at lines 17-18. // `</>` + `);` that survive at lines 17-18.
const diff = [ const diff = [
"SWAP 7.=16:", "PUT 7-16:",
"+\treturn (", "+\treturn (",
"+\t\t<>", "+\t\t<>",
"+\t\t\t<Composition", "+\t\t\t<Composition",
@@ -62,7 +101,7 @@ describe("boundary-balance repair", () => {
const file = ["it('a', () => {", "\tsetup();", "\trun();", "});", "after();"].join("\n"); const file = ["it('a', () => {", "\tsetup();", "\trun();", "});", "after();"].join("\n");
// `replace 2.=3:` replaces the two body lines but the payload also restates the // `replace 2.=3:` replaces the two body lines but the payload also restates the
// `});` at line 4, which survives — a duplicate close. // `});` 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["it('a', () => {", "\tsetup2();", "\trun2();", "});", "after();"].join("\n")); expect(text).toBe(["it('a', () => {", "\tsetup2();", "\trun2();", "});", "after();"].join("\n"));
expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); 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 // `replace 4.=6:` covers the params + return-type line, but the payload also
// restates the `planRender(` at line 3, which survives — a duplicate open. // restates the `planRender(` at line 3, which survives — a duplicate open.
const diff = [ const diff = [
"SWAP 4.=6:", "PUT 4-6:",
"+\tplanRender(", "+\tplanRender(",
"+\t\ta: string[],", "+\t\ta: string[],",
"+\t\tb: boolean,", "+\t\tb: boolean,",
@@ -117,7 +156,7 @@ describe("boundary-balance repair", () => {
const file = ["if (a) {", "\tfoo();", "}", "bar();"].join("\n"); const file = ["if (a) {", "\tfoo();", "}", "bar();"].join("\n");
// Payload duplicates `if (a) {` but is net +2 braces; dropping the one // Payload duplicates `if (a) {` but is net +2 braces; dropping the one
// opener cannot zero the delta, so nothing is repaired. // 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["if (a) {", "if (a) {", "\tif (b) {", "\t\tfoo();", "}", "bar();"].join("\n")); expect(text).toBe(["if (a) {", "if (a) {", "\tif (b) {", "\t\tfoo();", "}", "bar();"].join("\n"));
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -128,7 +167,7 @@ describe("boundary-balance repair", () => {
const file = ["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "};"].join("\n"); 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 // `replace 5.=5:` is the final `};`. Model inserts a new method but forgets to
// restate `};`; sparing it keeps the object literal balanced. // 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); const { text, warnings } = apply(file, diff);
expect(text).toBe( expect(text).toBe(
["const handlers = {", "\ta() {", "\t\treturn 1;", "\t},", "\tb() {", "\t\treturn 2;", "\t},", "};"].join( ["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. // keeping the deleted suffix would duplicate the closer outside the payload.
it("does not spare a deleted closing line that the payload already restates", () => { it("does not spare a deleted closing line that the payload already restates", () => {
const file = ["class Foo {", "\tok();", "\t}", "}"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["class Foo {", "\tok();", "}"].join("\n")); expect(text).toBe(["class Foo {", "\tok();", "}"].join("\n"));
@@ -159,7 +198,7 @@ describe("boundary-balance repair", () => {
"\tprint_status()", "\tprint_status()",
].join("\n"); ].join("\n");
const diff = [ const diff = [
"SWAP 2.=3:", "PUT 2-3:",
"+func _cmd_travel_homeworld():", "+func _cmd_travel_homeworld():",
"+\tvar destination = find_homeworld()", "+\tvar destination = find_homeworld()",
"+\ttravel_to(destination)", "+\ttravel_to(destination)",
@@ -183,7 +222,7 @@ describe("boundary-balance repair", () => {
it("preserves payloads where multi-line boundary echoes cover every line", () => { it("preserves payloads where multi-line boundary echoes cover every line", () => {
const file = ["A", "B", "old", "C", "D"].join("\n"); 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); 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", () => { it("preserves payloads made only of lines matching both replacement neighbors", () => {
const file = ["a", "old", "c"].join("\n"); 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); 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 // Payload deliberately opens with the same bare `}` that sits above the
// range and closes with the same `}` that sits below it; the payload is // 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. // 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); const { text, warnings } = apply(file, diff);
@@ -221,7 +260,7 @@ describe("boundary-balance repair", () => {
// (opener + closer) that duplicate the surviving neighbors are dropped. // (opener + closer) that duplicate the surviving neighbors are dropped.
it("still drops a balance-neutral wrapper echo", () => { it("still drops a balance-neutral wrapper echo", () => {
const file = ["function f() {", "old();", "}"].join("\n"); 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); const { text, warnings } = apply(file, diff);
@@ -235,7 +274,7 @@ describe("boundary-balance repair", () => {
const file = ["foo();", "bar();", "bar();", "baz();"].join("\n"); const file = ["foo();", "bar();", "bar();", "baz();"].join("\n");
// Replace line 2 with two balanced statements; the tail `bar();` equals // Replace line 2 with two balanced statements; the tail `bar();` equals
// the surviving line 3 but the payload is balanced — must NOT be dropped. // 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["foo();", "qux();", "bar();", "bar();", "baz();"].join("\n")); expect(text).toBe(["foo();", "qux();", "bar();", "bar();", "baz();"].join("\n"));
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -245,7 +284,7 @@ describe("boundary-balance repair", () => {
// could discard intended content, and it does not break syntax. // could discard intended content, and it does not break syntax.
it("does not drop a balance-neutral duplicated statement", () => { it("does not drop a balance-neutral duplicated statement", () => {
const file = ["a = 1;", "b = 2;", "c = 3;"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["a = 1;", "b = 2;", "b = 2;", "c = 3;"].join("\n")); expect(text).toBe(["a = 1;", "b = 2;", "b = 2;", "c = 3;"].join("\n"));
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -254,7 +293,7 @@ describe("boundary-balance repair", () => {
// Brackets inside strings must not trigger a spurious balance mismatch. // Brackets inside strings must not trigger a spurious balance mismatch.
it("ignores brackets inside string literals", () => { it("ignores brackets inside string literals", () => {
const file = ['const a = "}";', 'const b = "x";', 'const c = "y";'].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(['const a = "}";', 'const b = "}}}";', 'const c = "y";'].join("\n")); expect(text).toBe(['const a = "}";', 'const b = "}}}";', 'const c = "y";'].join("\n"));
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -265,7 +304,7 @@ describe("boundary-balance repair", () => {
// the range was one line short of the `const changedFiles` it retyped. // 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", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n")); expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n"));
expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); 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", () => { it("drops a one-sided JSX closer echo in a single-line expansion", () => {
const file = ["const view = (", " <section>", " <Old />", " </section>", ");"].join("\n"); const file = ["const view = (", " <section>", " <Old />", " </section>", ");"].join("\n");
const diff = ["SWAP 3.=3:", "+ <New />", "+ </section>"].join("\n"); const diff = ["PUT 3-3:", "+ <New />", "+ </section>"].join("\n");
const { text, warnings } = apply(file, diff); const { text, warnings } = apply(file, diff);
expect(text).toBe(["const view = (", " <section>", " <New />", " </section>", ");"].join("\n")); expect(text).toBe(["const view = (", " <section>", " <New />", " </section>", ");"].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", () => { it("drops a JSX closer echo after a self-closing tag with a greater-than prop expression", () => {
const file = ["const view = (", "<Foo>", "old text", "</Foo>", ");"].join("\n"); const file = ["const view = (", "<Foo>", "old text", "</Foo>", ");"].join("\n");
const diff = ["SWAP 3.=3:", "+<Foo value={a > b} />", "+</Foo>"].join("\n"); const diff = ["PUT 3-3:", "+<Foo value={a > b} />", "+</Foo>"].join("\n");
const { text, warnings } = apply(file, diff); const { text, warnings } = apply(file, diff);
expect(text).toBe(["const view = (", "<Foo>", "<Foo value={a > b} />", "</Foo>", ");"].join("\n")); expect(text).toBe(["const view = (", "<Foo>", "<Foo value={a > b} />", "</Foo>", ");"].join("\n"));
@@ -293,7 +332,7 @@ describe("boundary-balance repair", () => {
it("preserves a nested JSX closer that matches the surviving parent closer", () => { it("preserves a nested JSX closer that matches the surviving parent closer", () => {
const file = ["const view = (", '<section className="outer">', "old text", "</section>", ");"].join("\n"); const file = ["const view = (", '<section className="outer">', "old text", "</section>", ");"].join("\n");
const diff = ["SWAP 3.=3:", "+<section>", "+new text", "+</section>"].join("\n"); const diff = ["PUT 3-3:", "+<section>", "+new text", "+</section>"].join("\n");
const { text, warnings } = apply(file, diff); const { text, warnings } = apply(file, diff);
expect(text).toBe( expect(text).toBe(
@@ -313,7 +352,7 @@ describe("boundary-balance repair", () => {
it("preserves a nested JSX closer when the opener spans payload lines", () => { it("preserves a nested JSX closer when the opener spans payload lines", () => {
const file = ["const view = (", '<section className="outer">', "old text", "</section>", ");"].join("\n"); const file = ["const view = (", '<section className="outer">', "old text", "</section>", ");"].join("\n");
const diff = ["SWAP 3.=3:", "+<section", '+ className="inner"', "+>", "+new text", "+</section>"].join("\n"); const diff = ["PUT 3-3:", "+<section", '+ className="inner"', "+>", "+new text", "+</section>"].join("\n");
const { text, warnings } = apply(file, diff); const { text, warnings } = apply(file, diff);
expect(text).toBe( expect(text).toBe(
@@ -337,7 +376,7 @@ describe("boundary-balance repair", () => {
// the multi-line range (range one line low instead of one short). // 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", () => { it("drops a one-sided leading keeper echo in a multi-line rewrite", () => {
const file = ["setup();", "a();", "b();", "c();"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n")); expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n"));
expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true);
@@ -356,7 +395,7 @@ describe("boundary-balance repair", () => {
"}", "}",
].join("\n"); ].join("\n");
const diff = [ const diff = [
"SWAP 3.=4:", "PUT 3-4:",
"+ auto* handle = payloadFor<PyThreadHandle>(self);", "+ auto* handle = payloadFor<PyThreadHandle>(self);",
"+ if (!handle || !handle->isStarted())", "+ if (!handle || !handle->isStarted())",
].join("\n"); ].join("\n");
@@ -368,7 +407,7 @@ describe("boundary-balance repair", () => {
// mentions it. // mentions it.
it("rejects a trailing keeper echo when the payload cannot fill the widened range", () => { it("rejects a trailing keeper echo when the payload cannot fill the widened range", () => {
const file = ["a();", "b();", "c();", "keep();"].join("\n"); 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/); expect(() => apply(file, diff)).toThrow(/rejected: the body ends by restating/);
}); });
@@ -384,7 +423,7 @@ describe("boundary-balance repair", () => {
" }", " }",
" handle->setIdent(currentIdent());", " handle->setIdent(currentIdent());",
].join("\n"); ].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/); 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. // 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", () => { it("still spares a closer when the payload indentation claims the block interior", () => {
const file = ["if (!global) {", " setDone();", " return;", "}", "after();"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe( expect(text).toBe(
["if (!global) {", " setDone();", " return;", " setIdent();", "}", "after();"].join("\n"), ["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 `}`. // the per-group repair wrongly kept it, leaving a stray `}`.
it("does not keep a deleted closer when another hunk removes its opener (#3142)", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(['Text("New")', '\tText("Tail")'].join("\n")); expect(text).toBe(['Text("New")', '\tText("Tail")'].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); 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. // 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", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(['Text("New")', "const config = {", "\ta: 1,", "\tb: 2,", "};"].join("\n")); expect(text).toBe(['Text("New")', "const config = {", "\ta: 1,", "\tb: 2,", "};"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); 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. // 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", () => { it("keeps the closer when the matching opener is replaced rather than removed", () => {
const file = ["if (a) {", "\told();", "}"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["if (b) {", "\tnew();", "}"].join("\n")); expect(text).toBe(["if (b) {", "\tnew();", "}"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1);
@@ -440,7 +479,7 @@ describe("boundary-balance repair", () => {
"}", "}",
].join("\n"); ].join("\n");
const diff = [ const diff = [
"SWAP 3.=6:", "PUT 3-6:",
"+function supportsDevinThinking(config: ClientModelConfig): boolean {", "+function supportsDevinThinking(config: ClientModelConfig): boolean {",
"+\tif (NO_REASONING_LABEL_PATTERN.test(config.label)) return false;", "+\tif (NO_REASONING_LABEL_PATTERN.test(config.label)) return false;",
"+\treturn config.supportsThinking === true;", "+\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", () => { it("keeps only the non-restated outer closer for a nested deleted suffix", () => {
const file = ["class C {", "\told();", "\t}", "}"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["class C {", "\tnewMethod() {", "\t\treturn 1;", "\t}", "}"].join("\n")); expect(text).toBe(["class C {", "\tnewMethod() {", "\t\treturn 1;", "\t}", "}"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); 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", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["\told();", "\tnew();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n")); expect(text).toBe(["\told();", "\tnew();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1);
@@ -486,9 +525,9 @@ describe("boundary-balance repair", () => {
"\t}", "\t}",
].join("\n"); ].join("\n");
const diff = [ const diff = [
"SWAP 2.=3:", "PUT 2-3:",
"+\tnew();", "+\tnew();",
"SWAP 4.=6:", "PUT 4-6:",
"+function supportsDevinThinking(config: ClientModelConfig): boolean {", "+function supportsDevinThinking(config: ClientModelConfig): boolean {",
"+\treturn config.supportsThinking === true;", "+\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", () => { it("does not let an earlier kept closer cover a later orphan closer", () => {
const file = ["if (a) {", "\told();", "}", "}"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["if (a) {", "\tnew();", "}", "after();"].join("\n")); expect(text).toBe(["if (a) {", "\tnew();", "}", "after();"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); 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", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["class C {", "\tmethod() {", "\t\tnew();", "\t}", "}"].join("\n")); expect(text).toBe(["class C {", "\tmethod() {", "\t\tnew();", "\t}", "}"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); 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", () => { it("keeps an omitted inner closer when the outer closer survives below", () => {
const file = ["class C {", "\tmethod() {", "\t\told();", "\t}", "}", "}"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["class C {", "\tmethod() {", "\t\tnew();", "\t}", "}"].join("\n")); expect(text).toBe(["class C {", "\tmethod() {", "\t\tnew();", "\t}", "}"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["if (a) {", "\tnew();", "}"].join("\n")); 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", () => { it("counts a separately inserted closer immediately below the range", () => {
const file = ["class C {", "\told();", "}", "after();", "const obj = {", "\ta: 1,", "};"].join("\n"); 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); const { text, warnings } = apply(file, diff);
expect(text).toBe( expect(text).toBe(
["class C {", "\tnew();", "}", "after();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n"), ["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", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["if (a) {", "\tif (c) {", "\t\tnew();", "\t}", "}", "after();"].join("\n")); expect(text).toBe(["if (a) {", "\tif (c) {", "\t\tnew();", "\t}", "}", "after();"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1);
@@ -570,7 +609,7 @@ describe("boundary-balance repair", () => {
"\ta: 1,", "\ta: 1,",
"};", "};",
].join("\n"); ].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); const { text, warnings } = apply(file, diff);
expect(text).toBe( expect(text).toBe(
[ [
@@ -594,7 +633,7 @@ describe("boundary-balance repair", () => {
// bleed across into another hunk's delimiter count and mask its missing closer. // 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", () => { 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 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); const { text, warnings } = apply(file, diff);
expect(text).toBe(["const log = createLog(`", "prefix", "`);", "const obj = {", "\ta: 2", "};"].join("\n")); expect(text).toBe(["const log = createLog(`", "prefix", "`);", "const obj = {", "\ta: 2", "};"].join("\n"));
expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); 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 `});` // `replace 4.=5:` replaces the body lines but the payload also restates the `});`
// that survives at line 6 — the duplicate-closer mistake. // 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 }); const recovered = new Recovery(store).tryRecover({ path: PATH, currentText, fileHash, edits });
expect(recovered).not.toBeNull(); expect(recovered).not.toBeNull();
+51 -154
View File
@@ -4,7 +4,6 @@ import {
type BlockResolver, type BlockResolver,
type BlockSpan, type BlockSpan,
type Clipboard, type Clipboard,
computeFileHash,
InMemoryFilesystem, InMemoryFilesystem,
InMemorySnapshotStore, InMemorySnapshotStore,
Patch, Patch,
@@ -33,15 +32,15 @@ function taggedPatcher(files: Array<[string, string]>): {
} }
describe("clipboard parsing", () => { describe("clipboard parsing", () => {
it("lowers `CUT N.=M` to a capture plus per-line deletes", () => { it("lowers `CUT N-M` to a capture plus per-line deletes", () => {
const cut = parsePatch("CUT 2.=3").edits; const cut = parsePatch("CUT 2-3").edits;
expect(cut.map(edit => edit.kind)).toEqual(["cut", "delete", "delete"]); expect(cut.map(edit => edit.kind)).toEqual(["cut", "delete", "delete"]);
expect(cut[0]).toMatchObject({ kind: "cut", range: { start: { line: 2 }, end: { line: 3 } } }); expect(cut[0]).toMatchObject({ kind: "cut", range: { start: { line: 2 }, end: { line: 3 } } });
}); });
it("parses every PASTE position, tolerating a trailing colon", () => { it("parses every PUT paste locator, rejecting colons on register PUTs", () => {
const cursors = parsePatch("CUT 1\nPASTE.PRE 2\nPASTE.POST 3:\nPASTE.HEAD\nPASTE.TAIL:").edits.flatMap(edit => const cursors = parsePatch("CUT 1\nPUT <2\nPUT >3\nPUT <1\nPUT >$").edits.flatMap(edit =>
edit.kind === "paste" ? [edit.cursor] : [], edit.kind === "paste" && edit.at.kind === "gap" ? [edit.at.cursor] : [],
); );
expect(cursors).toEqual([ expect(cursors).toEqual([
{ kind: "before_anchor", anchor: { line: 2 } }, { kind: "before_anchor", anchor: { line: 2 } },
@@ -51,114 +50,118 @@ describe("clipboard parsing", () => {
]); ]);
}); });
it("rejects bare `PASTE` without a position", () => { it("rejects colon on register PUT", () => {
expect(() => parsePatch("PASTE")).toThrow(/`PASTE` needs a position/); expect(() => parsePatch("PUT >2 @name:")).toThrow(/never takes `:`/);
}); });
it("rejects body rows under clipboard ops", () => { it("rejects body rows under CUT", () => {
expect(() => parsePatch("CUT 1.=2\n+x")).toThrow(/`CUT N.=M` captures \+ deletes/); expect(() => parsePatch("CUT 1-2\n+x")).toThrow(/`CUT` deletes/);
expect(() => parsePatch("CUT 1\nPASTE.POST 1\n+x")).toThrow(/`PASTE` inserts the clipboard content/);
}); });
it("rejects a CUT range overlapping another hunk's range", () => { 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", () => { 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", () => { 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"]); expect(edits.map(edit => edit.kind)).toEqual(["cut", "delete", "paste"]);
}); });
}); });
describe("clipboard apply semantics", () => { describe("clipboard apply semantics", () => {
it("moves a range within a file (CUT + PASTE)", () => { it("moves a range within a file (CUT + PUT)", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=3\nPASTE.POST 5`); 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"); 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", () => { 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"); 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", () => { it("swaps two regions with named registers", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=2\nPASTE.POST 4\nCUT 3.=4\nPASTE.PRE 1`); 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"); expect(section.applyTo("a1\na2\nb1\nb2").text).toBe("b1\nb2\na1\na2");
}); });
it("rejects PASTE with an empty register", () => { it("rejects PUT with an empty register", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`); const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >1`);
expect(() => section.applyTo("l1\nl2\n")).toThrow(/found nothing in the clipboard/); expect(() => section.applyTo("l1\nl2\n")).toThrow(/Nothing to paste/);
}); });
it("drops an empty-register PASTE on the streaming-tolerant path", () => { it("drops an empty-register PUT on the streaming-tolerant path", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`); const section = Patch.parseSingle(`[${PATH}#1A2B]\nPUT >1`);
expect(section.applyPartialTo("l1\nl2\n").text).toBe("l1\nl2\n"); expect(section.applyPartialTo("l1\nl2\n").text).toBe("l1\nl2\n");
}); });
it("allows a CUT without a following PASTE", () => { it("allows a CUT without a following PUT", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=2`); const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2`);
expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l1\nl3\n"); expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l1\nl3\n");
}); });
it("allows consecutive CUTs and pastes the latest capture", () => { it("rejects multiple unlabeled CUTs before an unlabeled paste", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=1\nCUT 3.=3\nPASTE.TAIL`); 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"); expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l2\nl3\n");
}); });
it("rejects an out-of-range capture", () => { it("rejects an out-of-range capture", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 8.=9\nPASTE.HEAD`); const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 8-9\nPUT <1`);
expect(() => section.applyTo("l1\nl2\n")).toThrow(/out of range \(file has 3 lines\)/); 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 clipboard: Clipboard = {};
const cut = parsePatch("CUT 1.=1").edits; const cut = parsePatch("CUT 1 @r").edits;
const paste = parsePatch("PASTE.TAIL").edits; const paste = parsePatch("PUT >$ @r").edits;
expect(applyEdits("a\nb", cut, { clipboard }).text).toBe("b"); expect(applyEdits("a\nb", cut, { clipboard }).text).toBe("b");
expect(applyEdits("x\ny", paste, { clipboard }).text).toBe("x\ny\na"); expect(applyEdits("x\ny", paste, { clipboard }).text).toBe("x\ny\na");
}); });
}); });
describe("clipboard block ops", () => { describe("clipboard block ops", () => {
it("expands CUT.BLK to a span capture plus per-line deletes", () => { it("expands CUT N* to a span capture plus per-line deletes", () => {
const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits; const edits = parsePatch("CUT 2*\nPUT >$").edits;
const resolved = resolveBlockEdits(edits, "l1\nl2\nl3\nl4", PATH, stubResolver); const resolved = resolveBlockEdits(edits, "l1\nl2\nl3\nl4", PATH, stubResolver);
expect(resolved.map(edit => edit.kind)).toEqual(["cut", "delete", "delete", "paste"]); expect(resolved.map(edit => edit.kind)).toEqual(["cut", "delete", "delete", "paste"]);
expect(resolved[0]).toMatchObject({ kind: "cut", range: { start: { line: 2 }, end: { line: 3 } } }); expect(resolved[0]).toMatchObject({ kind: "cut", range: { start: { line: 2 }, end: { line: 3 } } });
}); });
it("moves a block after another block via PASTE.BLK.POST", () => { it("moves a block after another block via PUT >N*", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT.BLK 1\nPASTE.BLK.POST 3`); const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1*\nPUT >3*`);
// stub blocks: [1,2] and [3,4]. // stub blocks: [1,2] and [3,4].
expect(section.applyTo("a1\na2\nb1\nb2\nrest", stubResolver).text).toBe("b1\nb2\na1\na2\nrest"); expect(section.applyTo("a1\na2\nb1\nb2\nrest", stubResolver).text).toBe("b1\nb2\na1\na2\nrest");
}); });
it("echoes clipboard block resolutions with their op", () => { it("echoes clipboard block resolutions with their op", () => {
const seen: string[] = []; 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), onResolved: resolution => seen.push(resolution.op),
}); });
expect(seen).toEqual(["cut"]); 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 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`/); 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 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), onWarning: warning => warnings.push(warning),
}); });
expect(resolved.map(edit => edit.kind)).toEqual(["cut", "delete", "paste"]); 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], ["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); await patcher.apply(patch);
expect(fs.get("a.ts")).toBe("keep\n"); expect(await fs.readText("a.ts")).toBe("keep\n");
expect(fs.get("b.ts")).toBe("b1\nmove1\nmove2\n"); expect(await fs.readText("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");
}); });
}); });
+31 -34
View File
@@ -21,7 +21,7 @@ function tag(line: number): string {
} }
function sameLineRange(anchor: string): string { function sameLineRange(anchor: string): string {
return `SWAP ${anchor}..${anchor}:`; return `PUT ${anchor}:`;
} }
function applyDiff(content: string, diff: string): string { function applyDiff(content: string, diff: string): string {
@@ -87,50 +87,50 @@ describe("hashline parser — range-anchor contracts", () => {
const content = "aaa\nbbb\nccc"; const content = "aaa\nbbb\nccc";
it("keeps parsed sections reusable across target snapshots", () => { 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").text).toBe("aaa\nbbb\ntail");
expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); 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 = [ const diff = [
`INS.PRE ${tag(2)}:`, `PUT <${tag(2)}:`,
repl("before b"), repl("before b"),
`INS.POST ${tag(2)}:`, `PUT >${tag(2)}:`,
repl("after b"), repl("after b"),
"INS.HEAD:", "PUT <1:",
repl("top"), repl("top"),
"INS.TAIL:", "PUT >$:",
repl("tail"), repl("tail"),
].join("\n"); ].join("\n");
expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); 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)}`)).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"); expect(applyDiff(content, `${sameLineRange(tag(2))}\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc");
}); });
it("inserts after the final line without falling off the file", () => { 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", () => { it("preserves whitespace-bearing and sigil-leading payload exactly", () => {
const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; 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( expect(
applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`), applyDiff(content, `${sameLineRange(tag(2))}\n${repl("|literal")}\n${repl("^literal")}\n${repl("↓literal")}`),
).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); ).toBe("aaa\n|literal\n^literal\n↓literal\nccc");
}); });
it("strips copied read-output prefixes only inside pasted bare body rows", () => { 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); const { edits, warnings } = parsePatch(diff);
expect(applyEdits("aaa\nbbb\nccc\nddd\neee", edits).text).toBe("aaa\nline one\nline two\neee"); 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); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
}); });
it("rejects overlapping replacement ranges", () => { 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/); 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", () => { 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", path: "foo.ts",
diff: `INS.HEAD:\n${repl("x")}`, diff: `PUT <1:\n${repl("x")}`,
}); });
const cwd = process.cwd(); const cwd = process.cwd();
const absolute = `${cwd}/src/foo.ts`; const absolute = `${cwd}/src/foo.ts`;
expect(splitHashlineInput(`[${absolute}]\nINS.HEAD:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); expect(splitHashlineInput(`[${absolute}]\nPUT <1:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts");
expect(splitHashlineInput(`INS.HEAD:\n${repl("x")}`, { path: "a.ts" })).toEqual({ expect(splitHashlineInput(`PUT <1:\n${repl("x")}`, { path: "a.ts" })).toEqual({
path: "a.ts", 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/); expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
}); });
it("splits multiple sections and drops a trailing header without operations", () => { 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([ expect(splitHashlineInputs(input)).toEqual([
{ path: "a.ts", diff: `INS.HEAD:\n${repl("a")}` }, { path: "a.ts", diff: `PUT <1:\n${repl("a")}` },
{ path: "b.ts", diff: `INS.TAIL:\n${repl("b")}` }, { path: "b.ts", diff: `PUT >$:\n${repl("b")}` },
]); ]);
expect(splitHashlineInputs(["[a.ts]", "INS.HEAD:", repl("a"), "[b.ts]"].join("\n"))).toEqual([ expect(splitHashlineInputs(["[a.ts]", "PUT <1:", repl("a"), "[b.ts]"].join("\n"))).toEqual([
{ path: "a.ts", diff: `INS.HEAD:\n${repl("a")}` }, { path: "a.ts", diff: `PUT <1:\n${repl("a")}` },
]); ]);
}); });
it("rejects unified-diff hunk headers on the first line", () => { 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/); expect(() => splitHashlineInputs(input)).toThrow(/unified-diff hunk header/);
}); });
}); });
@@ -247,7 +247,7 @@ describe("Recovery", () => {
path: filePath, path: filePath,
currentText, currentText,
tag: v0Tag, 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(); expect(recovered).not.toBeNull();
@@ -259,7 +259,7 @@ describe("hashline abort sentinel", () => {
const sentinel = "*** Abort"; const sentinel = "*** Abort";
it("terminates parsing without surfacing a warning", () => { 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); const { edits, warnings } = parsePatch(diff);
expect(edits).toHaveLength(1); expect(edits).toHaveLength(1);
expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" });
@@ -269,11 +269,11 @@ describe("hashline abort sentinel", () => {
it("stops the input splitter before later sections", () => { it("stops the input splitter before later sections", () => {
const input = [ const input = [
"[a.ts]", "[a.ts]",
`INS.POST ${tag(1)}:`, `PUT >${tag(1)}:`,
repl("a-payload"), repl("a-payload"),
sentinel, sentinel,
"[b.ts]", "[b.ts]",
`INS.POST ${tag(1)}:`, `PUT >${tag(1)}:`,
repl("never"), repl("never"),
].join("\n"); ].join("\n");
const sections = splitHashlineInputs(input); const sections = splitHashlineInputs(input);
@@ -284,14 +284,11 @@ describe("hashline abort sentinel", () => {
}); });
describe("hashline parser — cut and blank payload semantics", () => { 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\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", "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", () => { 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", () => { it("preserves explicit blank replacement rows", () => {
const text = "a\nb\nc\nd\ne\n"; 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"); 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"); expect(applyDiff("a\nb\nc\n", splitHashlineInput(embedded).diff)).toBe("a\nfirst\n\nsecond\nc\n");
}); });
}); });
+2 -2
View File
@@ -15,7 +15,7 @@ const CONTENT = "one\ntwo\nthree\n";
describe("hashline file ops", () => { describe("hashline file ops", () => {
it("parses REM and rejects line ops in the same section", () => { it("parses REM and rejects line ops in the same section", () => {
expect(parsePatch("REM").fileOp).toEqual({ kind: "rem" }); 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", () => { it("parses MV with a normalized destination path", () => {
@@ -59,7 +59,7 @@ describe("hashline file ops", () => {
const tag = snapshots.record(PATH, CONTENT); const tag = snapshots.record(PATH, CONTENT);
const patcher = new Patcher({ fs, snapshots }); 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(result.sections[0]?.moveDest).toBe(DEST);
expect(fs.get(PATH)).toBeUndefined(); expect(fs.get(PATH)).toBeUndefined();
+30 -26
View File
@@ -8,14 +8,14 @@ function applyPatch(text: string, diff: string): string {
describe("hashline format v4", () => { describe("hashline format v4", () => {
it("replaces a concrete range with literal body rows in textual order", () => { it("replaces a concrete range with literal body rows in textual order", () => {
const text = "a\nb\nc"; 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"); expect(applyPatch(text, diff)).toBe("a\nbefore\nafter\nc");
}); });
it("deletes a single source line", () => { it("deletes a single source line", () => {
const text = "a\nb\nc"; 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", () => { it("deletes a concrete range", () => {
@@ -23,28 +23,31 @@ describe("hashline format v4", () => {
expect(applyPatch(text, "CUT 2.=3")).toBe("a\nd"); 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"; const text = "a\nb\nc\nd";
expect(applyPatch(text, "CUT 2.3")).toBe("a\nd"); for (const separator of ["-", ".", "=", "..", "…", " "]) {
expect(applyPatch(text, "SWAP 2.3:\n+middle")).toBe("a\nmiddle\nd"); 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", () => { it("inserts before and after concrete anchors", () => {
const text = "a\nb\nc"; 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"); expect(applyPatch(text, diff)).toBe("a\nbefore\nb\nafter\nc");
}); });
it("inserts at head and tail", () => { it("inserts at head and tail", () => {
const text = "a\nb"; const text = "a\nb";
expect(applyPatch(text, "INS.HEAD:\n+HEAD")).toBe("HEAD\na\nb"); expect(applyPatch(text, "PUT <1:\n+HEAD")).toBe("HEAD\na\nb");
expect(applyPatch(text, "INS.TAIL:\n+TAIL")).toBe("a\nb\nTAIL"); expect(applyPatch(text, "PUT >$:\n+TAIL")).toBe("a\nb\nTAIL");
}); });
it("treats an empty replace hunk as a delete and still rejects empty inserts", () => { it("treats an empty replace as deletion while still rejecting an empty insert", () => {
const text = "a\nb\nc"; const result = parsePatch("PUT 2-3:");
expect(applyPatch(text, "SWAP 2.=2:")).toBe("a\nc"); expect(applyEdits("a\nb\nc\nd", result.edits).text).toBe("a\nd");
expect(() => parsePatch("INS.HEAD:")).toThrow(/needs at least one/); 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", () => { it("rejects body rows under cut", () => {
@@ -59,8 +62,8 @@ describe("hashline format v4", () => {
it("auto-pipes bare body rows as literal text", () => { it("auto-pipes bare body rows as literal text", () => {
const text = "a\nb\nc"; const text = "a\nb\nc";
expect(applyPatch(text, "SWAP 2.=2:\nraw")).toBe("a\nraw\nc"); expect(applyPatch(text, "PUT 2-2:\nraw")).toBe("a\nraw\nc");
const { warnings } = parsePatch("SWAP 2.=2:\nraw"); const { warnings } = parsePatch("PUT 2-2:\nraw");
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); 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"; const text = "a\nb\nc";
// Without this fix, "3:text" becomes literal "3:text" in the file. // Without this fix, "3:text" becomes literal "3:text" in the file.
// With the fix, the "3:" prefix is stripped, yielding just "text". // 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(applyEdits(text, edits).text).toBe("a\nreplaced\nc");
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true); expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
}); });
it("validates insert anchors against file bounds", () => { 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/); 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", () => { it("rejects safe-integer ranges above the expansion limit", () => {
expect(() => parsePatch("SWAP 1.=100001:\n+x")).toThrow( expect(() => parsePatch("PUT 1-100001:\n+x")).toThrow(/replace range spans 100001 lines; the maximum is 100000/);
/replace range spans 100001 lines; the maximum is 100000/,
);
}); });
it("ignores cutting the trailing blank sentinel of a newline-terminated file", () => { 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", () => { 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"); 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", () => { 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"); expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n");
}); });
it("still allows inserts anchored on the trailing blank sentinel", () => { 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"); 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", () => { 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([]); expect(result.edits).toEqual([]);
}); });
it("flushes a streaming empty replace hunk when another hunk starts", () => { it("flushes a streaming CUT hunk when another hunk starts", () => {
const result = parsePatchStreaming("SWAP 2.=2:\nINS.TAIL:\n"); const result = parsePatchStreaming("CUT 2\nPUT >$:\n");
expect(result.edits).toEqual([{ kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }]); 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 },
]);
}); });
}); });
+21 -21
View File
@@ -27,13 +27,13 @@ function apply(text: string, patch: string): { text: string; warnings: string[]
describe("after-insert landing shift", () => { describe("after-insert landing shift", () => {
it("slides a shallower body past the closing line and warns", () => { 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( expect(text).toBe(
["function f() {", " if (x) {", " a();", " }", " c();", " b();", "}", ""].join("\n"), ["function f() {", " if (x) {", " a();", " }", " c();", " b();", "}", ""].join("\n"),
); );
expect(warnings).toHaveLength(1); 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", () => { 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"); ].join("\n");
// Body at depth 4 escapes both the `for` and the `if`. // 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.text.split("\n")[6]).toBe(" c();");
expect(outer.warnings[0]).toMatch(/moved past 2 closing lines to after line 6/); 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`. // 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.text.split("\n")[5]).toBe(" c();");
expect(inner.warnings[0]).toMatch(/moved past 1 closing line to after line 5/); 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", () => { 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();"); expect(text.split("\n")[3]).toBe(" c();");
}); });
it("never crosses content lines (indentation-only languages stay put)", () => { it("never crosses content lines (indentation-only languages stay put)", () => {
const py = ["def f():", " if x:", " a()", " b()", ""].join("\n"); 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(text).toBe(["def f():", " if x:", " a()", " c()", " b()", ""].join("\n"));
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
}); });
it("treats a body of pure closers as depth-neutral", () => { 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(text.split("\n")[3]).toBe(" }");
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
}); });
it("skips incomparable indentation styles (tabs file, spaces body)", () => { it("skips incomparable indentation styles (tabs file, spaces body)", () => {
const tabs = ["function f() {", "\tif (x) {", "\t\ta();", "\t}", "\tb();", "}", ""].join("\n"); 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();"); expect(text.split("\n")[3]).toBe(" c();");
}); });
it("refuses to cross a line targeted by another hunk", () => { 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. // 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(text).toBe(["function f() {", " if (x) {", " a();", " c();", " b();", "}", ""].join("\n"));
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -93,24 +93,24 @@ describe("after-insert landing shift", () => {
it("looks past blank lines between the anchor and the closer", () => { it("looks past blank lines between the anchor and the closer", () => {
const gapped = ["function f() {", " if (x) {", " a();", "", " }", " b();", "}", ""].join("\n"); 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( expect(text).toBe(
["function f() {", " if (x) {", " a();", "", " }", " c();", " b();", "}", ""].join("\n"), ["function f() {", " if (x) {", " a();", "", " }", " c();", " b();", "}", ""].join("\n"),
); );
expect(warnings[0]).toMatch(/after line 5/); expect(warnings[0]).toMatch(/after line 5/);
}); });
it("leaves `INS.PRE N:` untouched", () => { it("leaves `PUT < N:` untouched", () => {
const { text, warnings } = apply(FILE, "INS.PRE 4:\n+ c();"); const { text, warnings } = apply(FILE, "PUT <4:\n+ c();");
expect(text.split("\n")[3]).toBe(" c();"); expect(text.split("\n")[3]).toBe(" c();");
expect(warnings).toHaveLength(0); 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. // 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 stubResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 });
const text = ["function f() {", " const t = mk({", " });", "}", "x();", ""].join("\n"); 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); 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", () => { it("pulls a deeper body inside the block, after its last content line", () => {
const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 2 }); 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); const result = section.applyTo(BLOCK_FILE, resolver);
expect(result.text).toBe( expect(result.text).toBe(
@@ -152,13 +152,13 @@ describe("insert-after-block inward landing shift", () => {
"\n", "\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", () => { it("lands right after the opener of an empty block", () => {
const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 }); const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 });
const text = ["function f() {", " afterEach(() => {", " });", "}", ""].join("\n"); 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); 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", () => { it("crosses nested trailing closers and stops at the body's claimed depth", () => {
const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 });
const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); 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); 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)", () => { it("leaves a sibling-depth body after the block (the literal contract)", () => {
const resolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 2 }); 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); 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", () => { 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(text.split("\n")[4]).toBe(" leak();");
expect(warnings).toHaveLength(0); 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", () => { it("refuses to cross a closer targeted by another hunk", () => {
const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 }); const resolver: BlockResolver = (): BlockSpan => ({ start: 1, end: 5 });
const text = ["foo(() => {", " bar(() => {", " x();", " });", "});", ""].join("\n"); 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); const result = section.applyTo(text, resolver);
+88 -71
View File
@@ -9,7 +9,7 @@ const FILE = "a\nb\nc\nd\ne";
describe("hashline section headers", () => { describe("hashline section headers", () => {
it("accepts paths with spaces in anchored 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.path).toBe("dir with spaces/file.ts");
expect(section.fileHash).toBe("1A2B"); expect(section.fileHash).toBe("1A2B");
@@ -17,7 +17,7 @@ describe("hashline section headers", () => {
}); });
it("recovers apply_patch-contaminated headers whose paths contain spaces", () => { 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.path).toBe("dir with spaces/file.ts");
expect(section.fileHash).toBe("1A2B"); expect(section.fileHash).toBe("1A2B");
@@ -25,30 +25,30 @@ describe("hashline section headers", () => {
}); });
it("rejects trailing junk after a snapshot tag", () => { 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 copied from read]\nPUT 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:812]\nPUT 1-1:\n+after")).toThrow(/Input header must be/);
}); });
it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => { 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/, /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", () => { 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#1A2]\nPUT 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#1A2G]\nPUT 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#1A2B5]\nPUT 1-1:\n+after")).toThrow(/Input header must be/);
}); });
it("rejects malformed snapshot tags even with apply_patch noise", () => { 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", () => { it("reports bracket syntax with a 4-hex example when the header is missing", () => {
try { try {
Patch.parse("CUT 38.=40"); Patch.parse("CUT 38-40");
throw new Error("expected missing-header error"); throw new Error("expected missing-header error");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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/); expect(() => parsePatch("2 3\n+X")).toThrow(/Hunk headers need a verb/);
}); });
it("accepts canonical replace/cut/insert forms", () => { it("accepts canonical dot-equals replace/cut and gap forms", () => {
expect(applyPatch(FILE, "SWAP 2.=3:\n+X")).toBe("a\nX\nd\ne"); 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, "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, "PUT <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, "PUT >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, "PUT <1:\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 >$:\n+X")).toBe("a\nb\nc\nd\ne\nX");
}); });
it("accepts single-number replace and cut shorthand", () => { it("leniently accepts single-number replace and cut shorthand", () => {
expect(applyPatch(FILE, "SWAP 2:\n+X")).toBe("a\nX\nc\nd\ne"); expect(applyPatch(FILE, "PUT 2:\n+X")).toBe("a\nX\nc\nd\ne");
expect(applyPatch(FILE, "CUT 2")).toBe("a\nc\nd\ne"); expect(applyPatch(FILE, "CUT 2")).toBe("a\nc\nd\ne");
}); });
it("accepts alternate replace range separators and missing colon", () => { it("recovers top-level numbered snapshot rows as single-line replacements", () => {
expect(applyPatch(FILE, "SWAP 2-3:\n+X")).toBe("a\nX\nd\ne"); const result = parsePatch("2:B\n4:D");
expect(applyPatch(FILE, "SWAP 2\u20263:\n+X")).toBe("a\nX\nd\ne"); expect(applyEdits(FILE, result.edits).text).toBe("a\nB\nc\nD\ne");
expect(applyPatch(FILE, "SWAP 2 3:\n+X")).toBe("a\nX\nd\ne"); expect(result.warnings.some(w => /snapshot row.*single-line `PUT N\.=N:`/i.test(w))).toBe(true);
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("accepts missing colon on insert headers", () => { it("recovers a bare range header as an implicit PUT", () => {
expect(applyPatch(FILE, "INS.PRE 2\n+X")).toBe("a\nX\nb\nc\nd\ne"); const result = parsePatch("2.=3:\n+X");
expect(applyPatch(FILE, "INS.HEAD\n+X")).toBe("X\na\nb\nc\nd\ne"); 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", () => { it("ignores copied read elisions instead of writing them", () => {
// GLM 5.2 inserts a `.` between the line number/range and `:`, const result = parsePatch(
// e.g. `SWAP 2.=3.:` instead of `SWAP 2.=3:`. ["1:a", "2-3: omitted() { … }", "4:d", "[…2ln elided; re-read needed ranges with a.ts:2-3]"].join("\n"),
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"); expect(applyEdits(FILE, result.edits).text).toBe(FILE);
// `INS.POST 2.:` instead of `INS.POST 2:` expect(result.warnings.some(w => /Ignored copied read-output elision/.test(w))).toBe(true);
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` it("accepts a harmless trailing colon on bodyless CUT", () => {
expect(applyPatch(FILE, "CUT 2.=3.")).toBe("a\nd\ne"); const result = parsePatch("CUT 2-3:");
// `INS.HEAD.:` and `INS.TAIL.:` with stray dot expect(applyEdits(FILE, result.edits).text).toBe("a\nd\ne");
expect(applyPatch(FILE, "INS.HEAD.:\n+X")).toBe("X\na\nb\nc\nd\ne"); expect(result.warnings.some(w => /Ignored a trailing `:`/.test(w))).toBe(true);
expect(applyPatch(FILE, "INS.TAIL.:\n+X")).toBe("a\nb\nc\nd\ne\nX"); });
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", () => { describe("hashline body contracts", () => {
it("auto-pipes a bare body row while warning", () => { 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(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); 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", () => { 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(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); expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
}); });
it("preserves `+N:` literal payloads without stripping", () => { 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(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nc\nd\ne");
expect(result.warnings.some(w => /Auto-prefixed/.test(w))).toBe(false); 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)", () => { 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) // "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"); 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", () => { 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"); 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", () => { 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 // "3:keep" looks like a snapshot prefix but "plain" does not, so the body
// is genuine content (not a pasted snapshot) — strip nothing. // 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"); expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nplain\nd\ne");
}); });
it("keeps interior blank rows in a bare replace body", () => { 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"); 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", () => { 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"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nc\nbaz\ne");
}); });
it("skips blank rows when checking N: prefix uniformity", () => { 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"); expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\n\nbar\nd\ne");
}); });
it("leaves numeric-keyed literal bodies untouched (dict/YAML shape)", () => { 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'); expect(applyEdits(FILE, result.edits).text).toBe('a\n1: "one",\n2: "two",\nd\ne');
}); });
it("rejects `-` body rows with Markdown bullet escape guidance", () => { it("rejects ambiguous standalone `-` body rows with Markdown bullet guidance", () => {
expect(() => parsePatch("SWAP 2.=2:\n-old\n+new")).toThrow( expect(() => parsePatch("PUT 2-2:\n-old")).toThrow(/Markdown bullets or other literal `-` lines.*`\+- item`/);
/Markdown bullets or other literal `-` lines.*`\+- item`/,
);
}); });
it("auto-pipes a fully bare Markdown bullet body with a warning", () => { 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(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); expect(result.warnings.some(w => /bullet row/.test(w))).toBe(true);
}); });
it("auto-pipes a bare bullet row next to explicit `+- item` siblings", () => { 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(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); expect(result.warnings.some(w => /bullet row/.test(w))).toBe(true);
}); });
it("still rejects non-bullet bare `-` rows even in a fully bare body", () => { 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)", () => { 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 `+`", () => { 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( expect(applyPatch(FILE, "PUT 2-2:\n+- item\n+ - nested\n++plus")).toBe("a\n- item\n - nested\n+plus\nc\nd\ne");
"a\n- item\n - nested\n+plus\nc\nd\ne",
);
}); });
it("treats empty replace as delete and still rejects empty insert", () => { it("treats an empty replace as deletion and still rejects an empty insert", () => {
expect(applyPatch(FILE, "SWAP 2.=2:")).toBe("a\nc\nd\ne"); expect(applyPatch(FILE, "PUT 2-2:")).toBe("a\nc\nd\ne");
expect(() => parsePatch("INS.TAIL:")).toThrow(/`INS` needs/); expect(() => parsePatch("PUT >$:")).toThrow(/promises body rows/);
}); });
it("rejects cut with a body", () => { it("rejects cut with a body", () => {
@@ -209,29 +220,35 @@ describe("hashline body contracts", () => {
describe("hashline — apply_patch / unified-diff contamination", () => { describe("hashline — apply_patch / unified-diff contamination", () => {
it("rejects apply_patch sentinels as 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("*** Update File: a.ts\nPUT 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("*** Add File: a.ts\nPUT 2-2:\n+X")).toThrow(/apply_patch sentinel/);
}); });
it("rejects unified-diff hunk headers as contamination", () => { 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", () => { 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", () => { describe("hashline apply — duplicate boundary payloads", () => {
it("keeps replacement boundary echoes literal unless balance repair applies", () => { it("keeps replacement boundary echoes literal unless balance repair applies", () => {
const text = ["// one", "// two", "old();"].join("\n"); 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")); expect(applyPatch(text, diff)).toBe(["// one", "// two", "// one", "// two", "new();"].join("\n"));
}); });
it("keeps pure-insert context echoes literal", () => { it("keeps pure-insert context echoes literal", () => {
const text = ["aaa", "bbb", "ccc"].join("\n"); 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"); expect(applyPatch(text, diff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW");
}); });
}); });
+29 -31
View File
@@ -31,7 +31,7 @@ describe("Patcher snapshot tag integrity", () => {
const tag = snapshots.record(PATH, "before\n"); const tag = snapshots.record(PATH, "before\n");
const patcher = new Patcher({ fs, snapshots }); 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]?.op).toBe("update");
expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/); 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)])); await Bun.write(filePath, new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(source)]));
const snapshots = new InMemorySnapshotStore(); const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(filePath, source); 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); await new Patcher({ fs: new NodeFilesystem(), snapshots }).apply(patch);
@@ -71,14 +71,14 @@ describe("Patcher snapshot tag integrity", () => {
expect(snapshots.byHash(PATH, tag)).toBeNull(); expect(snapshots.byHash(PATH, tag)).toBeNull();
const patcher = new Patcher({ fs, snapshots }); 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n"); expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n");
}); });
it("normalizes lowercase section tags while parsing", () => { 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"); expect(section.fileHash).toBe("1A2B");
}); });
@@ -91,7 +91,7 @@ describe("Patcher snapshot tag integrity", () => {
const patcher = new Patcher({ fs, snapshots }); const patcher = new Patcher({ fs, snapshots });
try { 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"); throw new Error("expected MismatchError");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(MismatchError); expect(error).toBeInstanceOf(MismatchError);
@@ -115,7 +115,7 @@ describe("Patcher snapshot tag integrity", () => {
const bogus = live === "FFFF" ? "0000" : "FFFF"; const bogus = live === "FFFF" ? "0000" : "FFFF";
try { 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"); throw new Error("expected MismatchError");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(MismatchError); expect(error).toBeInstanceOf(MismatchError);
@@ -144,7 +144,7 @@ describe("Patcher snapshot tag integrity", () => {
const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]); const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]);
const patcher = new Patcher({ fs, snapshots }); 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"); 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 tag = snapshots.record(PATH, original);
const patcher = new Patcher({ fs, snapshots }); 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]; const section = result.sections[0];
if (!section) throw new Error("expected one section result"); 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 // A follow-up edit anchored on the returned tag must succeed against
// the real (drifted) file instead of failing a stale-tag mismatch. // 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"); 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 snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots }); 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, /Missing hashline snapshot tag.*use the write tool/s,
); );
expect(fs.get(PATH)).toBe("a\nb\n"); expect(fs.get(PATH)).toBe("a\nb\n");
@@ -216,7 +216,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const snapshots = new InMemorySnapshotStore(); const snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots }); 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/, /Missing hashline snapshot tag/,
); );
}); });
@@ -226,7 +226,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const snapshots = new InMemorySnapshotStore(); const snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots }); 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, /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 stale = live === "0000" ? "FFFF" : "0000";
const patcher = new Patcher({ fs, snapshots }); 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]; const section = result.sections[0];
expect(section?.op).toBe("update"); expect(section?.op).toBe("update");
@@ -254,7 +254,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
const tag = snapshots.record(PATH, content); const tag = snapshots.record(PATH, content);
const patcher = new Patcher({ fs, snapshots }); 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]; const section = result.sections[0];
expect(section?.op).toBe("update"); expect(section?.op).toBe("update");
@@ -272,7 +272,7 @@ describe("Patcher seen-line provenance", () => {
const tag = snapshots.record(PATH, CONTENT, [1, 2]); const tag = snapshots.record(PATH, CONTENT, [1, 2]);
const patcher = new Patcher({ fs, snapshots }); 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/, /never displayed \(it showed/,
); );
expect(fs.get(PATH)).toBe(CONTENT); expect(fs.get(PATH)).toBe(CONTENT);
@@ -284,7 +284,7 @@ describe("Patcher seen-line provenance", () => {
const tag = snapshots.record(PATH, CONTENT, [1, 2]); const tag = snapshots.record(PATH, CONTENT, [1, 2]);
const patcher = new Patcher({ fs, snapshots }); 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("l1\nL2\nl3\nl4\nl5\n"); 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]); snapshots.record(PATH, CONTENT, [4, 5]);
const patcher = new Patcher({ fs, snapshots }); 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n"); expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n");
@@ -313,7 +313,7 @@ describe("Patcher seen-line provenance", () => {
let message: string | undefined; let message: string | undefined;
try { 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) { } catch (err) {
message = (err as Error).message; 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 // The revealed line joins the snapshot's seen set, so a straight retry
// with the same [path#tag] header applies — no extra read required. // 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(result.sections[0]?.op).toBe("update");
expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n"); expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n");
}); });
@@ -382,7 +382,7 @@ describe("Patcher seen-line provenance", () => {
let message: string | undefined; let message: string | undefined;
try { 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) { } catch (err) {
message = (err as Error).message; 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. // having only seen the first 512 chars of a >4KB line.
let retryMessage: string | undefined; let retryMessage: string | undefined;
try { 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) { } catch (err) {
retryMessage = (err as Error).message; retryMessage = (err as Error).message;
} }
@@ -418,7 +418,7 @@ describe("Patcher seen-line provenance", () => {
const tag = snapshots.record(PATH, CONTENT); const tag = snapshots.record(PATH, CONTENT);
const patcher = new Patcher({ fs, snapshots }); 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(result.sections[0]?.op).toBe("update");
}); });
@@ -435,7 +435,7 @@ describe("Patcher tag-based path recovery", () => {
const patcher = new Patcher({ fs, snapshots }); const patcher = new Patcher({ fs, snapshots });
// The header carries only the basename — the model dropped the directory. // 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]; const section = result.sections[0];
expect(section?.op).toBe("update"); expect(section?.op).toBe("update");
@@ -453,7 +453,7 @@ describe("Patcher tag-based path recovery", () => {
const tag = snapshots.record(NESTED, CONTENT); const tag = snapshots.record(NESTED, CONTENT);
const patcher = new Patcher({ fs, snapshots }); 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); expect(fs.get(NESTED)).toBe(CONTENT);
}); });
@@ -464,9 +464,7 @@ describe("Patcher tag-based path recovery", () => {
const bogus = tag === "FFFF" ? "0000" : "FFFF"; const bogus = tag === "FFFF" ? "0000" : "FFFF";
const patcher = new Patcher({ fs, snapshots }); const patcher = new Patcher({ fs, snapshots });
await expect(patcher.apply(Patch.parse(`[file.ts#${bogus}]\nSWAP 2.=2:\n+TWO`))).rejects.toThrow( await expect(patcher.apply(Patch.parse(`[file.ts#${bogus}]\nPUT 2-2:\n+TWO`))).rejects.toThrow(/File not found/);
/File not found/,
);
}); });
it("declines recovery when two retained files share the filename and tag", async () => { 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); snapshots.record("b/file.ts", CONTENT);
const patcher = new Patcher({ fs, snapshots }); 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("a/file.ts")).toBe(CONTENT);
expect(fs.get("b/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 tag = snapshots.record(NESTED, CONTENT);
const patcher = new Patcher({ fs, snapshots }); 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); expect(fs.get(NESTED)).toBe(CONTENT);
}); });
@@ -513,7 +511,7 @@ describe("Patcher tag-based path recovery", () => {
const tag = snapshots.record(NESTED, CONTENT); const tag = snapshots.record(NESTED, CONTENT);
const patcher = new Patcher({ fs, snapshots }); 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(result.sections[0]?.path).toBe(NESTED);
expect(fs.get(NESTED)).toBe("one\nTWO\nthree\n"); expect(fs.get(NESTED)).toBe("one\nTWO\nthree\n");
}); });
@@ -530,6 +528,6 @@ describe("Patcher tag-based path recovery", () => {
const snapshots = new InMemorySnapshotStore(); const snapshots = new InMemorySnapshotStore();
const patcher = new Patcher({ fs, snapshots }); 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/);
}); });
}); });
@@ -44,7 +44,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
// rewrote. Replaying onto current would overwrite "L5-CHANGED" with // rewrote. Replaying onto current would overwrite "L5-CHANGED" with
// payload the model authored against the stale "L5". That is // payload the model authored against the stale "L5". That is
// corruption, not recovery. // 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, 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 // Edit anchored at line 3 — unchanged between v0 and v1. Recovery
// proves that the target and its surrounding context still map to the // proves that the target and its surrounding context still map to the
// same live lines before replaying the edit. // 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -87,7 +87,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
const h0 = store.record(PATH, v0Text); const h0 = store.record(PATH, v0Text);
const v1Text = lines("L1", "L2", "INSERTED", "L3", "L4", "L5", "L6"); const v1Text = lines("L1", "L2", "INSERTED", "L3", "L4", "L5", "L6");
store.record(PATH, v1Text); 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -107,7 +107,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
const h0 = store.record(PATH, v0Text); const h0 = store.record(PATH, v0Text);
const v1Text = lines("L1", "L3", "L4", "L5", "L6"); const v1Text = lines("L1", "L3", "L4", "L5", "L6");
store.record(PATH, v1Text); 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -127,7 +127,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
const h0 = store.record(PATH, v0Text); const h0 = store.record(PATH, v0Text);
const v1Text = lines("start", "mid", "DUP", "CHANGED", "tail"); const v1Text = lines("start", "mid", "DUP", "CHANGED", "tail");
store.record(PATH, v1Text); 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -145,7 +145,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
const v0Text = lines(...block, "middle", ...block, "tail"); const v0Text = lines(...block, "middle", ...block, "tail");
const hash = store.record(PATH, v0Text); const hash = store.record(PATH, v0Text);
const currentText = lines("head", "CHANGED_A", "CHANGED_B", "ctx1", "ctx2", "ctx3", "middle", ...block, "tail"); 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -164,7 +164,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
const h0 = store.record(PATH, v0Text); const h0 = store.record(PATH, v0Text);
const v1Text = lines("X", "L1", "L2", "L3", "L4", "BEFORE", "T", "AFTER", "L6"); const v1Text = lines("X", "L1", "L2", "L3", "L4", "BEFORE", "T", "AFTER", "L6");
store.record(PATH, v1Text); 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -186,7 +186,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => {
const h0 = store.record(PATH, v0Text); const h0 = store.record(PATH, v0Text);
const v1Text = lines("alpha", "INSERTED", "DUP", "beta", "DUP", "omega"); const v1Text = lines("alpha", "INSERTED", "DUP", "beta", "DUP", "omega");
store.record(PATH, v1Text); 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({ const recovered = new Recovery(store).tryRecover({
path: PATH, path: PATH,
@@ -238,7 +238,7 @@ describe("Recovery — colliding snapshot tags", () => {
path: PATH, path: PATH,
currentText, currentText,
fileHash: tag, 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")); expect(recovered?.text).toBe(lines("shared head", "model payload", "shared tail", "drifted trailer"));
@@ -255,7 +255,7 @@ describe("Recovery — colliding snapshot tags", () => {
path: PATH, path: PATH,
currentText, currentText,
fileHash: tag, fileHash: tag,
edits: parsePatch("SWAP 2.=2:\n+model payload").edits, edits: parsePatch("PUT 2-2:\n+model payload").edits,
}); });
expect(recovered).not.toBeNull(); expect(recovered).not.toBeNull();
@@ -277,7 +277,7 @@ describe("Recovery — ill-formed UTF-16 content", () => {
path: PATH, path: PATH,
currentText, currentText,
fileHash: hash, fileHash: hash,
edits: parsePatch("SWAP 3.=3:\n+model payload").edits, edits: parsePatch("PUT 3-3:\n+model payload").edits,
}); });
expect(recovered).not.toBeNull(); expect(recovered).not.toBeNull();
+35 -33
View File
@@ -281,40 +281,42 @@ function isMutationTool(toolName: unknown): boolean {
// Pure classification — single shared tokenizer is safe. // Pure classification — single shared tokenizer is safe.
const HASHLINE_OP_TOKENIZER = new HashlineTokenizer(); const HASHLINE_OP_TOKENIZER = new HashlineTokenizer();
/** Display label for a hashline op header, e.g. `SWAP.BLK` or `PASTE.POST`. */ function hashlinePutSuffix(register: string | undefined, hadColon: boolean, allowsAnonymousPaste: boolean): string {
function hashlineOpLabel(target: HashlineBlockTarget): 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) { switch (target.kind) {
case "replace": case "replace": {
return "SWAP"; const locator = target.range.start.line === target.range.end.line ? "N.=N" : "N.=M";
return `PUT ${locator}${hashlinePutSuffix(target.register, hadColon, false)}`;
}
case "block": case "block":
return "SWAP.BLK"; return `PUT N*${hashlinePutSuffix(target.register, hadColon, false)}`;
case "insert_before": case "insert_before":
return "INS.PRE"; return `PUT <N${hashlinePutSuffix(target.register, hadColon, true)}`;
case "insert_after": case "insert_after":
return "INS.POST"; return `PUT >N${hashlinePutSuffix(target.register, hadColon, true)}`;
case "bof": case "bof":
return "INS.HEAD"; return `PUT <1${hashlinePutSuffix(target.register, hadColon, true)}`;
case "eof": case "eof":
return "INS.TAIL"; return `PUT >$${hashlinePutSuffix(target.register, hadColon, true)}`;
case "insert_after_block": case "insert_after_block":
return "INS.BLK.POST"; return `PUT >N*${hashlinePutSuffix(target.register, hadColon, true)}`;
case "cut": case "cut": {
return "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": case "cut_block":
return "CUT.BLK"; return `CUT N*${hashlineCutSuffix(target.register, hadColon)}`;
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";
case "rem": case "rem":
return "REM"; return "REM";
case "move": case "move":
@@ -323,9 +325,9 @@ function hashlineOpLabel(target: HashlineBlockTarget): string {
} }
/** /**
* Count hashline op headers (`SWAP`, `CUT.BLK`, `PASTE.POST`, …) in an * Count canonical hashline op header shapes (`PUT N.=M:`, `CUT N*`,
* edit call's patch input. Returns `null` when the args carry no hashline * `PUT >N @reg`, …) in an edit call's patch input. Returns `null` when
* patch — non-hashline edit variants and malformed calls contribute nothing. * the args carry no hashline patch; non-hashline variants contribute nothing.
*/ */
function countHashlineOps(args: unknown): Record<string, number> | null { function countHashlineOps(args: unknown): Record<string, number> | null {
if (!args || typeof args !== "object" || !("input" in args)) return null; if (!args || typeof args !== "object" || !("input" in args)) return null;
@@ -334,7 +336,7 @@ function countHashlineOps(args: unknown): Record<string, number> | null {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const token of HASHLINE_OP_TOKENIZER.tokenizeAll(input)) { for (const token of HASHLINE_OP_TOKENIZER.tokenizeAll(input)) {
if (token.kind !== "op-block") continue; if (token.kind !== "op-block") continue;
const label = hashlineOpLabel(token.target); const label = hashlineOpLabel(token.target, token.hadColon);
counts[label] = (counts[label] ?? 0) + 1; counts[label] = (counts[label] ?? 0) + 1;
} }
return Object.keys(counts).length > 0 ? counts : null; return Object.keys(counts).length > 0 ? counts : null;
@@ -878,7 +880,7 @@ export interface TaskRunResult {
editFailures: EditFailure[]; editFailures: EditFailure[];
editWarnings: string[]; editWarnings: string[];
editAutocorrectCount: number; 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<string, number>; hashlineEditSubtypes?: Record<string, number>;
mutationIntentMatched?: boolean; mutationIntentMatched?: boolean;
mutationIntentReason?: string; mutationIntentReason?: string;
@@ -994,7 +996,7 @@ export interface BenchmarkSummary {
mutationIntentMatchRate?: number; mutationIntentMatchRate?: number;
/** Edit failure categories across all runs. */ /** Edit failure categories across all runs. */
editFailureCategories: Record<EditFailureCategory, number>; editFailureCategories: Record<EditFailureCategory, number>;
/** 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<string, number>; hashlineEditSubtypes?: Record<string, number>;
} }
@@ -1988,7 +1990,7 @@ export function buildBenchmarkResult(params: {
// Op counts aggregate every attempted edit call across ALL runs (retries and // Op counts aggregate every attempted edit call across ALL runs (retries and
// failed calls included) — deliberately: the mix shows what the model reaches // failed calls included) — deliberately: the mix shows what the model reaches
// for, not just what landed. Best-run-only would hide exactly the flailing // 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<string, number> = {}; const hashlineEditSubtypeTotals: Record<string, number> = {};
for (const run of allRuns) { for (const run of allRuns) {
const runOps = run.hashlineEditSubtypes; const runOps = run.hashlineEditSubtypes;
+70 -25
View File
@@ -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}$") _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( _HASHLINE_OP = re.compile(
r"^(SWAP\.BLK|SWAP|XCHG\.BLK|XCHG|DEL\.BLK|DEL|CUT\.BLK|CUT|" rf"""
r"PASTE\.BLK\.POST|PASTE\.PRE|PASTE\.POST|PASTE\.HEAD|PASTE\.TAIL|" ^\s*(?:
r"INS\.BLK\.POST|INS\.PRE|INS\.POST|INS\.HEAD|INS\.TAIL|REM|MV|" (?P<put>PUT)\s+
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", {_RANGE_LOCATOR}
re.I, |{_LINE_LOCATOR}\*
|[<>]\s*{_LINE_LOCATOR}\*?
|>\s*\$
)
(?:\s+{_REGISTER})?\s*:?
|(?P<cut>CUT)\s+(?:{_RANGE_LOCATOR}|{_LINE_LOCATOR}\*)(?:\s+{_REGISTER})?\s*:?
|(?P<mv>MV)\s+\S(?:.*\S)?\s*
|(?P<rem>REM)\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<op>
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 = { _HASHLINE_OP_ALIASES = {
"replace block": "SWAP.BLK", "REPLACE BLOCK": "SWAP.BLK",
"replace_block": "SWAP.BLK", "REPLACE_BLOCK": "SWAP.BLK",
"replace": "SWAP", "REPLACE": "SWAP",
"xchg.blk": "SWAP.BLK", "XCHG.BLK": "SWAP.BLK",
"xchg": "SWAP", "XCHG": "SWAP",
"delete block": "DEL.BLK", "DELETE BLOCK": "DEL.BLK",
"delete_block": "DEL.BLK", "DELETE_BLOCK": "DEL.BLK",
"delete": "DEL", "DELETE": "DEL",
"insert before": "INS.PRE", "INSERT BEFORE": "INS.PRE",
"insert after": "INS.POST", "INSERT AFTER": "INS.POST",
"insert head": "INS.HEAD", "INSERT HEAD": "INS.HEAD",
"insert tail": "INS.TAIL", "INSERT TAIL": "INS.TAIL",
"insert after block": "INS.BLK.POST", "INSERT AFTER BLOCK": "INS.BLK.POST",
"insert_after_block": "INS.BLK.POST", "INSERT_AFTER_BLOCK": "INS.BLK.POST",
} }
def _hashline_ops(inp: str) -> list[str]: 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] = [] ops: list[str] = []
for line in inp.splitlines(): for line in inp.splitlines():
if not line or line[0] == "+": stripped = line.lstrip()
if not stripped or stripped.startswith("+"):
continue continue
m = _HASHLINE_OP.match(line.lstrip()) match = _HASHLINE_OP.fullmatch(line)
if m: if match:
op = m.group(1).lower() 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)) ops.append(_HASHLINE_OP_ALIASES.get(op, op))
return ops return ops