- Validate line numbers as safe integers and reject values exceeding `Number.MAX_SAFE_INTEGER`. - Impose a maximum expansion limit of 100,000 lines on patch ranges to prevent memory and CPU exhaustion. - Inline delete range iteration directly into execution to avoid allocating intermediate anchor arrays.
125 lines
4.8 KiB
TypeScript
125 lines
4.8 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { applyEdits, parseLid, parsePatch, parsePatchStreaming, Tokenizer } from "@oh-my-pi/hashline";
|
|
|
|
function applyPatch(text: string, diff: string): string {
|
|
return applyEdits(text, parsePatch(diff).edits).text;
|
|
}
|
|
|
|
describe("hashline format v4", () => {
|
|
it("replaces a concrete range with literal body rows in textual order", () => {
|
|
const text = "a\nb\nc";
|
|
const diff = ["SWAP 2.=2:", "+before", "+after"].join("\n");
|
|
|
|
expect(applyPatch(text, diff)).toBe("a\nbefore\nafter\nc");
|
|
});
|
|
|
|
it("deletes a single source line", () => {
|
|
const text = "a\nb\nc";
|
|
expect(applyPatch(text, "DEL 2")).toBe("a\nc");
|
|
});
|
|
|
|
it("deletes a concrete range", () => {
|
|
const text = "a\nb\nc\nd";
|
|
expect(applyPatch(text, "DEL 2.=3")).toBe("a\nd");
|
|
});
|
|
|
|
it("accepts a single dot between integer range endpoints", () => {
|
|
const text = "a\nb\nc\nd";
|
|
expect(applyPatch(text, "DEL 2.3")).toBe("a\nd");
|
|
expect(applyPatch(text, "SWAP 2.3:\n+middle")).toBe("a\nmiddle\nd");
|
|
});
|
|
|
|
it("inserts before and after concrete anchors", () => {
|
|
const text = "a\nb\nc";
|
|
const diff = ["INS.PRE 2:", "+before", "INS.POST 2:", "+after"].join("\n");
|
|
expect(applyPatch(text, diff)).toBe("a\nbefore\nb\nafter\nc");
|
|
});
|
|
|
|
it("inserts at head and tail", () => {
|
|
const text = "a\nb";
|
|
expect(applyPatch(text, "INS.HEAD:\n+HEAD")).toBe("HEAD\na\nb");
|
|
expect(applyPatch(text, "INS.TAIL:\n+TAIL")).toBe("a\nb\nTAIL");
|
|
});
|
|
|
|
it("treats an empty replace hunk as a delete and still rejects empty inserts", () => {
|
|
const text = "a\nb\nc";
|
|
expect(applyPatch(text, "SWAP 2.=2:")).toBe("a\nc");
|
|
expect(() => parsePatch("INS.HEAD:")).toThrow(/needs at least one/);
|
|
});
|
|
|
|
it("rejects body rows under delete", () => {
|
|
expect(() => parsePatch("DEL 2\n+replacement")).toThrow(/does not take body rows/);
|
|
});
|
|
|
|
it("auto-pipes bare body rows as literal text", () => {
|
|
const text = "a\nb\nc";
|
|
expect(applyPatch(text, "SWAP 2.=2:\nraw")).toBe("a\nraw\nc");
|
|
const { warnings } = parsePatch("SWAP 2.=2:\nraw");
|
|
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
|
});
|
|
|
|
it("strips read-output line number prefix from auto-piped bare body rows", () => {
|
|
const text = "a\nb\nc";
|
|
// Without this fix, "3:text" becomes literal "3:text" in the file.
|
|
// With the fix, the "3:" prefix is stripped, yielding just "text".
|
|
const { edits, warnings } = parsePatch("SWAP 2.=2:\n3:replaced");
|
|
expect(applyEdits(text, edits).text).toBe("a\nreplaced\nc");
|
|
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
|
});
|
|
|
|
it("validates insert anchors against file bounds", () => {
|
|
const edits = parsePatch("INS.PRE 4:\n+x").edits;
|
|
expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/);
|
|
});
|
|
|
|
it("rejects unsafe line numbers before expanding ranges", () => {
|
|
const unsafe = String(Number.MAX_SAFE_INTEGER + 1);
|
|
expect(parseLid(String(Number.MAX_SAFE_INTEGER), 1)).toEqual({ line: Number.MAX_SAFE_INTEGER });
|
|
expect(() => parseLid(unsafe, 1)).toThrow();
|
|
expect(new Tokenizer().isOp(`SWAP ${unsafe}.=${unsafe}:`)).toBe(false);
|
|
});
|
|
|
|
it("rejects safe-integer ranges above the expansion limit", () => {
|
|
expect(() => parsePatch("SWAP 1.=100001:\n+x")).toThrow(
|
|
/replace range spans 100001 lines; the maximum is 100000/,
|
|
);
|
|
});
|
|
|
|
it("ignores deleting the trailing blank sentinel of a newline-terminated file", () => {
|
|
// "a\nb\n" splits into ["a", "b", ""]; line 3 is the phantom sentinel.
|
|
const edits = parsePatch("DEL 3").edits;
|
|
expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n");
|
|
});
|
|
|
|
it("treats a delete range ending at the trailing sentinel as ending at the last real line", () => {
|
|
const edits = parsePatch("DEL 2.=3").edits;
|
|
expect(applyEdits("a\nb\n", edits).text).toBe("a\n");
|
|
});
|
|
|
|
it("treats a replace range ending at the trailing sentinel as ending at the last real line", () => {
|
|
const edits = parsePatch("SWAP 2.=3:\n+B").edits;
|
|
expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n");
|
|
});
|
|
|
|
it("still allows inserts anchored on the trailing blank sentinel", () => {
|
|
const edits = parsePatch("INS.POST 3:\n+tail").edits;
|
|
expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n\ntail");
|
|
});
|
|
|
|
it("still deletes a genuine empty last line of a non-newline-terminated file", () => {
|
|
// "a\nb" has no sentinel; line 2 is real content.
|
|
const edits = parsePatch("DEL 2").edits;
|
|
expect(applyEdits("a\nb", edits).text).toBe("a");
|
|
});
|
|
|
|
it("does not flush a trailing streaming pending empty replace hunk", () => {
|
|
const result = parsePatchStreaming("SWAP 5.=5:\n");
|
|
expect(result.edits).toEqual([]);
|
|
});
|
|
|
|
it("flushes a streaming empty replace hunk when another hunk starts", () => {
|
|
const result = parsePatchStreaming("SWAP 2.=2:\nINS.TAIL:\n");
|
|
expect(result.edits).toEqual([{ kind: "delete", anchor: { line: 2 }, lineNum: 1, index: 0 }]);
|
|
});
|
|
});
|