Files
oh-my-pi/packages/coding-agent/src/edit/modes/atom.ts
T
can1357 ffdec04af2 refactor(packages/coding-agent): reorganized atom LID parsing rules
- Centralized `line-hash.ts` hash regex sources and resolved `atom.lark` via `resolveLarkLidPlaceholders`.
- Expanded `computeLineHash` to emit `>[a-z]` and `[a-z]<` hashes for brace-context anchors.
- Replaced atom/hashline parsers' hard-coded lid regex with shared `HASHLINE_HASH_RE_SRC` and lax counterparts.
- Removed `\\TEXT` continuation handling in atom rewrites and switched multi-line replacements to `+TEXT`.
- Added brace-body insertion warning when `@Lid` on `{`-ending lines inserts at non-body-safe indent.
- Suppressed duplicate auto-rebase warnings and kept unmatched `-`/`+` ranges separate in compact previews.
2026-05-02 04:34:39 +02:00

1781 lines
67 KiB
TypeScript

/**
* Atom edit mode.
*
* Single-string compact wire format. Each file section starts with `---path`;
* each following line is one statement:
*
* @Lid move cursor to just after the anchored line
* Lid=TEXT set the anchored line to TEXT and move cursor after it
* -Lid delete the anchored line and move cursor to its slot
* LidA..LidB=TEXT replace a range with one line; following `+TEXT` lines extend it
* +TEXT insert TEXT at the cursor (or extend the slot left by a preceding
* `Lid=…` / `LidA..LidB=…` op, since the cursor sits there)
* ^ move cursor to beginning of file
* $ move cursor to end of file
* ^Lid move cursor BEFORE the anchored line
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
import type { ToolSession } from "../../tools";
import { assertEditableFile, assertEditableFileContent } from "../../tools/auto-generated-guard";
import {
invalidateFsScanAfterDelete,
invalidateFsScanAfterRename,
invalidateFsScanAfterWrite,
} from "../../tools/fs-cache-invalidation";
import { outputMeta } from "../../tools/output-meta";
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
import { generateDiffString } from "../diff";
import {
computeLineHash,
HASHLINE_HASH_LAX_RE_SRC,
HASHLINE_HASH_WIDTH_LABEL,
HASHLINE_LID_LAX_CAPTURE_RE_SRC,
HASHLINE_LID_LAX_RE_SRC,
} from "../line-hash";
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
import type { EditToolDetails, LspBatchRequest } from "../renderer";
import {
ANCHOR_REBASE_WINDOW,
type Anchor,
buildCompactHashlineDiffPreview,
HashlineMismatchError,
type HashMismatch,
tryRebaseAnchor,
} from "./hashline";
// ═══════════════════════════════════════════════════════════════════════════
// Schema
// ═══════════════════════════════════════════════════════════════════════════
export const atomEditParamsSchema = Type.Object({ input: Type.String() });
export type AtomParams = Static<typeof atomEditParamsSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// Parser
// ═══════════════════════════════════════════════════════════════════════════
// All Lid-shape regexes derive from {@link HASHLINE_LID_LAX_RE_SRC} (and its
// capture-group sibling {@link HASHLINE_LID_LAX_CAPTURE_RE_SRC}). Atom uses
// the lax form so a syntactically well-formed but stale Lid (line was edited
// since read) flows through to a HashlineMismatchError downstream instead of
// an opaque parse error. Every Lid-shape change lives in line-hash.ts; the
// atom parser composes against the centralized sources below.
const LID_RE = new RegExp(`^${HASHLINE_LID_LAX_CAPTURE_RE_SRC}`);
const LID_EXACT_RE = new RegExp(`^${HASHLINE_LID_LAX_CAPTURE_RE_SRC}$`);
// Sentinel hash used for interior line anchors synthesized from `-LidA..LidB`
// range deletes. validateAtomAnchors recognizes this and skips hash checking
// (only the start and end Lids' hashes are validated by the user).
const RANGE_INTERIOR_HASH = "**";
interface ParsedAnchor {
line: number;
hash: string;
}
type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op: "delete" };
type AnchorStmt =
| { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
| { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
| { kind: "before_anchor"; anchor: ParsedAnchor; lineNum: number }
| { kind: "bof"; lineNum: number }
| { kind: "eof"; lineNum: number };
type InsertStmt = {
kind: "insert";
text: string;
lineNum: number;
};
type DiffishAddStmt = {
kind: "diffish_add";
anchor: ParsedAnchor;
separator: "=" | "|";
text: string;
lineNum: number;
};
type DeleteWithOldStmt = {
kind: "delete_with_old";
anchor: ParsedAnchor;
old: string;
lineNum: number;
};
type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
type AtomCursor =
| { kind: "bof" }
| { kind: "eof" }
| { kind: "anchor"; anchor: Anchor }
| { kind: "before_anchor"; anchor: Anchor };
export type AtomEdit =
| { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
| { kind: "set"; anchor: Anchor; text: string; lineNum: number; index: number; allowOldNewRepair: boolean }
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
interface AtomApplyResult {
lines: string;
firstChangedLine?: number;
warnings?: string[];
noopEdits?: AtomNoopEdit[];
}
interface AtomNoopEdit {
editIndex: number;
loc: string;
reason: string;
current: string;
}
interface IndexedAnchorEdit {
edit: Extract<AtomEdit, { kind: "insert" | "set" | "delete" }>;
idx: number;
}
function cloneCursor(cursor: AtomCursor): AtomCursor {
if (cursor.kind === "anchor") return { kind: "anchor", anchor: { ...cursor.anchor } };
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
return cursor;
}
function parseLidStmt(body: string, lineNum: number): ParsedStmt[] | null {
const m = LID_RE.exec(body);
if (!m) return null;
const ln = Number.parseInt(m[1], 10);
const hash = m[2];
const rest = body.slice(m[0].length);
const anchor = { line: ln, hash };
// Range replace: `LidA..LidB=TEXT` deletes the inclusive range LidA..LidB
// and inserts TEXT in its place. Following insert statements append more
// replacement lines through the normal hunk reorder path. Legacy `|` is
// accepted as a set separator for parity with single-line `Lid|TEXT`.
// Bare `LidA..LidB` recovers the common missing-`-` typo for range delete.
if (rest.startsWith("..")) {
const m2 = LID_RE.exec(rest.slice(2));
if (m2) {
const endLn = Number.parseInt(m2[1], 10);
const endHash = m2[2];
const after = rest.slice(2 + m2[0].length);
const range = `${ln}${hash}..${endLn}${endHash}`;
if (endLn < ln) {
throw new Error(
`Diff line ${lineNum}: range \`${range}\` ends before it starts. Use \`LidA..LidB=TEXT\` with LidA's line number ≤ LidB's.`,
);
}
if (endLn === ln && endHash !== hash) {
throw new Error(
`Diff line ${lineNum}: range \`${range}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`${ln}${hash}=TEXT\` for a single-line replacement.`,
);
}
const stmts: ParsedStmt[] = [];
for (let l = ln; l <= endLn; l++) {
const h = l === ln ? hash : l === endLn ? endHash : RANGE_INTERIOR_HASH;
stmts.push({
kind: "anchor_op",
anchor: { line: l, hash: h },
op: { op: "delete" },
lineNum,
});
}
if (after.trim().length === 0) return stmts;
const replacement = /^[ \t]*([=|])(.*)$/.exec(after);
if (replacement) {
if (replacement[2].includes("\r")) {
throw new Error(`Diff line ${lineNum}: set value contains a carriage return; use a single-line value.`);
}
stmts.push({ kind: "insert", text: replacement[2], lineNum });
return stmts;
}
}
}
if (rest.length === 0) {
return [{ kind: "bare_anchor", anchor, lineNum }];
}
const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
if (replacement) {
return [
{
kind: "anchor_op",
anchor,
op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
lineNum,
},
];
}
// Compound shorthand: `Lid+TEXT` collapses cursor-after-Lid + insert TEXT.
// Models sometimes write a run like `103rd=A` / `103rd+B` / `103rd+C` to
// mean "set 103 to A, then insert B and C below it". Treat each `Lid+...`
// as an independent cursor-move + insert; this matches semantics of the
// canonical `@Lid` + `+TEXT` two-line form.
if (rest[0] === "+") {
return [
{ kind: "bare_anchor", anchor, lineNum },
{ kind: "insert", text: rest.slice(1), lineNum },
];
}
return null;
}
function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
const trimmedBody = body.trimStart();
// Range delete: `-LidA..LidB` deletes the contiguous range LidA..LidB inclusive.
const rangeRe = new RegExp(`^${HASHLINE_LID_LAX_CAPTURE_RE_SRC}\\.\\.${HASHLINE_LID_LAX_CAPTURE_RE_SRC}$`);
const rangeMatch = rangeRe.exec(trimmedBody);
if (rangeMatch) {
const startLine = Number.parseInt(rangeMatch[1], 10);
const startHash = rangeMatch[2];
const endLine = Number.parseInt(rangeMatch[3], 10);
const endHash = rangeMatch[4];
if (endLine < startLine) {
throw new Error(
`Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` ends before it starts. Use \`-LidA..LidB\` with LidA's line number ≤ LidB's.`,
);
}
if (endLine === startLine && endHash !== startHash) {
throw new Error(
`Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`-${startLine}${startHash}\` for a single-line delete.`,
);
}
const stmts: ParsedStmt[] = [];
for (let ln = startLine; ln <= endLine; ln++) {
const hash = ln === startLine ? startHash : ln === endLine ? endHash : RANGE_INTERIOR_HASH;
stmts.push({
kind: "anchor_op",
anchor: { line: ln, hash },
op: { op: "delete" },
lineNum,
});
}
return stmts;
}
// `-LidA..LidB|TEXT` and `-LidA..LidB=TEXT` are not valid: ranges have no
// `|` (delete-with-old) form. Models reach for these when trying to
// "delete the range and replace with TEXT" — point at the `LidA..LidB=TEXT`
// shorthand instead.
const rangeWithSuffix = new RegExp(
`^${HASHLINE_LID_LAX_CAPTURE_RE_SRC}\\.\\.${HASHLINE_LID_LAX_CAPTURE_RE_SRC}[ \\t]*[=|](.*)$`,
).exec(trimmedBody);
if (rangeWithSuffix) {
const lidA = `${rangeWithSuffix[1]}${rangeWithSuffix[2]}`;
const lidB = `${rangeWithSuffix[3]}${rangeWithSuffix[4]}`;
const text = rangeWithSuffix[5];
throw new Error(
`Diff line ${lineNum}: \`-${lidA}..${lidB}\` cannot have a \`|\`/\`=\` suffix. To delete the range, use \`-${lidA}..${lidB}\` alone. To replace the range with one new line, drop the leading \`-\` and use \`${lidA}..${lidB}=${text}\`.`,
);
}
const exact = LID_EXACT_RE.exec(trimmedBody);
if (exact) {
const ln = Number.parseInt(exact[1], 10);
return [{ kind: "anchor_op", anchor: { line: ln, hash: exact[2] }, op: { op: "delete" }, lineNum }];
}
const m = LID_RE.exec(trimmedBody);
if (m && (trimmedBody[m[0].length] === "|" || trimmedBody[m[0].length] === "=")) {
const ln = Number.parseInt(m[1], 10);
const old = trimmedBody.slice(m[0].length + 1);
return [{ kind: "delete_with_old", anchor: { line: ln, hash: m[2] }, old, lineNum }];
}
if (m && trimmedBody[m[0].length] === " ") {
const ln = Number.parseInt(m[1], 10);
const text = trimmedBody.slice(m[0].length + 1);
return [
{ kind: "anchor_op", anchor: { line: ln, hash: m[2] }, op: { op: "delete" }, lineNum },
{ kind: "insert", text, lineNum },
];
}
return null;
}
function parseIndentedHashlineStmt(line: string, lineNum: number): ParsedStmt[] | null {
const trimmed = line.trimStart();
if (trimmed === line) return null;
const stmts = parseLidStmt(trimmed, lineNum);
if (!stmts) return null;
const safeHashlineEcho = stmts.every(
stmt => stmt.kind === "bare_anchor" || (stmt.kind === "anchor_op" && stmt.op.op === "set"),
);
return safeHashlineEcho ? stmts : null;
}
function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
const text = line.trimStart();
const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
const withoutMove = withoutLegacyMove.startsWith("@") ? withoutLegacyMove.slice(1) : withoutLegacyMove;
const withoutDelete = withoutMove.startsWith("-") ? withoutMove.slice(1).trimStart() : withoutMove;
const partial = new RegExp(`^(${HASHLINE_HASH_LAX_RE_SRC})(?=[ \\t]*[=|])`).exec(withoutDelete);
if (partial) {
throw new Error(
`Diff line ${lineNum}: \`${partial[1]}\` is not a full Lid. Use the full Lid from read output, e.g. \`119${partial[1]}\`.`,
);
}
const missing = /^([1-9]\d*)(?=[ \t]*[=|]|$)/.exec(withoutDelete);
if (missing) {
const prefix = text.startsWith("@@ ") ? `@@ ${missing[1]}` : missing[1];
throw new Error(
`Diff line ${lineNum}: \`${prefix}\` is missing the ${HASHLINE_HASH_WIDTH_LABEL} Lid suffix. Use the full Lid from read output, e.g. \`${prefix.startsWith("@@ ") ? "@@ " : ""}${missing[1]}ab\`.`,
);
}
throw new Error(`Diff line ${lineNum}: cannot parse "${raw}".`);
}
function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
// Strip trailing CR (CRLF tolerance).
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (line.length === 0) return [];
// `# ...` comments are silently ignored. Models often add section headers
// or annotations like `# Test 1: replace enum`; treating these as literal
// inserts corrupts files, and the canonical syntax has no comment op.
if (line[0] === "#") return [];
// `\TEXT` continuation has been removed. After a `Lid=…` or `LidA..LidB=…`
// op, the cursor sits on the just-set/just-deleted slot; the canonical way
// to extend a single-line set or range-replace into a multi-line replacement
// is to follow it with `+TEXT` lines, which insert at that cursor.
if (line[0] === "\\") {
throw new Error(
`Diff line ${lineNum}: \`\\TEXT\` continuation has been removed. Use \`+TEXT\` to insert content after a \`Lid=…\` or \`LidA..LidB=…\` op — the cursor sits on the just-set/just-deleted slot, so following \`+TEXT\` lines extend the replacement.`,
);
}
const indentedHashline = parseIndentedHashlineStmt(line, lineNum);
if (indentedHashline) return indentedHashline;
// `+TEXT` inserts at the cursor. Everything after `+` is content. A
// `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
// emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
if (line[0] === "+") {
const body = line.slice(1);
const m = LID_RE.exec(body);
if (m) {
const sep = body[m[0].length];
if (sep === "=" || sep === "|") {
const ln = Number.parseInt(m[1], 10);
const text = body.slice(m[0].length + 1);
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, separator: sep, text, lineNum }];
}
}
// Auto-fix: `+@Lid` and `+-Lid` are almost always typos where the agent
// prefixed a cursor-move or delete op with `+`. Insert content matching
// these op shapes is essentially never legitimate in source code, and
// silently emitting them as literal text corrupts the file (e.g. a stray
// `@12ly` line in a C++ source). Split into the op + a blank `+` insert
// so the line count of the edit script is preserved for any downstream
// offset-sensitive logic.
if (body.length > 1 && (body[0] === "@" || body[0] === "-")) {
try {
const opStmts = parseDiffLine(body, lineNum);
const allOps = opStmts.length > 0 && opStmts.every(s => s.kind !== "insert" && s.kind !== "diffish_add");
if (allOps) {
return [...opStmts, { kind: "insert", text: "", lineNum }];
}
} catch {
// Body looked op-shaped but failed to parse; fall through to literal insert.
}
}
return [{ kind: "insert", text: body, lineNum }];
}
// Canonical file-scope locators.
if (line === "^") return [{ kind: "bof", lineNum }];
if (line === "$") return [{ kind: "eof", lineNum }];
// Compound shorthand: `^+TEXT` and `$+TEXT` collapse a file-scope cursor
// move and an insert onto one line. Models occasionally do this when
// creating files from scratch (`^+content`) instead of the canonical
// `^\n+content`. No legitimate op line starts with `^+` or `$+`, so the
// expansion is unambiguous.
if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "+") {
const cursor: ParsedStmt = line[0] === "^" ? { kind: "bof", lineNum } : { kind: "eof", lineNum };
return [cursor, { kind: "insert", text: line.slice(2), lineNum }];
}
// `^=TEXT` and `$=TEXT` are not valid: `^` and `$` are cursor moves only.
// Models reach for these when trying to "replace the last line" or
// "replace the first line"; emit a clear diagnostic instead of falling
// through to "unrecognized op".
if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "=") {
const where = line[0] === "^" ? "first" : "last";
const sym = line[0];
throw new Error(
`Diff line ${lineNum}: \`${sym}=TEXT\` is not a valid op. \`${sym}\` only moves the cursor (${sym === "^" ? "BOF" : "EOF"}); it cannot replace a line. To replace the ${where} line, use its Lid (e.g. \`5xx=TEXT\`). To insert at ${sym === "^" ? "BOF" : "EOF"}, use \`${sym}\` followed by \`+TEXT\` on the next line.`,
);
}
// `^Lid` cursor moves BEFORE the anchored line (insert above). Compound
// shorthand `^Lid+TEXT` collapses the cursor move and an insert into one
// line. `^Lid=TEXT` and `^Lid|TEXT` are flagged as ambiguous: pick either
// `^Lid` (cursor before) + `+TEXT`, or `Lid=TEXT` (replace in place).
if (line[0] === "^" && line.length > 1) {
const m = LID_RE.exec(line.slice(1));
if (m) {
const ln = Number.parseInt(m[1], 10);
const hash = m[2];
const sep = line[1 + m[0].length];
if (sep === undefined) {
return [{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum }];
}
if (sep === "+") {
const text = line.slice(1 + m[0].length + 1);
return [
{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum },
{ kind: "insert", text, lineNum },
];
}
if (sep === "=" || sep === "|") {
throw new Error(
`Diff line ${lineNum}: \`^${ln}${hash}${sep}...\` mixes \`^Lid\` (cursor before line) with \`Lid=TEXT\` (replace line). Pick one: \`^${ln}${hash}\` then \`+TEXT\` on the next line to insert above; or \`${ln}${hash}=TEXT\` to replace the line in place.`,
);
}
}
}
// `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
// historical `-Lid TEXT` delete-then-insert recovery.
if (line[0] === "-") {
const parsed = parseDeleteStmt(line.slice(1), lineNum);
if (parsed) return parsed;
throw new Error(`Diff line ${lineNum}: \`-\` must be followed by a Lid (e.g. \`-5xx\`). Got "${raw}".`);
}
// Legacy move prefix. Runtime accepts old locators and common slipped edit
// operations, while the grammar/prompt bias models to canonical syntax.
if (line.startsWith("@@ ")) {
const body = line.slice(3);
if (body === "BOF") return [{ kind: "bof", lineNum }];
if (body === "EOF") return [{ kind: "eof", lineNum }];
const deleteStmt = body.startsWith("-") ? parseDeleteStmt(body.slice(1), lineNum) : null;
if (deleteStmt) return deleteStmt;
const lidStmt = parseLidStmt(body, lineNum);
if (lidStmt) return lidStmt;
throwMalformedLidDiagnostic(line, lineNum, raw);
}
// Canonical `@Lid` cursor moves. Leniently recover `@Lid=TEXT`,
// `@Lid|TEXT`, `@$`, and `@^`.
if (line[0] === "@") {
const body = line.slice(1);
if (body === "^") return [{ kind: "bof", lineNum }];
if (body === "$") return [{ kind: "eof", lineNum }];
const lidStmt = parseLidStmt(body, lineNum);
if (lidStmt) return lidStmt;
throwMalformedLidDiagnostic(line, lineNum, raw);
}
// `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
// A bare `Lid` is a cursor move.
const lidStmt = parseLidStmt(line, lineNum);
if (lidStmt) return lidStmt;
if (new RegExp(`^${HASHLINE_HASH_LAX_RE_SRC}(?=[ \\t]*[=|])`).test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
throwMalformedLidDiagnostic(line, lineNum, raw);
}
// Reject any line that doesn't match a recognized op. Common case: a model
// emitted multi-line content after a `Lid=` or similar without `+` prefixes,
// or pasted raw context. Silently treating these as inserts corrupts files.
const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
const trailingDash = new RegExp(`^(${HASHLINE_LID_LAX_RE_SRC})-\\s*$`).exec(line);
if (trailingDash) {
throw new Error(
`Diff line ${lineNum}: \`${line}\` looks like a delete with the operator on the wrong side. Use \`-${trailingDash[1]}\` to delete that line.`,
);
}
throw new Error(
`Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
);
}
function tokenizeDiff(diff: string): ParsedStmt[] {
const out: ParsedStmt[] = [];
const lines = diff.split("\n");
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const stmts = parseDiffLine(lines[i], lineNum);
for (const stmt of stmts) {
// Last-set-wins: when the same anchor (line+hash) gets a second `set`,
// drop the earlier one. Models sometimes echo the OLD line and then the
// NEW line as replacements (e.g. `119yh|OLD` / `119yh|NEW`); the last is
// the intended value.
if (stmt.kind === "anchor_op" && stmt.op.op === "set") {
const key = `${stmt.anchor.line}:${stmt.anchor.hash}`;
for (let j = out.length - 1; j >= 0; j--) {
const prior = out[j];
if (
prior.kind === "anchor_op" &&
prior.op.op === "set" &&
`${prior.anchor.line}:${prior.anchor.hash}` === key
) {
out.splice(j, 1);
break;
}
}
}
out.push(stmt);
}
}
return normalizeHunks(out);
}
// Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
// hunks and reorder so adds land at the FIRST delete's slot (block
// replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
// `set`; malformed standalone or mismatched `+Lid|TEXT`/`+Lid=TEXT` lines
// throw instead of silently dropping the Lid prefix.
function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
const isDelete = (s: ParsedStmt): boolean =>
(s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
const formatDiffishAdd = (stmt: DiffishAddStmt): string =>
`+${stmt.anchor.line}${stmt.anchor.hash}${stmt.separator}${stmt.text}`;
const out: ParsedStmt[] = [];
let i = 0;
while (i < stmts.length) {
const stmt = stmts[i];
if (!isDelete(stmt)) {
if (stmt.kind === "diffish_add") {
const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
throw new Error(
`Diff line ${stmt.lineNum}: \`${formatDiffishAdd(stmt)}\` is unified-diff syntax, not edit syntax. To replace a line, use \`${lid}=TEXT\`; to insert literal text, use \`+TEXT\` without a Lid prefix.`,
);
}
out.push(stmt);
i++;
continue;
}
const deletes: ParsedStmt[] = [];
while (i < stmts.length && isDelete(stmts[i])) {
deletes.push(stmts[i]);
i++;
}
const adds: ParsedStmt[] = [];
while (i < stmts.length && isAdd(stmts[i])) {
adds.push(stmts[i]);
i++;
}
const deletedLids = new Set(
deletes.map(d => {
const a = (d as { anchor: ParsedAnchor }).anchor;
return `${a.line}${a.hash}`;
}),
);
for (const add of adds) {
if (add.kind !== "diffish_add") continue;
const lid = `${add.anchor.line}${add.anchor.hash}`;
if (!deletedLids.has(lid)) {
throw new Error(
`Diff line ${add.lineNum}: \`${formatDiffishAdd(add)}\` references a Lid not in the preceding delete run. Use plain \`+TEXT\` for replacement lines, or delete \`${lid}\` before using a unified-diff recovery line for that Lid.`,
);
}
}
// Split the delete run into file-contiguous sub-runs. The block
// reorder (inserts land at the FIRST delete's slot) is meaningful only
// when the deletes describe a single contiguous file range. When the
// agent stacks deletes that target far-apart lines (e.g. `-186 -197
// -198 -199` to remove a debug line at 186 AND replace 197-199), each
// far-apart delete moves the cursor on its own; only the LAST
// contiguous group should attract the inserts.
const subruns = splitContiguousDeletes(deletes);
for (let r = 0; r < subruns.length - 1; r++) {
for (const d of subruns[r]) out.push(d);
}
const lastDeletes = subruns[subruns.length - 1];
// Single-line case: 1 delete in the last sub-run + 1 diffish_add same Lid → fuse to set.
if (lastDeletes.length === 1 && adds.length === 1 && adds[0].kind === "diffish_add") {
const dAnchor = (lastDeletes[0] as { anchor: ParsedAnchor }).anchor;
const a = adds[0];
if (a.anchor.line === dAnchor.line && a.anchor.hash === dAnchor.hash) {
out.push({
kind: "anchor_op",
anchor: a.anchor,
op: { op: "set", text: a.text, allowOldNewRepair: false },
lineNum: a.lineNum,
});
continue;
}
}
// Block: emit lastDeletes[0], then all inserts (which land at lastDeletes[0]'s slot
// because the cursor binds to lastDeletes[0] before the inserts), then the
// remaining lastDeletes.
out.push(lastDeletes[0]);
for (const add of adds) {
const text = add.kind === "insert" ? add.text : (add as DiffishAddStmt).text;
out.push({ kind: "insert", text, lineNum: add.lineNum });
}
for (let j = 1; j < lastDeletes.length; j++) {
out.push(lastDeletes[j]);
}
}
return out;
}
function makeAnchor(anchor: ParsedAnchor): Anchor {
return { line: anchor.line, hash: anchor.hash };
}
function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
if (deletes.length === 0) return [];
const getLine = (s: ParsedStmt): number => {
if (s.kind === "anchor_op") return s.anchor.line;
if (s.kind === "delete_with_old") return s.anchor.line;
throw new Error("internal: splitContiguousDeletes received non-delete stmt");
};
const subruns: ParsedStmt[][] = [];
let current: ParsedStmt[] = [deletes[0]];
for (let i = 1; i < deletes.length; i++) {
if (getLine(deletes[i]) === getLine(deletes[i - 1]) + 1) {
current.push(deletes[i]);
} else {
subruns.push(current);
current = [deletes[i]];
}
}
subruns.push(current);
return subruns;
}
// ═══════════════════════════════════════════════════════════════════════════
// Build cursor-program from ParsedStmt[]
// ═══════════════════════════════════════════════════════════════════════════
export function parseAtom(diff: string): AtomEdit[] {
return parseAtomWithWarnings(diff).edits;
}
export function parseAtomWithWarnings(diff: string): { edits: AtomEdit[]; warnings: string[] } {
const edits: AtomEdit[] = [];
const warnings: string[] = [];
let cursor: AtomCursor = { kind: "eof" };
let index = 0;
for (const stmt of tokenizeDiff(diff)) {
if (stmt.kind === "insert") {
edits.push({ kind: "insert", cursor: cloneCursor(cursor), text: stmt.text, lineNum: stmt.lineNum, index });
index++;
continue;
}
if (stmt.kind === "bof") {
cursor = { kind: "bof" };
continue;
}
if (stmt.kind === "eof") {
cursor = { kind: "eof" };
continue;
}
if (stmt.kind === "delete_with_old") {
const anchor = makeAnchor(stmt.anchor);
cursor = { kind: "anchor", anchor: { ...anchor } };
edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index, oldAssertion: stmt.old });
index++;
continue;
}
if (stmt.kind === "diffish_add") {
throw new Error("Internal edit error: unresolved diff-ish add reached parser.");
}
if (stmt.kind === "before_anchor") {
cursor = { kind: "before_anchor", anchor: makeAnchor(stmt.anchor) };
continue;
}
const anchor = makeAnchor(stmt.anchor);
cursor = { kind: "anchor", anchor: { ...anchor } };
if (stmt.kind === "bare_anchor") continue;
if (stmt.op.op === "set") {
if (stmt.op.text.includes("\r")) {
throw new Error(
`Diff line ${stmt.lineNum}: set value contains a carriage return; use a single-line value.`,
);
}
edits.push({
kind: "set",
anchor,
text: stmt.op.text,
lineNum: stmt.lineNum,
index,
allowOldNewRepair: stmt.op.allowOldNewRepair,
});
index++;
continue;
}
edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index });
index++;
}
return { edits, warnings };
}
function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
const body = diff
.split("\n")
.map(line => (line.endsWith("\r") ? line.slice(0, -1) : line))
.filter(line => line.trim().length > 0)
.slice(0, 3)
.map(line => ` ${line}`)
.join("\n");
const preview = body.length > 0 ? `\nReceived only locator/context lines:\n${body}` : "";
return `Cursor moved but no mutation found. Add +TEXT to insert, -Lid to delete, or Lid=TEXT to replace.${preview}`;
}
// ═══════════════════════════════════════════════════════════════════════════
// Apply cursor-program
// ═══════════════════════════════════════════════════════════════════════════
function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
if (edit.cursor.kind === "anchor" || edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
return [];
}
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
const mismatches: HashMismatch[] = [];
const rebasedAnchors = new Map<Anchor, HashMismatch>();
const emittedRebaseKeys = new Set<string>();
for (const edit of edits) {
for (const anchor of getAtomEditAnchors(edit)) {
if (anchor.line < 1 || anchor.line > fileLines.length) {
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
}
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
if (actualHash === anchor.hash) continue;
const rebased = tryRebaseAnchor(anchor, fileLines);
if (rebased !== null) {
const original = `${anchor.line}${anchor.hash}`;
rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
anchor.line = rebased;
const rebaseKey = `${original}→${rebased}${anchor.hash}`;
if (!emittedRebaseKeys.has(rebaseKey)) {
emittedRebaseKeys.add(rebaseKey);
warnings.push(
`Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
);
}
continue;
}
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
}
}
// Detect post-rebase conflicts. If any conflicting anchor was rebased, surface
// the original hash mismatch instead — the rebase itself is what created the
// conflict, and the model needs to fix the stale anchor, not deduplicate.
const seenLines = new Map<number, Anchor>();
for (const edit of edits) {
if (edit.kind !== "set" && edit.kind !== "delete") continue;
const existing = seenLines.get(edit.anchor.line);
if (existing) {
const rebasedA = rebasedAnchors.get(edit.anchor);
const rebasedB = rebasedAnchors.get(existing);
if (rebasedA) mismatches.push(rebasedA);
else if (rebasedB) mismatches.push(rebasedB);
continue;
}
seenLines.set(edit.anchor.line, edit.anchor);
}
return mismatches;
}
function validateNoConflictingAtomMutations(edits: AtomEdit[]): void {
const mutatingPerLine = new Map<number, string>();
for (const edit of edits) {
if (edit.kind !== "set" && edit.kind !== "delete") continue;
const existing = mutatingPerLine.get(edit.anchor.line);
if (existing) {
if (existing === "delete" && edit.kind === "delete") continue;
throw new Error(
`Conflicting ops on anchor line ${edit.anchor.line}: \`${existing}\` and \`${edit.kind}\`. ` +
"At most one mutating op (set/delete) is allowed per anchor.",
);
}
mutatingPerLine.set(edit.anchor.line, edit.kind);
}
}
// Heuristic: warn when `@Lid` lands on a brace-opening line and the
// subsequent inserts are at sibling indent (≤ anchor indent), suggesting
// the agent meant `^<nextSibling>` instead. The line still ends with `{`
// after the hash-marker swap (markers became `[a-z]<` / `>[a-z]`); the
// raw line content is unchanged, so this check works regardless of hash
// alphabet.
function detectAtomBracePositioningWarnings(edits: AtomEdit[], fileLines: string[]): string[] {
const warnings: string[] = [];
const seen = new Set<number>();
const getLeadingWhitespace = (line: string): string => {
const m = /^\s*/.exec(line);
return m ? m[0] : "";
};
const formatPreview = (text: string): string => (text.length > 60 ? `${text.slice(0, 60)}…` : text);
for (const edit of edits) {
// Only `@Lid` (after-anchor) inserts are at risk. `^Lid` (before),
// BOF, EOF, and direct `set`/`delete` ops do not have the foot-gun.
if (edit.kind !== "insert" || edit.cursor.kind !== "anchor") continue;
const anchor = edit.cursor.anchor;
if (seen.has(anchor.line)) continue;
if (anchor.line < 1 || anchor.line > fileLines.length) continue;
const anchorLine = fileLines[anchor.line - 1];
const trimmedEnd = anchorLine.trimEnd();
if (!trimmedEnd.endsWith("{")) continue;
// Find the first non-blank `+TEXT` after this `@Lid`.
const firstInsert = edits.find(
e =>
e.kind === "insert" &&
e.cursor.kind === "anchor" &&
e.cursor.anchor.line === anchor.line &&
e.text.trim().length > 0,
);
if (!firstInsert || firstInsert.kind !== "insert") continue;
const anchorIndent = getLeadingWhitespace(anchorLine);
const insertIndent = getLeadingWhitespace(firstInsert.text);
// Body content is STRICTLY more indented than the brace-opening line.
// `startsWith(anchorIndent)` rejects mixed tab/space cases that look
// longer in chars but differ in actual indent shape.
const isProperlyNested = insertIndent.length > anchorIndent.length && insertIndent.startsWith(anchorIndent);
if (isProperlyNested) continue;
seen.add(anchor.line);
const lid = `${anchor.line}${anchor.hash}`;
warnings.push(
`@${lid} inserts at indent ${insertIndent.length} just inside the brace-opening line "${formatPreview(trimmedEnd)}" (indent ${anchorIndent.length}); the inserted content sits inside the {...} block as the first body element. If you meant a sibling (after the closing brace), use \`^Lid\` on the next sibling line. If you meant body content, indent past column ${anchorIndent.length}.`,
);
}
return warnings;
}
function repairAtomOldNewSetLine(currentLine: string, nextLine: string): string {
const marker = `${currentLine}|`;
if (!nextLine.startsWith(marker)) return nextLine;
const repaired = nextLine.slice(marker.length);
return repaired.length > 0 ? repaired : nextLine;
}
function insertAtStart(fileLines: string[], lines: string[]): void {
if (lines.length === 0) return;
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...lines);
return;
}
fileLines.splice(0, 0, ...lines);
}
function insertAtEnd(fileLines: string[], lines: string[]): number | undefined {
if (lines.length === 0) return undefined;
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...lines);
return 1;
}
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
fileLines.splice(insertIdx, 0, ...lines);
return insertIdx + 1;
}
function isSameFileCursor(a: AtomCursor, b: AtomCursor): boolean {
return a.kind === b.kind && a.kind !== "anchor";
}
function collectFileInsertRuns(
fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
): Array<{ cursor: AtomCursor; lines: string[] }> {
const runs: Array<{ cursor: AtomCursor; lines: string[] }> = [];
for (const edit of fileInserts.sort((a, b) => a.index - b.index)) {
const prev = runs[runs.length - 1];
if (prev && isSameFileCursor(prev.cursor, edit.cursor)) {
prev.lines.push(edit.text);
continue;
}
runs.push({ cursor: edit.cursor, lines: [edit.text] });
}
return runs;
}
function applyFileCursorInserts(
fileLines: string[],
fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
): number | undefined {
let firstChangedLine: number | undefined;
const trackFirstChanged = (line: number) => {
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
};
for (const run of collectFileInsertRuns(fileInserts)) {
if (run.cursor.kind === "bof") {
insertAtStart(fileLines, run.lines);
trackFirstChanged(1);
continue;
}
if (run.cursor.kind === "eof") {
const changedLine = insertAtEnd(fileLines, run.lines);
if (changedLine !== undefined) trackFirstChanged(changedLine);
}
}
return firstChangedLine;
}
function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
if (edit.kind !== "insert") return edit.anchor;
if (edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
throw new Error("Internal edit error: file-scoped insert reached anchor application.");
}
return edit.cursor.anchor;
}
// Heuristic: detect (and when safe, auto-fix) lines that became adjacent
// duplicates of themselves after the edit, when they were not adjacent
// duplicates before. This is the signature of a botched block rewrite that
// missed one delete on the front or back of the deletion range, leaving a
// stale copy of a line the agent already re-emitted (e.g. inserting a new
// closing `}` while the original `}` was never deleted, producing `}\n}`).
// A single edit may damage multiple unrelated segments (e.g. two block
// rewrites that each missed their trailing `}`), so detection and auto-fix
// operate on every new adjacent duplicate at once.
//
// Auto-fix is gated on bracket balance: we only remove the duplicate line if
// its removal restores the original file's `{}`/`()`/`[]` delta. That makes
// the fix safe in the common case (a stray closing brace shifts balance by
// one) and conservative when the duplicate is intentional (balance unchanged
// → warning only). When two adjacent lines are textually identical, removing
// either yields the same content, so we don't have to decide which is "the
// stale copy" — we just remove one and verify balance restores.
function detectAndAutoFixDuplicates(
originalLines: string[],
finalLines: string[],
): { fixed: string[] | null; warnings: string[] } {
const countAdjacent = (lines: string[]): Map<string, number> => {
const counts = new Map<string, number>();
for (let i = 0; i + 1 < lines.length; i++) {
if (lines[i] !== lines[i + 1]) continue;
if (lines[i].trim().length === 0) continue;
counts.set(lines[i], (counts.get(lines[i]) ?? 0) + 1);
}
return counts;
};
const computeBalance = (lines: string[]): { brace: number; paren: number; bracket: number } => {
let brace = 0;
let paren = 0;
let bracket = 0;
for (const line of lines) {
for (const ch of line) {
if (ch === "{") brace++;
else if (ch === "}") brace--;
else if (ch === "(") paren++;
else if (ch === ")") paren--;
else if (ch === "[") bracket++;
else if (ch === "]") bracket--;
}
}
return { brace, paren, bracket };
};
const balancesEqual = (
a: { brace: number; paren: number; bracket: number },
b: { brace: number; paren: number; bracket: number },
): boolean => a.brace === b.brace && a.paren === b.paren && a.bracket === b.bracket;
const orig = countAdjacent(originalLines);
const fin = countAdjacent(finalLines);
const newDupPositions: number[] = [];
for (let i = 0; i + 1 < finalLines.length; i++) {
if (finalLines[i] !== finalLines[i + 1]) continue;
if (finalLines[i].trim().length === 0) continue;
const text = finalLines[i];
if ((fin.get(text) ?? 0) <= (orig.get(text) ?? 0)) continue;
newDupPositions.push(i);
}
if (newDupPositions.length === 0) return { fixed: null, warnings: [] };
const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
// Auto-fix when removing one line from each new adjacent duplicate pair
// collectively restores the original bracket balance. The balance check is
// the safety gate: if we over- or under-correct (e.g. when 3+ adjacent
// identical lines confuse the per-pair scan), the trial balance will not
// match and we fall through to warnings.
const origBalance = computeBalance(originalLines);
const finalBalance = computeBalance(finalLines);
if (!balancesEqual(origBalance, finalBalance)) {
const trial = finalLines.slice();
// Remove in reverse so earlier indices remain valid.
for (let i = newDupPositions.length - 1; i >= 0; i--) {
trial.splice(newDupPositions[i], 1);
}
if (balancesEqual(computeBalance(trial), origBalance)) {
const previews = newDupPositions.map(pos => `${pos + 1} (${formatPreview(finalLines[pos])})`).join(", ");
const noun = newDupPositions.length === 1 ? "duplicate line" : "duplicate lines";
return {
fixed: trial,
warnings: [
`AUTO-FIX applied — verify the result. Removed ${noun} ${previews} to restore {}/()/[] balance the edit broke. If this is wrong, re-issue the edit so the duplicate is not produced in the first place.`,
],
};
}
}
const warnings = newDupPositions.slice(0, 3).map(pos => {
return `Suspicious duplicate: lines ${pos + 1} and ${pos + 2} are both ${formatPreview(finalLines[pos])}. The edit may have left a stale copy of a line you meant to replace — verify the result.`;
});
return { fixed: null, warnings };
}
export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult {
if (edits.length === 0) {
return { lines: text, firstChangedLine: undefined };
}
const fileLines = text.split("\n");
const originalLines = fileLines.slice();
const warnings: string[] = [];
let firstChangedLine: number | undefined;
const noopEdits: AtomNoopEdit[] = [];
const mismatches = validateAtomAnchors(edits, fileLines, warnings);
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, fileLines);
}
validateNoConflictingAtomMutations(edits);
for (const w of detectAtomBracePositioningWarnings(edits, fileLines)) warnings.push(w);
const trackFirstChanged = (line: number) => {
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
};
const anchorEdits: IndexedAnchorEdit[] = [];
const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
edits.forEach((edit, idx) => {
if (edit.kind === "insert" && edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
fileInserts.push(edit);
return;
}
anchorEdits.push({ edit, idx });
});
const byLine = new Map<number, IndexedAnchorEdit[]>();
for (const entry of anchorEdits) {
const line = getAnchorForAnchorEdit(entry.edit).line;
const bucket = byLine.get(line);
if (bucket) {
bucket.push(entry);
} else {
byLine.set(line, [entry]);
}
}
const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
for (const line of anchorLines) {
const bucket = byLine.get(line);
if (!bucket) continue;
bucket.sort((a, b) => a.idx - b.idx);
const idx = line - 1;
const currentLine = fileLines[idx];
let replacement: string[] = [currentLine];
let replacementSet = false;
let anchorMutated = false;
const beforeLines: string[] = [];
const afterLines: string[] = [];
for (const { edit } of bucket) {
switch (edit.kind) {
case "insert":
if (edit.cursor.kind === "before_anchor") {
beforeLines.push(edit.text);
} else {
afterLines.push(edit.text);
}
break;
case "set":
replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
replacementSet = true;
anchorMutated = true;
break;
case "delete":
// `-Lid|OLD` / `-Lid=OLD`: the OLD payload is informational only.
// The Lid hash already validates the line content (and auto-rebases
// when lines have shifted), so we ignore any OLD mismatch here.
replacement = [];
replacementSet = true;
anchorMutated = true;
break;
}
}
const replacementProducesNoChange =
beforeLines.length === 0 &&
afterLines.length === 0 &&
replacement.length === 1 &&
replacement[0] === currentLine;
if (replacementProducesNoChange) {
const firstEdit = bucket[0]?.edit;
const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
noopEdits.push({
editIndex: bucket[0]?.idx ?? 0,
loc: anchor ? `${anchor.line}${anchor.hash}` : `${line}`,
reason:
firstEdit?.kind === "set"
? "replacement is identical to the current line content; use `Lid=NEW_TEXT` and do not copy an unchanged read line"
: "replacement is identical to the current line content",
current: currentLine,
});
continue;
}
const combined = [...beforeLines, ...replacement, ...afterLines];
fileLines.splice(idx, 1, ...combined);
if (anchorMutated || beforeLines.length > 0) {
trackFirstChanged(line);
} else if (afterLines.length > 0) {
trackFirstChanged(line + 1);
}
if (!replacementSet && beforeLines.length === 0 && afterLines.length === 0) continue;
}
const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
if (fileFirstChangedLine !== undefined) trackFirstChanged(fileFirstChangedLine);
const dupCheck = detectAndAutoFixDuplicates(originalLines, fileLines);
if (dupCheck.fixed !== null) {
fileLines.length = 0;
fileLines.push(...dupCheck.fixed);
}
for (const w of dupCheck.warnings) warnings.push(w);
return {
lines: fileLines.join("\n"),
firstChangedLine,
...(warnings.length > 0 ? { warnings } : {}),
...(noopEdits.length > 0 && firstChangedLine === undefined ? { noopEdits } : {}),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Wire-format split: extract `---` headers from the input string.
// ═══════════════════════════════════════════════════════════════════════════
const FILE_HEADER_PREFIX = "---";
const REMOVE_FILE_OPERATION = "!rm";
const MOVE_FILE_OPERATION = "!mv";
type AtomWholeFileOperation =
| { kind: "delete"; lineNum: number }
| { kind: "move"; destination: string; lineNum: number };
interface AtomInputSection {
path: string;
diff: string;
wholeFileOperation?: AtomWholeFileOperation;
}
export interface SplitAtomOptions {
cwd?: string;
path?: string;
}
function isBlankHeaderPreamble(line: string): boolean {
return line.replace(/\r$/, "").trim().length === 0;
}
function unquoteAtomPath(pathText: string): string {
if (pathText.length < 2) return pathText;
const first = pathText[0];
const last = pathText[pathText.length - 1];
if ((first === '"' || first === "'") && first === last) {
return pathText.slice(1, -1);
}
return pathText;
}
function normalizeAtomPath(rawPath: string, cwd?: string): string {
const unquoted = unquoteAtomPath(rawPath.trim());
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
return isWithinCwd ? relative || "." : unquoted;
}
function parseAtomHeaderLine(line: string, cwd?: string): string | null {
if (!line.startsWith(FILE_HEADER_PREFIX)) return null;
let body = line.slice(FILE_HEADER_PREFIX.length);
if (body.startsWith(" ")) body = body.slice(1);
const parsedPath = normalizeAtomPath(body, cwd);
if (parsedPath.length === 0) {
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
}
return parsedPath;
}
function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
const trimmed = rawPath.trim();
if (trimmed.length === 0) {
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
}
const quote = trimmed[0];
if (quote === '"' || quote === "'") {
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
}
} else if (/\s/.test(trimmed)) {
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
}
const destination = normalizeAtomPath(trimmed, cwd);
if (destination.length === 0) {
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
}
return destination;
}
function parseAtomWholeFileOperationLine(
rawLine: string,
lineNum: number,
cwd?: string,
): AtomWholeFileOperation | null {
const line = rawLine.replace(/\r$/, "").trimEnd();
if (line === REMOVE_FILE_OPERATION) {
return { kind: "delete", lineNum };
}
if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
throw new Error(`Diff line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
}
if (line === MOVE_FILE_OPERATION) {
throw new Error(`Diff line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
}
if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
return {
kind: "move",
destination: parseSingleAtomPathArgument(rawDestination, MOVE_FILE_OPERATION, lineNum, cwd),
lineNum,
};
}
return null;
}
function getAtomWholeFileOperation(
sectionPath: string,
lines: string[],
cwd?: string,
): AtomWholeFileOperation | undefined {
let operation: AtomWholeFileOperation | undefined;
let operationToken = "";
let hasLineEdit = false;
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const line = lines[i].replace(/\r$/, "");
if (line.trim().length === 0) continue;
const parsed = parseAtomWholeFileOperationLine(line, lineNum, cwd);
if (parsed) {
if (operation) {
throw new Error(
`Edit section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
);
}
operation = parsed;
operationToken = parsed.kind === "delete" ? REMOVE_FILE_OPERATION : MOVE_FILE_OPERATION;
continue;
}
hasLineEdit = true;
}
if (operation && hasLineEdit) {
throw new Error(
`Edit section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
);
}
return operation;
}
function hasAtomHeaderLine(input: string): boolean {
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
return stripped.split("\n").some(rawLine => rawLine.replace(/\r$/, "").startsWith(FILE_HEADER_PREFIX));
}
const RECOGNIZABLE_OP_RES: readonly RegExp[] = [
/^\$\+.*$/,
new RegExp(`^\\^${HASHLINE_LID_LAX_RE_SRC}(?:\\+.*)?$`),
new RegExp(`^- ?${HASHLINE_LID_LAX_RE_SRC}(?:\\.\\.${HASHLINE_LID_LAX_RE_SRC})?(?:[ \\t]*[=|].*| .*)?$`),
new RegExp(`^@?${HASHLINE_LID_LAX_RE_SRC}(?:\\+.*|[ \\t]*[=|].*|\\.\\.${HASHLINE_LID_LAX_RE_SRC}[ \\t]*=.*)?$`),
new RegExp(`^@@ (?:BOF|EOF|(?:- ?)?${HASHLINE_LID_LAX_RE_SRC}(?:[ \\t]*[=|].*)?)$`),
];
function containsRecognizableAtomOperations(input: string): boolean {
for (const rawLine of input.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (line.length === 0) continue;
if (line[0] === "+") return true;
if (line === "$" || line === "^") return true;
for (const re of RECOGNIZABLE_OP_RES) {
if (re.test(line)) return true;
}
}
return false;
}
function stripLeadingBlankLines(input: string): string {
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
const lines = stripped.split("\n");
while (lines.length > 0 && isBlankHeaderPreamble(lines[0] ?? "")) {
lines.shift();
}
return lines.join("\n");
}
function normalizeStandaloneFileOpInput(input: string, cwd?: string): string | null {
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
const lines = stripped.split("\n");
let firstIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].replace(/\r$/, "").trim().length > 0) {
firstIdx = i;
break;
}
}
if (firstIdx === -1) return null;
const firstLine = lines[firstIdx].replace(/\r$/, "");
const remaining = lines.slice(firstIdx + 1).join("\n");
if (remaining.trim().length > 0) return null;
const rmMatch = /^!rm\s+(\S.*)$/.exec(firstLine);
if (rmMatch) {
const sourcePath = parseSingleAtomPathArgument(rmMatch[1], REMOVE_FILE_OPERATION, firstIdx + 1, cwd);
return `${FILE_HEADER_PREFIX}${sourcePath}\n${REMOVE_FILE_OPERATION}`;
}
const mvMatch = /^!mv\s+(\S+)\s+(\S.*)$/.exec(firstLine);
if (mvMatch) {
const sourcePath = parseSingleAtomPathArgument(mvMatch[1], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
const destPath = parseSingleAtomPathArgument(mvMatch[2], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
return `${FILE_HEADER_PREFIX}${sourcePath}\n${MOVE_FILE_OPERATION} ${destPath}`;
}
return null;
}
function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
if (hasAtomHeaderLine(input)) return input;
const standalone = normalizeStandaloneFileOpInput(input, options.cwd);
if (standalone !== null) return standalone;
if (!options.path || !containsRecognizableAtomOperations(input)) {
return input;
}
const fallbackPath = normalizeAtomPath(options.path, options.cwd);
if (fallbackPath.length === 0) return input;
return `${FILE_HEADER_PREFIX}${fallbackPath}\n${input}`;
}
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
}
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
if (result.details === undefined) {
return { diff: "" };
}
return result.details;
}
/**
* Split the wire-format `input` string into `{ path, diff }`. The first
* non-empty line MUST be `---<path>` or `--- <path>`. Tolerates a leading BOM.
*/
export function splitAtomInput(input: string, options: SplitAtomOptions = {}): { path: string; diff: string } {
const [section] = splitAtomInputs(input, options);
return section;
}
export function splitAtomInputs(input: string, options: SplitAtomOptions = {}): AtomInputSection[] {
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
const lines = stripped.split("\n");
const firstLine = (lines[0] ?? "").replace(/\r$/, "");
if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
const preview = JSON.stringify(firstLine.slice(0, 120));
throw new Error(
`input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${preview}.\n` +
`Example: "${FILE_HEADER_PREFIX}src/foo.ts" then your edit ops on the following lines. ` +
`To delete a file: "${FILE_HEADER_PREFIX}<path>\\n!rm". To rename: "${FILE_HEADER_PREFIX}<src>\\n!mv <dest>".`,
);
}
const sections: AtomInputSection[] = [];
let currentPath = "";
let currentLines: string[] = [];
const flush = () => {
if (currentPath.length === 0) return;
const wholeFileOperation = getAtomWholeFileOperation(currentPath, currentLines, options.cwd);
sections.push({
path: currentPath,
diff: currentLines.join("\n"),
...(wholeFileOperation ? { wholeFileOperation } : {}),
});
currentLines = [];
};
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "");
const headerPath = parseAtomHeaderLine(line, options.cwd);
if (headerPath !== null) {
flush();
currentPath = headerPath;
continue;
}
currentLines.push(rawLine);
}
flush();
return sections;
}
// ═════════════════════════════════════════════════════════════════════════════
// Executor
// ═══════════════════════════════════════════════════════════════════════════
export interface ExecuteAtomSingleOptions {
session: ToolSession;
input: string;
path?: string;
signal?: AbortSignal;
batchRequest?: LspBatchRequest;
writethrough: WritethroughCallback;
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
}
interface ReadAtomFileResult {
exists: boolean;
rawContent: string;
}
async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
try {
return { exists: true, rawContent: await Bun.file(absolutePath).text() };
} catch (error) {
if (isEnoent(error)) return { exists: false, rawContent: "" };
throw error;
}
}
function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
return edits.some(
edit =>
edit.kind === "set" ||
edit.kind === "delete" ||
edit.cursor.kind === "anchor" ||
edit.cursor.kind === "before_anchor",
);
}
function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
let diagnostic = `Edits to ${path} resulted in no changes being made.`;
if (result.noopEdits && result.noopEdits.length > 0) {
const details = result.noopEdits
.map(e => {
const preview =
e.current.length > 0
? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
: "";
return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
})
.join("\n");
diagnostic += `\n${details}`;
const setNoops = result.noopEdits.filter(e => e.reason.startsWith("replacement is identical"));
if (setNoops.length > 0) {
diagnostic +=
"\n\nHint: each `Lid=TEXT` you emit MUST contain text that differs from the line currently anchored by Lid. " +
"Do not echo lines back from `read` output unchanged. If you intended to leave a line as-is, omit it from the patch.";
}
}
return diagnostic;
}
async function executeAtomWholeFileOperation(
options: ExecuteAtomSingleOptions & AtomInputSection & { wholeFileOperation: AtomWholeFileOperation },
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
const { session, path: sectionPath, wholeFileOperation } = options;
const absolutePath = resolvePlanPath(session, sectionPath);
if (sectionPath.endsWith(".ipynb")) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
if (wholeFileOperation.kind === "delete") {
enforcePlanModeWrite(session, sectionPath, { op: "delete" });
await assertEditableFile(absolutePath, sectionPath);
try {
await fs.unlink(absolutePath);
} catch (error) {
if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
throw error;
}
invalidateFsScanAfterDelete(absolutePath);
return {
content: [{ type: "text", text: `Deleted ${sectionPath}` }],
details: { diff: "", op: "delete", meta: outputMeta().get() },
};
}
const destinationPath = wholeFileOperation.destination;
if (destinationPath.endsWith(".ipynb")) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
if (absoluteDestinationPath === absolutePath) {
throw new Error("rename path is the same as source path");
}
await assertEditableFile(absolutePath, sectionPath);
try {
await fs.mkdir(path.dirname(absoluteDestinationPath), { recursive: true });
await fs.rename(absolutePath, absoluteDestinationPath);
} catch (error) {
if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
throw error;
}
invalidateFsScanAfterRename(absolutePath, absoluteDestinationPath);
return {
content: [{ type: "text", text: `Moved ${sectionPath} to ${destinationPath}` }],
details: { diff: "", op: "update", move: destinationPath, meta: outputMeta().get() },
};
}
async function preflightAtomSection(options: ExecuteAtomSingleOptions & AtomInputSection): Promise<void> {
const { session, path: sectionPath, diff } = options;
if (options.wholeFileOperation) {
const { wholeFileOperation } = options;
const absolutePath = resolvePlanPath(session, sectionPath);
if (sectionPath.endsWith(".ipynb")) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
if (wholeFileOperation.kind === "delete") {
enforcePlanModeWrite(session, sectionPath, { op: "delete" });
await assertEditableFile(absolutePath, sectionPath);
return;
}
const destinationPath = wholeFileOperation.destination;
if (destinationPath.endsWith(".ipynb")) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
if (absoluteDestinationPath === absolutePath) {
throw new Error("rename path is the same as source path");
}
await assertEditableFile(absolutePath, sectionPath);
return;
}
const { edits } = parseAtomWithWarnings(diff);
if (edits.length === 0 && diff.trim().length > 0) {
throw new Error(formatNoAtomEditDiagnostic(sectionPath, diff));
}
enforcePlanModeWrite(session, sectionPath, { op: "update" });
if (sectionPath.endsWith(".ipynb") && edits.length > 0) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
const absolutePath = resolvePlanPath(session, sectionPath);
const source = await readAtomFile(absolutePath);
if (!source.exists && hasAnchorScopedEdit(edits)) {
throw new Error(`File not found: ${sectionPath}`);
}
if (source.exists) {
assertEditableFileContent(source.rawContent, sectionPath);
}
const { text } = stripBom(source.rawContent);
const originalNormalized = normalizeToLF(text);
const result = applyAtomEdits(originalNormalized, edits);
if (originalNormalized === result.lines && (result.noopEdits?.length ?? 0) === 0) {
throw new Error(formatNoChangeDiagnostic(sectionPath, result));
}
}
async function executeAtomSection(
options: ExecuteAtomSingleOptions & AtomInputSection,
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
const { session, path, diff, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
if (options.wholeFileOperation) {
return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
}
const { edits, warnings: parseWarnings } = parseAtomWithWarnings(diff);
if (edits.length === 0 && diff.trim().length > 0) {
throw new Error(formatNoAtomEditDiagnostic(path, diff));
}
enforcePlanModeWrite(session, path, { op: "update" });
if (path.endsWith(".ipynb") && edits.length > 0) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
const absolutePath = resolvePlanPath(session, path);
const source = await readAtomFile(absolutePath);
if (!source.exists && hasAnchorScopedEdit(edits)) {
throw new Error(`File not found: ${path}`);
}
if (source.exists) {
assertEditableFileContent(source.rawContent, path);
}
const { bom, text } = stripBom(source.rawContent);
const originalEnding = detectLineEnding(text);
const originalNormalized = normalizeToLF(text);
const result = applyAtomEdits(originalNormalized, edits);
if (originalNormalized === result.lines) {
const allNoop = (result.noopEdits?.length ?? 0) > 0;
if (!allNoop) {
throw new Error(formatNoChangeDiagnostic(path, result));
}
// Every edit was a no-op (TEXT identical to the anchored line). Returning
// success here breaks retry loops where models hammer the same `Lid=TEXT`
// when TEXT happens to already match. The response makes the no-op
// explicit so the model knows nothing changed and to move on.
return {
content: [{ type: "text", text: formatNoChangeDiagnostic(path, result) }],
details: { diff: "", op: "update", meta: outputMeta().get() },
};
}
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
const diagnostics = await writethrough(
absolutePath,
finalContent,
signal,
Bun.file(absolutePath),
batchRequest,
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
);
invalidateFsScanAfterWrite(absolutePath);
const diffResult = generateDiffString(originalNormalized, result.lines);
const meta = outputMeta()
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
.get();
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
const allWarnings = [...parseWarnings, ...(result.warnings ?? [])];
const warningsBlock = allWarnings.length > 0 ? `\n\nWarnings:\n${allWarnings.join("\n")}` : "";
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
return {
content: [
{
type: "text",
text: `${resultText}${previewBlock}${warningsBlock}`,
},
],
details: {
diff: diffResult.diff,
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
diagnostics,
op: source.exists ? "update" : "create",
meta,
},
};
}
export async function executeAtomSingle(
options: ExecuteAtomSingleOptions,
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
const sections = splitAtomInputs(options.input, { cwd: options.session.cwd, path: options.path });
if (sections.length === 1) {
const [section] = sections;
return executeAtomSection({ ...options, ...section });
}
for (const section of sections) {
await preflightAtomSection({ ...options, ...section });
}
const results = [];
for (const section of sections) {
results.push({
path: section.path,
result: await executeAtomSection({ ...options, ...section }),
});
}
return {
content: [
{
type: "text",
text: results.map(({ result }) => getTextContent(result)).join("\n\n"),
},
],
details: {
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
perFileResults: results.map(({ path, result }) => {
const details = getEditDetails(result);
return {
path,
diff: details.diff,
firstChangedLine: details.firstChangedLine,
diagnostics: details.diagnostics,
op: details.op,
move: details.move,
meta: details.meta,
};
}),
},
};
}