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.
This commit is contained in:
can1357
2026-04-30 00:05:37 +02:00
parent c124c74b5d
commit 222e1cd348
4 changed files with 59 additions and 16 deletions
@@ -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
+23 -8
View File
@@ -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");
}
@@ -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.
</critical>
+28 -1
View File
@@ -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");