import { assert, assertEquals, assertMatch, assertRejects } from "@std/assert"; import { decodeBase64 } from "@std/encoding/base64"; import { PNG, tempDir } from "@berry/util"; import { readFile, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { localEnvironment } from "@berry/api/environment.ts"; import { testExtension } from "@berry/api/testing.ts"; import code from "./mod.ts"; type Fixture = { cwd: string; path: string; call(name: string, args?: Record, signal?: AbortSignal): Promise; }; function test(name: string, run: (f: Fixture) => Promise, content: string = "") { Deno.test(name, async () => { await using dir = await tempDir(); const path = join(dir.path, "file.txt"); await writeFile(path, content); const extension = testExtension(code, { env: localEnvironment(dir.path) }); await run({ cwd: dir.path, path, call: async (name, args = {}, signal) => await extension.call(name, { path: "file.txt", ...args } as never, { signal }) as string, }); }); } const anchors = (output: string) => output.split("\n").filter((row) => row.includes("│")).map((row) => row.split("│")[0]); const unanchored = (output: string) => output.replace(/^([+-])[A-Za-z]{4}│/gm, "$1").split("\n").slice(2); test("diffs carry the new lines' hashes and keep replacement text literal", async (f) => { const [old] = anchors(await f.call("read")); assertMatch(old, /^[A-Za-z]{4}$/); const output = await f.call("edit", { edits: [{ old: [old], new: ["ABCD│literal"] }] }); assert(output.includes(`-${old}│original`)); assertMatch(output, /^\+[A-Za-z]{4}│ABCD│literal$/m); }, "original\n"); test( "batched diffs merge nearby context without duplicating it and separate distant hunks", async (f) => { const old = anchors(await f.call("read")); const output = await f.call("edit", { edits: [ { old: [old[16]], new: ["last"] }, { old: [old[2]], new: ["first", "extra"] }, { old: [old[5]], new: [] }, ], }); assertEquals(unanchored(output), [ "@@ original line 1 @@", " line 0", " line 1", "-line 2", "+first", "+extra", " line 3", " line 4", "-line 5", " line 6", " line 7", " line 8", "@@ original line 14 @@", " line 13", " line 14", " line 15", "-line 16", "+last", " line 17", " line 18", " line 19", ]); }, Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n"), ); test("references survive external line movement", async (f) => { const [first, target, last] = anchors(await f.call("read")); await writeFile(f.path, "inserted\nlast\nfirst\ntarget\n"); assertEquals(anchors(await f.call("read")).slice(1), [last, first, target]); await f.call("edit", { edits: [{ old: [target], new: ["changed"] }] }); assertEquals(await readFile(f.path, "utf8"), "inserted\nlast\nfirst\nchanged\n"); }, "first\ntarget\nlast\n"); test("duplicate lines need unique adjacent context, which is preserved", async (f) => { const original = "left\nsame\nright\nleft\nsame\nother\n"; const [left, same, right, leftAgain, sameAgain] = anchors(await f.call("read")); assertEquals(left, leftAgain); assertEquals(same, sameAgain); await assertRejects( () => f.call("edit", { edits: [{ old: [same], new: ["changed"] }] }), Error, "ambiguous", ); await assertRejects( () => f.call("edit", { edits: [{ before: [left], old: [same], new: ["changed"] }] }), Error, "ambiguous", ); assertEquals(await readFile(f.path, "utf8"), original); await f.call("edit", { edits: [{ before: [left], old: [same], after: [right], new: ["changed"] }], }); assertEquals(await readFile(f.path, "utf8"), "left\nchanged\nright\nleft\nsame\nother\n"); }, "left\nsame\nright\nleft\nsame\nother\n"); test( "batches resolve against the original, regardless of order or line count changes", async (f) => { const [one, two, three, four] = anchors(await f.call("read")); await f.call("edit", { edits: [{ old: [four], new: ["last"] }, { old: [one], new: ["first", "extra"] }], }); assertEquals(await readFile(f.path, "utf8"), "first\nextra\ntwo\nthree\nlast\n"); assertEquals(anchors(await f.call("read")).slice(2, 4), [two, three]); }, "one\ntwo\nthree\nfour\n", ); test("a stale interior line refuses the entire batch without writing", async (f) => { const [one, two, three, four] = anchors(await f.call("read")); const external = "one\nexternal\nthree\nfour\n"; await writeFile(f.path, external); await assertRejects( () => f.call("edit", { edits: [{ old: [four], new: ["would change"] }, { old: [one, two, three], new: ["replacement"], }], }), Error, "stale", ); assertEquals(await readFile(f.path, "utf8"), external); }, "one\ntwo\nthree\nfour\n"); test("hashes include trailing whitespace and the entire line", async (f) => { const long = "x".repeat(1000); const original = `text\ntext \n${long}a\n${long}b\n`; await writeFile(f.path, original); const hashes = anchors(await f.call("read")); assertEquals(new Set(hashes).size, 4); await writeFile(f.path, original.replace("text \n", "text \n")); await assertRejects( () => f.call("edit", { edits: [{ old: [hashes[1]], new: ["changed"] }] }), Error, "stale", ); }); test("overlapping ranges and same-position insertions are refused", async (f) => { const original = "one\ntwo\nthree\n"; const [one, two, three] = anchors(await f.call("read")); for ( const edits of [ [{ old: [one, two], new: [] }, { old: [two, three], new: [] }], [{ before: [one], old: [], new: ["x"] }, { after: [two], old: [], new: ["y"] }], ] ) { await assertRejects(() => f.call("edit", { edits }), Error, "Overlapping"); assertEquals(await readFile(f.path, "utf8"), original); } }, "one\ntwo\nthree\n"); test("context may overlap other edits, but is always checked against the original", async (f) => { const [one, two, three] = anchors(await f.call("read")); await f.call("edit", { edits: [ { old: [one], after: [two], new: ["first"] }, { before: [one], old: [two], after: [three], new: ["second"] }, ], }); assertEquals(await readFile(f.path, "utf8"), "first\nsecond\nthree\n"); }, "one\ntwo\nthree\n"); test("insert before/after, delete, and reuse hashes returned by edit", async (f) => { const [middle] = anchors(await f.call("read")); const diff = await f.call("edit", { edits: [{ old: [], after: [middle], new: ["first"] }, { before: [middle], old: [], new: ["last"], }], }); assertEquals(await readFile(f.path, "utf8"), "first\nmiddle\nlast\n"); const [first, last] = anchors(diff).map((anchor) => anchor.slice(1)); await f.call("edit", { edits: [{ old: [first, middle, last], new: [] }] }); assertEquals(await readFile(f.path, "utf8"), ""); }, "middle\n"); test("empty files can be seeded, but nonempty files require a selector", async (f) => { assertMatch(await f.call("read"), /Empty file/); await f.call("edit", { edits: [{ old: [], new: ["seed"] }] }); assertEquals(await readFile(f.path, "utf8"), "seed\n"); await assertRejects( () => f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }), Error, "context", ); }); test("BOM, mixed line endings, and missing final newline survive edits", async (f) => { const [one, two, three] = anchors(await f.call("read")); await f.call("edit", { edits: [{ old: [one], new: ["first"] }] }); assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree"); await f.call("edit", { edits: [{ before: [three], old: [], new: ["last"] }] }); assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree\r\nlast"); assertEquals(anchors(await f.call("read"))[1], two); }, "\uFEFFone\r\ntwo\nthree"); test("blank replacement lines are not deletion, including at unterminated EOF", async (f) => { const [, two] = anchors(await f.call("read")); await f.call("edit", { edits: [{ old: [two], new: [""] }] }); assertEquals(await readFile(f.path, "utf8"), "one\n\n"); }, "one\ntwo"); test("no-op edits do not write, and replacement text is literal", async (f) => { const [one] = anchors(await f.call("read")); const before = await stat(f.path); assertMatch(await f.call("edit", { edits: [{ old: [one], new: ["one"] }] }), /No changes/); assertEquals((await stat(f.path)).mtimeMs, before.mtimeMs); await f.call("edit", { edits: [{ old: [one], new: [`${one}│one`, "one", "one"] }] }); assertEquals(await readFile(f.path, "utf8"), `${one}│one\none\none\n`); }, "one\n"); test("invalid text and embedded newlines are refused without changing bytes", async (f) => { for ( const content of [ new Uint8Array([0xff, 0xfe, 65, 0]), new Uint8Array([97, 0, 98]), new Uint8Array([0xff]), ] ) { await writeFile(f.path, content); await assertRejects(() => f.call("read"), Error, "UTF-8"); await assertRejects( () => f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }), Error, "UTF-8", ); assertEquals(new Uint8Array(await readFile(f.path)), content); } await writeFile(f.path, "one\n"); const [one] = anchors(await f.call("read")); for (const line of ["two\nthree", "two\rthree", "two\0three"]) { await assertRejects(() => f.call("edit", { edits: [{ old: [one], new: [line] }] })); assertEquals(await readFile(f.path, "utf8"), "one\n"); } }); test("paged reads provide the same anchors and respect line and byte limits", async (f) => { const page = await f.call("read"); assertEquals(anchors(page).length, 2000); assertMatch(page, /offset=2001/); const small = await f.call("read", { offset: 2, limit: 2 }); assertEquals(anchors(small), anchors(page).slice(1, 3)); assertMatch(small, /offset=4/); assertEquals(anchors(await f.call("read", { offset: 2001 })).length, 5); await assertRejects(() => f.call("read", { offset: 2006 }), Error, "beyond"); await writeFile(f.path, ("x".repeat(30_000) + "\n").repeat(3)); const bytes = await f.call("read"); assertEquals(anchors(bytes).length, 1); assertMatch(bytes, /offset=2/); await writeFile(f.path, "x".repeat(60_000)); await assertRejects(() => f.call("read"), Error, "exceeds"); }, Array.from({ length: 2005 }, (_, i) => i.toString(36)).join("\n")); test("parallel edits through symlink aliases do not overwrite each other", async (f) => { const alias = f.path + ".link"; await symlink(f.path, alias); const [one, two] = anchors(await f.call("read")); await Promise.all([ f.call("edit", { edits: [{ old: [one], new: ["first"] }] }), f.call("edit", { path: alias, edits: [{ old: [two], new: ["second"] }] }), ]); assertEquals(await readFile(f.path, "utf8"), "first\nsecond\n"); }, "one\ntwo\n"); test("images resolve to an image block", async (f) => { await writeFile(join(f.cwd, "pixel.png"), decodeBase64(PNG)); assertEquals(await f.call("read", { path: "pixel.png" }), { type: "image", data: PNG, mimeType: "image/png", } as unknown as string); }); test("aborted calls do not write", async (f) => { const [one] = anchors(await f.call("read")); await assertRejects(() => f.call("edit", { edits: [{ old: [one], new: ["changed"] }] }, AbortSignal.abort()) ); assertEquals(await readFile(f.path, "utf8"), "one\n"); }, "one\n"); test("text edits replace exact substrings and return reusable line hashes", async (f) => { const output = await f.call("edit", { edits: [{ oldText: "const x = 1;\nconst y", newText: 'const x = "$&";\nconst z' }], }); assertEquals(await readFile(f.path, "utf8"), 'const x = "$&";\nconst z = 2;\n'); const added = anchors(output).filter((anchor) => anchor.startsWith("+")).map((a) => a.slice(1)); await f.call("edit", { edits: [{ old: [added[1]], new: ["done"] }] }); assertEquals(await readFile(f.path, "utf8"), 'const x = "$&";\ndone\n'); }, "const x = 1;\nconst y = 2;\n"); test("text and hash edits share original coordinates even within one line", async (f) => { const [heading] = anchors(await f.call("read")); await f.call("edit", { edits: [ { oldText: "right", newText: "R" }, { old: [heading], new: ["new heading", "extra"] }, { oldText: "left", newText: "longer left" }, ], }); assertEquals(await readFile(f.path, "utf8"), "new heading\nextra\nlonger left + R\n"); }, "heading\nleft + right\n"); test("ambiguous text includes overlapping occurrences and can be disambiguated", async (f) => { await assertRejects( () => f.call("edit", { edits: [{ oldText: "aa", newText: "x" }] }), Error, "ambiguous", ); assertEquals(await readFile(f.path, "utf8"), "aaa\n"); await f.call("edit", { edits: [{ oldText: "aa\n", newText: "x\n" }] }); assertEquals(await readFile(f.path, "utf8"), "ax\n"); }, "aaa\n"); test( "missing text refuses the entire batch rather than matching earlier replacements", async (f) => { await assertRejects( () => f.call("edit", { edits: [{ oldText: "one", newText: "two" }, { oldText: "two", newText: "three" }], }), Error, "not found", ); assertEquals(await readFile(f.path, "utf8"), "one\n"); }, "one\n", ); test("overlap between text and hash ranges refuses the entire batch", async (f) => { const [line] = anchors(await f.call("read")); await assertRejects( () => f.call("edit", { edits: [{ old: [line], new: ["whole"] }, { oldText: "part", newText: "piece" }], }), Error, "Overlapping", ); assertEquals(await readFile(f.path, "utf8"), "a part of a line\n"); }, "a part of a line\n"); test("empty oldText seeds only empty files and each entry must choose one form", async (f) => { await f.call("edit", { edits: [{ oldText: "", newText: "seed" }] }); assertEquals(await readFile(f.path, "utf8"), "seed"); await assertRejects(() => f.call("edit", { edits: [{ oldText: "", newText: "unsafe" }] })); await assertRejects(() => f.call("edit", { edits: [{ oldText: "seed", newText: "text", old: [], new: ["hash"] }] }) ); assertEquals(await readFile(f.path, "utf8"), "seed"); }); test( "text edits preserve BOM and literal line endings, including deleting through EOF", async (f) => { const before = await stat(f.path); assertMatch( await f.call("edit", { edits: [{ oldText: "one", newText: "one" }] }), /No changes/, ); assertEquals((await stat(f.path)).mtimeMs, before.mtimeMs); await f.call("edit", { edits: [{ oldText: "one\r\ntwo\n", newText: "first\nsecond\r\n" }], }); assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\nsecond\r\nthree"); await f.call("edit", { edits: [{ oldText: "\r\nthree", newText: "" }] }); assertEquals(await readFile(f.path, "utf8"), "\uFEFFfirst\nsecond"); }, "\uFEFFone\r\ntwo\nthree", ); test("hash replacement can keep an unterminated line and add a following line", async (f) => { const [line] = anchors(await f.call("read")); await f.call("edit", { edits: [{ old: [line], new: ["one", "two"] }] }); assertEquals(await readFile(f.path, "utf8"), "one\ntwo"); }, "one");