feat(hashline): implemented clipboard operations in hashline engine
- Implemented clipboard register management, parsing, and execution rules for CUT, COPY, and PASTE operations in the hashline engine. - Added session-persistent clipboard state and integration across agent session execution, diff previews, and streaming tools. - Added comprehensive validation, error messages, recovery handling, and test coverage for clipboard and block operations.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Added
|
||||
|
||||
- Added server-name autocomplete for `/mcp` commands (`enable`, `disable`, `test`, `remove`, `reconnect`, `reauth`, `unauth`) using configured and runtime-discovered MCP servers.
|
||||
- Added `CUT`/`COPY`/`PASTE` ops to the hashline edit tool for moving code without retyping it: `CUT N.=M` / `COPY N.=M` (and `.BLK` block forms) capture lines into a clipboard register, `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` / `PASTE.BLK.POST N` insert them. The register flows across sections within a patch (cross-file moves) and persists across edit calls per session; streaming and permission diff previews render pasted content, and copy-only sections report "copied to clipboard" instead of tripping the no-change guard.
|
||||
- Added `--from-claude` and `--from-codex` session imports (including compaction state for Codex), also available from `/resume @claude` and `/resume @codex`.
|
||||
- Added interactive Exa API-key onboarding through `/login exa`, opening the official key dashboard and saving pasted keys for authenticated web search while preserving `EXA_API_KEY` and explicit-selection public MCP fallback behavior ([#1798](https://github.com/can1357/oh-my-pi/issues/1798)).
|
||||
- Added `ExtensionContext.getAsyncJobSnapshot()` so extensions can read the owning session's async-job state without relying on process-global job-manager identity
|
||||
@@ -23,6 +24,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Anthropic prompt-cache cold misses on session resume with multiple OAuth accounts: the account that served a session is now recorded in the session file (as a `credential_pin` sha-256 of the account + org/project scope, so exports carry no plaintext identity) and re-pinned on resume with the session's effective last-use time, so a fresh process no longer re-ranks accounts by usage headroom — which systematically routed away from the just-used account and cold-missed the entire account-scoped cache prefix. Sticky routing was previously stored only in the auth store's KV cache, which is in-memory when a remote auth broker is configured.
|
||||
- Fixed Anthropic prompt-cache cold misses on session resume with multiple OAuth accounts: the account that served a session is now recorded in the session file (as a PII-free `credential_pin` hash) and re-pinned on resume, so a fresh process no longer re-ranks accounts by usage headroom — which systematically routed away from the just-used account and cold-missed the entire account-scoped cache prefix. Sticky routing was previously stored only in the auth store's KV cache, which is in-memory when a remote auth broker is configured.
|
||||
- Fixed concurrent `createAgentSession` calls with the default agent id failing initialization with `Agent "Main" was replaced during session initialization` — each in-process embedder (e.g. the edit benchmark runner) can now pass a private registry via the newly exported `AgentRegistry`, keeping every top-level session's "Main" out of the process-global roster race.
|
||||
- Fixed task tool blocks duplicating their per-agent progress rows into terminal scrollback on every update: live task frames now pin the transcript live region so mid-run rows are never recorded as frozen snapshots, and a detached background task freezes its progress the moment any of its rows commit to scrollback instead of mutating committed history.
|
||||
- Fixed Codex reset fireworks comparing different quota tiers or plans, preventing false celebrations when usage reports switch between Spark and base weekly limits.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Session-bound edit clipboard register.
|
||||
*
|
||||
* `CUT`/`COPY`/`PASTE` hashline ops share one {@link Clipboard} per session so
|
||||
* content captured by one edit call can be pasted by a later one (and across
|
||||
* files within one call). The executor works on a fork per batch and commits
|
||||
* it back only after every write lands — see `executeHashlineSingle`.
|
||||
*/
|
||||
import type { Clipboard } from "@oh-my-pi/hashline";
|
||||
|
||||
interface EditClipboardOwner {
|
||||
editClipboard?: Clipboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up (or lazily create) the clipboard register attached to a session.
|
||||
* Storage lives on `session.editClipboard` so it ages out exactly with the
|
||||
* session itself.
|
||||
*/
|
||||
export function getEditClipboard(session: EditClipboardOwner): Clipboard {
|
||||
session.editClipboard ??= {};
|
||||
return session.editClipboard;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import * as path from "node:path";
|
||||
import {
|
||||
type ApplyResult,
|
||||
applyEdits,
|
||||
type Clipboard,
|
||||
type Cursor,
|
||||
computeFileHash,
|
||||
type Edit,
|
||||
@@ -26,8 +27,10 @@ import {
|
||||
parsePatchStreaming,
|
||||
Recovery,
|
||||
resolveBlockEdits,
|
||||
resolveClipboardEdits,
|
||||
type SnapshotStore,
|
||||
stripBom,
|
||||
validateClipboardSequence,
|
||||
} from "@oh-my-pi/hashline";
|
||||
import { resolveToCwd } from "../../tools/path-utils";
|
||||
import { generateDiffString } from "../diff";
|
||||
@@ -48,6 +51,15 @@ export interface HashlineDiffOptions {
|
||||
* authoring input; the final apply path still validates through Patcher.
|
||||
*/
|
||||
skipHashValidation?: boolean;
|
||||
/**
|
||||
* Clipboard register shared across the sections of one patch preview.
|
||||
* `CUT`/`COPY` in an earlier section feeds `PASTE` in a later one, so the
|
||||
* preview a user approves shows the pasted content exactly like apply
|
||||
* will. Callers previewing a multi-section patch MUST thread one register
|
||||
* through the sections in patch order; omitted, each section gets a
|
||||
* private register (same-file cut/paste still previews correctly).
|
||||
*/
|
||||
clipboard?: Clipboard;
|
||||
}
|
||||
|
||||
async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
|
||||
@@ -144,6 +156,7 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
||||
return edits.some(edit => {
|
||||
if (edit.kind === "delete") return true;
|
||||
if (edit.kind === "block") return true;
|
||||
if (edit.kind === "copy") return true;
|
||||
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
||||
});
|
||||
}
|
||||
@@ -204,14 +217,24 @@ function applyPreviewEdits(args: {
|
||||
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
|
||||
const edits = parsePreviewEdits(section, options.streaming);
|
||||
const resolved = resolvePreviewEdits({ section, absolutePath, normalized, snapshots, expected, liveMatches, edits });
|
||||
if (options.skipHashValidation || expected === undefined || liveMatches) return applyEdits(normalized, resolved);
|
||||
if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved);
|
||||
const clipboard = options.clipboard ?? {};
|
||||
// Mirror the Patcher: surface clipboard sequencing mistakes with their
|
||||
// targeted message before the recovery path below swallows them. Streaming
|
||||
// previews stay lenient — a mid-typed op transiently violating sequencing
|
||||
// must not flash an error frame.
|
||||
if (!options.streaming) validateClipboardSequence(resolved, clipboard);
|
||||
const applyOptions = { clipboard, ...(options.streaming ? { onEmptyPaste: "drop" as const } : {}) };
|
||||
if (options.skipHashValidation || expected === undefined || liveMatches) {
|
||||
return applyEdits(normalized, resolved, applyOptions);
|
||||
}
|
||||
if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved, applyOptions);
|
||||
|
||||
const recovered = new Recovery(snapshots).tryRecover({
|
||||
path: absolutePath,
|
||||
currentText: normalized,
|
||||
fileHash: expected,
|
||||
edits: resolved,
|
||||
clipboard,
|
||||
});
|
||||
if (recovered) return recovered;
|
||||
throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
|
||||
@@ -250,18 +273,40 @@ function insertCursorLine(cursor: Cursor, fileLineCount: number): number {
|
||||
function buildStreamingSectionDiff(
|
||||
section: PatchSection,
|
||||
normalized: string,
|
||||
clipboard: Clipboard,
|
||||
): { diff: string; firstChangedLine: number | undefined } | { error: string } {
|
||||
const { edits, fileOp } = parsePatchStreaming(section.diff);
|
||||
const resolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, { onUnresolved: "drop" });
|
||||
const blockResolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, {
|
||||
onUnresolved: "drop",
|
||||
});
|
||||
const fileLines = normalized.split("\n");
|
||||
// Expand clipboard ops so a `PASTE` previews the moved rows. Resolve into
|
||||
// a scratch register and commit it back only on success: a mid-typed
|
||||
// capture (out-of-range while the range digits are still streaming,
|
||||
// transient sequencing violations) throws AFTER earlier captures may have
|
||||
// landed, and leaking those into the shared register would let a later
|
||||
// section preview a paste that never happened. On failure, fall back to
|
||||
// filtering the clipboard edits out of this section's frame.
|
||||
const scratch: Clipboard = { ...clipboard };
|
||||
let resolved: readonly Edit[];
|
||||
try {
|
||||
resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" });
|
||||
if (scratch.lines === undefined) delete clipboard.lines;
|
||||
else clipboard.lines = scratch.lines;
|
||||
if (scratch.pendingCut === undefined) delete clipboard.pendingCut;
|
||||
else clipboard.pendingCut = scratch.pendingCut;
|
||||
} catch {
|
||||
resolved = blockResolved.filter(edit => edit.kind !== "copy" && edit.kind !== "paste");
|
||||
}
|
||||
if (resolved.length === 0) {
|
||||
// A whole-file op (REM / MV) carries no line edits: the change is the
|
||||
// delete/move itself, conveyed by the result header, so emit an empty
|
||||
// diff rather than a misleading "No changes" error.
|
||||
if (fileOp) return { diff: "", firstChangedLine: undefined };
|
||||
// diff rather than a misleading "No changes" error. A copy-only section
|
||||
// likewise changes nothing itself — it feeds a later `PASTE`.
|
||||
if (fileOp || section.isClipboardSource) return { diff: "", firstChangedLine: undefined };
|
||||
return { error: `No changes would be made to ${section.path}.` };
|
||||
}
|
||||
|
||||
const fileLines = normalized.split("\n");
|
||||
const rows: string[] = [];
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
@@ -299,7 +344,10 @@ function buildStreamingSectionDiff(
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) return { error: `No changes would be made to ${section.path}.` };
|
||||
if (rows.length === 0) {
|
||||
if (section.isClipboardSource) return { diff: "", firstChangedLine: undefined };
|
||||
return { error: `No changes would be made to ${section.path}.` };
|
||||
}
|
||||
return { diff: rows.join("\n"), firstChangedLine };
|
||||
}
|
||||
|
||||
@@ -323,12 +371,13 @@ export async function computeHashlineSectionDiff(
|
||||
// diff: feed the in-flight ops through the natural-order builder so the
|
||||
// streamed cursor stays pinned to the bottom. The args-complete pass
|
||||
// (`streaming` unset) falls through to the real Myers diff below.
|
||||
if (options.streaming) return buildStreamingSectionDiff(section, normalized);
|
||||
if (options.streaming) return buildStreamingSectionDiff(section, normalized, options.clipboard ?? {});
|
||||
const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
|
||||
if (normalized === result.text) {
|
||||
// REM/MV-only sections change no text; the header conveys the
|
||||
// delete/move, so don't surface a "No changes" error.
|
||||
if (section.fileOp) return { diff: "", firstChangedLine: undefined };
|
||||
// delete/move, so don't surface a "No changes" error. A copy-only
|
||||
// section likewise changes nothing itself — it feeds a later `PASTE`.
|
||||
if (section.fileOp || section.isClipboardSource) return { diff: "", firstChangedLine: undefined };
|
||||
return { error: `No changes would be made to ${section.path}.` };
|
||||
}
|
||||
return generateDiffString(normalized, result.text, undefined, { path: section.path });
|
||||
|
||||
@@ -13,11 +13,15 @@
|
||||
import {
|
||||
type BlockResolution,
|
||||
buildCompactDiffPreview,
|
||||
type Clipboard,
|
||||
commitClipboard,
|
||||
forkClipboard,
|
||||
MismatchError as HashlineMismatchError,
|
||||
Patch,
|
||||
Patcher,
|
||||
type PatchSectionResult,
|
||||
type PreparedSection,
|
||||
pendingCutWarning,
|
||||
} from "@oh-my-pi/hashline";
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
||||
@@ -25,6 +29,7 @@ import type { ToolSession } from "../../tools";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { generateDiffString } from "../diff";
|
||||
import { getEditClipboard } from "../edit-clipboard";
|
||||
import { getFileSnapshotStore } from "../file-snapshot-store";
|
||||
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
|
||||
import { pruneOversizedEditSnapshots } from "../snapshot-details";
|
||||
@@ -98,12 +103,26 @@ interface RenderedSection {
|
||||
perFileResult: EditToolPerFileResult;
|
||||
}
|
||||
|
||||
const BLOCK_OP_LABELS: Record<BlockResolution["op"], string> = {
|
||||
replace: "SWAP.BLK",
|
||||
delete: "DEL.BLK",
|
||||
insert_after: "INS.BLK.POST",
|
||||
copy: "COPY.BLK",
|
||||
cut: "CUT.BLK",
|
||||
paste_after: "PASTE.BLK.POST",
|
||||
};
|
||||
|
||||
function formatBlockResolution(resolution: BlockResolution): string {
|
||||
const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "SWAP.BLK";
|
||||
const op = BLOCK_OP_LABELS[resolution.op];
|
||||
const lines = resolution.end - resolution.start + 1;
|
||||
const span =
|
||||
resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
|
||||
const suffix = resolution.op === "insert_after" ? `; body lands after line ${resolution.end}` : "";
|
||||
const suffix =
|
||||
resolution.op === "insert_after"
|
||||
? `; body lands after line ${resolution.end}`
|
||||
: resolution.op === "paste_after"
|
||||
? `; clipboard lands after line ${resolution.end}`
|
||||
: "";
|
||||
return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})${suffix}`;
|
||||
}
|
||||
|
||||
@@ -194,6 +213,25 @@ function renderSection(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a copy-only (`COPY`/`COPY.BLK`) section: it filled the clipboard for
|
||||
* a later `PASTE` and intentionally left its own file unchanged, so the
|
||||
* standard "no changes" diagnostics do not apply. The fresh header still
|
||||
* re-mints the snapshot tag for follow-up edits.
|
||||
*/
|
||||
function clipboardSourceResult(result: PatchSectionResult): RenderedSection {
|
||||
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
||||
return {
|
||||
toolResult: {
|
||||
content: [
|
||||
{ type: "text", text: `${result.header}\nCopied lines to the clipboard; file unchanged.${warningsBlock}` },
|
||||
],
|
||||
details: { diff: "", op: "update", meta: outputMeta().get() },
|
||||
},
|
||||
perFileResult: { path: result.path, diff: "", op: "update" },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeHashlineSingle(
|
||||
options: ExecuteHashlineSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
||||
@@ -213,13 +251,25 @@ export async function executeHashlineSingle(
|
||||
const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines");
|
||||
const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines });
|
||||
|
||||
// The clipboard register is session-persistent: `CUT` in one edit call can
|
||||
// `PASTE` in a later one. Each batch works on a fork and publishes it back
|
||||
// only after every write lands, so a failed batch never poisons the
|
||||
// register (a retried `CUT` would otherwise trip the overwrite guard).
|
||||
const sessionClipboard = getEditClipboard(options.session);
|
||||
const clipboard = forkClipboard(sessionClipboard);
|
||||
|
||||
// Single-section fast path: prepare, commit, render.
|
||||
const inputHash = hashPatchInput(options.input);
|
||||
if (patch.sections.length === 1) {
|
||||
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
|
||||
const prepared = await patcher.prepare(patch.sections[0]);
|
||||
const prepared = await patcher.prepare(patch.sections[0], clipboard);
|
||||
const sectionResult = await patcher.commit(prepared);
|
||||
commitClipboard(clipboard, sessionClipboard);
|
||||
if (clipboard.pendingCut !== undefined) sectionResult.warnings.push(pendingCutWarning(clipboard.pendingCut));
|
||||
if (sectionResult.op === "noop") {
|
||||
if (prepared.section.isClipboardSource) {
|
||||
return clipboardSourceResult(sectionResult).toolResult;
|
||||
}
|
||||
const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
|
||||
if (escalate) {
|
||||
throw new ToolError(noChangeLoopDiagnostic(sectionResult.path, count));
|
||||
@@ -231,12 +281,23 @@ export async function executeHashlineSingle(
|
||||
}
|
||||
|
||||
// Multi-section: prepare every section up front so we fail fast before
|
||||
// any write hits the filesystem.
|
||||
// any write hits the filesystem. One clipboard register spans the batch,
|
||||
// so `CUT`/`COPY` in one section feeds `PASTE` in a later one.
|
||||
const prepared: PreparedSection[] = [];
|
||||
for (const section of patch.sections) prepared.push(await patcher.prepare(section));
|
||||
// Register state after each section's prepare. Commits are non-atomic: a
|
||||
// mid-batch write failure leaves earlier sections on disk, so the session
|
||||
// register must reflect exactly the landed prefix — content a landed CUT
|
||||
// deleted would otherwise be lost.
|
||||
const sectionStates: Clipboard[] = [];
|
||||
for (const section of patch.sections) {
|
||||
prepared.push(await patcher.prepare(section, clipboard));
|
||||
sectionStates.push(forkClipboard(clipboard));
|
||||
}
|
||||
assertUniqueCanonicalPaths(prepared);
|
||||
for (const entry of prepared) {
|
||||
if (entry.isNoop) {
|
||||
// A copy-only section legitimately changes nothing: it exists to fill
|
||||
// the clipboard for a `PASTE` in a later section.
|
||||
if (entry.isNoop && !entry.section.isClipboardSource) {
|
||||
const { count, escalate } = recordNoopEdit(options.session, entry.canonicalPath, inputHash);
|
||||
throw escalate
|
||||
? new ToolError(noChangeLoopDiagnostic(entry.section.path, count))
|
||||
@@ -251,7 +312,12 @@ export async function executeHashlineSingle(
|
||||
const isLast = i === prepared.length - 1;
|
||||
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
|
||||
const sectionResult = await patcher.commit(prepared[i]);
|
||||
commitClipboard(sectionStates[i], sessionClipboard);
|
||||
if (sectionResult.op === "noop") {
|
||||
if (prepared[i].section.isClipboardSource) {
|
||||
rendered.push(clipboardSourceResult(sectionResult));
|
||||
continue;
|
||||
}
|
||||
const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
|
||||
throw escalate
|
||||
? new ToolError(noChangeLoopDiagnostic(sectionResult.path, count))
|
||||
@@ -260,14 +326,18 @@ export async function executeHashlineSingle(
|
||||
resetNoopEdit(options.session, sectionResult.canonicalPath);
|
||||
rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared[i].section.path));
|
||||
}
|
||||
// Every write landed; carry any still-pending cut forward as a warning.
|
||||
const pendingCutNote =
|
||||
clipboard.pendingCut === undefined ? "" : `\n\nWarnings:\n${pendingCutWarning(clipboard.pendingCut)}`;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: rendered
|
||||
.map(r => r.toolResult.content.map(part => (part.type === "text" ? part.text : "")).join("\n"))
|
||||
.join("\n\n"),
|
||||
text:
|
||||
rendered
|
||||
.map(r => r.toolResult.content.map(part => (part.type === "text" ? part.text : "")).join("\n"))
|
||||
.join("\n\n") + pendingCutNote,
|
||||
},
|
||||
],
|
||||
details: pruneOversizedEditSnapshots({
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
import {
|
||||
ABORT_MARKER,
|
||||
BEGIN_PATCH_MARKER,
|
||||
type Clipboard,
|
||||
containsRecognizableHashlineOperations,
|
||||
END_PATCH_MARKER,
|
||||
forkClipboard,
|
||||
type PatchSection as HashlineInputSection,
|
||||
Patch as HashlinePatch,
|
||||
type SnapshotStore,
|
||||
@@ -50,6 +52,13 @@ export interface StreamingDiffContext {
|
||||
* not flicker in the preview.
|
||||
*/
|
||||
isStreaming?: boolean;
|
||||
/**
|
||||
* Session-persistent clipboard register (`CUT`/`COPY`/`PASTE`). Previews
|
||||
* fork it per frame — never mutating it — so a `PASTE` of content cut in
|
||||
* an earlier edit call (or an earlier section of this patch) renders the
|
||||
* real rows.
|
||||
*/
|
||||
clipboard?: Clipboard;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,7 +551,9 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
// to parse; suppress until the next chunk arrives. Once args are
|
||||
// complete, surface the error so the model sees what went wrong.
|
||||
if (ctx.isStreaming) return null;
|
||||
const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots);
|
||||
const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots, {
|
||||
clipboard: forkClipboard(ctx.clipboard),
|
||||
});
|
||||
ctx.signal.throwIfAborted();
|
||||
return [toPerFilePreview("", result)];
|
||||
}
|
||||
@@ -558,12 +569,16 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
const trailingProcessedIndex = sectionsToProcess.length - 1;
|
||||
|
||||
const previews: PerFileDiffPreview[] = [];
|
||||
// Fork the session register per preview frame: sections feed each other
|
||||
// in patch order, but a preview must never mutate the live register.
|
||||
const clipboard = forkClipboard(ctx.clipboard);
|
||||
for (let i = 0; i < sectionsToProcess.length; i++) {
|
||||
ctx.signal.throwIfAborted();
|
||||
const section = sectionsToProcess[i];
|
||||
const result = await computeHashlineSectionDiff(section, ctx.cwd, ctx.snapshots, {
|
||||
streaming: ctx.isStreaming,
|
||||
skipHashValidation: ctx.isStreaming === true,
|
||||
clipboard,
|
||||
});
|
||||
ctx.signal.throwIfAborted();
|
||||
// Ignore parse/apply errors from the trailing (actively-typed)
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
}
|
||||
|
||||
// Apply filter mode
|
||||
const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init'].includes(entry.type);
|
||||
const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init', 'credential_pin'].includes(entry.type);
|
||||
let passesFilter = true;
|
||||
|
||||
switch (filterMode) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SnapshotStore } from "@oh-my-pi/hashline";
|
||||
import type { Clipboard, SnapshotStore } from "@oh-my-pi/hashline";
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import {
|
||||
Box,
|
||||
@@ -222,6 +222,8 @@ export interface ToolExecutionUi {
|
||||
|
||||
export interface ToolExecutionOptions {
|
||||
snapshots?: SnapshotStore;
|
||||
/** Session-persistent edit clipboard register, forked per preview frame. */
|
||||
clipboard?: Clipboard;
|
||||
showImages?: boolean; // default: true (only used if terminal supports images)
|
||||
editFuzzyThreshold?: number;
|
||||
editAllowFuzzy?: boolean;
|
||||
@@ -285,6 +287,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
#editFuzzyThreshold: number | undefined;
|
||||
#editAllowFuzzy: boolean | undefined;
|
||||
#snapshots?: SnapshotStore;
|
||||
#clipboard?: Clipboard;
|
||||
#isPartial = true;
|
||||
#resultVersion = 0;
|
||||
#lastDisplayKey: string | undefined;
|
||||
@@ -393,6 +396,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
this.#editFuzzyThreshold = options.editFuzzyThreshold;
|
||||
this.#editAllowFuzzy = options.editAllowFuzzy;
|
||||
this.#snapshots = options.snapshots;
|
||||
this.#clipboard = options.clipboard;
|
||||
this.#liveRegion = options.liveRegion;
|
||||
this.#tool = tool;
|
||||
this.#ui = ui;
|
||||
@@ -550,6 +554,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
cwd: this.#cwd,
|
||||
signal: controller.signal,
|
||||
snapshots: this.#snapshots!,
|
||||
clipboard: this.#clipboard,
|
||||
fuzzyThreshold: this.#editFuzzyThreshold,
|
||||
allowFuzzy: this.#editAllowFuzzy,
|
||||
isStreaming,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { logger, prompt, sanitizeText } from "@oh-my-pi/pi-utils";
|
||||
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
||||
import { extractTextContent } from "../../commit/utils";
|
||||
import { settings } from "../../config/settings";
|
||||
import { getEditClipboard } from "../../edit/edit-clipboard";
|
||||
import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
|
||||
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
||||
import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
|
||||
@@ -810,6 +811,7 @@ export class EventController {
|
||||
renderArgs,
|
||||
{
|
||||
snapshots: getFileSnapshotStore(this.ctx.viewSession),
|
||||
clipboard: getEditClipboard(this.ctx.viewSession),
|
||||
showImages: settings.get("terminal.showImages"),
|
||||
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
||||
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
||||
@@ -1033,6 +1035,7 @@ export class EventController {
|
||||
event.args,
|
||||
{
|
||||
snapshots: getFileSnapshotStore(this.ctx.viewSession),
|
||||
clipboard: getEditClipboard(this.ctx.viewSession),
|
||||
showImages: settings.get("terminal.showImages"),
|
||||
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
||||
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
|
||||
import type { AdvisorMessageDetails } from "../../advisor";
|
||||
import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
|
||||
import { settings } from "../../config/settings";
|
||||
import { getEditClipboard } from "../../edit/edit-clipboard";
|
||||
import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
|
||||
import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
|
||||
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
||||
@@ -497,6 +498,7 @@ export class UiHelpers {
|
||||
renderArgs,
|
||||
{
|
||||
snapshots: getFileSnapshotStore(this.ctx.viewSession),
|
||||
clipboard: getEditClipboard(this.ctx.viewSession),
|
||||
showImages: settings.get("terminal.showImages"),
|
||||
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
||||
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
||||
|
||||
@@ -19,7 +19,7 @@ import * as path from "node:path";
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { isPromise } from "node:util/types";
|
||||
|
||||
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import {
|
||||
type AfterToolCallContext,
|
||||
type AfterToolCallResult,
|
||||
@@ -410,6 +410,8 @@ export class AgentSession {
|
||||
getXdevToolEntries: () => Array<{ name: string; summary: string }>;
|
||||
readonly yieldQueue: YieldQueue;
|
||||
fileSnapshotStore?: InMemorySnapshotStore;
|
||||
/** Per-session `CUT`/`COPY`/`PASTE` clipboard register shared across edit calls. */
|
||||
editClipboard?: Clipboard;
|
||||
|
||||
#powerAssertion: MacOSPowerAssertion | undefined;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
@@ -351,6 +351,10 @@ export interface ToolSession {
|
||||
* file changed out-of-band. Lazily initialized by `getFileSnapshotStore`. */
|
||||
fileSnapshotStore?: InMemorySnapshotStore;
|
||||
|
||||
/** Per-session `CUT`/`COPY`/`PASTE` clipboard register shared across edit
|
||||
* calls. Lazily initialized by `getEditClipboard`. */
|
||||
editClipboard?: Clipboard;
|
||||
|
||||
/** Per-session log of unresolved git merge conflict regions surfaced by
|
||||
* `read`. Each entry gets a stable id N referenced by `write conflict://N`
|
||||
* to splice the recorded region with replacement content. Lazily initialized
|
||||
|
||||
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { formatHashlineHeader, InMemorySnapshotStore, missingSnapshotTagMessage } from "@oh-my-pi/hashline";
|
||||
import {
|
||||
forkClipboard,
|
||||
formatHashlineHeader,
|
||||
InMemorySnapshotStore,
|
||||
missingSnapshotTagMessage,
|
||||
} from "@oh-my-pi/hashline";
|
||||
import {
|
||||
adjustIndentation,
|
||||
computeEditDiff,
|
||||
@@ -268,6 +273,68 @@ describe("computeHashlineDiff", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("previews a same-file CUT + PASTE as the moved content", async () => {
|
||||
const sourcePath = path.join(tempDir, "source.txt");
|
||||
const text = "l1\nl2\nl3\n";
|
||||
await Bun.write(sourcePath, text);
|
||||
|
||||
const snapshotStore = new InMemorySnapshotStore();
|
||||
const tag = snapshotStore.record(sourcePath, text);
|
||||
const result = await computeHashlineDiff(
|
||||
{ input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 1.=1\nPASTE.TAIL` },
|
||||
tempDir,
|
||||
snapshotStore,
|
||||
);
|
||||
expect("diff" in result).toBe(true);
|
||||
if ("diff" in result) {
|
||||
expect(result.diff).toContain("-1|l1");
|
||||
expect(result.diff).toContain("+3|l1");
|
||||
}
|
||||
});
|
||||
|
||||
test("previews a PASTE from a forked session register without touching the source", async () => {
|
||||
const sourcePath = path.join(tempDir, "source.txt");
|
||||
const text = "l1\n";
|
||||
await Bun.write(sourcePath, text);
|
||||
|
||||
const snapshotStore = new InMemorySnapshotStore();
|
||||
const tag = snapshotStore.record(sourcePath, text);
|
||||
// Mirror the streaming-strategy contract: the session register is forked
|
||||
// once per preview frame; sections then thread the fork in patch order.
|
||||
const sessionRegister = { lines: ["carried"], pendingCut: "CUT 5" };
|
||||
const result = await computeHashlineDiff(
|
||||
{ input: `${formatHashlineHeader(sourcePath, tag)}\nPASTE.TAIL` },
|
||||
tempDir,
|
||||
snapshotStore,
|
||||
{ clipboard: forkClipboard(sessionRegister) },
|
||||
);
|
||||
expect("diff" in result).toBe(true);
|
||||
if ("diff" in result) {
|
||||
expect(result.diff).toContain("|carried");
|
||||
}
|
||||
expect(sessionRegister.pendingCut).toBe("CUT 5");
|
||||
});
|
||||
|
||||
test("streams paste rows in the natural-order preview", async () => {
|
||||
const sourcePath = path.join(tempDir, "source.txt");
|
||||
const text = "l1\nl2\nl3\n";
|
||||
await Bun.write(sourcePath, text);
|
||||
|
||||
const snapshotStore = new InMemorySnapshotStore();
|
||||
const tag = snapshotStore.record(sourcePath, text);
|
||||
const result = await computeHashlineDiff(
|
||||
{ input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 2.=2\nPASTE.HEAD` },
|
||||
tempDir,
|
||||
snapshotStore,
|
||||
{ streaming: true, skipHashValidation: true },
|
||||
);
|
||||
expect("diff" in result).toBe(true);
|
||||
if ("diff" in result) {
|
||||
expect(result.diff).toContain("-2|l2");
|
||||
expect(result.diff).toContain("+1|l2");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a tagless head/tail insert in the preview path, matching apply", async () => {
|
||||
const relativePath = "source.txt";
|
||||
await Bun.write(path.join(tempDir, relativePath), "first\n");
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added clipboard ops: `CUT N.=M` / `COPY N.=M` capture lines into a register (`CUT` also deletes them), `CUT.BLK N` / `COPY.BLK N` capture tree-sitter blocks, and `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` / `PASTE.BLK.POST N` insert the captured lines without retyping. The register flows top-to-bottom across sections, so content moves between files in one patch; `PASTE` does not consume it and the last capture wins.
|
||||
- Added `PatcherOptions.clipboard` for a host-owned register that persists across `Patcher.apply` batches. Batches work on a fork (`forkClipboard`) published per landed section (`commitClipboard`), so failed batches never poison the register and a mid-batch write failure still preserves content already cut from disk; un-pasted `CUT` content becomes a carry-forward warning instead of an error.
|
||||
- Added clipboard safety guards: a `PASTE` with an empty register, a capture overwriting un-pasted `CUT` content, a batch-local `CUT` that is never pasted, and clipboard ops in same-path sections interleaved across another file's section are all rejected with targeted diagnostics. `CUT`/`COPY` ranges participate in overlap validation, the seen-lines guard, and drift recovery (every captured line must remap).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented CPU and memory exhaustion in streaming previews by rejecting line anchors above Number.MAX_SAFE_INTEGER and ranges spanning more than 100,000 lines.
|
||||
|
||||
@@ -52,6 +52,9 @@ Inside a section:
|
||||
- `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
|
||||
- `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
|
||||
- `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
|
||||
- `CUT A.=B` / `COPY A.=B` — capture lines into the clipboard register (`CUT` also deletes them).
|
||||
- `CUT.BLK A` / `COPY.BLK A` — capture the resolved block (`CUT.BLK` also deletes it).
|
||||
- `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`.
|
||||
- `REM` — delete the whole file named by the section header.
|
||||
- `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
|
||||
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* which absorbs common model mistakes where a payload restates unchanged range
|
||||
* boundaries or duplicates/drops structural closers.
|
||||
*/
|
||||
|
||||
import { resolveClipboardEdits } from "./clipboard";
|
||||
import {
|
||||
afterInsertLandingShiftWarning,
|
||||
ambiguousBoundaryEchoMessage,
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
UNRESOLVED_BLOCK_INTERNAL,
|
||||
} from "./messages";
|
||||
import { cloneCursor } from "./tokenizer";
|
||||
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
|
||||
import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
|
||||
|
||||
type LineOrigin = "original" | "insert" | "replacement";
|
||||
|
||||
@@ -1221,24 +1223,44 @@ function repairAfterInsertLandings(
|
||||
return { edits: out ?? edits, warnings };
|
||||
}
|
||||
|
||||
/** Optional knobs for {@link applyEdits}. */
|
||||
export interface ApplyEditsOptions {
|
||||
/**
|
||||
* Clipboard register filled by `copy` edits and read by `paste` edits.
|
||||
* Thread one register through every section of a batch to move content
|
||||
* across files; when omitted, the call gets a private register (same-file
|
||||
* cut/paste still works).
|
||||
*/
|
||||
clipboard?: Clipboard;
|
||||
/** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
||||
onEmptyPaste?: "throw" | "drop";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
||||
*
|
||||
* Returns the post-edit text and the first changed line number (1-indexed).
|
||||
* Throws if an anchor is out of bounds.
|
||||
*/
|
||||
export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
||||
export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
|
||||
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
||||
|
||||
const fileLines = text.split("\n");
|
||||
|
||||
// Clipboard pre-pass: capture `copy` ranges from the original lines and
|
||||
// expand `paste` edits into plain inserts, in authored order.
|
||||
const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
|
||||
...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
|
||||
});
|
||||
|
||||
// Block edits are deferred until `resolveBlockEdits` expands them into
|
||||
// concrete inserts + deletes. Reaching the applier with one still present
|
||||
// is an internal wiring bug, not authored-input error.
|
||||
for (const edit of edits) {
|
||||
for (const edit of concrete) {
|
||||
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
||||
}
|
||||
const appliedEdits = edits as readonly AppliedEdit[];
|
||||
const appliedEdits = concrete as readonly AppliedEdit[];
|
||||
|
||||
const fileLines = text.split("\n");
|
||||
const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
|
||||
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
@@ -16,10 +16,13 @@ import { STRUCTURAL_CLOSER_RE } from "./apply";
|
||||
import {
|
||||
BLOCK_RESOLVER_UNAVAILABLE,
|
||||
type BlockDiagnosticSuggestions,
|
||||
type BlockOp,
|
||||
blockSingleLineMessage,
|
||||
blockUnresolvedMessage,
|
||||
insertAfterBlockCloserLoweredWarning,
|
||||
insertAfterBlockUnresolvedLoweredWarning,
|
||||
pasteAfterBlockCloserLoweredWarning,
|
||||
pasteAfterBlockUnresolvedLoweredWarning,
|
||||
} from "./messages";
|
||||
import type { BlockResolution, BlockResolver, BlockSpan, Cursor, Edit } from "./types";
|
||||
|
||||
@@ -124,7 +127,7 @@ export function resolveBlockEdits(
|
||||
resolved.push(edit);
|
||||
continue;
|
||||
}
|
||||
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
|
||||
const op: BlockOp = edit.mode ?? (edit.payloads.length === 0 ? "delete" : "replace");
|
||||
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
||||
if (span === null) {
|
||||
// `insert_after_block N:` never fails the patch — lower it to plain
|
||||
@@ -135,9 +138,19 @@ export function resolveBlockEdits(
|
||||
// - otherwise (unsupported language, blank line, unparsable block,
|
||||
// or no resolver wired): "after the block at N" degrades to
|
||||
// "after line N" — warn to verify the landing line.
|
||||
if (op === "insert_after") {
|
||||
if (op === "insert_after" || op === "paste_after") {
|
||||
const anchorText = text.split("\n")[edit.anchor.line - 1];
|
||||
const isCloser = anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText);
|
||||
if (op === "paste_after") {
|
||||
options.onWarning?.(
|
||||
isCloser
|
||||
? pasteAfterBlockCloserLoweredWarning(edit.anchor.line)
|
||||
: pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
|
||||
);
|
||||
const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
|
||||
resolved.push({ kind: "paste", cursor, lineNum: edit.lineNum, index: synthIndex++ });
|
||||
continue;
|
||||
}
|
||||
options.onWarning?.(
|
||||
isCloser
|
||||
? insertAfterBlockCloserLoweredWarning(edit.anchor.line)
|
||||
@@ -183,6 +196,36 @@ export function resolveBlockEdits(
|
||||
end: span.end,
|
||||
op,
|
||||
});
|
||||
if (op === "paste_after") {
|
||||
// Mirror the block-lowered insert: paste after the block's last
|
||||
// line, tagging `blockStart` so landing correction can slide a body
|
||||
// claiming a depth inside the block back across its trailing closers.
|
||||
resolved.push({
|
||||
kind: "paste",
|
||||
cursor: { kind: "after_anchor", anchor: { line: span.end } },
|
||||
lineNum: edit.lineNum,
|
||||
index: synthIndex++,
|
||||
blockStart: span.start,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (op === "copy" || op === "cut") {
|
||||
// Capture the resolved span; a cut also deletes it line-by-line,
|
||||
// exactly like the parser's lowering of `CUT N.=M`.
|
||||
resolved.push({
|
||||
kind: "copy",
|
||||
range: { start: { line: span.start }, end: { line: span.end } },
|
||||
cut: op === "cut",
|
||||
lineNum: edit.lineNum,
|
||||
index: synthIndex++,
|
||||
});
|
||||
if (op === "cut") {
|
||||
for (let line = span.start; line <= span.end; line++) {
|
||||
resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (op === "insert_after") {
|
||||
// Mirror the parser's `insert after N:` lowering: one `after_anchor`
|
||||
// insert per payload row, anchored on the block's last line. The
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Clipboard register support for `CUT` / `COPY` / `PASTE` ops.
|
||||
*
|
||||
* `CUT N.=M` / `COPY N.=M` (and their `.BLK` block-resolved forms) capture the
|
||||
* range's current lines into a {@link Clipboard} register — cut additionally
|
||||
* lowers to ordinary per-line deletes at parse/resolve time. `PASTE.PRE|POST N`,
|
||||
* `PASTE.HEAD|TAIL`, and `PASTE.BLK.POST N` insert the captured lines at a
|
||||
* cursor. The register is threaded through a whole patch application in source
|
||||
* order — across sections — so content can move between files without being
|
||||
* retyped: the last capture wins, and a `PASTE` does not consume the register,
|
||||
* so the same content can land in several places.
|
||||
*
|
||||
* {@link resolveClipboardEdits} is the applier's pre-pass: it runs inside
|
||||
* {@link applyEdits} against the exact text the edits apply to (after block
|
||||
* resolution and recovery remapping), captures `copy` edits, and expands
|
||||
* `paste` edits into plain inserts.
|
||||
*/
|
||||
import { HL_COPY_KEYWORD, HL_CUT_KEYWORD, HL_RANGE_SEP } from "./format";
|
||||
import { EMPTY_PASTE, overwrittenCutMessage, unusedCutMessage } from "./messages";
|
||||
import { cloneCursor } from "./tokenizer";
|
||||
import type { Clipboard, Edit } from "./types";
|
||||
|
||||
type CopyEdit = Extract<Edit, { kind: "copy" }>;
|
||||
|
||||
/** `CUT 5.=10` / `COPY 5` display form for diagnostics. */
|
||||
function describeCopyEdit(edit: CopyEdit): string {
|
||||
const { start, end } = edit.range;
|
||||
const range = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`;
|
||||
return `${edit.cut ? HL_CUT_KEYWORD : HL_COPY_KEYWORD} ${range}`;
|
||||
}
|
||||
|
||||
/** True when at least one edit reads or writes the clipboard register (including unresolved `.BLK` forms). */
|
||||
export function hasClipboardEdit(edits: readonly Edit[]): boolean {
|
||||
return edits.some(
|
||||
edit =>
|
||||
edit.kind === "copy" ||
|
||||
edit.kind === "paste" ||
|
||||
(edit.kind === "block" && (edit.mode === "copy" || edit.mode === "cut" || edit.mode === "paste_after")),
|
||||
);
|
||||
}
|
||||
/** Optional knobs for {@link resolveClipboardEdits}. */
|
||||
export interface ResolveClipboardEditsOptions {
|
||||
/** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
|
||||
onEmptyPaste?: "throw" | "drop";
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand clipboard edits against `fileLines` (the text the edit batch applies
|
||||
* to): `copy` edits capture their range into `clipboard` and emit nothing;
|
||||
* `paste` edits become one plain insert per captured line. Non-clipboard edits
|
||||
* pass through untouched, and the fast path returns the input unchanged when
|
||||
* there is nothing to resolve. Edits are processed in array order — the
|
||||
* authored patch order — so a `PASTE` uses the most recent capture above it.
|
||||
*
|
||||
* Throws on an out-of-range capture, a capture that would overwrite un-pasted
|
||||
* `CUT` content, and (unless `onEmptyPaste: "drop"`) a `PASTE` with an empty
|
||||
* register.
|
||||
*/
|
||||
export function resolveClipboardEdits(
|
||||
edits: readonly Edit[],
|
||||
fileLines: readonly string[],
|
||||
clipboard: Clipboard,
|
||||
options: ResolveClipboardEditsOptions = {},
|
||||
): readonly Edit[] {
|
||||
if (!hasClipboardEdit(edits)) return edits;
|
||||
const onEmptyPaste = options.onEmptyPaste ?? "throw";
|
||||
const resolved: Edit[] = [];
|
||||
let synthIndex = 0;
|
||||
for (const edit of edits) {
|
||||
if (edit.kind === "copy") {
|
||||
const { start, end } = edit.range;
|
||||
if (start.line < 1 || end.line > fileLines.length) {
|
||||
throw new Error(
|
||||
`line ${edit.lineNum}: \`${describeCopyEdit(edit)}\` is out of range (file has ${fileLines.length} lines).`,
|
||||
);
|
||||
}
|
||||
if (clipboard.pendingCut !== undefined) {
|
||||
throw new Error(overwrittenCutMessage(clipboard.pendingCut, edit.lineNum));
|
||||
}
|
||||
clipboard.lines = fileLines.slice(start.line - 1, end.line);
|
||||
if (edit.cut) clipboard.pendingCut = describeCopyEdit(edit);
|
||||
else delete clipboard.pendingCut;
|
||||
continue;
|
||||
}
|
||||
if (edit.kind === "paste") {
|
||||
const lines = clipboard.lines;
|
||||
if (lines === undefined) {
|
||||
if (onEmptyPaste === "drop") continue;
|
||||
throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
|
||||
}
|
||||
delete clipboard.pendingCut;
|
||||
for (const text of lines) {
|
||||
resolved.push({
|
||||
kind: "insert",
|
||||
cursor: cloneCursor(edit.cursor),
|
||||
text,
|
||||
lineNum: edit.lineNum,
|
||||
index: synthIndex++,
|
||||
...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
resolved.push(edit);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow working copy of a register, for transactional batches: resolve the
|
||||
* whole batch against the fork, then {@link commitClipboard} it back only
|
||||
* after the batch's writes actually land. A failed batch leaves the source
|
||||
* register untouched, so retries are never blocked by phantom captures and a
|
||||
* failed `PASTE` never clears a real pending cut.
|
||||
*/
|
||||
export function forkClipboard(source?: Clipboard): Clipboard {
|
||||
return source === undefined ? {} : { ...source };
|
||||
}
|
||||
|
||||
/** Publish a fork's state back to its source register. */
|
||||
export function commitClipboard(fork: Clipboard, target: Clipboard): void {
|
||||
if (fork.lines === undefined) delete target.lines;
|
||||
else target.lines = fork.lines;
|
||||
if (fork.pendingCut === undefined) delete target.pendingCut;
|
||||
else target.pendingCut = fork.pendingCut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw when the register still holds un-pasted `CUT` content. Called at the
|
||||
* end of a batch by whoever created the register: a cut that never landed
|
||||
* means the patch deleted lines it meant to move (the classic truncated-patch
|
||||
* failure), so it is rejected rather than silently applied as a delete.
|
||||
*/
|
||||
export function assertClipboardConsumed(clipboard: Clipboard): void {
|
||||
if (clipboard.pendingCut !== undefined) throw new Error(unusedCutMessage(clipboard.pendingCut));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate clipboard sequencing (a `PASTE` before any capture, a capture over
|
||||
* un-pasted `CUT` content) without mutating `clipboard` or touching file text.
|
||||
* The patcher runs this before its recovery path, where {@link applyEdits}
|
||||
* failures are swallowed and re-surfaced as tag-mismatch errors — sequencing
|
||||
* mistakes must keep their targeted message instead.
|
||||
*/
|
||||
export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
|
||||
let hasLines = clipboard.lines !== undefined;
|
||||
let pendingCut = clipboard.pendingCut;
|
||||
for (const edit of edits) {
|
||||
if (edit.kind === "copy") {
|
||||
if (pendingCut !== undefined) throw new Error(overwrittenCutMessage(pendingCut, edit.lineNum));
|
||||
hasLines = true;
|
||||
pendingCut = edit.cut ? describeCopyEdit(edit) : undefined;
|
||||
} else if (edit.kind === "paste") {
|
||||
if (!hasLines) throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
|
||||
pendingCut = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,18 @@ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
|
||||
export const HL_DELETE_BLOCK_KEYWORD = "DEL.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: `COPY N.=M` captures lines into the clipboard register. */
|
||||
export const HL_COPY_KEYWORD = "COPY";
|
||||
/** Hunk-header keyword: `CUT N.=M` captures lines into the clipboard register and deletes them. */
|
||||
export const HL_CUT_KEYWORD = "CUT";
|
||||
/** Hunk-header keyword: `COPY.BLK N` captures the tree-sitter block at N into the clipboard. */
|
||||
export const HL_COPY_BLOCK_KEYWORD = "COPY.BLK";
|
||||
/** Hunk-header keyword: `CUT.BLK N` captures the tree-sitter block at N and deletes its span. */
|
||||
export const HL_CUT_BLOCK_KEYWORD = "CUT.BLK";
|
||||
/** Hunk-header keyword prefix: `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` inserts the clipboard. */
|
||||
export const HL_PASTE_KEYWORD = "PASTE";
|
||||
/** Hunk-header keyword: `PASTE.BLK.POST N` inserts the clipboard after the tree-sitter block at N. */
|
||||
export const HL_PASTE_AFTER_BLOCK_KEYWORD = "PASTE.BLK.POST";
|
||||
/** File-level keyword: `REM` deletes the whole file named by the section header. */
|
||||
export const HL_REM_KEYWORD = "REM";
|
||||
/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
|
||||
|
||||
@@ -7,13 +7,19 @@ file_header: "[" filename "#" file_hash "]" LF
|
||||
file_hash: /[0-9A-F]{4}/
|
||||
filename: /[^#\r\n]+/
|
||||
|
||||
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk
|
||||
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | copy_hunk | copy_block_hunk | paste_hunk | paste_block_hunk | remove_hunk | move_hunk
|
||||
replace_hunk: replace_anchor LF emit_op*
|
||||
replace_block_hunk: replace_block_anchor LF emit_op+
|
||||
insert_hunk: insert_anchor LF emit_op+
|
||||
insert_block_hunk: insert_block_anchor LF emit_op+
|
||||
delete_hunk: "DEL " header_range LF
|
||||
delete_block_hunk: "DEL.BLK " LID LF
|
||||
copy_hunk: copy_verb " " header_range LF
|
||||
copy_block_hunk: copy_verb ".BLK " LID LF
|
||||
copy_verb: "CUT" | "COPY"
|
||||
paste_hunk: "PASTE." paste_pos LF
|
||||
paste_block_hunk: "PASTE.BLK.POST " LID LF
|
||||
paste_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
|
||||
remove_hunk: "REM" LF
|
||||
move_hunk: "MV " filename LF emit_op*
|
||||
replace_anchor: "SWAP " header_range ":"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./apply";
|
||||
export * from "./block";
|
||||
export * from "./clipboard";
|
||||
export * from "./diff-preview";
|
||||
export * from "./format";
|
||||
export * from "./fs";
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
import * as path from "node:path";
|
||||
import { applyEdits } from "./apply";
|
||||
import { resolveBlockEdits } from "./block";
|
||||
import { assertClipboardConsumed, hasClipboardEdit } from "./clipboard";
|
||||
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
||||
import { CLIPBOARD_INTERLEAVED_SECTIONS } from "./messages";
|
||||
import { parsePatch, parsePatchStreaming } from "./parser";
|
||||
import { Tokenizer } from "./tokenizer";
|
||||
import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
|
||||
import type { ApplyResult, BlockResolver, Clipboard, Edit, FileOp, SplitOptions } from "./types";
|
||||
|
||||
// Pure classification — single shared tokenizer is safe.
|
||||
const TOKENIZER = new Tokenizer();
|
||||
@@ -97,6 +99,14 @@ interface RawSection {
|
||||
path: string;
|
||||
fileHash?: string;
|
||||
diff: string;
|
||||
/**
|
||||
* True when this section coalesced same-path sections that were NOT
|
||||
* adjacent in the authored input (another file's section sat between
|
||||
* them). Merging moves the later ops up to the first occurrence, which
|
||||
* would silently reorder the clipboard register sequence — so clipboard
|
||||
* ops are rejected in such sections.
|
||||
*/
|
||||
interleaved?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,10 +249,13 @@ export class PatchSection {
|
||||
readonly diff: string;
|
||||
#parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined;
|
||||
|
||||
#interleavedMerge: boolean;
|
||||
|
||||
constructor(raw: RawSection) {
|
||||
this.path = raw.path;
|
||||
this.fileHash = raw.fileHash;
|
||||
this.diff = raw.diff;
|
||||
this.#interleavedMerge = raw.interleaved === true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,6 +266,12 @@ export class PatchSection {
|
||||
parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } {
|
||||
this.#parsed ??= parsePatch(this.diff);
|
||||
const parsed = this.#parsed;
|
||||
// Same-path sections merge into their first occurrence; when that merge
|
||||
// crossed another file's section, the authored top-to-bottom register
|
||||
// order is gone, so clipboard ops cannot apply deterministically.
|
||||
if (this.#interleavedMerge && hasClipboardEdit(parsed.edits)) {
|
||||
throw new Error(CLIPBOARD_INTERLEAVED_SECTIONS);
|
||||
}
|
||||
const fileOp =
|
||||
parsed.fileOp === undefined
|
||||
? undefined
|
||||
@@ -289,10 +308,28 @@ export class PatchSection {
|
||||
if (edit.kind === "delete") return true;
|
||||
// A `replace_block N:` edit is anchored to concrete content on line N.
|
||||
if (edit.kind === "block") return true;
|
||||
// A `COPY`/`CUT` range reads concrete content.
|
||||
if (edit.kind === "copy") return true;
|
||||
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this section is a pure clipboard source: at least one edit,
|
||||
* and every edit is a non-cut `COPY`/`COPY.BLK`. Only such a section may
|
||||
* legitimately change nothing in its own file (it exists to fill the
|
||||
* clipboard for a later `PASTE`), so only it is exempt from no-change
|
||||
* validation — a section mixing COPY with mutating edits that all no-op'd
|
||||
* still trips the guard.
|
||||
*/
|
||||
get isClipboardSource(): boolean {
|
||||
const edits = this.edits;
|
||||
return (
|
||||
edits.length > 0 &&
|
||||
edits.every(edit => (edit.kind === "copy" && !edit.cut) || (edit.kind === "block" && edit.mode === "copy"))
|
||||
);
|
||||
}
|
||||
|
||||
/** Anchor lines touched by this section, sorted ascending and deduplicated. */
|
||||
collectAnchorLines(): readonly number[] {
|
||||
const lines = new Set<number>();
|
||||
@@ -305,6 +342,10 @@ export class PatchSection {
|
||||
lines.add(edit.anchor.line);
|
||||
continue;
|
||||
}
|
||||
if (edit.kind === "copy") {
|
||||
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line);
|
||||
continue;
|
||||
}
|
||||
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
|
||||
lines.add(edit.cursor.anchor.line);
|
||||
}
|
||||
@@ -321,15 +362,22 @@ export class PatchSection {
|
||||
*
|
||||
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
||||
* unresolvable block throws (this is the final, authoritative preview path).
|
||||
*
|
||||
* `clipboard` is the register shared by `CUT`/`COPY`/`PASTE` ops. Pass one
|
||||
* when applying several sections of a batch so content can move across
|
||||
* files; when omitted, a private register is used and un-pasted `CUT`
|
||||
* content is rejected at the end of the call.
|
||||
*/
|
||||
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
||||
applyTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult {
|
||||
const { edits, warnings } = this.parse();
|
||||
const resolveWarnings: string[] = [];
|
||||
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
|
||||
onUnresolved: "throw",
|
||||
onWarning: warning => resolveWarnings.push(warning),
|
||||
});
|
||||
const result = applyEdits(text, resolved);
|
||||
const register = clipboard ?? {};
|
||||
const result = applyEdits(text, resolved, { clipboard: register });
|
||||
if (clipboard === undefined) assertClipboardConsumed(register);
|
||||
// Preserve parse warnings so consumers don't need to call `parse()`
|
||||
// separately.
|
||||
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
|
||||
@@ -347,16 +395,17 @@ export class PatchSection {
|
||||
*
|
||||
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
|
||||
* unresolvable block is silently dropped so a half-written file does not
|
||||
* throw mid-stream.
|
||||
* throw mid-stream. A `PASTE` with an empty register is dropped for the
|
||||
* same reason, and un-pasted `CUT` content is never rejected here.
|
||||
*/
|
||||
applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
||||
applyPartialTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult {
|
||||
const { edits, warnings } = parsePatchStreaming(this.diff);
|
||||
const resolveWarnings: string[] = [];
|
||||
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
|
||||
onUnresolved: "drop",
|
||||
onWarning: warning => resolveWarnings.push(warning),
|
||||
});
|
||||
const result = applyEdits(text, resolved);
|
||||
const result = applyEdits(text, resolved, { clipboard: clipboard ?? {}, onEmptyPaste: "drop" });
|
||||
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
|
||||
return merged.length > 0
|
||||
? { ...result, warnings: merged }
|
||||
@@ -374,6 +423,7 @@ export class PatchSection {
|
||||
path,
|
||||
...(this.fileHash !== undefined ? { fileHash: this.fileHash } : {}),
|
||||
diff: this.diff,
|
||||
...(this.#interleavedMerge ? { interleaved: true } : {}),
|
||||
});
|
||||
next.#parsed = this.#parsed;
|
||||
return next;
|
||||
@@ -432,7 +482,8 @@ export class Patch {
|
||||
* fails. Path order is preserved by first occurrence.
|
||||
*/
|
||||
function mergeSamePathSections(sections: RawSection[]): RawSection[] {
|
||||
const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
|
||||
const byPath = new Map<string, { fileHash?: string; diffs: string[]; interleaved: boolean }>();
|
||||
let previousPath: string | undefined;
|
||||
for (const section of sections) {
|
||||
const existing = byPath.get(section.path);
|
||||
if (existing) {
|
||||
@@ -446,17 +497,24 @@ function mergeSamePathSections(sections: RawSection[]): RawSection[] {
|
||||
);
|
||||
}
|
||||
if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
|
||||
// Merging across another file's section moves these ops up to the
|
||||
// first occurrence; flag it so clipboard ops can refuse the reorder.
|
||||
if (previousPath !== section.path) existing.interleaved = true;
|
||||
existing.diffs.push(section.diff);
|
||||
previousPath = section.path;
|
||||
continue;
|
||||
}
|
||||
byPath.set(section.path, {
|
||||
...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
|
||||
diffs: [section.diff],
|
||||
interleaved: false,
|
||||
});
|
||||
previousPath = section.path;
|
||||
}
|
||||
return Array.from(byPath, ([sectionPath, entry]) => ({
|
||||
path: sectionPath,
|
||||
...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
|
||||
diff: entry.diffs.join("\n"),
|
||||
...(entry.interleaved ? { interleaved: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,21 @@ export function formatAnchoredContext(anchorLines: readonly number[], fileLines:
|
||||
return rows;
|
||||
}
|
||||
/** Concrete range operation rejected because its absolute end precedes its start. */
|
||||
export type AbsoluteRangeOp = "replace" | "delete";
|
||||
export type AbsoluteRangeOp = "replace" | "delete" | "copy" | "cut";
|
||||
|
||||
/** Header forms per concrete-range op, used to compose retry suggestions. */
|
||||
const RANGE_OP_FORMS: Record<AbsoluteRangeOp, { keyword: string; colon: string; blockKeyword: string }> = {
|
||||
replace: { keyword: "SWAP", colon: ":", blockKeyword: "SWAP.BLK" },
|
||||
delete: { keyword: "DEL", colon: "", blockKeyword: "DEL.BLK" },
|
||||
copy: { keyword: "COPY", colon: "", blockKeyword: "COPY.BLK" },
|
||||
cut: { keyword: "CUT", colon: "", blockKeyword: "CUT.BLK" },
|
||||
};
|
||||
|
||||
/** `OP.BLK N`-style header for a concrete-range op (`SWAP.BLK 5:` / `CUT.BLK 5`). */
|
||||
function blockFormAt(op: AbsoluteRangeOp, line: number): string {
|
||||
const forms = RANGE_OP_FORMS[op];
|
||||
return `${forms.blockKeyword} ${line}${forms.colon}`;
|
||||
}
|
||||
|
||||
/** Explain absolute range endpoints and provide safe, non-applying retry forms. */
|
||||
export function invalidAbsoluteRangeMessage(
|
||||
@@ -41,15 +55,14 @@ export function invalidAbsoluteRangeMessage(
|
||||
op: AbsoluteRangeOp,
|
||||
block?: BlockSpan,
|
||||
): string {
|
||||
const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `DEL ${start}`;
|
||||
const forms = RANGE_OP_FORMS[op];
|
||||
const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `${forms.keyword} ${start}`;
|
||||
const countedEnd = start + end - 1;
|
||||
const counted =
|
||||
Number.isSafeInteger(countedEnd) && countedEnd >= start
|
||||
? op === "replace"
|
||||
? `SWAP ${start}${HL_RANGE_SEP}${countedEnd}:`
|
||||
: `DEL ${start}${HL_RANGE_SEP}${countedEnd}`
|
||||
? `${forms.keyword} ${start}${HL_RANGE_SEP}${countedEnd}${forms.colon}`
|
||||
: null;
|
||||
const blockForm = op === "replace" ? `SWAP.BLK ${start}:` : `DEL.BLK ${start}`;
|
||||
const blockForm = blockFormAt(op, start);
|
||||
let message =
|
||||
`line ${patchLine}: Invalid absolute range: start ${start}, end ${end}. ` +
|
||||
`The value after \`${HL_RANGE_SEP}\` is an absolute source line, not a line count or replacement length. ` +
|
||||
@@ -122,17 +135,18 @@ export interface BlockDiagnosticSuggestions {
|
||||
*/
|
||||
export function blockUnresolvedMessage(
|
||||
line: number,
|
||||
op: "replace" | "delete" = "replace",
|
||||
op: AbsoluteRangeOp = "replace",
|
||||
fileLines?: readonly string[],
|
||||
suggestions: BlockDiagnosticSuggestions = {},
|
||||
): string {
|
||||
const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
|
||||
const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
|
||||
const forms = RANGE_OP_FORMS[op];
|
||||
const phrase = blockFormAt(op, line);
|
||||
const fallback = `${forms.keyword} ${line}${HL_RANGE_SEP}M${forms.colon}`;
|
||||
const anchorText = fileLines?.[line - 1];
|
||||
const nextBlock = suggestions.nextBlock;
|
||||
let message: string;
|
||||
if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) {
|
||||
const retry = op === "delete" ? `DEL.BLK ${nextBlock.start}` : `SWAP.BLK ${nextBlock.start}:`;
|
||||
const retry = blockFormAt(op, nextBlock.start);
|
||||
message =
|
||||
`Line ${line} is blank; no syntactic block can begin there. ` +
|
||||
`The next multi-line block begins at line ${nextBlock.start} and ends at line ${nextBlock.end}. ` +
|
||||
@@ -144,7 +158,7 @@ export function blockUnresolvedMessage(
|
||||
}
|
||||
const enclosingBlock = suggestions.enclosingBlock;
|
||||
if (enclosingBlock) {
|
||||
const retry = op === "delete" ? `DEL.BLK ${enclosingBlock.start}` : `SWAP.BLK ${enclosingBlock.start}:`;
|
||||
const retry = blockFormAt(op, enclosingBlock.start);
|
||||
message +=
|
||||
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
|
||||
`and ends at line ${enclosingBlock.end}; use \`${retry}\` to target it.`;
|
||||
@@ -158,24 +172,44 @@ export function blockUnresolvedMessage(
|
||||
|
||||
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
||||
export const BLOCK_RESOLVER_UNAVAILABLE =
|
||||
"`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
|
||||
"Block ops (`SWAP.BLK`, `DEL.BLK`, `INS.BLK.POST`, `CUT.BLK`, `COPY.BLK`, `PASTE.BLK.POST`) are not available here (no block resolver configured). Use a concrete line range.";
|
||||
|
||||
/**
|
||||
* `insert_after_block N:` anchored on a closing-delimiter line, lowered to
|
||||
* plain `insert after N:` — the closer ends a block, and inserting after it
|
||||
* is exactly what the plain form does.
|
||||
* An after-block op anchored on a closing-delimiter line, lowered to its
|
||||
* plain after-line form — the closer ends a block, and inserting after it is
|
||||
* exactly what the plain form does.
|
||||
*/
|
||||
export function insertAfterBlockCloserLoweredWarning(line: number): string {
|
||||
return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
|
||||
function closerLoweredWarning(blockForm: string, plainForm: string): string {
|
||||
return `\`${blockForm}\` anchors on a closing delimiter, so it was applied as plain \`${plainForm}\`. Anchor on the line that OPENS the construct.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `insert_after_block N:` anchor unresolvable (unsupported language, blank
|
||||
* line, parse error, or no resolver), lowered to plain `insert after N:` —
|
||||
* applying with a warning beats failing the patch.
|
||||
* An after-block op whose anchor was unresolvable (unsupported language,
|
||||
* blank line, parse error, or no resolver), lowered to its plain after-line
|
||||
* form — applying with a warning beats failing the patch.
|
||||
*/
|
||||
function unresolvedLoweredWarning(blockForm: string, line: number, plainForm: string): string {
|
||||
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:`. */
|
||||
export function insertAfterBlockCloserLoweredWarning(line: number): string {
|
||||
return closerLoweredWarning(`INS.BLK.POST ${line}:`, `INS.POST ${line}:`);
|
||||
}
|
||||
|
||||
/** `INS.BLK.POST N:` anchor unresolvable; applied as `INS.POST N:`. */
|
||||
export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
|
||||
return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
|
||||
return unresolvedLoweredWarning(`INS.BLK.POST ${line}:`, line, `INS.POST ${line}:`);
|
||||
}
|
||||
|
||||
/** `PASTE.BLK.POST N` anchored on a closing-delimiter line; applied as `PASTE.POST N`. */
|
||||
export function pasteAfterBlockCloserLoweredWarning(line: number): string {
|
||||
return closerLoweredWarning(`PASTE.BLK.POST ${line}`, `PASTE.POST ${line}`);
|
||||
}
|
||||
|
||||
/** `PASTE.BLK.POST N` anchor unresolvable; applied as `PASTE.POST N`. */
|
||||
export function pasteAfterBlockUnresolvedLoweredWarning(line: number): string {
|
||||
return unresolvedLoweredWarning(`PASTE.BLK.POST ${line}`, line, `PASTE.POST ${line}`);
|
||||
}
|
||||
/**
|
||||
* A one-sided boundary echo whose payload is too short to be the widened
|
||||
@@ -248,6 +282,42 @@ export const MOVE_TAKES_NO_BODY =
|
||||
/** `delete_block N` hunk received a body row. */
|
||||
export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
|
||||
|
||||
/** `COPY N.=M` hunk received a body row. */
|
||||
export const COPY_TAKES_NO_BODY = `\`COPY N${HL_RANGE_SEP}M\` captures lines into the clipboard and takes no body rows.`;
|
||||
|
||||
/** `CUT N.=M` hunk received a body row. */
|
||||
export const CUT_TAKES_NO_BODY = `\`CUT N${HL_RANGE_SEP}M\` captures + deletes lines and takes no body rows. To replace lines with new content, use \`SWAP N${HL_RANGE_SEP}M:\`.`;
|
||||
|
||||
/** `PASTE` hunk received a body row. */
|
||||
export const PASTE_TAKES_NO_BODY =
|
||||
"`PASTE` inserts the clipboard content and takes no `+` body rows. To insert literal text, use `INS`.";
|
||||
|
||||
/** `PASTE` ran with an empty clipboard register. */
|
||||
export const EMPTY_PASTE = `\`PASTE\` found nothing in the clipboard. Ops run top-to-bottom across the whole patch (sections included): put \`CUT N${HL_RANGE_SEP}M\` / \`COPY N${HL_RANGE_SEP}M\` (or \`CUT.BLK N\` / \`COPY.BLK N\`) above the \`PASTE\`.`;
|
||||
|
||||
/** A `CUT` filled the register and the whole patch finished without a `PASTE`. */
|
||||
export function unusedCutMessage(cutForm: string): string {
|
||||
return `\`${cutForm}\` deleted lines that were never pasted — no \`PASTE\` follows it in the patch. Add a \`PASTE.PRE|POST N\` / \`PASTE.HEAD|TAIL\` op, or use \`DEL\` to delete without moving.`;
|
||||
}
|
||||
|
||||
/** A later CUT/COPY overwrote a register whose CUT content was never pasted. */
|
||||
export function overwrittenCutMessage(cutForm: string, lineNum: number): string {
|
||||
return `line ${lineNum}: the clipboard still holds \`${cutForm}\` content that was never pasted. \`PASTE\` it before capturing again, or use \`DEL\` if those lines should just be deleted.`;
|
||||
}
|
||||
|
||||
/** Batch ended with un-pasted `CUT` content in a session-persistent register. */
|
||||
export function pendingCutWarning(cutForm: string): string {
|
||||
return `\`${cutForm}\` content is in the clipboard but was not pasted by this edit. It stays available — \`PASTE\` it in a follow-up edit; capturing again before pasting it will be rejected.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clipboard ops inside a same-path section that was merged across another
|
||||
* file's section. Same-path sections coalesce into their first occurrence, so
|
||||
* an interleaved layout would silently reorder the register sequence.
|
||||
*/
|
||||
export const CLIPBOARD_INTERLEAVED_SECTIONS =
|
||||
"`CUT`/`COPY`/`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, with every capture above its `PASTE`.";
|
||||
|
||||
/** Insert hunk with no body. */
|
||||
export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
|
||||
|
||||
@@ -413,33 +483,32 @@ export function unseenLinesMessage(
|
||||
}
|
||||
|
||||
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
||||
export type BlockOp = "replace" | "delete" | "insert_after";
|
||||
export type BlockOp = "replace" | "delete" | "insert_after" | "copy" | "cut" | "paste_after";
|
||||
|
||||
/** 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 }> = {
|
||||
replace: { keyword: "SWAP.BLK", colon: ":", plain: line => `SWAP ${line}${HL_RANGE_SEP}${line}:` },
|
||||
delete: { keyword: "DEL.BLK", colon: "", plain: line => `DEL ${line}` },
|
||||
insert_after: { keyword: "INS.BLK.POST", colon: ":", plain: line => `INS.POST ${line}:` },
|
||||
copy: { keyword: "COPY.BLK", colon: "", plain: line => `COPY ${line}` },
|
||||
cut: { keyword: "CUT.BLK", colon: "", plain: line => `CUT ${line}` },
|
||||
paste_after: { keyword: "PASTE.BLK.POST", colon: "", plain: line => `PASTE.POST ${line}` },
|
||||
};
|
||||
|
||||
/**
|
||||
* A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
|
||||
* single line — almost always a bare statement the model mis-anchored, not a
|
||||
* multi-line construct. The plain op is unambiguous for one line; the block
|
||||
* form only earns its keep when it spares counting a closing line you cannot
|
||||
* see. Reject and point at both fixes.
|
||||
* single line: line N is a bare statement, not the opening line of a
|
||||
* multi-line construct. The plain op is exact for one line, so reject and
|
||||
* point at it (with the enclosing block as an alternative when known).
|
||||
*/
|
||||
export function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock?: BlockSpan): string {
|
||||
const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
|
||||
const plainForm =
|
||||
op === "insert_after"
|
||||
? `INS.POST ${line}:`
|
||||
: op === "delete"
|
||||
? `DEL ${line}`
|
||||
: `SWAP ${line}${HL_RANGE_SEP}${line}:`;
|
||||
const forms = BLOCK_OP_FORMS[op];
|
||||
const plainForm = forms.plain(line);
|
||||
let message =
|
||||
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
||||
`\`${forms.keyword} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
||||
`of a multi-line construct. For only this statement use \`${plainForm}\`.`;
|
||||
if (enclosingBlock) {
|
||||
const enclosingForm =
|
||||
op === "insert_after"
|
||||
? `INS.BLK.POST ${enclosingBlock.start}:`
|
||||
: op === "delete"
|
||||
? `DEL.BLK ${enclosingBlock.start}`
|
||||
: `SWAP.BLK ${enclosingBlock.start}:`;
|
||||
const enclosingForm = `${forms.keyword} ${enclosingBlock.start}${forms.colon}`;
|
||||
message +=
|
||||
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
|
||||
`and ends at line ${enclosingBlock.end}; use \`${enclosingForm}\` to target it.`;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
|
||||
import {
|
||||
type AbsoluteRangeOp,
|
||||
BARE_BODY_AUTO_PIPED_WARNING,
|
||||
COPY_TAKES_NO_BODY,
|
||||
CUT_TAKES_NO_BODY,
|
||||
DELETE_BLOCK_TAKES_NO_BODY,
|
||||
DELETE_TAKES_NO_BODY,
|
||||
EMPTY_BLOCK,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
MINUS_BULLET_AUTO_PIPED_WARNING,
|
||||
MINUS_ROW_REJECTED,
|
||||
MOVE_TAKES_NO_BODY,
|
||||
PASTE_TAKES_NO_BODY,
|
||||
REM_TAKES_NO_BODY,
|
||||
} from "./messages";
|
||||
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
||||
@@ -75,6 +78,27 @@ function isSkippableCommentLine(line: string): boolean {
|
||||
return line.trimStart().startsWith("#");
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-row rejection message for targets that take no `+TEXT` rows, or `null`
|
||||
* for targets whose header is followed by a body.
|
||||
*/
|
||||
function bodylessTargetMessage(target: BlockTarget): string | null {
|
||||
switch (target.kind) {
|
||||
case "delete":
|
||||
return DELETE_TAKES_NO_BODY;
|
||||
case "delete_block":
|
||||
return DELETE_BLOCK_TAKES_NO_BODY;
|
||||
case "copy":
|
||||
case "copy_block":
|
||||
return target.cut ? CUT_TAKES_NO_BODY : COPY_TAKES_NO_BODY;
|
||||
case "paste":
|
||||
case "paste_after_block":
|
||||
return PASTE_TAKES_NO_BODY;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripped remainder of a bare `N: <value>` row that is a lone quoted or
|
||||
* numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
|
||||
@@ -121,6 +145,12 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
|
||||
if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
|
||||
return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`;
|
||||
}
|
||||
// Bare `PASTE` (optionally `PASTE 5` / `PASTE:`) — the op requires an
|
||||
// explicit position suffix; a bare form would otherwise surface as a
|
||||
// confusing body-row rejection under the preceding hunk.
|
||||
if (/^PASTE(?:\s+[1-9]\d*)?\s*:?\s*$/.test(trimmed)) {
|
||||
return "`PASTE` needs a position: use `PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N`.";
|
||||
}
|
||||
if (/^[1-9]\d*\s*$/.test(trimmed)) {
|
||||
return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
|
||||
}
|
||||
@@ -210,6 +240,9 @@ export class Executor {
|
||||
if (token.target.kind === "replace" || token.target.kind === "delete") {
|
||||
validateRange(token.target.range, token.lineNum, token.target.kind);
|
||||
}
|
||||
if (token.target.kind === "copy") {
|
||||
validateRange(token.target.range, token.lineNum, token.target.cut ? "cut" : "copy");
|
||||
}
|
||||
if (token.target.kind === "rem") {
|
||||
this.#flushPending();
|
||||
this.#setFileOp({ kind: "rem" }, token.lineNum);
|
||||
@@ -241,8 +274,7 @@ export class Executor {
|
||||
endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
||||
this.#consumePendingSkippableComments();
|
||||
if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
|
||||
else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
|
||||
this.#flushPending();
|
||||
else if (this.#pending && bodylessTargetMessage(this.#pending.target) !== null) this.#flushPending();
|
||||
else this.#pending = undefined;
|
||||
this.#validateFileOp();
|
||||
this.#validateNoOverlappingDeletes();
|
||||
@@ -312,8 +344,8 @@ export class Executor {
|
||||
`Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
|
||||
);
|
||||
}
|
||||
if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
||||
if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
||||
const noBodyOnLiteral = bodylessTargetMessage(pending.target);
|
||||
if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`);
|
||||
this.#commitDeferredBlanks(pending);
|
||||
pending.payloads.push({ kind: "literal", text, lineNum });
|
||||
}
|
||||
@@ -327,9 +359,8 @@ export class Executor {
|
||||
this.#handleBlank(text, lineNum);
|
||||
return;
|
||||
}
|
||||
if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
||||
if (this.#pending.target.kind === "delete_block")
|
||||
throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
||||
const noBodyOnRaw = bodylessTargetMessage(this.#pending.target);
|
||||
if (noBodyOnRaw !== null) throw new Error(`line ${lineNum}: ${noBodyOnRaw}`);
|
||||
const row: PayloadRow = { kind: "literal", text, lineNum, bare: true };
|
||||
// `-` rows are held and judged at flush time by #resolveMinusRows,
|
||||
// once the whole body is visible.
|
||||
@@ -364,7 +395,7 @@ export class Executor {
|
||||
#handleBlank(text: string, lineNum: number): void {
|
||||
const pending = this.#pending;
|
||||
if (!pending) return;
|
||||
if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
|
||||
if (bodylessTargetMessage(pending.target) !== null) return;
|
||||
if (pending.payloads.length === 0) return;
|
||||
pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
|
||||
}
|
||||
@@ -455,7 +486,27 @@ export class Executor {
|
||||
for (let line = range.start.line; line <= range.end.line; line++) this.#pushDelete({ line }, lineNum);
|
||||
}
|
||||
|
||||
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
|
||||
#pushCopy(range: ParsedRange, cut: boolean, lineNum: number): void {
|
||||
this.#edits.push({
|
||||
kind: "copy",
|
||||
range: { start: { ...range.start }, end: { ...range.end } },
|
||||
cut,
|
||||
lineNum,
|
||||
index: this.#editIndex++,
|
||||
});
|
||||
// A CUT deletes its range exactly like `DEL N.=M`; the copy edit above
|
||||
// only captures. Keeping the deletes as ordinary per-line edits lets
|
||||
// overlap validation, recovery remapping, and the applier treat them
|
||||
// identically to a plain delete.
|
||||
if (cut) this.#pushDeleteRange(range, lineNum);
|
||||
}
|
||||
|
||||
#pushBlock(
|
||||
anchor: Anchor,
|
||||
payloads: readonly PayloadRow[],
|
||||
lineNum: number,
|
||||
mode?: "insert_after" | "copy" | "cut" | "paste_after",
|
||||
): void {
|
||||
this.#edits.push({
|
||||
kind: "block",
|
||||
anchor: { ...anchor },
|
||||
@@ -486,6 +537,22 @@ export class Executor {
|
||||
this.#pushBlock(target.anchor, [], lineNum);
|
||||
return;
|
||||
}
|
||||
if (target.kind === "copy") {
|
||||
this.#pushCopy(target.range, target.cut, lineNum);
|
||||
return;
|
||||
}
|
||||
if (target.kind === "copy_block") {
|
||||
this.#pushBlock(target.anchor, [], lineNum, target.cut ? "cut" : "copy");
|
||||
return;
|
||||
}
|
||||
if (target.kind === "paste") {
|
||||
this.#edits.push({ kind: "paste", cursor: cloneCursor(target.cursor), lineNum, index: this.#editIndex++ });
|
||||
return;
|
||||
}
|
||||
if (target.kind === "paste_after_block") {
|
||||
this.#pushBlock(target.anchor, [], lineNum, "paste_after");
|
||||
return;
|
||||
}
|
||||
if (target.kind === "block") {
|
||||
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
|
||||
this.#pushBlock(target.anchor, payloads, lineNum);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import * as path from "node:path";
|
||||
import { applyEdits } from "./apply";
|
||||
import { hasBlockEdit, resolveBlockEdits } from "./block";
|
||||
import { assertClipboardConsumed, commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard";
|
||||
import { computeFileHash, formatHashlineHeader } from "./format";
|
||||
import type { Filesystem, WriteResult } from "./fs";
|
||||
import { isNotFound } from "./fs";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
HEADTAIL_DRIFT_WARNING,
|
||||
missingSnapshotTagMessage,
|
||||
pathRecoveredFromTagMessage,
|
||||
pendingCutWarning,
|
||||
type RevealedLine,
|
||||
unseenLinesMessage,
|
||||
writeDriftWarning,
|
||||
@@ -42,7 +44,7 @@ import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, s
|
||||
import { InvalidAbsoluteRangeError } from "./parser";
|
||||
import { Recovery, type RecoveryResult } from "./recovery";
|
||||
import type { Snapshot, SnapshotStore } from "./snapshots";
|
||||
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Edit, FileOp } from "./types";
|
||||
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Clipboard, Edit, FileOp } from "./types";
|
||||
|
||||
/**
|
||||
* Upper bound on the number of unseen anchor lines whose actual file content
|
||||
@@ -81,6 +83,15 @@ export interface PatcherOptions {
|
||||
* validate on content hash alone and any anchor into the tagged content applies.
|
||||
*/
|
||||
enforceSeenLines?: boolean;
|
||||
/**
|
||||
* Host-owned clipboard register shared across batches, so `CUT` content
|
||||
* can be `PASTE`d by a later {@link Patcher.apply} call. Each batch works
|
||||
* on a fork and publishes it back only after every write lands; un-pasted
|
||||
* `CUT` content at batch end becomes a warning instead of an error.
|
||||
* Omitted, every batch gets its own register and un-pasted `CUT` content
|
||||
* is rejected when the batch ends.
|
||||
*/
|
||||
clipboard?: Clipboard;
|
||||
}
|
||||
|
||||
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
|
||||
@@ -158,6 +169,8 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
||||
if (edit.kind === "delete") return true;
|
||||
// A `replace_block N:` edit anchors to concrete content on line N.
|
||||
if (edit.kind === "block") return true;
|
||||
// A `COPY`/`CUT` range reads concrete content.
|
||||
if (edit.kind === "copy") return true;
|
||||
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
||||
});
|
||||
}
|
||||
@@ -211,6 +224,7 @@ export class Patcher {
|
||||
readonly snapshots: SnapshotStore;
|
||||
readonly recovery: Recovery;
|
||||
readonly blockResolver: BlockResolver | undefined;
|
||||
readonly clipboard: Clipboard | undefined;
|
||||
readonly #enforceSeenLines: boolean;
|
||||
|
||||
constructor(options: PatcherOptions) {
|
||||
@@ -221,6 +235,7 @@ export class Patcher {
|
||||
this.snapshots = options.snapshots;
|
||||
this.recovery = new Recovery(options.snapshots);
|
||||
this.blockResolver = options.blockResolver;
|
||||
this.clipboard = options.clipboard;
|
||||
this.#enforceSeenLines = options.enforceSeenLines ?? true;
|
||||
}
|
||||
|
||||
@@ -231,19 +246,44 @@ export class Patcher {
|
||||
* {@link PatchSectionResult} per section in the original patch order.
|
||||
*/
|
||||
async apply(patch: Patch): Promise<PatcherApplyResult> {
|
||||
// One register per batch: `CUT`/`COPY` in one section feeds `PASTE` in
|
||||
// a later one, so content can move across files. A host-owned register
|
||||
// (see PatcherOptions.clipboard) additionally persists across batches:
|
||||
// work on a fork and publish it per landed section, so a failed batch
|
||||
// never poisons the persistent register and a mid-batch failure still
|
||||
// preserves content already cut from disk.
|
||||
const clipboard = forkClipboard(this.clipboard);
|
||||
|
||||
// Single-section fast path.
|
||||
if (patch.sections.length === 1) {
|
||||
const prepared = await this.prepare(patch.sections[0]);
|
||||
return { sections: [await this.commit(prepared)] };
|
||||
const prepared = await this.prepare(patch.sections[0], clipboard);
|
||||
if (this.clipboard === undefined) assertClipboardConsumed(clipboard);
|
||||
const result = await this.commit(prepared);
|
||||
if (this.clipboard !== undefined) {
|
||||
commitClipboard(clipboard, this.clipboard);
|
||||
if (clipboard.pendingCut !== undefined) result.warnings.push(pendingCutWarning(clipboard.pendingCut));
|
||||
}
|
||||
return { sections: [result] };
|
||||
}
|
||||
|
||||
// Prepare every section first so any failure (stale hash, missing
|
||||
// file, parse error, in-memory no-op) surfaces before any write.
|
||||
const prepared: PreparedSection[] = [];
|
||||
for (const section of patch.sections) prepared.push(await this.prepare(section));
|
||||
// Register state after each section's prepare. Commits are non-atomic:
|
||||
// when a later write fails, the sections before it are already on disk,
|
||||
// so the host register must reflect exactly the landed prefix — content
|
||||
// a landed CUT deleted would otherwise be lost.
|
||||
const sectionStates: Clipboard[] = [];
|
||||
for (const section of patch.sections) {
|
||||
prepared.push(await this.prepare(section, clipboard));
|
||||
sectionStates.push(forkClipboard(clipboard));
|
||||
}
|
||||
if (this.clipboard === undefined) assertClipboardConsumed(clipboard);
|
||||
assertUniqueCanonicalPaths(prepared);
|
||||
for (const entry of prepared) {
|
||||
if (entry.isNoop) {
|
||||
// A copy-only section legitimately changes nothing: it exists to
|
||||
// fill the clipboard for a later `PASTE`.
|
||||
if (entry.isNoop && !entry.section.isClipboardSource) {
|
||||
throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
|
||||
}
|
||||
}
|
||||
@@ -266,6 +306,10 @@ export class Patcher {
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (this.clipboard !== undefined) commitClipboard(sectionStates[index], this.clipboard);
|
||||
}
|
||||
if (this.clipboard !== undefined && clipboard.pendingCut !== undefined) {
|
||||
results[results.length - 1]?.warnings.push(pendingCutWarning(clipboard.pendingCut));
|
||||
}
|
||||
return { sections: results };
|
||||
}
|
||||
@@ -275,11 +319,14 @@ export class Patcher {
|
||||
* No writes hit the filesystem. Use for CI checks and dry runs.
|
||||
*/
|
||||
async preflight(patch: Patch): Promise<void> {
|
||||
// Dry run: fork the register and never publish it back.
|
||||
const clipboard = forkClipboard(this.clipboard);
|
||||
const prepared: PreparedSection[] = [];
|
||||
for (const section of patch.sections) prepared.push(await this.prepare(section));
|
||||
for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard));
|
||||
if (this.clipboard === undefined) assertClipboardConsumed(clipboard);
|
||||
assertUniqueCanonicalPaths(prepared);
|
||||
for (const entry of prepared) {
|
||||
if (entry.isNoop) {
|
||||
if (entry.isNoop && !entry.section.isClipboardSource) {
|
||||
throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
|
||||
}
|
||||
}
|
||||
@@ -310,10 +357,15 @@ export class Patcher {
|
||||
* {@link PreparedSection} which can be fed to {@link commit} to land
|
||||
* the result on the filesystem.
|
||||
*
|
||||
* `clipboard` is the register shared by `CUT`/`COPY`/`PASTE` ops. Pass the
|
||||
* batch's register when preparing several sections so content can move
|
||||
* across files; when omitted, a private register is used and un-pasted
|
||||
* `CUT` content is rejected before this method returns.
|
||||
*
|
||||
* Throws on parse error, missing-file-for-anchored-edit, or unrecovered
|
||||
* tag mismatch ({@link MismatchError}).
|
||||
*/
|
||||
async prepare(section: PatchSection): Promise<PreparedSection> {
|
||||
async prepare(section: PatchSection, clipboard?: Clipboard): Promise<PreparedSection> {
|
||||
const parsed = await this.#parseWithRangeDiagnostics(section);
|
||||
const parseWarnings = [...parsed.warnings];
|
||||
const fileOp = parsed.fileOp;
|
||||
@@ -360,6 +412,7 @@ export class Patcher {
|
||||
const lineEnding = detectLineEnding(text);
|
||||
const normalized = normalizeToLF(text);
|
||||
|
||||
const register = clipboard ?? {};
|
||||
const applyResult =
|
||||
fileOp?.kind === "rem"
|
||||
? this.#applyWithRecovery({
|
||||
@@ -368,6 +421,7 @@ export class Patcher {
|
||||
exists: read.exists,
|
||||
normalized,
|
||||
edits: [],
|
||||
clipboard: register,
|
||||
})
|
||||
: this.#applyWithRecovery({
|
||||
section: target,
|
||||
@@ -375,7 +429,9 @@ export class Patcher {
|
||||
exists: read.exists,
|
||||
normalized,
|
||||
edits: parsed.edits,
|
||||
clipboard: register,
|
||||
});
|
||||
if (clipboard === undefined) assertClipboardConsumed(register);
|
||||
|
||||
return new PreparedSection(
|
||||
target,
|
||||
@@ -639,8 +695,9 @@ export class Patcher {
|
||||
exists: boolean;
|
||||
normalized: string;
|
||||
edits: readonly Edit[];
|
||||
clipboard: Clipboard;
|
||||
}): ApplyResult {
|
||||
const { section, canonicalPath, exists, normalized, edits } = args;
|
||||
const { section, canonicalPath, exists, normalized, edits, clipboard } = args;
|
||||
const expected = exists ? section.fileHash : undefined;
|
||||
// The 4-hex tag is content-derived: when the live text hashes to it,
|
||||
// trust the match and apply directly. `storedSnapshotForTag` feeds the
|
||||
@@ -673,6 +730,11 @@ export class Patcher {
|
||||
onWarning: warning => resolveWarnings.push(warning),
|
||||
});
|
||||
}
|
||||
// Surface clipboard sequencing mistakes (`PASTE` before any capture,
|
||||
// capturing over un-pasted `CUT` content) with their targeted message
|
||||
// before the recovery path below, which swallows apply failures and
|
||||
// re-surfaces them as tag-mismatch errors.
|
||||
validateClipboardSequence(resolved, clipboard);
|
||||
const withResolveWarnings = (result: ApplyResult): ApplyResult =>
|
||||
resolveWarnings.length === 0
|
||||
? result
|
||||
@@ -689,7 +751,7 @@ export class Patcher {
|
||||
if (expected !== undefined && this.#enforceSeenLines) {
|
||||
this.#assertSeenLines(section, expected, matchedSnapshot);
|
||||
}
|
||||
const result = applyEdits(normalized, resolved);
|
||||
const result = applyEdits(normalized, resolved, { clipboard });
|
||||
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
||||
}
|
||||
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
||||
@@ -697,7 +759,7 @@ export class Patcher {
|
||||
// content and warn instead of hard-failing — unlike an anchored
|
||||
// mismatch, which cannot be safely relocated and must reject.
|
||||
if (!hasAnchorScopedEdit(resolved)) {
|
||||
const result = applyEdits(normalized, resolved);
|
||||
const result = applyEdits(normalized, resolved, { clipboard });
|
||||
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
|
||||
}
|
||||
// File drifted: map every anchor from the tagged snapshot to unchanged
|
||||
@@ -707,6 +769,7 @@ export class Patcher {
|
||||
currentText: normalized,
|
||||
fileHash: expected,
|
||||
edits: resolved,
|
||||
clipboard,
|
||||
});
|
||||
if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
|
||||
const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body.
|
||||
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL`, `CUT`/`COPY`, and `PASTE` have no body.
|
||||
|
||||
<headers>
|
||||
Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search`, REQUIRED on every section — no hashless form. Create new files with `write`; hashline only edits existing files.
|
||||
@@ -13,6 +13,9 @@ Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from you
|
||||
`INS.POST N:` — insert the body rows immediately after line N.
|
||||
`INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`.
|
||||
`INS.HEAD:` / `INS.TAIL:` — insert the body rows at the very start / end of the file.
|
||||
`CUT N.=M` / `COPY N.=M` — capture original lines N.=M into the clipboard (CUT also deletes them, like `DEL`). No body.
|
||||
`CUT.BLK N` / `COPY.BLK N` — capture the whole syntactic block that BEGINS on line N.
|
||||
`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N` — insert the clipboard at the position. No body — the clipboard IS the body.
|
||||
`REM` — delete the whole file named by the section header. No body, no line ops.
|
||||
`MV DEST` — move/rename the section file to `DEST` (a path, quoted when it contains spaces). Line edits above `MV` land on the source first, then the final content is written at `DEST`.
|
||||
Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
|
||||
@@ -39,6 +42,8 @@ Body rows appear only under a `:` header. Every body row is `+TEXT` — add a li
|
||||
- Markdown: a heading line IS a block opener — `SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` on a `##`/`###` heading resolves its WHOLE section (heading through every nested deeper heading, up to the next same-or-higher heading). So `DEL.BLK` drops the section, `SWAP.BLK` rewrites it, `INS.BLK.POST` lands after it (end the inserted body with a blank line to keep the next heading separated).
|
||||
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
|
||||
- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. (A multi-line `SWAP` whose body restates the line just past the range is auto-dropped as an off-by-one keeper with a warning — issue the payload for the range only; never lean on the repair.)
|
||||
- Move code with `CUT`+`PASTE`, never by retyping it in a body. The clipboard runs top-to-bottom through the whole patch — across `[file]` sections, so content moves between files — and persists across edit calls. The last `CUT`/`COPY` wins; `PASTE` may repeat (it does not consume). Content pastes verbatim, indentation included — re-indenting a move needs `SWAP`.
|
||||
- `CUT` content must land: un-pasted `CUT` content is rejected or carried to the next call with a warning, and a new `CUT`/`COPY` before pasting it is an error.
|
||||
- NEVER format/restyle code with this tool; run the project formatter instead.
|
||||
</rules>
|
||||
|
||||
@@ -111,6 +116,16 @@ INS.POST 2:
|
||||
+ - nested task
|
||||
```
|
||||
|
||||
Move the `greet` function after line 4 without retyping it, then copy line 4 into a sibling file — the clipboard flows top-to-bottom across sections:
|
||||
```
|
||||
[greet.py#A1B2]
|
||||
CUT.BLK 1
|
||||
PASTE.POST 4
|
||||
COPY 4.=4
|
||||
[other.py#3C4D]
|
||||
PASTE.HEAD
|
||||
```
|
||||
|
||||
Replace the whole `greet` function block — `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
|
||||
```
|
||||
[greet.py#A1B2]
|
||||
@@ -162,6 +177,10 @@ INS.BLK.POST 3:
|
||||
# RIGHT
|
||||
INS.POST 3:
|
||||
+after()
|
||||
|
||||
# WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20` (no colon, no rows).
|
||||
PASTE.POST 20:
|
||||
+function f() {}
|
||||
</anti-patterns>
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -10,13 +10,15 @@ import { diffLineRuns } from "@oh-my-pi/pi-natives";
|
||||
import { applyEdits } from "./apply";
|
||||
import { RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING } from "./messages";
|
||||
import type { SnapshotStore } from "./snapshots";
|
||||
import type { Anchor, ApplyResult, Edit } from "./types";
|
||||
import type { Anchor, ApplyResult, Clipboard, Edit } from "./types";
|
||||
|
||||
export interface RecoveryArgs {
|
||||
path: string;
|
||||
currentText: string;
|
||||
fileHash: string;
|
||||
edits: readonly Edit[];
|
||||
/** Shared clipboard register for `copy`/`paste` edits, threaded into the replay apply. */
|
||||
clipboard?: Clipboard;
|
||||
}
|
||||
|
||||
export interface RecoveryResult {
|
||||
@@ -41,6 +43,13 @@ function getEditAnchors(edit: Edit): Anchor[] {
|
||||
// Recovery only ever receives already-resolved edits (no `block`); this arm
|
||||
// exists for type-exhaustiveness over the full `Edit` union.
|
||||
if (edit.kind === "block") return [edit.anchor];
|
||||
if (edit.kind === "copy") {
|
||||
// Every line of the captured range is an anchor: capturing a range
|
||||
// whose interior changed would paste content the author never saw.
|
||||
const anchors: Anchor[] = [];
|
||||
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line });
|
||||
return anchors;
|
||||
}
|
||||
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
|
||||
}
|
||||
|
||||
@@ -213,6 +222,22 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
|
||||
remapped.push({ ...edit, anchor });
|
||||
continue;
|
||||
}
|
||||
if (edit.kind === "copy") {
|
||||
// Map every line of the captured range, not just its endpoints: an
|
||||
// unmapped interior line means the content drifted and the capture
|
||||
// would carry text the author never saw. The uniform-offset rule
|
||||
// below keeps the mapped range contiguous.
|
||||
const start = mapLine(edit.range.start.line);
|
||||
if (start === null) return null;
|
||||
let end = start;
|
||||
for (let line = edit.range.start.line + 1; line <= edit.range.end.line; line++) {
|
||||
const mapped = mapLine(line);
|
||||
if (mapped === null) return null;
|
||||
end = mapped;
|
||||
}
|
||||
remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } });
|
||||
continue;
|
||||
}
|
||||
|
||||
let blockStart = edit.blockStart;
|
||||
if (blockStart !== undefined) {
|
||||
@@ -243,12 +268,13 @@ function replayRemappedAnchorsOnCurrent(
|
||||
currentText: string,
|
||||
edits: readonly Edit[],
|
||||
recoveryWarning: string,
|
||||
clipboard: Clipboard | undefined,
|
||||
): RecoveryResult | null {
|
||||
const remapped = remapEditsToCurrent(previousText, currentText, edits);
|
||||
if (remapped === null) return null;
|
||||
let applied: ApplyResult;
|
||||
try {
|
||||
applied = applyEdits(currentText, remapped.edits);
|
||||
applied = applyEdits(currentText, remapped.edits, clipboard === undefined ? {} : { clipboard });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -276,13 +302,13 @@ export class Recovery {
|
||||
* caller should then surface a {@link MismatchError}.
|
||||
*/
|
||||
tryRecover(args: RecoveryArgs): RecoveryResult | null {
|
||||
const { path, currentText, fileHash, edits } = args;
|
||||
const { path, currentText, fileHash, edits, clipboard } = args;
|
||||
// When retained texts collide on the 16-bit tag, use the latest one.
|
||||
// Recovery still requires its anchors and context to map unambiguously.
|
||||
const snapshot = this.store.byHash(path, fileHash);
|
||||
if (!snapshot) return null;
|
||||
const recoveryWarning =
|
||||
this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
|
||||
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning);
|
||||
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning, clipboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
*/
|
||||
import {
|
||||
describeAnchorExamples,
|
||||
HL_COPY_BLOCK_KEYWORD,
|
||||
HL_COPY_KEYWORD,
|
||||
HL_CUT_BLOCK_KEYWORD,
|
||||
HL_CUT_KEYWORD,
|
||||
HL_DELETE_BLOCK_KEYWORD,
|
||||
HL_DELETE_KEYWORD,
|
||||
HL_FILE_HASH_LENGTH,
|
||||
@@ -24,6 +28,8 @@ import {
|
||||
HL_INSERT_KEYWORD,
|
||||
HL_INSERT_TAIL,
|
||||
HL_MOVE_KEYWORD,
|
||||
HL_PASTE_AFTER_BLOCK_KEYWORD,
|
||||
HL_PASTE_KEYWORD,
|
||||
HL_PAYLOAD_REPLACE,
|
||||
HL_REM_KEYWORD,
|
||||
HL_REPLACE_BLOCK_KEYWORD,
|
||||
@@ -220,6 +226,10 @@ export type BlockTarget =
|
||||
| { kind: "insert_before"; anchor: Anchor }
|
||||
| { kind: "insert_after"; anchor: Anchor }
|
||||
| { kind: "insert_after_block"; anchor: Anchor }
|
||||
| { kind: "copy"; range: ParsedRange; cut: boolean }
|
||||
| { kind: "copy_block"; anchor: Anchor; cut: boolean }
|
||||
| { kind: "paste"; cursor: Cursor }
|
||||
| { kind: "paste_after_block"; anchor: Anchor }
|
||||
| { kind: "rem" }
|
||||
| { kind: "move"; dest: string }
|
||||
| { kind: "bof" }
|
||||
@@ -270,30 +280,52 @@ function consumeReplaceColon(line: string, index: number, end: number): number {
|
||||
return skipWhitespace(line, afterEquals + 1, end);
|
||||
}
|
||||
|
||||
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
|
||||
interface PositionScan {
|
||||
cursor: Cursor;
|
||||
nextIndex: number;
|
||||
}
|
||||
|
||||
/** Scan the `.PRE N` / `.POST N` / `.HEAD` / `.TAIL` positional suffix shared by `INS` and `PASTE`. */
|
||||
function scanPositionalSuffix(line: string, index: number, end: number): PositionScan | null {
|
||||
if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
|
||||
const cursor = skipWhitespace(line, index + 1, end);
|
||||
const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
|
||||
const probe = skipWhitespace(line, index + 1, end);
|
||||
const beforeEnd = scanKeyword(line, probe, end, HL_INSERT_BEFORE);
|
||||
if (beforeEnd !== null) {
|
||||
const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
|
||||
if (anchor === null) return null;
|
||||
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
||||
return { target: { kind: "insert_before", anchor: { line: anchor.line } }, nextIndex };
|
||||
return { cursor: { kind: "before_anchor", anchor: { line: anchor.line } }, nextIndex };
|
||||
}
|
||||
const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
|
||||
const afterEnd = scanKeyword(line, probe, end, HL_INSERT_AFTER);
|
||||
if (afterEnd !== null) {
|
||||
const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
|
||||
if (anchor === null) return null;
|
||||
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
|
||||
return { target: { kind: "insert_after", anchor: { line: anchor.line } }, nextIndex };
|
||||
return { cursor: { kind: "after_anchor", anchor: { line: anchor.line } }, nextIndex };
|
||||
}
|
||||
const headEnd = scanKeyword(line, cursor, end, HL_INSERT_HEAD);
|
||||
if (headEnd !== null) return { target: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) };
|
||||
const tailEnd = scanKeyword(line, cursor, end, HL_INSERT_TAIL);
|
||||
if (tailEnd !== null) return { target: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) };
|
||||
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 {
|
||||
const scan = scanPositionalSuffix(line, index, end);
|
||||
if (scan === null) return null;
|
||||
const { cursor, nextIndex } = scan;
|
||||
switch (cursor.kind) {
|
||||
case "before_anchor":
|
||||
return { target: { kind: "insert_before", anchor: cursor.anchor }, nextIndex };
|
||||
case "after_anchor":
|
||||
return { target: { kind: "insert_after", anchor: cursor.anchor }, nextIndex };
|
||||
case "bof":
|
||||
return { target: { kind: "bof" }, nextIndex };
|
||||
case "eof":
|
||||
return { target: { kind: "eof" }, nextIndex };
|
||||
}
|
||||
}
|
||||
|
||||
function unquotePath(pathText: string): string {
|
||||
if (pathText.length < 2) return pathText;
|
||||
const first = pathText[0];
|
||||
@@ -394,6 +426,68 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
|
||||
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` / `COPY.BLK N` — capture the tree-sitter block at N into the
|
||||
// clipboard (cut also deletes its span). Scanned before the plain forms so
|
||||
// the `.BLK` suffix is not consumed as a malformed 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: "copy_block", anchor: { line: anchor.line }, cut: true },
|
||||
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
||||
};
|
||||
}
|
||||
const copyBlockEnd = scanKeyword(line, cursor, end, HL_COPY_BLOCK_KEYWORD);
|
||||
if (copyBlockEnd !== null) {
|
||||
const anchor = scanLineNumber(line, skipWhitespace(line, copyBlockEnd, end), end);
|
||||
if (anchor === null) return null;
|
||||
return {
|
||||
target: { kind: "copy_block", anchor: { line: anchor.line }, cut: false },
|
||||
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
|
||||
};
|
||||
}
|
||||
// `CUT N.=M` / `COPY N.=M` — capture concrete lines (cut also deletes
|
||||
// them). A trailing colon is tolerated and ignored; body rows are rejected
|
||||
// by the parser.
|
||||
const cutEnd = scanKeyword(line, cursor, end, HL_CUT_KEYWORD);
|
||||
if (cutEnd !== null) {
|
||||
const range = scanHeaderRange(line, cutEnd, end, true);
|
||||
if (range === null) return null;
|
||||
const next = skipStrayDot(line, range.nextIndex, end);
|
||||
return {
|
||||
target: { kind: "copy", range: range.range, cut: true },
|
||||
nextIndex: consumeOptionalColon(line, next, end),
|
||||
};
|
||||
}
|
||||
const copyEnd = scanKeyword(line, cursor, end, HL_COPY_KEYWORD);
|
||||
if (copyEnd !== null) {
|
||||
const range = scanHeaderRange(line, copyEnd, end, true);
|
||||
if (range === null) return null;
|
||||
const next = skipStrayDot(line, range.nextIndex, end);
|
||||
return {
|
||||
target: { kind: "copy", range: range.range, cut: false },
|
||||
nextIndex: consumeOptionalColon(line, next, end),
|
||||
};
|
||||
}
|
||||
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
|
||||
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
|
||||
return null;
|
||||
@@ -491,7 +585,10 @@ function classifyLine(line: string, lineNum: number): Token {
|
||||
line.startsWith(HL_DELETE_KEYWORD, lead) ||
|
||||
line.startsWith(HL_INSERT_KEYWORD, lead) ||
|
||||
line.startsWith(HL_REM_KEYWORD, lead) ||
|
||||
line.startsWith(HL_MOVE_KEYWORD, lead);
|
||||
line.startsWith(HL_MOVE_KEYWORD, lead) ||
|
||||
line.startsWith(HL_CUT_KEYWORD, lead) ||
|
||||
line.startsWith(HL_COPY_KEYWORD, lead) ||
|
||||
line.startsWith(HL_PASTE_KEYWORD, lead);
|
||||
if (isHunkLead) {
|
||||
const hunk = tryParseHunkHeader(line);
|
||||
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
|
||||
|
||||
@@ -40,6 +40,37 @@ export type Edit =
|
||||
blockStart?: number;
|
||||
}
|
||||
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
||||
| {
|
||||
/**
|
||||
* Clipboard capture (`COPY N.=M` / `CUT N.=M`, or the resolved form of
|
||||
* `COPY.BLK N` / `CUT.BLK N`). Captures the range's current lines into
|
||||
* the {@link Clipboard} register during the applier's clipboard
|
||||
* pre-pass. Emits no text change itself — a `CUT` additionally lowers
|
||||
* to one `delete` per range line at parse/resolve time, exactly like
|
||||
* `DEL N.=M`.
|
||||
*/
|
||||
kind: "copy";
|
||||
range: ParsedRange;
|
||||
/** True for `CUT` (capture + delete), false for `COPY` (capture only). */
|
||||
cut: boolean;
|
||||
lineNum: number;
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Clipboard insertion (`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` /
|
||||
* `PASTE.TAIL`, or the resolved form of `PASTE.BLK.POST N`). Expanded
|
||||
* by the clipboard pre-pass into one plain insert per captured line.
|
||||
* `blockStart` mirrors the insert variant's field for block-lowered
|
||||
* pastes so landing correction can slide the body across trailing
|
||||
* closer lines.
|
||||
*/
|
||||
kind: "paste";
|
||||
cursor: Cursor;
|
||||
lineNum: number;
|
||||
index: number;
|
||||
blockStart?: number;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Deferred block edit (`replace_block N:` / `delete_block N` /
|
||||
@@ -50,13 +81,15 @@ export type Edit =
|
||||
* the same `replacement` inserts + deletes that `replace start.=end:`
|
||||
* produces; an empty `payloads` (from `delete_block`) becomes a pure
|
||||
* range deletion; `mode: "insert_after"` becomes plain `after_anchor`
|
||||
* inserts at the block's last line. `applyEdits` never sees this
|
||||
* variant.
|
||||
* inserts at the block's last line; `mode: "copy"`/`"cut"` becomes a
|
||||
* `copy` edit over the resolved span (plus per-line deletes for cut);
|
||||
* `mode: "paste_after"` becomes a `paste` edit anchored after the
|
||||
* block's last line. `applyEdits` never sees this variant.
|
||||
*/
|
||||
kind: "block";
|
||||
anchor: Anchor;
|
||||
payloads: string[];
|
||||
mode?: "insert_after";
|
||||
mode?: "insert_after" | "copy" | "cut" | "paste_after";
|
||||
lineNum: number;
|
||||
index: number;
|
||||
};
|
||||
@@ -149,7 +182,7 @@ export interface BlockResolution {
|
||||
/** Last line of the resolved span (1-indexed, inclusive). */
|
||||
end: number;
|
||||
/** Which block op produced this resolution. */
|
||||
op: "replace" | "delete" | "insert_after";
|
||||
op: "replace" | "delete" | "insert_after" | "copy" | "cut" | "paste_after";
|
||||
}
|
||||
|
||||
/** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
|
||||
@@ -170,3 +203,22 @@ export interface BlockResolverRequest {
|
||||
* the contract; the host injects a tree-sitter-backed implementation.
|
||||
*/
|
||||
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
|
||||
|
||||
/**
|
||||
* Mutable clipboard register threaded through one patch application. Filled
|
||||
* by `CUT`/`COPY` edits, read by `PASTE` edits, in patch source order —
|
||||
* across sections, so content can move between files. Create one per batch
|
||||
* (`{}`) and hand it to every {@link Patcher.prepare} / `applyTo` call in
|
||||
* that batch; callers that omit it get a private per-call register.
|
||||
*/
|
||||
export interface Clipboard {
|
||||
/** Lines captured by the most recent `CUT`/`COPY`, or unset. */
|
||||
lines?: readonly string[];
|
||||
/**
|
||||
* Human-readable form of the `CUT` that filled the register (e.g.
|
||||
* `CUT 5.=10`), cleared by the first `PASTE`. A register that still has a
|
||||
* pending cut at batch end (or when overwritten) is an authoring error:
|
||||
* lines were deleted but never landed anywhere.
|
||||
*/
|
||||
pendingCut?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
applyEdits,
|
||||
type BlockResolver,
|
||||
type BlockSpan,
|
||||
type Clipboard,
|
||||
computeFileHash,
|
||||
InMemoryFilesystem,
|
||||
InMemorySnapshotStore,
|
||||
Patch,
|
||||
Patcher,
|
||||
parsePatch,
|
||||
parsePatchStreaming,
|
||||
resolveBlockEdits,
|
||||
} from "@oh-my-pi/hashline";
|
||||
|
||||
const PATH = "x.ts";
|
||||
|
||||
// Deterministic stub: the block beginning on line N spans [N, N+1].
|
||||
const stubResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 });
|
||||
|
||||
function taggedPatcher(files: Array<[string, string]>): {
|
||||
fs: InMemoryFilesystem;
|
||||
snapshots: InMemorySnapshotStore;
|
||||
patcher: Patcher;
|
||||
tags: Map<string, string>;
|
||||
} {
|
||||
const fs = new InMemoryFilesystem(files);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tags = new Map<string, string>();
|
||||
for (const [path, text] of files) tags.set(path, snapshots.record(path, text));
|
||||
return { fs, snapshots, patcher: new Patcher({ fs, snapshots }), tags };
|
||||
}
|
||||
|
||||
describe("clipboard parsing", () => {
|
||||
it("lowers `CUT N.=M` to a capture plus per-line deletes, `COPY N.=M` to a capture only", () => {
|
||||
const cut = parsePatch("CUT 2.=3").edits;
|
||||
expect(cut.map(edit => edit.kind)).toEqual(["copy", "delete", "delete"]);
|
||||
expect(cut[0]).toMatchObject({ kind: "copy", cut: true, range: { start: { line: 2 }, end: { line: 3 } } });
|
||||
|
||||
const copy = parsePatch("COPY 2.=3").edits;
|
||||
expect(copy.map(edit => edit.kind)).toEqual(["copy"]);
|
||||
expect(copy[0]).toMatchObject({ kind: "copy", cut: false });
|
||||
});
|
||||
|
||||
it("parses every PASTE position, tolerating a trailing colon", () => {
|
||||
const cursors = parsePatch("COPY 1\nPASTE.PRE 2\nPASTE.POST 3:\nPASTE.HEAD\nPASTE.TAIL:").edits.flatMap(edit =>
|
||||
edit.kind === "paste" ? [edit.cursor] : [],
|
||||
);
|
||||
expect(cursors).toEqual([
|
||||
{ kind: "before_anchor", anchor: { line: 2 } },
|
||||
{ kind: "after_anchor", anchor: { line: 3 } },
|
||||
{ kind: "bof" },
|
||||
{ kind: "eof" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects bare `PASTE` without a position", () => {
|
||||
expect(() => parsePatch("COPY 1\nPASTE")).toThrow(/`PASTE` needs a position/);
|
||||
});
|
||||
|
||||
it("rejects body rows under clipboard ops", () => {
|
||||
expect(() => parsePatch("CUT 1.=2\n+x")).toThrow(/`CUT N.=M` captures \+ deletes/);
|
||||
expect(() => parsePatch("COPY 1.=2\n+x")).toThrow(/`COPY N.=M` captures lines/);
|
||||
expect(() => parsePatch("COPY 1\nPASTE.POST 1\n+x")).toThrow(/`PASTE` inserts the clipboard content/);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
it("reports inverted CUT/COPY ranges with op-specific retry forms", () => {
|
||||
expect(() => parsePatch("CUT 5.=2")).toThrow(/`CUT 5`.*`CUT 5.=6`/);
|
||||
});
|
||||
|
||||
it("flushes a trailing bodyless clipboard op in streaming mode", () => {
|
||||
const { edits } = parsePatchStreaming("COPY 1\nPASTE.TAIL");
|
||||
expect(edits.map(edit => edit.kind)).toEqual(["copy", "paste"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboard apply semantics", () => {
|
||||
it("moves a range within a file (CUT + PASTE)", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=3\nPASTE.POST 5`);
|
||||
expect(section.applyTo("l1\nl2\nl3\nl4\nl5\n").text).toBe("l1\nl4\nl5\nl2\nl3\n");
|
||||
});
|
||||
|
||||
it("duplicates COPY content to several places; PASTE does not consume", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCOPY 2.=2\nPASTE.HEAD\nPASTE.TAIL`);
|
||||
expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l2\nl1\nl2\nl3\nl2\n");
|
||||
});
|
||||
|
||||
it("swaps two regions with sequential CUT/PASTE pairs on original coordinates", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=2\nPASTE.POST 4\nCUT 3.=4\nPASTE.PRE 1`);
|
||||
expect(section.applyTo("a1\na2\nb1\nb2").text).toBe("b1\nb2\na1\na2");
|
||||
});
|
||||
|
||||
it("rejects PASTE with an empty register", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`);
|
||||
expect(() => section.applyTo("l1\nl2\n")).toThrow(/found nothing in the clipboard/);
|
||||
});
|
||||
|
||||
it("drops an empty-register PASTE on the streaming-tolerant path", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`);
|
||||
expect(section.applyPartialTo("l1\nl2\n").text).toBe("l1\nl2\n");
|
||||
});
|
||||
|
||||
it("rejects a CUT that is never pasted when the call owns the register", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=2`);
|
||||
expect(() => section.applyTo("l1\nl2\nl3\n")).toThrow(/never pasted/);
|
||||
});
|
||||
|
||||
it("rejects capturing over un-pasted CUT content", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=1\nCUT 3.=3\nPASTE.TAIL`);
|
||||
expect(() => section.applyTo("l1\nl2\nl3\n")).toThrow(/still holds `CUT 1` content/);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range capture", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCOPY 8.=9\nPASTE.HEAD`);
|
||||
expect(() => section.applyTo("l1\nl2\n")).toThrow(/out of range \(file has 3 lines\)/);
|
||||
});
|
||||
|
||||
it("threads a caller-owned register across applyEdits calls", () => {
|
||||
const clipboard: Clipboard = {};
|
||||
const cut = parsePatch("CUT 1.=1").edits;
|
||||
const paste = parsePatch("PASTE.TAIL").edits;
|
||||
expect(applyEdits("a\nb", cut, { clipboard }).text).toBe("b");
|
||||
expect(applyEdits("x\ny", paste, { clipboard }).text).toBe("x\ny\na");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboard block ops", () => {
|
||||
it("expands CUT.BLK to a span capture plus per-line deletes", () => {
|
||||
const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits;
|
||||
const resolved = resolveBlockEdits(edits, "l1\nl2\nl3\nl4", PATH, stubResolver);
|
||||
expect(resolved.map(edit => edit.kind)).toEqual(["copy", "delete", "delete", "paste"]);
|
||||
expect(resolved[0]).toMatchObject({ kind: "copy", cut: true, range: { start: { line: 2 }, end: { line: 3 } } });
|
||||
});
|
||||
|
||||
it("moves a block after another block via PASTE.BLK.POST", () => {
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT.BLK 1\nPASTE.BLK.POST 3`);
|
||||
// stub blocks: [1,2] and [3,4].
|
||||
expect(section.applyTo("a1\na2\nb1\nb2\nrest", stubResolver).text).toBe("b1\nb2\na1\na2\nrest");
|
||||
});
|
||||
|
||||
it("echoes clipboard block resolutions with their op", () => {
|
||||
const seen: string[] = [];
|
||||
resolveBlockEdits(parsePatch("COPY.BLK 2\nPASTE.TAIL").edits, "l1\nl2\nl3", PATH, stubResolver, {
|
||||
onResolved: resolution => seen.push(resolution.op),
|
||||
});
|
||||
expect(seen).toEqual(["copy"]);
|
||||
});
|
||||
|
||||
it("rejects a single-line CUT.BLK resolution with the plain-op retry", () => {
|
||||
const single: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line });
|
||||
const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits;
|
||||
expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, single)).toThrow(/use `CUT 2`/);
|
||||
});
|
||||
|
||||
it("lowers an unresolvable PASTE.BLK.POST to a plain paste with a warning", () => {
|
||||
const warnings: string[] = [];
|
||||
const resolved = resolveBlockEdits(parsePatch("COPY 1\nPASTE.BLK.POST 2").edits, "a\nb\nc", PATH, () => null, {
|
||||
onWarning: warning => warnings.push(warning),
|
||||
});
|
||||
expect(resolved.map(edit => edit.kind)).toEqual(["copy", "paste"]);
|
||||
expect(warnings.some(warning => warning.includes("`PASTE.BLK.POST 2`"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboard across sections and batches", () => {
|
||||
it("moves lines between files within one patch", async () => {
|
||||
const a = "keep\nmove1\nmove2\n";
|
||||
const b = "b1\n";
|
||||
const { fs, patcher, tags } = taggedPatcher([
|
||||
["a.ts", a],
|
||||
["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`);
|
||||
await patcher.apply(patch);
|
||||
|
||||
expect(fs.get("a.ts")).toBe("keep\n");
|
||||
expect(fs.get("b.ts")).toBe("b1\nmove1\nmove2\n");
|
||||
});
|
||||
|
||||
it("allows a copy-only section to be a no-op clipboard source", async () => {
|
||||
const a = "shared\n";
|
||||
const b = "b1\n";
|
||||
const { fs, patcher, tags } = taggedPatcher([
|
||||
["a.ts", a],
|
||||
["b.ts", b],
|
||||
]);
|
||||
|
||||
const result = await patcher.apply(
|
||||
Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCOPY 1.=1\n[b.ts#${tags.get("b.ts")}]\nPASTE.HEAD`),
|
||||
);
|
||||
|
||||
expect(result.sections[0]?.op).toBe("noop");
|
||||
expect(fs.get("a.ts")).toBe(a);
|
||||
expect(fs.get("b.ts")).toBe("shared\nb1\n");
|
||||
});
|
||||
|
||||
it("still rejects a no-op section that mixes COPY with mutating edits", async () => {
|
||||
const a = "l1\nl2\n";
|
||||
const b = "b1\n";
|
||||
const { patcher, tags } = taggedPatcher([
|
||||
["a.ts", a],
|
||||
["b.ts", b],
|
||||
]);
|
||||
|
||||
// The SWAP restates the existing line, so the section changes nothing;
|
||||
// the COPY beside it must not shield it from the no-change guard.
|
||||
const patch = Patch.parse(
|
||||
`[a.ts#${tags.get("a.ts")}]\nCOPY 1.=1\nSWAP 2.=2:\n+l2\n[b.ts#${tags.get("b.ts")}]\nPASTE.TAIL`,
|
||||
);
|
||||
await expect(patcher.apply(patch)).rejects.toThrow(/no changes/);
|
||||
});
|
||||
|
||||
it("rejects the whole batch when a batch-local CUT is never pasted", async () => {
|
||||
const a = "l1\nl2\n";
|
||||
const { fs, patcher, tags } = taggedPatcher([["a.ts", a]]);
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCUT 1.=1`))).rejects.toThrow(/never pasted/);
|
||||
expect(fs.get("a.ts")).toBe(a);
|
||||
});
|
||||
|
||||
it("persists a host-owned register across apply calls with a carry-forward warning", 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.some(warning => warning.includes("in the clipboard"))).toBe(true);
|
||||
expect(clipboard).toMatchObject({ lines: ["l1"], pendingCut: "CUT 1" });
|
||||
|
||||
await patcher.apply(Patch.parse(`[b.ts#${tagB}]\nPASTE.TAIL`));
|
||||
expect(fs.get("b.ts")).toBe("b1\nl1\n");
|
||||
expect(clipboard.pendingCut).toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
expect(clipboard.pendingCut).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]\nDEL 1\n[b.ts#3C4D]\nDEL 1\n[a.ts#1A2B]\nDEL 3`);
|
||||
expect(patch.sections).toHaveLength(2);
|
||||
expect(patch.sections[0]?.edits.map(edit => edit.kind)).toEqual(["delete", "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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user