From 222e1cd348b5cdaecd2febac30ccc6b22a7e2e91 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Apr 2026 00:05:37 +0200 Subject: [PATCH] feat(edit): enabled Lid= and LidA..LidB replacements to support backslash continuation - Updated the Atom grammar to parse replacement blocks via a set block rule that no longer targets range replacements only. - Extended continuation preprocessing to allow backslash lines after single-line replace operations (including legacy `@` and `|` forms) while preserving the active-replacement check. - Added tests and prompt documentation for single-line continuation cases and for rejection of backslash-like text outside an active replacement. --- .../coding-agent/src/edit/modes/atom.lark | 5 +-- packages/coding-agent/src/edit/modes/atom.ts | 31 ++++++++++++++----- .../coding-agent/src/prompts/tools/atom.md | 10 +++--- packages/coding-agent/test/core/atom.test.ts | 29 ++++++++++++++++- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/edit/modes/atom.lark b/packages/coding-agent/src/edit/modes/atom.lark index 26d6225d6..476c062d0 100644 --- a/packages/coding-agent/src/edit/modes/atom.lark +++ b/packages/coding-agent/src/edit/modes/atom.lark @@ -8,8 +8,8 @@ file_header: "---" filename LF filename: /(.+)/ line_change: line* mutation_line line* -line: insert_line | delete_line | set_line | continuation_line | move_line | blank -mutation_line: insert_line | delete_line | set_line +line: insert_line | delete_line | set_block | move_line | blank +mutation_line: insert_line | delete_line | set_block whole_file_change: blank* whole_file_line blank* whole_file_line: remove_file | move_file @@ -20,6 +20,7 @@ destination: /(?:[^ \t\r\n]+|"[^"\r\n]+"|'[^'\r\n]+')/ insert_line: "+" /(.*)/ LF delete_line: "-" (LID ".." LID | LID) LF set_line: (LID ".." LID | LID) WS? "=" /(.*)/ LF +set_block: set_line continuation_line* continuation_line: "\\" /(.*)/ LF move_line: ("@" LID | "^" LID | "^" | "$") LF diff --git a/packages/coding-agent/src/edit/modes/atom.ts b/packages/coding-agent/src/edit/modes/atom.ts index d14965436..a50e64af1 100644 --- a/packages/coding-agent/src/edit/modes/atom.ts +++ b/packages/coding-agent/src/edit/modes/atom.ts @@ -8,7 +8,7 @@ * 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; following \TEXT lines continue it - * \TEXT append TEXT to the active range replacement + * \TEXT append TEXT to the active replacement (set or range) * +TEXT insert TEXT at the cursor * ^ move cursor to beginning of file * $ move cursor to end of file @@ -485,22 +485,35 @@ function isRangeReplaceStart(line: string): boolean { return /^[1-9]\d*[a-z]{2}\.\.[1-9]\d*[a-z]{2}[ \t]*=/.test(line); } -// Explicit range continuation uses `\TEXT` after `LidA..LidB=FIRST`. -// The leading backslash is the continuation marker; the rest of the line is -// inserted literally, so `\\TEXT` inserts a line starting with `\TEXT`. -// Raw unprefixed continuation remains an undocumented best-effort recovery -// for old transcripts, but canonical patches should use backslash lines. +// A single-line `Lid=TEXT` (or legacy `Lid|TEXT`, with optional leading `@`) +// also opens a replacement that `\TEXT` continuation lines may extend. The +// continuation lines become inserts at the cursor (which sits on the just-set +// line), turning `Lid=A` + `\B` + `\C` into "set the line to A, then insert B +// and C below it" — i.e. a multi-line rewrite of one anchor without forcing +// the user to switch to the `LidA..LidB=` range form. +function isReplaceStart(line: string): boolean { + if (isRangeReplaceStart(line)) return true; + const stripped = line.startsWith("@") ? line.slice(1) : line; + return /^[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(stripped); +} + +// Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or +// `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the +// rest of the line is inserted literally, so `\\TEXT` inserts a line starting +// with `\TEXT`. Raw unprefixed continuation remains an undocumented +// best-effort recovery for range replacements only, kept for old transcripts. function preprocessRangeReplaceContinuation(diff: string): string { const lines = diff.split("\n"); let inRangeReplace = false; + let inReplace = false; for (let i = 0; i < lines.length; i++) { const rawLine = lines[i]; const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; if (line.startsWith("\\")) { - if (!inRangeReplace) { + if (!inReplace) { throw new Error( - `Diff line ${i + 1}: \\TEXT continuation is only valid immediately after a LidA..LidB=FIRST_LINE range replacement.`, + `Diff line ${i + 1}: \\TEXT continuation is only valid immediately after a Lid=TEXT or LidA..LidB=FIRST_LINE replacement.`, ); } lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine.slice(1)}`; @@ -510,6 +523,7 @@ function preprocessRangeReplaceContinuation(diff: string): string { if (inRangeReplace) { if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) { inRangeReplace = isRangeReplaceStart(line); + inReplace = isReplaceStart(line); continue; } @@ -518,6 +532,7 @@ function preprocessRangeReplaceContinuation(diff: string): string { } inRangeReplace = isRangeReplaceStart(line); + inReplace = isReplaceStart(line); } return lines.join("\n"); } diff --git a/packages/coding-agent/src/prompts/tools/atom.md b/packages/coding-agent/src/prompts/tools/atom.md index cf4f8e0c6..20daac464 100644 --- a/packages/coding-agent/src/prompts/tools/atom.md +++ b/packages/coding-agent/src/prompts/tools/atom.md @@ -15,8 +15,8 @@ $ move cursor to EOF (after the last line) + insert one blank line at the cursor Lid=TEXT replace the anchored line with TEXT LidA..LidB=TEXT replace the range with one line; following `\TEXT` lines append literal lines to the replacement -\TEXT append literal TEXT to the active range replacement -\ append a blank line to the active range replacement +\TEXT append literal TEXT to the active replacement (after `Lid=…` or `LidA..LidB=…`) +\ append a blank line to the active replacement Lid= blank the anchored line's content but KEEP the line (results in an empty line, NOT a removed line; use `-Lid` to remove) -Lid delete the anchored line (repeat for multi-line delete) -LidA..LidB delete the contiguous line range LidA..LidB (inclusive) @@ -29,8 +29,8 @@ Lid= blank the anchored line's content but KEEP the line (results in an em - TEXT in `+TEXT`, `Lid=TEXT`, and `\TEXT` is literal line content, INCLUDING leading whitespace. You **MUST NOT** trim or re-indent it. - Consecutive `+TEXT` ops produce consecutive lines in the order written. You **MUST NOT** separate them with a stray `+` unless you intend to insert a blank line. - `Lid=TEXT` rewrites ONE line. To rewrite K adjacent lines, you **MUST** use `LidA..LidB=FIRST_LINE` followed immediately by `\NEXT_LINE` continuation lines. You **MUST** use bare `\` for blank replacement lines. -- You **MUST** prefix every range-replacement continuation line with `\`, especially when the replacement line starts with edit syntax characters such as `#`, `+`, `-`, `@`, `$`, `^`, `!`, or a Lid-shaped token. -- `\TEXT` **MUST** appear only immediately after an active range replacement block. It **MUST NOT** be used as a general insert operator. +- You **MUST** prefix every replacement continuation line with `\`, especially when the replacement line starts with edit syntax characters such as `#`, `+`, `-`, `@`, `$`, `^`, `!`, or a Lid-shaped token. +- `\TEXT` **MUST** appear only immediately after an active `Lid=…` or `LidA..LidB=…` replacement. It **MUST NOT** be used as a general insert operator. - The legacy `-LidA..LidB` + `+TEXT…` block-rewrite form also works. - To insert ABOVE a line, you **MUST** use `^Lid` then `+TEXT`. To insert above line 1, you **MUST** use `^` (BOF) then `+TEXT`. To insert below a line, you **MUST** use `@Lid` then `+TEXT`. - Multiple `---PATH` sections **MAY** appear in one input; each section is applied in order. @@ -131,6 +131,6 @@ $ - TEXT after `=`, `+`, or `\` includes leading whitespace verbatim. You **MUST NOT** trim or re-indent it. - This is NOT unified diff. You **MUST NOT** write `@@` headers, `-OLD`/`+NEW` pairs, context lines, or `+Lid|…` (bad: `+5th|new text`; good: `5th=new text`). - You **MUST NOT** split `Lid=TEXT` across two physical lines. -- For a contiguous range replacement, you **MUST** use `LidA..LidB=FIRST_LINE` followed by `\NEXT_LINE` continuation lines, or use `-LidA..LidB` + `+TEXT…` (delete + insert). +- For a contiguous range replacement, you **MAY** use either `Lid=FIRST_LINE` + `\NEXT_LINE…` (extends one anchor) or `LidA..LidB=FIRST_LINE` + `\NEXT_LINE…` (collapses an existing range), or fall back to `-LidA..LidB` + `+TEXT…` (delete + insert). - The tool is syntax-blind. Indentation, brackets, fences, table widths — you remain responsible. diff --git a/packages/coding-agent/test/core/atom.test.ts b/packages/coding-agent/test/core/atom.test.ts index f1aeb47ca..8d13b39be 100644 --- a/packages/coding-agent/test/core/atom.test.ts +++ b/packages/coding-agent/test/core/atom.test.ts @@ -204,10 +204,37 @@ describe("atom parser — basic forms", () => { expect(applyDiff(longer, diff)).toBe(["aaa", "first", ...literalLines, "ddd"].join("\n")); }); - it("backslash continuation is rejected outside a range replacement", () => { + it("backslash continuation is rejected outside a replacement op", () => { expect(() => parseAtom("\\orphan")).toThrow(/\\TEXT continuation is only valid/); }); + it("`Lid=FIRST` accepts backslash continuation lines (single-line set extends to multi-line)", () => { + const content = "aaa\nbbb\nccc"; + const diff = [`${tag(2, "bbb")}=export function label() {`, "\\ return 1;", "\\}"].join("\n"); + expect(applyDiff(content, diff)).toBe("aaa\nexport function label() {\n return 1;\n}\nccc"); + }); + + it("`@Lid=FIRST` accepts backslash continuation lines (legacy `@`-prefixed form)", () => { + const content = "aaa\nbbb\nccc"; + const diff = [`@${tag(2, "bbb")}=ONE`, "\\TWO"].join("\n"); + expect(applyDiff(content, diff)).toBe("aaa\nONE\nTWO\nccc"); + }); + + it("`Lid|FIRST` (legacy set syntax) accepts backslash continuation lines", () => { + const content = "aaa\nbbb\nccc"; + const diff = [`${tag(2, "bbb")}|ONE`, "\\TWO"].join("\n"); + expect(applyDiff(content, diff)).toBe("aaa\nONE\nTWO\nccc"); + }); + + it("backslash continuation after `Lid=FIRST` rejects unprefixed recovery (range-only fallback)", () => { + const content = "aaa\nbbb\nccc"; + // Only range replacements get the legacy "raw unprefixed continuation" recovery; + // after a single-line set, an unprefixed line below should parse as its own op + // (or error), not silently fold into the replacement. + const diff = [`${tag(2, "bbb")}=ONE`, "rawline"].join("\n"); + expect(() => applyDiff(content, diff)).toThrow(); + }); + it("backslash continuation stops before the next atom op line", () => { const longer = "aaa\nbbb\nccc\nddd\neee"; const diff = [`${tag(2, "bbb")}..${tag(4, "ddd")}=ONE`, "\\TWO", `@${tag(1, "aaa")}`, "+BELOW"].join("\n");