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:
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user