diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 67aa0d719..5ea3df977 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog ## [Unreleased] +### Changed + +- Updated hashline syntax: replaced `↑`/`↓` payload sigils with `^` repeat syntax and `|` literal rows for clearer edit semantics +- Changed hashline delete syntax from bare `A:` or `A-B:` to explicit `A-B:-` inline delete marker +- Modified hashline anchor syntax to require explicit range notation `A-B:` instead of shorthand `A:` for single-line operations +- Updated hashline description in settings to clarify pure insert context behavior without arrow notation ## [15.5.7] - 2026-05-27 ### Added @@ -8887,4 +8893,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 79b4fdbcd..0a566f09f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1575,7 +1575,7 @@ export const SETTINGS_SCHEMA = { tab: "editing", label: "Hashline Duplicate Insert Drop", description: - "Drop payload lines that duplicate adjacent file context — 2+-line context echoes on `↑`/`↓` inserts, and a single boundary line at either edge of an `A-B:` replacement", + "Drop payload lines that duplicate adjacent file context — 2+-line context echoes on pure inserts, and a single boundary line at either edge of an `A-B:` replacement", }, }, "edit.blockAutoGenerated": { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 8e05d9890..c17ccf99c 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -32,10 +32,8 @@ import { import * as z from "zod/v4"; /** - * The test bodies were written against the legacy hashline API surface. The - * shims below project the new `@oh-my-pi/hashline` shapes onto the legacy - * names so production code can use the new names directly while we keep the - * pre-existing behavior assertions intact. + * The test bodies use a small adapter over the package API so production code + * can use the package names directly while assertions stay compact. */ function applyHashlineEdits( text: string, @@ -89,8 +87,7 @@ beforeAll(async () => { }); const repl = (text: string): string => `|${text}`; -const above = (text: string): string => `↑${text}`; -const below = (text: string): string => `↓${text}`; +const repeat = (start: string, end = start): string => `^${start}-${end}`; const outputSep = ":"; const outputSepRe = ":"; @@ -187,7 +184,9 @@ describe("hashline normalization", () => { describe("hashline parser — range-anchor syntax", () => { it("keeps parsed edits reusable across different target snapshots", () => { - const section = Patch.parseSingle(["¶a.ts", `${tag(2, "bbb")}:`, below("tail")].join("\n")); + const section = Patch.parseSingle( + ["¶a.ts", `${sameLineRange(tag(2, "bbb"))}:`, repeat(tag(2, "bbb")), repl("tail")].join("\n"), + ); expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail"); expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc"); @@ -197,26 +196,27 @@ describe("hashline parser — range-anchor syntax", () => { it("inserts payload before/after a Lid, and at BOF/EOF", () => { const diff = [ - `${tag(2, "bbb")}:`, - above("before b"), - below("after b"), + `${sameLineRange(tag(2, "bbb"))}:`, + repl("before b"), + repeat(tag(2, "bbb")), + repl("after b"), "BOF:", - below("top"), + repl("top"), "EOF:", - below("tail"), + repl("tail"), ].join("\n"); expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail"); }); it("inserts after the final line without falling off the file", () => { - const diff = [`${tag(3, "ccc")}:`, below("tail")].join("\n"); + const diff = [`${sameLineRange(tag(3, "ccc"))}:`, repeat(tag(3, "ccc")), repl("tail")].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail"); }); - it("deletes a line or range when the block has no payload rows", () => { - expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}:`)).toBe("aaa\nccc"); - expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}:`)).toBe("aaa"); + it("deletes a line or range via inline delete", () => { + expect(applyDiff(content, `${sameLineRange(tag(2, "bbb"))}:-`)).toBe("aaa\nccc"); + expect(applyDiff(content, `${tag(2, "bbb")}-${tag(3, "ccc")}:-`)).toBe("aaa"); }); it("replaces a line with one blank when given an explicit empty replace payload", () => { @@ -225,67 +225,76 @@ describe("hashline parser — range-anchor syntax", () => { }); it("replaces one line or an inclusive range with payload lines", () => { - const single = [`${tag(2, "bbb")}:`, repl("BBB")].join("\n"); + const single = [`${sameLineRange(tag(2, "bbb"))}:`, repl("BBB")].join("\n"); expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc"); const range = [`${tag(2, "bbb")}-${tag(3, "ccc")}:`, repl("BBB"), repl("CCC")].join("\n"); expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC"); }); - it("treats single-anchor replace sugar as equivalent to an explicit one-line range", () => { + it("rejects the removed bare `A:` shorthand as a normal unrecognized row", () => { const anchor = tag(2, "bbb"); - expect(parseHashline(`${anchor}:\n${repl("BBB")}\n${repl("CCC")}`).edits).toEqual( - parseHashline(`${anchor}-${anchor}:\n${repl("BBB")}\n${repl("CCC")}`).edits, - ); - expect(applyDiff(content, `${anchor}:\n${repl("BBB")}\n${repl("CCC")}`)).toBe( - applyDiff(content, `${anchor}-${anchor}:\n${repl("BBB")}\n${repl("CCC")}`), - ); + expect(() => parseHashline(`${anchor}:\n${repl("BBB")}`)).toThrow(/payload line has no preceding/); + }); + + it("replaces empty anchor blocks with one blank line", () => { + const anchor = tag(2, "bbb"); + expect(applyDiff(content, `${sameLineRange(anchor)}:`)).toBe("aaa\n\nccc"); + expect(applyDiff(content, `${anchor}-${tag(3, "ccc")}:`)).toBe("aaa\n"); }); it("rejects inline payload on anchor rows", () => { const anchor = tag(2, "bbb"); - for (const diff of [`${anchor}:NEW`, `${anchor}-${tag(3, "ccc")}:NEW`, "BOF:NEW", "EOF:NEW"]) { + for (const diff of [`${sameLineRange(anchor)}:NEW`, `${anchor}-${tag(3, "ccc")}:NEW`, "BOF:NEW", "EOF:NEW"]) { expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/); } }); - it("routes interleaved payload rows to stable above, replace, and below buckets", () => { + it("emits body rows in textual order", () => { const diff = [ - `${tag(2, "bbb")}:`, - below("below 1"), - above("above 1"), + `${sameLineRange(tag(2, "bbb"))}:`, + repl("above 1"), + repl("above 2"), repl("BBB"), - above("above 2"), - below("below 2"), + repl("below 1"), + repl("below 2"), ].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nabove 1\nabove 2\nBBB\nbelow 1\nbelow 2\nccc"); }); - it("preserves the anchor when only above/below payload rows are present", () => { - const diff = [`${tag(2, "bbb")}:`, above("before"), below("after")].join("\n"); + it("preserves the anchor when repeat rows re-emit it", () => { + const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("before"), repeat(tag(2, "bbb")), repl("after")].join( + "\n", + ); expect(applyDiff(content, diff)).toBe("aaa\nbefore\nbbb\nafter\nccc"); }); - it("escapes literal leading payload sigils by doubling them", () => { - const diff = [`${tag(2, "bbb")}:`, repl("|literal"), above("↑literal"), below("↓literal")].join("\n"); - expect(applyDiff(content, diff)).toBe("aaa\n↑literal\n|literal\n↓literal\nccc"); + it("escapes literal leading payload sigils with literal rows", () => { + const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("|literal"), repl("^literal"), repl("↓literal")].join( + "\n", + ); + expect(applyDiff(content, diff)).toBe("aaa\n|literal\n^literal\n↓literal\nccc"); }); - it("rejects replacement payload at virtual BOF/EOF anchors", () => { - expect(() => parseHashline(["BOF:", repl("HEAD")].join("\n"))).toThrow(/virtual positions/); - expect(() => parseHashline(["EOF:", repl("TAIL")].join("\n"))).toThrow(/virtual positions/); + it("accepts literal payload at virtual BOF/EOF anchors", () => { + expect(applyDiff(content, ["BOF:", repl("HEAD")].join("\n"))).toBe("HEAD\naaa\nbbb\nccc"); + expect(applyDiff(content, ["EOF:", repl("TAIL")].join("\n"))).toBe("aaa\nbbb\nccc\nTAIL"); }); it("rejects unprefixed payload continuation lines", () => { const anchor = tag(2, "bbb"); - expect(() => parseHashline(`${anchor}:\n${repl("FIRST")}\nSECOND`)).toThrow(/must start with/); + expect(() => parseHashline(`${sameLineRange(anchor)}:\n${repl("FIRST")}\nSECOND`)).toThrow(/must start with/); }); it("preserves whitespace-bearing payload exactly", () => { const anchor = tag(2, "bbb"); const payload = "\tconst streamKeepaliveMs = opts.streamKeepaliveMs;"; - expect(applyDiff(content, [`${anchor}:`, below(payload)].join("\n"))).toBe(`aaa\nbbb\n${payload}\nccc`); - expect(applyDiff(content, [`${anchor}:`, above(payload)].join("\n"))).toBe(`aaa\n${payload}\nbbb\nccc`); + expect(applyDiff(content, [`${sameLineRange(anchor)}:`, repeat(anchor), repl(payload)].join("\n"))).toBe( + `aaa\nbbb\n${payload}\nccc`, + ); + expect(applyDiff(content, [`${sameLineRange(anchor)}:`, repl(payload), repeat(anchor)].join("\n"))).toBe( + `aaa\n${payload}\nbbb\nccc`, + ); }); it("auto-absorbs duplicated multiline prefix boundaries during replacement", () => { @@ -343,8 +352,9 @@ describe("hashline parser — range-anchor syntax", () => { repl("alpha"), repl("X"), repl("Y"), - `${tag(4, "Y")}:`, - above("extra"), + `${sameLineRange(tag(4, "Y"))}:`, + repl("extra"), + repeat(tag(4, "Y")), ].join("\n"); expect(applyDiff(source, diff)).toBe(["alpha", "X", "Y", "X", "extra", "Y", "Z"].join("\n")); @@ -401,24 +411,29 @@ describe("hashline parser — range-anchor syntax", () => { }); it("does not auto-drop generic (multi-line) pure-insert duplicate boundaries by default", () => { - // Multi-line context echo (`aaa`, `bbb`) is gated on the + // Multi-line context echo (`bbb`, `ccc`) is gated on the // `autoDropPureInsertDuplicates` opt-in. Single-line pure-insert // duplicates stay literal because they are ambiguous. const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n"); - expect(applyDiff(source, diff)).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc"); + const diff = ["EOF:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n"); + expect(applyDiff(source, diff)).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW"); }); it("preserves a duplicated single structural suffix for pure insert by default", () => { const source = ["if ok {", " keep();", " }"].join("\n"); - const diff = [`${tag(3, " }")}:`, above(" added();"), above(" }")].join("\n"); + const diff = ["EOF:", repl(" added();"), repl(" }")].join("\n"); - expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " added();", " }", " }"].join("\n")); + expect(applyDiff(source, diff)).toBe(["if ok {", " keep();", " }", " added();", " }"].join("\n")); }); - it("preserves a duplicated single structural prefix for pure insert even when duplicate absorption is enabled", () => { + it("preserves a duplicated single structural prefix even when duplicate absorption is enabled", () => { const source = [" });", "next();"].join("\n"); - const diff = [`${tag(1, " });")}:`, below(" });"), below("added();")].join("\n"); + const diff = [ + `${sameLineRange(tag(1, " });"))}:`, + repeat(tag(1, " });")), + repl(" });"), + repl("added();"), + ].join("\n"); const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true }); expect(result.lines).toBe([" });", " });", "added();", "next();"].join("\n")); @@ -427,77 +442,49 @@ describe("hashline parser — range-anchor syntax", () => { it("preserves an intentional non-structural anchor duplicate for below insert by default", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, below("bbb"), below("NEW")].join("\n"); + const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repeat(tag(2, "bbb")), repl("bbb"), repl("NEW")].join("\n"); expect(applyDiff(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); }); it("preserves an intentional non-structural anchor duplicate for above insert by default", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, above("NEW"), above("bbb")].join("\n"); + const diff = [`${sameLineRange(tag(2, "bbb"))}:`, repl("NEW"), repl("bbb"), repeat(tag(2, "bbb"))].join("\n"); expect(applyDiff(source, diff)).toBe("aaa\nNEW\nbbb\nbbb\nccc"); }); it("does not drop a single structural pure-insert suffix when it preserves balance", () => { const source = ["if outer {", "}"].join("\n"); - const diff = [`${tag(2, "}")}:`, above("if inner {"), above("}")].join("\n"); + const diff = [`${sameLineRange(tag(2, "}"))}:`, repl("if inner {"), repl("}"), repeat(tag(2, "}"))].join("\n"); expect(applyDiff(source, diff)).toBe(["if outer {", "if inner {", "}", "}"].join("\n")); }); - it("auto-absorbs duplicated leading payload of a pure below insert", () => { - // Payload echoes the two file lines AT/ABOVE the insertion point - // (aaa, bbb), then adds NEW. The leading echo is absorbed. - const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n"); - expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc"); - }); - - it("auto-absorbs context-wrap echo (leading-above + trailing-below) on below insert", () => { - // Payload wraps NEW with context above (aaa, bbb) AND below (ccc, ddd). - // Both ends should be absorbed, leaving only NEW inserted after bbb. - const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW"), below("ccc"), below("ddd")].join( - "\n", - ); - expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); - }); - - it("auto-absorbs duplicated trailing payload of a pure above insert", () => { - // Insert before line 3 ("ccc"). Trailing payload echoes the anchor and the - // line after it. Drop the trailing duplicates. - const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); - const diff = [`${tag(3, "ccc")}:`, above("NEW"), above("ccc"), above("ddd")].join("\n"); - expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nNEW\nccc\nddd"); - }); - it("auto-absorbs duplicated leading payload at EOF insert", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - // `EOF:` payload echoes the last two file lines, then adds NEW. - const diff = ["EOF:", below("bbb"), below("ccc"), below("NEW")].join("\n"); + const diff = ["EOF:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n"); expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nNEW"); }); it("auto-absorbs duplicated trailing payload at BOF insert", () => { - const source = ["aaa", "bbb", "ccc"].join("\n"); - // `BOF:` payload prepends NEW but trails with the first two file lines. - const diff = ["BOF:", above("NEW"), above("aaa"), above("bbb")].join("\n"); - expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc"); + const source = ["aaa", "bbb", "ccc", "ddd"].join("\n"); + const diff = ["BOF:", repl("NEW"), repl("aaa"), repl("bbb")].join("\n"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("NEW\naaa\nbbb\nccc\nddd"); }); it("preserves a single duplicated anchor line in a pure insert even when generic duplicate absorption is enabled", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, below("bbb"), below("NEW")].join("\n"); + const diff = ["EOF:", repl("ccc"), repl("NEW")].join("\n"); - expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nbbb\nNEW\nccc"); + expect(applyDiffWithPureInsertAutoDrop(source, diff)).toBe("aaa\nbbb\nccc\nccc\nNEW"); }); it("surfaces a warning when pure-insert duplicates are auto-dropped", () => { const source = ["aaa", "bbb", "ccc"].join("\n"); - const diff = [`${tag(2, "bbb")}:`, below("aaa"), below("bbb"), below("NEW")].join("\n"); + const diff = ["EOF:", repl("bbb"), repl("ccc"), repl("NEW")].join("\n"); const result = applyHashlineEdits(source, parseHashline(diff).edits, { autoDropPureInsertDuplicates: true }); - expect(result.lines).toBe("aaa\nbbb\nNEW\nccc"); + expect(result.lines).toBe("aaa\nbbb\nccc\nNEW"); expect(result.warnings).toBeDefined(); expect(result.warnings).toEqual( expect.arrayContaining([expect.stringMatching(/Auto-dropped 2 duplicate line\(s\) at the start of insert/)]), @@ -525,14 +512,14 @@ describe("hashline parser — range-anchor syntax", () => { const diff = [ "# This is a comment line from a model explanation.", "## Another comment line.", - `${tag(2, "bbb")}:`, + `${sameLineRange(tag(2, "bbb"))}:`, repl("BBB"), ].join("\n"); expect(applyDiff(content, diff)).toBe("aaa\nBBB\nccc"); }); it("does not skip comment lines when they are not immediately before an operation", () => { - const diff = ["# This is a stray comment.", "", `${tag(2, "bbb")}:`, repl("BBB")].join("\n"); + const diff = ["# This is a stray comment.", "", `${sameLineRange(tag(2, "bbb"))}:`, repl("BBB")].join("\n"); expect(() => parseHashline(diff)).toThrow(/payload line has no preceding/); }); @@ -549,13 +536,12 @@ describe("hashline parser — range-anchor syntax", () => { }); it("inserts explicit blank lines above and below an anchor", () => { - const anchor = { line: 1 }; - expect(parseHashline(`${tag(1, "aaa")}:\n${above("")}`).edits).toEqual([ - { kind: "insert", cursor: { kind: "before_anchor", anchor }, text: "", lineNum: 1, index: 0 }, - ]); - expect(parseHashline(`${tag(1, "aaa")}:\n${below("")}`).edits).toEqual([ - { kind: "insert", cursor: { kind: "after_anchor", anchor }, text: "", lineNum: 1, index: 0 }, - ]); + expect(applyDiff(content, `${sameLineRange(tag(1, "aaa"))}:\n${repl("")}\n${repeat(tag(1, "aaa"))}`)).toBe( + "\naaa\nbbb\nccc", + ); + expect(applyDiff(content, `${sameLineRange(tag(1, "aaa"))}:\n${repeat(tag(1, "aaa"))}\n${repl("")}`)).toBe( + "aaa\n\nbbb\nccc", + ); }); it("rejects orphan payload lines with no preceding op", () => { @@ -563,18 +549,20 @@ describe("hashline parser — range-anchor syntax", () => { }); it("rejects ranges with `..` separator", () => { - expect(() => parseHashline(`${tag(2, "bbb")}..${tag(3, "ccc")}:\n${repl("BBB")}`).edits).toThrow( + expect(() => parseHashline(`${tag(2, "bbb")}..${sameLineRange(tag(3, "ccc"))}:\n${repl("BBB")}`).edits).toThrow( /payload line has no preceding/, ); }); it("describes the new block shape on unknown-op lines", () => { - expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`).edits).toThrow(/Use A-B:, A:, BOF:, or EOF:/); + expect(() => parseHashline(`-${sameLineRange(tag(2, "bbb"))}`).edits).toThrow(/Use A-B:, A-B:-, BOF:, or EOF:/); }); it("rejects `LINE:TEXT` copied verbatim from read output", () => { const anchor = tag(2, "bbb"); - expect(() => parseHashline(`${anchor}:BBB`)).toThrow(/Inline payload on the anchor line is rejected/); + expect(() => parseHashline(`${sameLineRange(anchor)}:BBB`)).toThrow( + /Inline payload on the anchor line is rejected/, + ); expect(() => parseHashline(`${anchor}-${tag(3, "ccc")}:BBB`)).toThrow( /Inline payload on the anchor line is rejected/, ); @@ -582,8 +570,10 @@ describe("hashline parser — range-anchor syntax", () => { it("leniently strips `*`/`>` line-marker decoration from anchors", () => { const anchor = tag(2, "bbb"); - expect(applyDiff(content, `*${anchor}:\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc"); - expect(applyDiff(content, `>${anchor}:\n${above("X")}`)).toBe("aaa\nX\nbbb\nccc"); + expect(applyDiff(content, `*${sameLineRange(anchor)}:\n${repl("BBB")}`)).toBe("aaa\nBBB\nccc"); + expect(applyDiff(content, `>${sameLineRange(anchor)}:\n${repl("X")}\n${repeat(anchor)}`)).toBe( + "aaa\nX\nbbb\nccc", + ); }); it("rejects arrow replace syntax as an unrecognized payload line", () => { @@ -593,16 +583,18 @@ describe("hashline parser — range-anchor syntax", () => { it("preserves payload text containing arrow sigils after the leading payload sigil", () => { const anchor = tag(2, "bbb"); - expect(applyDiff(content, `${anchor}:\n${repl("bbb↑")}\n${below("tail↓")}`)).toBe("aaa\nbbb↑\ntail↓\nccc"); + expect(applyDiff(content, `${sameLineRange(anchor)}:\n${repl("bbb↑")}\n${repl("tail↓")}`)).toBe( + "aaa\nbbb↑\ntail↓\nccc", + ); }); - it("accepts BOF/EOF inserts with either arrow payload sigil", () => { - expect(applyDiff(content, `BOF:\n${below("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc"); - expect(applyDiff(content, `EOF:\n${above("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL"); + it("accepts BOF/EOF inserts with literal payload rows", () => { + expect(applyDiff(content, `BOF:\n${repl("HEAD")}`)).toBe("HEAD\naaa\nbbb\nccc"); + expect(applyDiff(content, `EOF:\n${repl("TAIL")}`)).toBe("aaa\nbbb\nccc\nTAIL"); }); it("coalesces two replace ops targeting the same single line (last wins)", () => { - const diff = `${tag(2, "bbb")}:\n${repl("BBB")}\n${tag(2, "bbb")}:\n${repl("BBB2")}`; + const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("BBB")}\n${sameLineRange(tag(2, "bbb"))}:\n${repl("BBB2")}`; const { edits, warnings } = parseHashline(diff); expect(applyHashlineEdits("aaa\nbbb\nccc", edits).lines).toBe("aaa\nBBB2\nccc"); expect(warnings).toEqual([ @@ -624,7 +616,7 @@ describe("hashline parser — range-anchor syntax", () => { // The inner anchors still clash with the outer range's deletes and the // post-hoc validator catches the overlap. const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("NEW1")}\n${tag(3, "ccc")}-${tag(5, "eee")}:\n${repl("NEW2")}`; - expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the : block on line 1/); + expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by another op on line 1/); }); it("uses `|` payload lines inside a multi-line replacement", () => { @@ -637,12 +629,12 @@ describe("hashline parser — range-anchor syntax", () => { }); it("rejects read-output `N:TEXT` lines inside a pending `A-B:` block", () => { - const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("line one")}\n${tag(3, "ccc")}:line two`; + const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:\n${repl("line one")}\n${sameLineRange(tag(3, "ccc"))}:line two`; expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/); }); it("treats `N:` outside the pending range as a separate op", () => { - const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("line one")}\n${tag(5, "eee")}:\n${repl("line five")}`; + const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:\n${repl("line one")}\n${sameLineRange(tag(5, "eee"))}:\n${repl("line five")}`; const { edits, warnings } = parseHashline(diff); expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee\nfff", edits).lines).toBe( "aaa\nline one\nddd\nline five\nfff", @@ -650,13 +642,13 @@ describe("hashline parser — range-anchor syntax", () => { expect(warnings).toEqual([]); }); - it("accepts multiple inserts in the same bucket", () => { - const diff = `${tag(2, "bbb")}:\n${above("X")}\n${above("Y")}`; + it("accepts multiple literal rows before a repeated anchor", () => { + const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("X")}\n${repl("Y")}\n${repeat(tag(2, "bbb"))}`; expect(applyDiff(content, diff)).toBe("aaa\nX\nY\nbbb\nccc"); }); - it("accepts a replace alongside an insert at the same anchor", () => { - const diff = `${tag(2, "bbb")}:\n${above("ABOVE")}\n${repl("NEW")}`; + it("accepts a replace alongside surrounding literal rows", () => { + const diff = `${sameLineRange(tag(2, "bbb"))}:\n${repl("ABOVE")}\n${repl("NEW")}`; expect(applyDiff(content, diff)).toBe("aaa\nABOVE\nNEW\nccc"); }); }); @@ -683,50 +675,50 @@ describe("splitHashlineInput — ¶ headers", () => { }); it("strips leading blank lines", () => { - expect(splitHashlineInput(`\n¶foo.ts\nBOF:\n${below("x")}`)).toEqual({ + expect(splitHashlineInput(`\n¶foo.ts\nBOF:\n${repl("x")}`)).toEqual({ path: "foo.ts", - diff: `BOF:\n${below("x")}`, + diff: `BOF:\n${repl("x")}`, }); }); it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => { const cwd = process.cwd(); const absolute = path.join(cwd, "src", "foo.ts"); - expect(splitHashlineInput(`¶${absolute}\nBOF:\n${below("x")}`, { cwd }).path).toBe("src/foo.ts"); + expect(splitHashlineInput(`¶${absolute}\nBOF:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts"); }); it("uses explicit fallback path only when input has recognizable operations", () => { - expect(splitHashlineInput(`BOF:\n${below("x")}`, { path: "a.ts" })).toEqual({ + expect(splitHashlineInput(`BOF:\n${repl("x")}`, { path: "a.ts" })).toEqual({ path: "a.ts", - diff: `BOF:\n${below("x")}`, + diff: `BOF:\n${repl("x")}`, }); expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/); }); it("splits multiple edit sections", () => { - const input = ["¶a.ts", "BOF:", below("a"), "¶b.ts", "EOF:", below("b")].join("\n"); + const input = ["¶a.ts", "BOF:", repl("a"), "¶b.ts", "EOF:", repl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `BOF:\n${below("a")}` }, - { path: "b.ts", diff: `EOF:\n${below("b")}` }, + { path: "a.ts", diff: `BOF:\n${repl("a")}` }, + { path: "b.ts", diff: `EOF:\n${repl("b")}` }, ]); }); it("tolerates extra ¶ chars on the section header", () => { - const input = ["¶¶a.ts", "BOF:", below("a"), "¶¶¶b.ts", "EOF:", below("b")].join("\n"); + const input = ["¶¶a.ts", "BOF:", repl("a"), "¶¶¶b.ts", "EOF:", repl("b")].join("\n"); expect(splitHashlineInputs(input)).toEqual([ - { path: "a.ts", diff: `BOF:\n${below("a")}` }, - { path: "b.ts", diff: `EOF:\n${below("b")}` }, + { path: "a.ts", diff: `BOF:\n${repl("a")}` }, + { path: "b.ts", diff: `EOF:\n${repl("b")}` }, ]); }); it("silently drops a duplicate header with no operations between them", () => { - const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", "BOF:", below("x")].join("\n"); - expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF:\n${below("x")}` }]); + const input = ["¶¶src/foo.ts", "¶¶src/foo.ts", "BOF:", repl("x")].join("\n"); + expect(splitHashlineInputs(input)).toEqual([{ path: "src/foo.ts", diff: `BOF:\n${repl("x")}` }]); }); it("silently drops a trailing header with no operations", () => { - const input = ["¶¶a.ts", "BOF:", below("a"), "¶¶b.ts"].join("\n"); - expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF:\n${below("a")}` }]); + const input = ["¶¶a.ts", "BOF:", repl("a"), "¶¶b.ts"].join("\n"); + expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `BOF:\n${repl("a")}` }]); }); }); @@ -755,7 +747,7 @@ it("preflights write policy for every section before committing a batch", async describe("hashline executor", () => { it("creates a missing file with a file-scoped insert", async () => { await withTempDir(async tempDir => { - const input = `¶new.ts\nBOF:\n${below("export const x = 1;")}\n`; + const input = `¶new.ts\nBOF:\n${repl("export const x = 1;")}\n`; const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("¶new.ts#"); expect(await Bun.file(path.join(tempDir, "new.ts")).text()).toBe("export const x = 1;"); @@ -765,16 +757,15 @@ describe("hashline executor", () => { await withTempDir(async tempDir => { const filePath = path.join(tempDir, "a.ts"); const source = ["aaa", "bbb", "ccc"].join("\n"); - const input = `${header("a.ts", source)}\n${tag(2, "bbb")}:\n${below("aaa")}\n${below("bbb")}\n${below("NEW")}\n`; - + const input = `${header("a.ts", source)}\nEOF:\n${repl("bbb")}\n${repl("ccc")}\n${repl("NEW")}\n`; await Bun.write(filePath, source); await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); - expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\naaa\nbbb\nNEW\nccc"); + expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nccc\nbbb\nccc\nNEW"); await Bun.write(filePath, source); const enabled = Settings.isolated({ "edit.hashlineAutoDropPureInsertDuplicates": true }); const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input, enabled)); - expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nNEW\nccc"); + expect(await Bun.file(filePath).text()).toBe("aaa\nbbb\nccc\nNEW"); expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("Auto-dropped"); }); }); @@ -848,8 +839,9 @@ describe("hashline executor", () => { repl("L2h"), repl("L2i"), header("a.ts", `${original}\n`), - `${tag(8, "L8")}:`, - below("INSERTED"), + `${sameLineRange(tag(8, "L8"))}:`, + repeat(tag(8, "L8")), + repl("INSERTED"), ].join("\n"); await executeHashlineSingle(hashlineExecuteOptions(tempDir, input)); @@ -894,15 +886,15 @@ describe("hashlineEditParamsSchema — payload shape", () => { }); it("tolerates provider extra fields without declaring `path`", () => { - expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF:\n${below("x")}` }).success).toBe( + expect(hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\nBOF:\n${repl("x")}` }).success).toBe( true, ); }); it("accepts `_input` as a provider-emitted alias for `input`", () => { - const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\nBOF:\n${below("x")}` }); + const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\nBOF:\n${repl("x")}` }); expect(parsed.success).toBe(true); - if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\nBOF:\n${below("x")}`); + if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\nBOF:\n${repl("x")}`); }); it("still requires `input`", () => { @@ -1147,7 +1139,7 @@ describe("hashline — anchor-stale recovery via read snapshot cache", () => { absolutePath: fakePath, currentText, fileHash: computeFileHash(v0Text), - edits: parseHashline(`10:\n${repl("L10-EDITED")}`).edits, + edits: parseHashline(`10-10:\n${repl("L10-EDITED")}`).edits, options: {}, }); @@ -1208,32 +1200,42 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => const sentinel = "*** Abort"; it("parser breaks at *** Abort and surfaces a warning", () => { - const diff = [`${tag(1, "alpha")}:`, below("HELLO"), sentinel, `${tag(99, "junk")}:`, below("never")].join("\n"); + const diff = [ + `${sameLineRange(tag(1, "alpha"))}:`, + repeat(tag(1, "alpha")), + repl("HELLO"), + sentinel, + `${sameLineRange(tag(99, "junk"))}:`, + repeat(tag(99, "junk")), + repl("never"), + ].join("\n"); const { edits, warnings } = parseHashline(diff); - expect(edits).toHaveLength(1); - expect(edits[0]).toMatchObject({ kind: "insert", text: "HELLO" }); + expect(edits).toHaveLength(3); + expect(edits[1]).toMatchObject({ kind: "insert", text: "HELLO" }); expect(warnings.length).toBeGreaterThan(0); expect(warnings[0]).toMatch(/truncated mid-call/i); }); it("appended sentinel from harmony-leak truncation: ops above are preserved", () => { // Mirrors the exact shape harmony-leak emits inside a single section. - const diff = `${tag(1, "alpha")}:\n${below("KEPT")}\n*** Abort\n`; + const diff = `${sameLineRange(tag(1, "alpha"))}:\n${repeat(tag(1, "alpha"))}\n${repl("KEPT")}\n*** Abort\n`; const { edits, warnings } = parseHashline(diff); - expect(edits).toHaveLength(1); - expect(edits[0]).toMatchObject({ text: "KEPT" }); + expect(edits).toHaveLength(3); + expect(edits[1]).toMatchObject({ text: "KEPT" }); expect(warnings.length).toBeGreaterThan(0); }); it("splitter respects *** Abort like *** End Patch", () => { const input = [ `¶a.ts`, - `${tag(1, "alpha")}:`, - below("a-payload"), + `${sameLineRange(tag(1, "alpha"))}:`, + repeat(tag(1, "alpha")), + repl("a-payload"), sentinel, `¶b.ts`, - `${tag(1, "beta")}:`, - below("never-emitted"), + `${sameLineRange(tag(1, "beta"))}:`, + repeat(tag(1, "beta")), + repl("never-emitted"), ].join("\n"); const sections = splitHashlineInputs(input); expect(sections).toHaveLength(1); @@ -1242,37 +1244,43 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () => }); it("clean input without sentinel produces no warning", () => { - const diff = `${tag(1, "alpha")}:\n${below("PAYLOAD")}\n`; + const diff = `${sameLineRange(tag(1, "alpha"))}:\n${repeat(tag(1, "alpha"))}\n${repl("PAYLOAD")}\n`; const { warnings } = parseHashline(diff); expect(warnings).toEqual([]); }); }); -describe("hashline parser — blank payload rows", () => { - it("bare A: deletes the line", () => { +describe("hashline parser — delete and empty-block semantics", () => { + it("inline delete deletes a single line", () => { const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:\n`); + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-2:-\n`); expect(applyDiff(text, diff)).toBe("line1\nline3\n"); }); - it("bare A-B: deletes the range", () => { + it("inline delete deletes the range", () => { const text = "line1\nline2\nline3\nline4\n"; - const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-3:\n`); + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-3:-\n`); expect(applyDiff(text, diff)).toBe("line1\nline4\n"); }); - it("A: with inline body is rejected", () => { + it("an `A-B:` anchor with no payload becomes a blank-line replacement", () => { const text = "line1\nline2\nline3\n"; - const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2:replacement\n`); + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-2:\n`); + expect(applyDiff(text, diff)).toBe("line1\n\nline3\n"); + }); + + it("`A-B:` with inline body is still rejected", () => { + const text = "line1\nline2\nline3\n"; + const { diff } = splitHashlineInput(`${header("a.ts", text)}\n2-2:replacement\n`); expect(() => parseHashline(diff)).toThrow(/Inline payload on the anchor line is rejected/); }); - it("explicit empty above/below rows insert blank lines", () => { + it("explicit empty literal rows insert blank lines when the anchor is repeated", () => { const text = "line1\nline2\nline3\n"; - const aboveDiff = splitHashlineInput(`${header("a.ts", text)}\n2:\n${above("")}\n`).diff; + const aboveDiff = splitHashlineInput(`${header("a.ts", text)}\n2-2:\n${repl("")}\n${repeat("2")}\n`).diff; expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n"); - const belowDiff = splitHashlineInput(`${header("a.ts", text)}\n2:\n${below("")}\n`).diff; + const belowDiff = splitHashlineInput(`${header("a.ts", text)}\n2-2:\n${repeat("2")}\n${repl("")}\n`).diff; expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n"); }); }); @@ -1280,28 +1288,28 @@ describe("hashline parser — blank payload rows", () => { describe("hashline parser — explicit blank payload rows", () => { it("raw blank lines between ops are ignored", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `${header("a.ts", text)}\n1:\n${repl("A")}\n\n3:\n${repl("C")}\n`; + const ops = `${header("a.ts", text)}\n1-1:\n${repl("A")}\n\n3-3:\n${repl("C")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n"); }); it("empty replace payload rows are appended as blank payload lines", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `${header("a.ts", text)}\n1:\n${repl("A")}\n${repl("")}\n${repl("")}\n3:\n${repl("C")}\n`; + const ops = `${header("a.ts", text)}\n1-1:\n${repl("A")}\n${repl("")}\n${repl("")}\n3-3:\n${repl("C")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n"); }); - it("bare A: followed by two empty replace rows replaces the line with two blanks", () => { + it("`A-A:` followed by two empty replace rows replaces the line with two blanks", () => { const text = "a\nb\nc\nd\ne\n"; - const ops = `${header("a.ts", text)}\n2:\n${repl("")}\n${repl("")}\n4:\n${repl("D")}\n`; + const ops = `${header("a.ts", text)}\n2-2:\n${repl("")}\n${repl("")}\n4-4:\n${repl("D")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n"); }); it("empty replace row inside payload between two content lines is preserved", () => { const text = "a\nb\nc\n"; - const ops = `${header("a.ts", text)}\n2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; + const ops = `${header("a.ts", text)}\n2-2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`; const { diff } = splitHashlineInput(ops); expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n"); }); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index b01b85a37..3f0e9b21c 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -236,9 +236,9 @@ describe("computeHashlineDiff", () => { const line = "unchanged content"; await Bun.write(sourcePath, `${line}\n`); - // `1:` with the same line in the replace bucket is a true no-op: the edit + // `1-1:` with the same line in the replace bucket is a true no-op: the edit // fires through computeHashlineDiff but produces identical content. - const input = `¶${sourcePath}#${computeFileHash(`${line}\n`)}\n1:\n|${line}\n`; + const input = `¶${sourcePath}#${computeFileHash(`${line}\n`)}\n1-1:\n|${line}\n`; const result = await computeHashlineDiff({ input }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { @@ -250,14 +250,14 @@ describe("computeHashlineDiff", () => { const sourcePath = path.join(tempDir, "source.txt"); await Bun.write(sourcePath, "first\n"); - const result = await computeHashlineDiff({ input: `¶${sourcePath}\nEOF:\n↓second` }, tempDir); + const result = await computeHashlineDiff({ input: `¶${sourcePath}\nEOF:\n|second` }, tempDir); expect("diff" in result).toBe(true); if ("diff" in result) { expect(result.diff).toContain("second"); } }); test("returns a handled error when the source path is a local URL", async () => { - const result = await computeHashlineDiff({ input: "¶local://PLAN.md\nEOF:\n↓x" }, tempDir); + const result = await computeHashlineDiff({ input: "¶local://PLAN.md\nEOF:\n|x" }, tempDir); expect("error" in result).toBe(true); if ("error" in result) { diff --git a/packages/coding-agent/test/edit-streaming-preview.test.ts b/packages/coding-agent/test/edit-streaming-preview.test.ts index 2faafeab7..fb6dfbd8f 100644 --- a/packages/coding-agent/test/edit-streaming-preview.test.ts +++ b/packages/coding-agent/test/edit-streaming-preview.test.ts @@ -54,7 +54,7 @@ describe("hashline streaming preview (multi-section)", () => { const ctx = (cwd: string) => ({ cwd, signal: new AbortController().signal }); test("keeps section A's preview when section B's header just arrived", async () => { - const input = ["¶a.ts", "BOF:", "↓// new", "¶b.ts"].join("\n"); + const input = ["¶a.ts", "BOF:", "|// new", "¶b.ts"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -65,7 +65,7 @@ describe("hashline streaming preview (multi-section)", () => { test("ignores parse errors from the trailing in-progress section", async () => { // `7:bad` has inline payload — the trailing section is still being typed. - const input = ["¶a.ts", "BOF:", "↓// new", "¶b.ts", "7:bad"].join("\n"); + const input = ["¶a.ts", "BOF:", "|// new", "¶b.ts", "7:bad"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).not.toBeNull(); expect(previews).toHaveLength(1); @@ -74,7 +74,7 @@ describe("hashline streaming preview (multi-section)", () => { }); test("renders both sections once each has at least one valid op", async () => { - const input = ["¶a.ts", "BOF:", "↓// new a", "¶b.ts", "BOF:", "↓// new b"].join("\n"); + const input = ["¶a.ts", "BOF:", "|// new a", "¶b.ts", "BOF:", "|// new b"].join("\n"); const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never); expect(previews).toHaveLength(2); expect(previews?.map(p => p.path).sort()).toEqual(["a.ts", "b.ts"]); diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 80a34d1c1..200610e7e 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -38,7 +38,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const component = editToolRenderer.renderCall( { - input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n↓// preview", + input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -56,7 +56,7 @@ describe("editToolRenderer", () => { const component = new ToolExecutionComponent( "edit", { - input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "↓pub fn streaming_preview() {"].join( + input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "|pub fn streaming_preview() {"].join( "\n", ), }, @@ -68,7 +68,7 @@ describe("editToolRenderer", () => { const rendered = Bun.stripANSI(component.render(160).join("\n")); expect(rendered).toContain("crates/pi-natives/src/shell.rs"); expect(rendered).not.toContain("EOF:"); - expect(rendered).not.toContain("↓pub fn streaming_preview() {"); + expect(rendered).not.toContain("|pub fn streaming_preview() {"); expect(rendered).not.toContain("*** Begin Patch"); }); @@ -76,7 +76,7 @@ describe("editToolRenderer", () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall( { - input: "¶foo bar.ts\nBOF:\n↓// preview", + input: "¶foo bar.ts\nBOF:\n|// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -84,7 +84,7 @@ describe("editToolRenderer", () => { const quotedComponent = editToolRenderer.renderCall( { - input: "¶'baz qux.ts'\nBOF:\n↓// preview", + input: "¶'baz qux.ts'\nBOF:\n|// preview", }, { expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -103,7 +103,7 @@ describe("editToolRenderer", () => { // renderer keeps the title clean. const canonical = editToolRenderer.renderCall( { - input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n↓// preview", + input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview", }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, @@ -111,7 +111,7 @@ describe("editToolRenderer", () => { // Even longer runs should still produce the clean path. const triple = editToolRenderer.renderCall( - { input: "¶¶¶a/b/c.ts\nBOF:\n↓// preview" }, + { input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" }, { expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } }, uiTheme, ); @@ -138,7 +138,7 @@ describe("editToolRenderer", () => { { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, uiTheme, { - input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n↓// preview", + input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview", }, ); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index ce7854c3b..b2e4fe8b5 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed `A:` shorthand syntax; use explicit `A-A:` for single-line anchors +- Removed `↑` and `↓` payload sigils; use `|TEXT` for literal rows and `^A-B` for repeating original lines +- Removed standalone delete rows; use inline `A-B:-` syntax instead +- Removed `after_anchor` cursor kind; all inserts now use `before_anchor` positioning +- Replaced insert-above/insert-below payload sigils with linear body rows: `|TEXT` emits literal text and `^A-B` repeats original file lines inline. +- Replaced standalone delete rows with inline range deletes: use `A-B:-`. +- Changed empty `A-B:`, `BOF:`, and `EOF:` blocks to write one blank line instead of being rejected. + +### Added + +- Added `A-B:-` inline delete syntax for concrete range anchors +- Added `^A-B` repeat payload syntax to emit original file lines inline +- Added support for empty anchor blocks to write one blank line at the anchor position + +### Changed + +- Changed payload row format from three sigils (`|`, `↑`, `↓`) to two (`|`, `^`) +- Changed range anchor syntax to require explicit `A-B` form (no single-line shorthand) +- Changed error messages to reference new syntax and remove references to removed sigils ## [15.5.5] - 2026-05-27 diff --git a/packages/hashline/README.md b/packages/hashline/README.md index 8be2acd98..79e1d63c7 100644 --- a/packages/hashline/README.md +++ b/packages/hashline/README.md @@ -26,7 +26,7 @@ await fs.writeText( const patcher = new Patcher({ fs }); const patch = Patch.parse(String.raw`¶hello.ts -1: +1-1: |const greeting = "hello";`); const result = await patcher.apply(patch); @@ -46,12 +46,12 @@ hash and the edit, the patcher refuses (or, with a `SnapshotStore`, tries session-aware recovery). Inside a hunk: - -- `A-B:` — anchor lines A..B (single-anchor `A:` is sugar for `A-A:`). +- `A-B:` — anchor lines A..B (use `A-A:` for a single line; no shorthand). +- `A-B:-` — delete lines A..B. - `BOF:` / `EOF:` — virtual anchors at the beginning/end of file. -- `|TEXT` — replace-bucket payload. A non-empty replace bucket replaces A..B. -- `↑TEXT` — insert before A (`BOF:` treats `↑`/`↓` equivalently). -- `↓TEXT` — insert after B (`EOF:` treats `↑`/`↓` equivalently). +- `|TEXT` — literal body row. +- `^A-B` — repeat original file lines A..B inline (`^A-A` for one line). +- Empty body — write one blank line at the anchor/virtual position. ## Abstractions diff --git a/packages/hashline/package.json b/packages/hashline/package.json index 1bc5f8b3e..f6f2874c4 100644 --- a/packages/hashline/package.json +++ b/packages/hashline/package.json @@ -28,6 +28,7 @@ "check": "biome check . && bun run check:types", "check:types": "tsgo -p tsconfig.json --noEmit", "lint": "biome lint .", + "test": "bun test", "fix": "biome check --write --unsafe .", "fmt": "biome format --write ." }, diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 4d4e05b26..ef088d6c0 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -18,13 +18,15 @@ import type { Anchor, ApplyOptions, ApplyResult, Cursor, Edit } from "./types"; type LineOrigin = "original" | "insert" | "replacement"; +type InsertEdit = Extract; +type DeleteEdit = Extract; +type AppliedEdit = InsertEdit | DeleteEdit; + interface IndexedEdit { - edit: Edit; + edit: AppliedEdit; idx: number; } -type DeleteEdit = Extract; - interface ReplacementGroup { startIndex: number; endIndex: number; @@ -33,31 +35,32 @@ interface ReplacementGroup { deletes: DeleteEdit[]; } -function isReplacementInsert(edit: Edit): edit is Extract & { mode: "replacement" } { +function isReplacementInsert(edit: Edit): edit is InsertEdit & { mode: "replacement" } { return edit.kind === "insert" && edit.mode === "replacement"; } +function rangeAnchors(start: Anchor, end: Anchor): Anchor[] { + const anchors: Anchor[] = []; + for (let line = start.line; line <= end.line; line++) anchors.push({ line }); + return anchors; +} + +function getCursorAnchors(cursor: Cursor): Anchor[] { + return cursor.kind === "before_anchor" ? [cursor.anchor] : []; +} + function getEditAnchors(edit: Edit): Anchor[] { if (edit.kind === "delete") return [edit.anchor]; - switch (edit.cursor.kind) { - case "before_anchor": - case "after_anchor": - return [edit.cursor.anchor]; - case "bof": - case "eof": - return []; - default: { - const _exhaustive: never = edit.cursor; - return _exhaustive; - } - } + if (edit.kind === "repeat") + return [...getCursorAnchors(edit.cursor), ...rangeAnchors(edit.range.start, edit.range.end)]; + return getCursorAnchors(edit.cursor); } /** * Verify every anchored edit points at an existing line. File-version binding is * checked once per section via the header hash before this function runs. */ -function validateLineBounds(edits: Edit[], fileLines: string[]): void { +function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void { for (const edit of edits) { for (const anchor of getEditAnchors(edit)) { if (anchor.line < 1 || anchor.line > fileLines.length) { @@ -67,6 +70,44 @@ function validateLineBounds(edits: Edit[], fileLines: string[]): void { } } +function assertLineExists(line: number, fileLines: string[]): void { + if (line < 1 || line > fileLines.length) { + throw new Error(`Line ${line} does not exist (file has ${fileLines.length} lines)`); + } +} + +function cloneAppliedEdit(edit: AppliedEdit, index: number): AppliedEdit { + if (edit.kind === "delete") return { ...edit, anchor: { ...edit.anchor }, index }; + return { ...edit, cursor: cloneCursor(edit.cursor), index }; +} + +function expandRepeatEdits(edits: Edit[], fileLines: string[]): AppliedEdit[] { + const expanded: AppliedEdit[] = []; + for (const edit of edits) { + if (edit.kind !== "repeat") { + expanded.push(cloneAppliedEdit(edit, expanded.length)); + continue; + } + if (edit.range.end.line < edit.range.start.line) { + throw new Error( + `line ${edit.lineNum}: range ${edit.range.start.line}-${edit.range.end.line} ends before it starts.`, + ); + } + for (let line = edit.range.start.line; line <= edit.range.end.line; line++) { + assertLineExists(line, fileLines); + expanded.push({ + kind: "insert", + cursor: cloneCursor(edit.cursor), + text: fileLines[line - 1] ?? "", + lineNum: edit.lineNum, + index: expanded.length, + ...(edit.mode === undefined ? {} : { mode: edit.mode }), + }); + } + } + return expanded; +} + function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void { if (lines.length === 0) return; const origins = lines.map((): LineOrigin => "insert"); @@ -95,13 +136,13 @@ function insertAtEnd(fileLines: string[], lineOrigins: LineOrigin[], lines: stri } /** Bucket edits by the line they target so we can apply each line's group in one splice. */ -function getAnchorTargetLine(edit: Edit): number | undefined { +function getAnchorTargetLine(edit: AppliedEdit): number | undefined { if (edit.kind === "delete") return edit.anchor.line; - if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line; + if (edit.cursor.kind === "before_anchor") return edit.cursor.anchor.line; return undefined; } -function collectAnchorTargetLines(edits: Edit[]): Set { +function collectAnchorTargetLines(edits: AppliedEdit[]): Set { const lines = new Set(); for (const edit of edits) { const line = getAnchorTargetLine(edit); @@ -110,7 +151,7 @@ function collectAnchorTargetLines(edits: Edit[]): Set { return lines; } -function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined { +function findReplacementGroup(edits: AppliedEdit[], startIndex: number): ReplacementGroup | undefined { const first = edits[startIndex]; if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined; @@ -278,14 +319,14 @@ function countMatchingSingleStructuralSuffixBoundary( /** * Single-line non-structural boundary duplicate detector for replacement * groups. Mirrors the same boundary check the pure-insert absorber uses for - * `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, but applied to the - * top/bottom edges of an `A-B:` replacement payload. Catches mistakes like + * leading/trailing context echoes, but applied to the top/bottom edges of an + * `A-B:` replacement payload. Catches mistakes like * `103-138:` + `|const X = …` where line 102 already reads `const X = …`. * * Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block * absorb already runs unconditionally, and the structural single-line * absorber is balance-validated; a non-structural single-line duplicate is - * ambiguous (could be an intentional `2:foo` over a line that happens to + * ambiguous (could be an intentional `2-2:foo` over a line that happens to * sit next to another `foo`), so we only fire when the user has opted in. */ function countMatchingSingleNonStructuralPrefixDuplicate( @@ -325,7 +366,7 @@ function contiguousRange(start: number, count: number): number[] { return Array.from({ length: count }, (_, offset) => start + offset); } -function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): Edit { +function deleteEditForAutoAbsorbedLine(line: number, sourceLineNum: number, index: number): AppliedEdit { return { kind: "delete", anchor: { line }, @@ -345,9 +386,7 @@ interface PureInsertGroup { function cursorMatches(a: Cursor, b: Cursor): boolean { if (a.kind !== b.kind) return false; if (a.kind === "bof" || a.kind === "eof") return true; - const aAnchor = (a as { anchor: Anchor }).anchor; - const bAnchor = (b as { anchor: Anchor }).anchor; - return aAnchor.line === bAnchor.line; + return a.anchor.line === b.anchor.line; } /** @@ -357,7 +396,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean { * instead). Returns the contiguous payload so we can check it for boundary * duplicates against the file. */ -function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined { +function findPureInsertGroup(edits: AppliedEdit[], startIndex: number): PureInsertGroup | undefined { const first = edits[startIndex]; if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined; @@ -393,11 +432,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup function pureInsertNeighborhood(cursor: Cursor, fileLines: string[]): { aboveEndIdx: number; belowStartIdx: number } { if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 }; if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length }; - if (cursor.kind === "before_anchor") { - return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 }; - } - // after_anchor - return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line }; + return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 }; } interface PureInsertAbsorbResult { @@ -411,8 +446,8 @@ interface PureInsertAbsorbResult { /** * For a pure-insert group, drop only multi-line context echoes that exactly * duplicate the file lines adjacent to the insertion point. Single-line pure - * insert duplicates are ambiguous (`N↓}` may be an accidental anchor echo or an - * intentional inserted delimiter), so they are left literal even when generic + * insert duplicates are ambiguous (a repeated `}` may be an accidental anchor + * echo or an intentional inserted delimiter), so they are left literal even when * duplicate absorption is enabled. */ function tryAbsorbPureInsertGroup( @@ -479,13 +514,13 @@ function tryAbsorbPureInsertGroup( } function absorbReplacementBoundaryDuplicates( - edits: Edit[], + edits: AppliedEdit[], fileLines: string[], warnings: string[], options: ApplyOptions, -): Edit[] { +): AppliedEdit[] { let nextSyntheticIndex = edits.length; - const absorbed: Edit[] = []; + const absorbed: AppliedEdit[] = []; // Anchor targets are stable across the loop because we only ever append // synthetic deletes (never mutate originals). A line in this set that @@ -649,32 +684,10 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions = if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line; }; - validateLineBounds(edits, fileLines); + const expandedEdits = expandRepeatEdits(edits, fileLines); + validateLineBounds(expandedEdits, fileLines); - const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options); - const targetEdits: Edit[] = []; - - // Normalize after_anchor inserts to before_anchor of the next line, or EOF - // when the anchor is the final line. Keep the authored edit objects - // immutable: PatchSection caches parsed edits and callers may apply them - // repeatedly against different snapshots. - for (const edit of normalizedEdits) { - if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") { - targetEdits.push(edit); - continue; - } - const anchorLine = edit.cursor.anchor.line; - targetEdits.push({ - ...edit, - cursor: - anchorLine >= fileLines.length - ? { kind: "eof" } - : { - kind: "before_anchor", - anchor: { line: anchorLine + 1 }, - }, - }); - } + const targetEdits = absorbReplacementBoundaryDuplicates(expandedEdits, fileLines, warnings, options); // Partition edits into BOF, EOF, and anchor-targeted buckets. const bofLines: string[] = []; diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index 33a3a5b8e..3b13bb91c 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -7,15 +7,16 @@ /** Anchor terminator for every hashline operation block. */ export const HL_OP_REPLACE = ":"; -/** Payload sigil for lines that replace the anchored range in place. */ +/** Inline-delete suffix for concrete range anchors (`A-B:-`). */ +export const HL_OP_DELETE_SUFFIX = ":-"; + +/** Payload sigil for literal body rows. */ export const HL_PAYLOAD_REPLACE = "|"; -/** Payload sigil for lines inserted before the anchored range. */ -export const HL_PAYLOAD_ABOVE = "↑"; -/** Payload sigil for lines inserted after the anchored range. */ -export const HL_PAYLOAD_BELOW = "↓"; +/** Payload sigil for body rows that repeat original file lines. */ +export const HL_PAYLOAD_REPEAT = "^"; /** All hashline payload sigils, concatenated for fast membership tests. */ -export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_ABOVE}${HL_PAYLOAD_BELOW}`; +export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_REPEAT}`; /** Hashline edit file-section header marker. */ export const HL_FILE_PREFIX = "¶"; @@ -46,7 +47,12 @@ export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`; export const HL_LINE_RE_RAW = `[1-9]\\d*`; /** Capture-group form of {@link HL_LINE_RE_RAW}. */ -export const HL_LINE_CAPTURE_RE_RAW = `([1-9]\\d*)`; +export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`; + +/** Regex for repeat payload rows (`^A-B`). */ +export const HL_PAYLOAD_REPEAT_RE = new RegExp( + `^\\${HL_PAYLOAD_REPEAT}${HL_LINE_CAPTURE_RE_RAW}-${HL_LINE_CAPTURE_RE_RAW}$`, +); /** Four-hex-character file hash carried by a hashline section header. */ export const HL_FILE_HASH_RE_RAW = `[0-9a-f]{4}`; diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index 111663d10..4d8d877f7 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -8,14 +8,15 @@ update_hunk: "¶" filename ("#" file_hash)? LF block* filename: /([^\s#]+)/ file_hash: /[0-9a-f]{4}/ -block: anchor ":" LF payload* -payload: above_payload | replace_payload | below_payload -above_payload: "↑" /[^\n]*/ LF -replace_payload: "|" /[^\n]*/ LF -below_payload: "↓" /[^\n]*/ LF +block: anchor ":" delete_suffix? LF payload* +delete_suffix: "-" +payload: literal_payload | repeat_payload +literal_payload: "|" /[^\n]*/ LF +repeat_payload: "^" range LF anchor: range | "BOF" | "EOF" -range: LID ("-" LID)? +range: LID "-" LID + LID: /[1-9]\d*/ %import common.LF diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index adf441231..bded952ba 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -192,14 +192,14 @@ export class PatchSection { } /** - * True when at least one edit anchors to a concrete file line (range or - * before/after_anchor insert). Pure BOF/EOF inserts do not count: those - * are safe to apply to files that don't yet exist. + * True when at least one edit anchors to concrete file content. Pure BOF/EOF + * literal inserts do not count: those are safe to apply to files that don't + * yet exist. */ get hasAnchorScopedEdit(): boolean { return this.edits.some(edit => { - if (edit.kind === "delete") return true; - return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; + if (edit.kind === "delete" || edit.kind === "repeat") return true; + return edit.cursor.kind === "before_anchor"; }); } @@ -211,7 +211,10 @@ export class PatchSection { lines.add(edit.anchor.line); continue; } - if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") { + if (edit.kind === "repeat") { + for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line); + } + if (edit.cursor.kind === "before_anchor") { lines.add(edit.cursor.anchor.line); } } diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index a983b7e3d..691ebf593 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -36,9 +36,13 @@ export const REPLACE_PAIR_COALESCED_WARNING = /** Error text prefix emitted when an anchor line carries inline payload. */ export const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected."; -/** Error text emitted when `|` replacement payload targets BOF/EOF. */ +/** Error text emitted when inline delete targets BOF/EOF. */ export const VIRTUAL_REPLACE_REJECTED_MESSAGE = - "BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `↑` or `↓` payload lines."; + "BOF:/EOF: anchors are virtual positions and cannot use `:-`. Use `|TEXT` or `^A-B` body rows to insert at a virtual position."; + +/** Error text emitted when `^A` repeat shorthand is used. */ +export const REPEAT_SHORTHAND_REJECTED_MESSAGE = + "Repeat payload shorthand `^A` is rejected. Use explicit `^A-A` for one line."; /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */ export const RECOVERY_EXTERNAL_WARNING = diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index e7c84355f..05ae164ea 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -13,21 +13,15 @@ * * Convenience entry point: {@link parsePatch}. */ -import { HL_OP_REPLACE, HL_PAYLOAD_ABOVE, HL_PAYLOAD_BELOW, HL_PAYLOAD_REPLACE } from "./format"; +import { HL_PAYLOAD_REPEAT, HL_PAYLOAD_REPLACE } from "./format"; import { ABORT_WARNING, INLINE_PAYLOAD_REJECTED_PREFIX, + REPEAT_SHORTHAND_REJECTED_MESSAGE, REPLACE_PAIR_COALESCED_WARNING, VIRTUAL_REPLACE_REJECTED_MESSAGE, } from "./messages"; -import { - type BlockTarget, - cloneCursor, - type ParsedRange, - type PayloadBucket, - type Token, - Tokenizer, -} from "./tokenizer"; +import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer"; import type { Anchor, Cursor, Edit } from "./types"; function validateRangeOrder(range: ParsedRange, lineNum: number): void { @@ -56,17 +50,11 @@ function isSkippableCommentLine(line: string): boolean { return line.trimStart().startsWith("#"); } -function sigilForBucket(bucket: PayloadBucket): string { - if (bucket === "above") return HL_PAYLOAD_ABOVE; - if (bucket === "below") return HL_PAYLOAD_BELOW; - return HL_PAYLOAD_REPLACE; -} - function describeTarget(target: BlockTarget): string { if (target.kind === "bof") return "BOF:"; if (target.kind === "eof") return "EOF:"; const { start, end } = target.range; - return start.line === end.line ? `${start.line}:` : `${start.line}-${end.line}:`; + return `${start.line}-${end.line}:`; } interface PendingComment { @@ -74,11 +62,9 @@ interface PendingComment { text: string; } -interface PayloadRow { - bucket: PayloadBucket; - text: string; - lineNum: number; -} +type PayloadRow = + | { kind: "literal"; text: string; lineNum: number } + | { kind: "repeat"; range: ParsedRange; lineNum: number }; interface Pending { target: BlockTarget; @@ -146,10 +132,17 @@ export class Executor { case "blank": this.#consumePendingSkippableComments(); return; - case "payload": + case "payload-literal": this.#consumePendingSkippableComments(); - this.#handlePayload(token.bucket, token.text, token.lineNum); + this.#handleLiteralPayload(token.text, token.lineNum); return; + case "payload-repeat": + this.#consumePendingSkippableComments(); + this.#handleRepeatPayload(token.range, token.lineNum); + return; + case "payload-repeat-shorthand": + this.#consumePendingSkippableComments(); + throw new Error(`line ${token.lineNum}: ${REPEAT_SHORTHAND_REJECTED_MESSAGE}`); case "raw": if (this.#pending === undefined && isSkippableCommentLine(token.text)) { this.#skippableComments.push({ text: token.text, lineNum: token.lineNum }); @@ -160,11 +153,22 @@ export class Executor { return; case "op-block": this.#discardPendingSkippableComments(); + if (token.deleteSuffix) { + if (token.target.kind !== "range") { + throw new Error(`line ${token.lineNum}: ${VIRTUAL_REPLACE_REJECTED_MESSAGE}`); + } + validateRangeOrder(token.target.range, token.lineNum); + this.#flushPending(); + for (const anchor of expandRange(token.target.range)) { + this.#pushDelete(anchor, token.lineNum); + } + return; + } if (token.inlineBody !== undefined) { throw new Error( `line ${token.lineNum}: ${INLINE_PAYLOAD_REJECTED_PREFIX} ` + - `Use a bare anchor line such as ${describeTarget(token.target)}, then put payload on following rows prefixed with ` + - `${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW}.`, + `Use a bare anchor line such as ${describeTarget(token.target)}, then put body rows below it prefixed with ` + + `${HL_PAYLOAD_REPLACE} or ${HL_PAYLOAD_REPEAT}.`, ); } if (token.target.kind === "range") validateRangeOrder(token.target.range, token.lineNum); @@ -245,24 +249,33 @@ export class Executor { if (sourceLines.length < 2) continue; const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b); throw new Error( - `line ${secondBlock}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE} block on line ${firstBlock}. ` + + `line ${secondBlock}: anchor line ${anchorLine} is already targeted by another op on line ${firstBlock}. ` + `Issue ONE block per range; payload is only the final desired content, never a before/after pair.`, ); } } - #handlePayload(bucket: PayloadBucket, text: string, lineNum: number): void { + #handleLiteralPayload(text: string, lineNum: number): void { const pending = this.#pending; if (!pending) { throw new Error( - `line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. ` + - `Got ${JSON.stringify(`${sigilForBucket(bucket)}${text}`)}.`, + `line ${lineNum}: payload line has no preceding A-B:, BOF:, or EOF: anchor. ` + + `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`, ); } - if (bucket === "replace" && pending.target.kind !== "range") { - throw new Error(`line ${lineNum}: ${VIRTUAL_REPLACE_REJECTED_MESSAGE}`); + pending.payloads.push({ kind: "literal", text, lineNum }); + } + + #handleRepeatPayload(range: ParsedRange, lineNum: number): void { + const pending = this.#pending; + if (!pending) { + throw new Error( + `line ${lineNum}: payload line has no preceding A-B:, BOF:, or EOF: anchor. ` + + `Got ${JSON.stringify(`${HL_PAYLOAD_REPEAT}${range.start.line}-${range.end.line}`)}.`, + ); } - pending.payloads.push({ bucket, text, lineNum }); + validateRangeOrder(range, lineNum); + pending.payloads.push({ kind: "repeat", range, lineNum }); } #handleRaw(text: string, lineNum: number): void { @@ -270,7 +283,7 @@ export class Executor { if (text.trim().length === 0) return; throw new Error( `line ${lineNum}: payload row in a hashline block must start with ` + - `${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW}. Got ${JSON.stringify(text)}.`, + `${HL_PAYLOAD_REPLACE} or ${HL_PAYLOAD_REPEAT}A-B. Got ${JSON.stringify(text)}.`, ); } @@ -281,13 +294,13 @@ export class Executor { const firstChar = text[0]; if (firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") { throw new Error( - `line ${lineNum}: unrecognized hashline block. Use A-B:, A:, BOF:, or EOF: anchors followed by ` + - `${HL_PAYLOAD_REPLACE}, ${HL_PAYLOAD_ABOVE}, or ${HL_PAYLOAD_BELOW} payload rows. Got ${JSON.stringify(text)}.`, + `line ${lineNum}: unrecognized hashline block. Use A-B:, A-B:-, BOF:, or EOF: anchors followed by ` + + `${HL_PAYLOAD_REPLACE}TEXT or ${HL_PAYLOAD_REPEAT}A-B body rows. Got ${JSON.stringify(text)}.`, ); } throw new Error( - `line ${lineNum}: payload line has no preceding A-B:, A:, BOF:, or EOF: anchor. Got ${JSON.stringify(text)}.`, + `line ${lineNum}: payload line has no preceding A-B:, BOF:, or EOF: anchor. Got ${JSON.stringify(text)}.`, ); } @@ -302,10 +315,29 @@ export class Executor { }); } + #pushRepeat(cursor: Cursor, range: ParsedRange, lineNum: number, mode?: "replacement"): void { + this.#edits.push({ + kind: "repeat", + cursor: cloneCursor(cursor), + range: { start: { ...range.start }, end: { ...range.end } }, + lineNum, + index: this.#editIndex++, + ...(mode === undefined ? {} : { mode }), + }); + } + #pushDelete(anchor: Anchor, lineNum: number): void { this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ }); } + #emitPayloadRow(cursor: Cursor, payload: PayloadRow, lineNum: number, mode?: "replacement"): void { + if (payload.kind === "literal") { + this.#pushInsert(cursor, payload.text, lineNum, mode); + return; + } + this.#pushRepeat(cursor, payload.range, lineNum, mode); + } + #flushPending(): void { const pending = this.#pending; if (!pending) return; @@ -313,46 +345,27 @@ export class Executor { const { target, lineNum, payloads } = pending; if (target.kind === "bof" || target.kind === "eof") { const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" }; - for (const payload of payloads) { - this.#pushInsert(cursor, payload.text, lineNum); + if (payloads.length === 0) { + this.#pushInsert(cursor, "", lineNum); + } else { + for (const payload of payloads) { + this.#emitPayloadRow(cursor, payload, lineNum); + } } this.#pending = undefined; return; } - const above: string[] = []; - const replacement: string[] = []; - const below: string[] = []; - for (const payload of payloads) { - if (payload.bucket === "above") above.push(payload.text); - else if (payload.bucket === "below") below.push(payload.text); - else replacement.push(payload.text); - } - - for (const text of above) { - this.#pushInsert({ kind: "before_anchor", anchor: { ...target.range.start } }, text, lineNum); - } - - if (replacement.length > 0) { - for (const text of replacement) { - this.#pushInsert( - { kind: "before_anchor", anchor: { ...target.range.start } }, - text, - lineNum, - "replacement", - ); - } - for (const anchor of expandRange(target.range)) { - this.#pushDelete(anchor, lineNum); - } - } else if (above.length === 0 && below.length === 0) { - for (const anchor of expandRange(target.range)) { - this.#pushDelete(anchor, lineNum); + const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } }; + if (payloads.length === 0) { + this.#pushInsert(cursor, "", lineNum, "replacement"); + } else { + for (const payload of payloads) { + this.#emitPayloadRow(cursor, payload, lineNum, "replacement"); } } - - for (const text of below) { - this.#pushInsert({ kind: "after_anchor", anchor: { ...target.range.end } }, text, lineNum); + for (const anchor of expandRange(target.range)) { + this.#pushDelete(anchor, lineNum); } this.#pending = undefined; diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index 6dfc1c502..308d31475 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -106,8 +106,8 @@ export class PreparedSection { function hasAnchorScopedEdit(edits: readonly Edit[]): boolean { return edits.some(edit => { - if (edit.kind === "delete") return true; - return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor"; + if (edit.kind === "delete" || edit.kind === "repeat") return true; + return edit.cursor.kind === "before_anchor"; }); } diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 9d3fdf7a7..3d72d57c5 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -2,129 +2,48 @@ Your patch language is a compact, line-anchored edit format. Patch payload = one or more file sections: - ``` ¶PATH#HASH A-B: -|replacement line -↑inserted above line -↓inserted below line +|literal line +^A-B +A-B:- +BOF: +|literal at start +EOF: +|literal at end ``` - -- `HASH` comes from the latest `read`/`search` header. Missing? Re-`read`. -- No context rows, no gutters, no unchanged lines. -- Anchor rows are ALWAYS bare: `A-B:`, `A:`, `BOF:`, `EOF:`. -- Payload rows MUST start with `|`, `↑`, or `↓`. -- The first sigil is stripped; remaining bytes are file content. +- `HASH` comes from latest `read`/`search`; missing? re-read. +- `A-B:` anchors original lines A..B; use `A-A:` for one line. +- `BOF:`/`EOF:` insert at file start/end. +- `A-B:-` deletes original lines A..B. +- Body rows are linear; output order = row order. +- `|TEXT` emits literal `TEXT`; bare `|` emits blank. +- `^A-B` repeats original lines A..B; one line = `^A-A`. - -`A-B:` — anchor A..B inclusive. -`A:` — shorthand for `A-A:`. -`BOF:` — virtual position before line 1. -`EOF:` — virtual position after the last line. - - - -`|content` — replace A..B with `content`. -`↑content` — insert `content` before A. -`↓content` — insert `content` after B. - - -- **No payload → delete.** `5:` deletes line 5. -- **Buckets combine.** `↑` before A, `|` in place, `↓` after B. -- **Bucket order ignores interleaving.** Output order = all `↑`, then `|`/original, then all `↓`. -- **Order within a bucket is preserved.** Two `↑` rows stack top-down. -- **Blank payload = explicit.** Bare `|`, `↑`, or `↓` writes one blank line. -- **Line numbers are frozen.** Later anchors still reference pre-edit lines. +- Concrete `A-B:` body replaces A..B. +- Concrete `A-B:` with no body replaces A..B with one blank line. +- Virtual `BOF:`/`EOF:` body inserts there. +- Virtual empty body inserts one blank line. +- Line numbers are frozen for the whole patch. -# Replace line 1 with two lines; insert one line below the replacement. +# Replace line 1 with two lines. ``` ¶a.ts#1a2b -1: +1-1: |const X = "b"; |export const Y = X; -↓const Z = Y; ``` - -# Insert above line 3. Line 3 survives because there is no `|` row. +# Insert below line 5. ``` ¶a.ts#1a2b -3: -↑function helper() { return X; } +5-5: +^5-5 +|const Y = X; ``` - -# Delete lines 5..7. -``` -¶a.ts#1a2b -5-7: -``` - -# Replace line 5 with one blank line. -``` -¶a.ts#1a2b -5: -| -``` - - - -- **NEVER use inline payload.** `5:content` is invalid; write `5:` then `|content`. -- **Do not repeat preserved lines.** If line 5 should survive, omit `|`. -- **`↑`/`↓` payloads are new bytes only.** Never echo the anchor or a neighbor line — that line already exists; copying it into a `↓` row appends a duplicate. -- **Do not echo read gutters.** `84:content` is not payload. -- **Do not replay past B.** Stop before B+1; widen the anchor if B+1 changes. -- **NEVER fabricate file hashes.** Missing? Re-`read`. - - - -# WRONG — inline payload after anchor. -5:const X = "b"; -# RIGHT -5: -|const X = "b"; - -# WRONG — replacing line 5 just to keep it while inserting above. -5: -↑const Y = X; -|const X = "a"; -# RIGHT — no `|`; line 5 survives automatically. -5: -↑const Y = X; - -# WRONG — echoing the anchor into a ↓ payload duplicates it. -# Line 5 already contains `const X = 1;`. -5: -↓const X = 1; -↓const Y = 2; -# RIGHT — payload is only the new line; the anchor survives automatically. -5: -↓const Y = 2; - -# WRONG — read-output gutters inside payload. -5-6: -5:const X = "b"; -6:export const Y = X; -# RIGHT -5-6: -|const X = "b"; -|export const Y = X; - -# WRONG — line numbers shifted mentally after the first block. -1: -↓new line -2: -↓another new line -# `2:` still targets original line 2, not `new line`. - - - -- Anchor rows are bare ranges ending in `:`. -- Payload rows start with `|`, `↑`, or `↓`. -- `|` means replace anchored lines. -- Only `↑`/`↓` means preserve anchored lines. -- Payload is only new content; no context rows. - +# Delete lines 5..7: `5-7:-`. + \ No newline at end of file diff --git a/packages/hashline/src/recovery.ts b/packages/hashline/src/recovery.ts index 5c8405e9e..e1239d346 100644 --- a/packages/hashline/src/recovery.ts +++ b/packages/hashline/src/recovery.ts @@ -73,18 +73,14 @@ function collectAnchorLines(edits: readonly Edit[]): number[] { function getEditAnchors(edit: Edit): Anchor[] { if (edit.kind === "delete") return [edit.anchor]; - switch (edit.cursor.kind) { - case "before_anchor": - case "after_anchor": - return [edit.cursor.anchor]; - case "bof": - case "eof": - return []; - default: { - const _exhaustive: never = edit.cursor; - return _exhaustive; - } + const cursorAnchors = edit.cursor.kind === "before_anchor" ? [edit.cursor.anchor] : []; + if (edit.kind === "insert") return cursorAnchors; + + const repeatAnchors: Anchor[] = []; + for (let line = edit.range.start.line; line <= edit.range.end.line; line++) { + repeatAnchors.push({ line }); } + return cursorAnchors.concat(repeatAnchors); } /** diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index fe9be2aba..1d9c02ee4 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -17,9 +17,9 @@ import { describeAnchorExamples, HL_FILE_HASH_SEP, HL_FILE_PREFIX, + HL_OP_DELETE_SUFFIX, HL_OP_REPLACE, - HL_PAYLOAD_ABOVE, - HL_PAYLOAD_BELOW, + HL_PAYLOAD_REPEAT, HL_PAYLOAD_REPLACE, } from "./format"; import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages"; @@ -33,13 +33,13 @@ const CHAR_HASH = 35; const CHAR_TAB = 9; const CHAR_SPACE = 32; const CHAR_HYPHEN = 45; + const CHAR_LOWER_A = 97; const CHAR_LOWER_F = 102; const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0); const CHAR_OP_REPLACE = HL_OP_REPLACE.charCodeAt(0); const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0); -const CHAR_PAYLOAD_ABOVE = HL_PAYLOAD_ABOVE.charCodeAt(0); -const CHAR_PAYLOAD_BELOW = HL_PAYLOAD_BELOW.charCodeAt(0); +const CHAR_PAYLOAD_REPEAT = HL_PAYLOAD_REPEAT.charCodeAt(0); const FILE_HASH_LENGTH = 4; function isDigitCode(code: number): boolean { @@ -106,10 +106,8 @@ export function splitHashlineLines(text: string): string[] { export function cloneCursor(cursor: Cursor): Cursor { if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } }; - if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } }; return cursor; } - // Leniently accept anchors copied from read/search output: // - optional leading line-marker decoration (`*`, `>`, `-`) // - the required bare line number / BOF / EOF anchor @@ -162,18 +160,14 @@ function scanRange(line: string, end = trimEndIndex(line)): RangeScan | null { const start = scanLineNumber(line, numberStart, end); if (start === null) return null; - let nextIndex = start.nextIndex; - let rangeEnd = start.line; - if (nextIndex < end && line.charCodeAt(nextIndex) === CHAR_HYPHEN) { - const endNumber = scanLineNumber(line, nextIndex + 1, end); - if (endNumber === null) return null; - rangeEnd = endNumber.line; - nextIndex = endNumber.nextIndex; - } + // Ranges MUST be written `A-B` (the explicit single-line form `A-A` is fine). + if (start.nextIndex >= end || line.charCodeAt(start.nextIndex) !== CHAR_HYPHEN) return null; + const endNumber = scanLineNumber(line, start.nextIndex + 1, end); + if (endNumber === null) return null; return { - range: { start: { line: start.line }, end: { line: rangeEnd } }, - nextIndex: skipWhitespace(line, nextIndex, end), + range: { start: { line: start.line }, end: { line: endNumber.line } }, + nextIndex: skipWhitespace(line, endNumber.nextIndex, end), }; } @@ -187,8 +181,6 @@ function startsWithWord(line: string, index: number, end: number, word: string): export type BlockTarget = { kind: "range"; range: ParsedRange } | { kind: "bof" } | { kind: "eof" }; -export type PayloadBucket = "above" | "replace" | "below"; - interface TargetScan { target: BlockTarget; nextIndex: number; @@ -212,6 +204,7 @@ function scanBlockTarget(line: string, end = trimEndIndex(line)): TargetScan | n interface ParsedBlockOp { target: BlockTarget; inlineBody: string | undefined; + deleteSuffix: boolean; } function tryParseBlockOp(line: string): ParsedBlockOp | null { @@ -222,18 +215,35 @@ function tryParseBlockOp(line: string): ParsedBlockOp | null { const opIndex = skipWhitespace(line, target.nextIndex, end); if (opIndex >= end || line.charCodeAt(opIndex) !== CHAR_OP_REPLACE) return null; + if ( + opIndex === target.nextIndex && + line.startsWith(HL_OP_DELETE_SUFFIX, opIndex) && + opIndex + HL_OP_DELETE_SUFFIX.length === end + ) { + return { target: target.target, inlineBody: undefined, deleteSuffix: true }; + } + const inlineStart = opIndex + HL_OP_REPLACE.length; return { target: target.target, inlineBody: skipWhitespace(line, inlineStart, end) === end ? undefined : line.slice(inlineStart, end), + deleteSuffix: false, }; } -function payloadBucketForCode(code: number): PayloadBucket | undefined { - if (code === CHAR_PAYLOAD_ABOVE) return "above"; - if (code === CHAR_PAYLOAD_REPLACE) return "replace"; - if (code === CHAR_PAYLOAD_BELOW) return "below"; - return undefined; +function tryParseRepeatPayload(line: string): ParsedRange | "shorthand" | null { + const end = trimEndIndex(line); + if (line.length === 0 || line.charCodeAt(0) !== CHAR_PAYLOAD_REPEAT) return null; + + const start = scanLineNumber(line, 1, end); + if (start === null) return null; + if (start.nextIndex === end) return "shorthand"; + if (start.nextIndex >= end || line.charCodeAt(start.nextIndex) !== CHAR_HYPHEN) return null; + + const finish = scanLineNumber(line, start.nextIndex + 1, end); + if (finish === null) return null; + if (skipWhitespace(line, finish.nextIndex, end) !== end) return null; + return { start: { line: start.line }, end: { line: finish.line } }; } /** @@ -289,8 +299,10 @@ export type Token = | (TokenBase & { kind: "envelope-end" }) | (TokenBase & { kind: "abort" }) | (TokenBase & { kind: "header"; path: string; fileHash?: string }) - | (TokenBase & { kind: "op-block"; target: BlockTarget; inlineBody: string | undefined }) - | (TokenBase & { kind: "payload"; bucket: PayloadBucket; text: string }) + | (TokenBase & { kind: "op-block"; target: BlockTarget; inlineBody: string | undefined; deleteSuffix: boolean }) + | (TokenBase & { kind: "payload-literal"; text: string }) + | (TokenBase & { kind: "payload-repeat"; range: ParsedRange }) + | (TokenBase & { kind: "payload-repeat-shorthand" }) | (TokenBase & { kind: "raw"; text: string }); function classifyLine(line: string, lineNum: number): Token { @@ -308,13 +320,26 @@ function classifyLine(line: string, lineNum: number): Token { } } - const payloadBucket = payloadBucketForCode(line.charCodeAt(0)); - if (payloadBucket !== undefined) { - return { kind: "payload", lineNum, bucket: payloadBucket, text: line.slice(1) }; + const firstCode = line.charCodeAt(0); + if (firstCode === CHAR_PAYLOAD_REPLACE) { + return { kind: "payload-literal", lineNum, text: line.slice(1) }; + } + if (firstCode === CHAR_PAYLOAD_REPEAT) { + const range = tryParseRepeatPayload(line); + if (range === "shorthand") return { kind: "payload-repeat-shorthand", lineNum }; + if (range !== null) return { kind: "payload-repeat", lineNum, range }; } const op = tryParseBlockOp(line); - if (op !== null) return { kind: "op-block", lineNum, target: op.target, inlineBody: op.inlineBody }; + if (op !== null) { + return { + kind: "op-block", + lineNum, + target: op.target, + inlineBody: op.inlineBody, + deleteSuffix: op.deleteSuffix, + }; + } return { kind: "raw", lineNum, text: line }; } diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index fede06b20..0262abb26 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -9,19 +9,15 @@ export interface Anchor { line: number; } -/** Where an `insert` edit should land relative to existing content. */ -export type Cursor = - | { kind: "bof" } - | { kind: "eof" } - | { kind: "before_anchor"; anchor: Anchor } - | { kind: "after_anchor"; anchor: Anchor }; +/** Where an `insert` or `repeat` edit should land relative to existing content. */ +export type Cursor = { kind: "bof" } | { kind: "eof" } | { kind: "before_anchor"; anchor: Anchor }; /** * A single low-level edit produced by the parser and consumed by the applier. - * Multi-line replacements decompose to one `insert` per replacement line plus - * one `delete` per consumed line. Replacement inserts are tagged so the applier - * can distinguish "insert above this deleted line" from "new content for this - * deleted line" when a source block carries both buckets. + * Multi-line replacements decompose to one `insert`/`repeat` per replacement + * line plus one `delete` per consumed line. Replacement payloads are tagged so + * the applier can distinguish literal insertion from new content for a deleted + * line. */ export type Edit = | { @@ -32,6 +28,14 @@ export type Edit = index: number; mode?: "replacement"; } + | { + kind: "repeat"; + cursor: Cursor; + range: ParsedRange; + lineNum: number; + index: number; + mode?: "replacement"; + } | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }; /** Result of applying a parsed set of edits to a text body. */ diff --git a/packages/hashline/test/format-v2.test.ts b/packages/hashline/test/format-v2.test.ts new file mode 100644 index 000000000..e3ce637f9 --- /dev/null +++ b/packages/hashline/test/format-v2.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { applyEdits, parsePatch, parsePatchStreaming } from "@oh-my-pi/hashline"; + +function applyPatch(text: string, diff: string): string { + return applyEdits(text, parsePatch(diff).edits).text; +} + +describe("hashline format v2", () => { + it("emits literal and repeat body rows in textual order", () => { + const text = "a\nb\nc"; + const diff = ["2-2:", "|before", "^1-2", "|after"].join("\n"); + + expect(applyPatch(text, diff)).toBe("a\nbefore\na\nb\nafter\nc"); + }); + + it("repeats a single source line with explicit A-A syntax", () => { + const text = "a\nb\nc"; + const diff = ["2-2:", "^3-3"].join("\n"); + + expect(applyPatch(text, diff)).toBe("a\nc\nc"); + }); + + it("keeps the file unchanged when a repeat covers the anchored range", () => { + const text = "a\nb\nc\nd"; + const diff = ["2-3:", "^2-3"].join("\n"); + + expect(applyPatch(text, diff)).toBe(text); + }); + + it("deletes a concrete range with inline delete", () => { + const text = "a\nb\nc\nd"; + + expect(applyPatch(text, "2-3:-")).toBe("a\nd"); + }); + + it("rejects body rows after inline delete", () => { + expect(() => parsePatch("2-2:-\n|x")).toThrow(/payload line has no preceding/); + }); + + it("treats an empty concrete block as a blank-line replacement", () => { + const text = "a\nb\nc"; + + expect(applyPatch(text, "2-2:")).toBe("a\n\nc"); + }); + + it("treats empty BOF and EOF blocks as one blank-line insert", () => { + const text = "a\nb"; + + expect(applyPatch(text, "BOF:")).toBe("\na\nb"); + expect(applyPatch(text, "EOF:")).toBe("a\nb\n"); + }); + + it("rejects repeat shorthand with an explicit-range hint", () => { + expect(() => parsePatch("2-2:\n^2")).toThrow(/\^A-A/); + }); + + it("rejects removed insert sigils through the normal body-row diagnostic", () => { + expect(() => parsePatch("2-2:\n↑x")).toThrow(/must start with \| or \^A-B/); + expect(() => parsePatch("2-2:\n↓x")).toThrow(/must start with \| or \^A-B/); + }); + + it("rejects removed standalone delete rows through the normal op diagnostic", () => { + expect(() => parsePatch("-5")).toThrow(/unrecognized hashline block/); + expect(() => parsePatch("-5..7")).toThrow(/unrecognized hashline block/); + }); + + it("validates repeat ranges against file bounds", () => { + const edits = parsePatch("1-1:\n^4-4").edits; + + expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/); + }); + + it("does not flush a streaming pending empty block", () => { + const result = parsePatchStreaming("5-5:\n"); + + expect(result.edits).toEqual([]); + }); +}); diff --git a/packages/hashline/test/recovery-session-chain.test.ts b/packages/hashline/test/recovery-session-chain.test.ts index 909d0839e..e0f9cb42a 100644 --- a/packages/hashline/test/recovery-session-chain.test.ts +++ b/packages/hashline/test/recovery-session-chain.test.ts @@ -41,7 +41,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // rewrote. Replaying onto current would overwrite "L5-CHANGED" with // payload the model authored against the stale "L5". That is // corruption, not recovery. - const { edits } = parsePatch("5-5:\n+L5-MODEL"); + const { edits } = parsePatch("5-5:\n|L5-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH, @@ -59,7 +59,7 @@ describe("Recovery — session-chain replay anchor-content gate", () => { // merge fails (patch context includes the rewritten line 5), but the // replay fallback is safe because the model's anchor still names the // same logical content. - const { edits } = parsePatch("3-3:\n+L3-MODEL"); + const { edits } = parsePatch("3-3:\n|L3-MODEL"); const recovered = new Recovery(store).tryRecover({ path: PATH,