Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

202 lines · 9.8 KBRaw History
1import { describe, expect, it } from "vitest";
2import { diffLineTokens, fenceHighlighter, highlightLines, langForFence, langForPath, LANGS, LIMITS, ready, tokenize, type Lang, type TokLine } from "../../src/web/highlight.js";
3import { fenceLangs, renderMarkdown } from "../../src/web/markdown.js";
4
5const text = (line: TokLine | null | undefined) => line?.map((t) => t.text).join("") ?? null;
6
7describe("language detection", () => {
8 it("maps file names and extensions", () => {
9 const cases: Record<string, string | null> = {
10 "src/app.ts": "typescript",
11 "src/App.tsx": "tsx",
12 "lib/x.mjs": "javascript",
13 "lib/x.cjs": "javascript",
14 "a/b.jsx": "jsx",
15 "Dockerfile": "docker",
16 "deploy/Dockerfile.prod": "docker",
17 "web.dockerfile": "docker",
18 "Makefile": "make",
19 "GNUmakefile": "make",
20 ".github/workflows/ci.yml": "yaml",
21 "config.yaml": "yaml",
22 "inc/x.h": "c",
23 "inc/x.hpp": "cpp",
24 "src/x.cc": "cpp",
25 "run.sh": "shellscript",
26 "x.bash": "shellscript",
27 ".zshrc": "shellscript",
28 ".env": "dotenv",
29 ".env.local": "dotenv",
30 ".gitignore": null,
31 "tsconfig.json": "jsonc",
32 "tsconfig.build.json": "jsonc",
33 "package.json": "json",
34 "Cargo.toml": "toml",
35 "main.go": "go",
36 "lib.rs": "rust",
37 "App.vue": "vue",
38 "Page.svelte": "svelte",
39 "main.tf": "terraform",
40 "nginx.conf": "nginx",
41 "Gemfile": "ruby",
42 "build.gradle": "groovy",
43 "api.proto": "proto",
44 "fix.patch": "diff",
45 "x.m": "objective-c",
46 "core.clj": "clojure",
47 "README": null,
48 "LICENSE": null,
49 "notes.txt": null,
50 "image.png": null,
51 };
52 for (const [path, lang] of Object.entries(cases)) expect([path, langForPath(path)]).toEqual([path, lang]);
53 });
54
55 it("maps fence names and aliases", () => {
56 expect(langForFence("ts")).toBe("typescript");
57 expect(langForFence("TypeScript")).toBe("typescript");
58 expect(langForFence("yml")).toBe("yaml");
59 expect(langForFence("bash")).toBe("shellscript");
60 expect(langForFence("sh")).toBe("shellscript");
61 expect(langForFence("console")).toBe("shellscript");
62 expect(langForFence("c++")).toBe("cpp");
63 expect(langForFence("c#")).toBe("csharp");
64 expect(langForFence("golang")).toBe("go");
65 expect(langForFence("dockerfile")).toBe("docker");
66 expect(langForFence("hcl")).toBe("hcl");
67 expect(langForFence("{.python}")).toBe("python");
68 expect(langForFence("text")).toBeNull();
69 expect(langForFence("mermaid")).toBeNull();
70 });
71});
72
73describe("tokens", () => {
74 it("tokenizes with both themes as CSS variables and leaves default text bare", async () => {
75 const out = await highlightLines(["const a = 1; // one", "", "export {};"], "typescript");
76 expect(out).toHaveLength(3);
77 expect(out!.map(text)).toEqual(["const a = 1; // one", "", "export {};"]);
78 const kw = out![0]!.find((t) => t.text === "const")!;
79 expect(kw.style).toMatch(/^--shiki-light:#[0-9A-F]{6};--shiki-dark:#[0-9A-F]{6}$/i);
80 expect(out![0]!.some((t) => !t.style)).toBe(true);
81 expect(out![0]!.every((t) => !t.style || /^(--shiki-[a-z-]+:[^;"<>]+;?)+$/.test(t.style))).toBe(true);
82 });
83
84 it("loads every grammar on the JavaScript regex engine", async () => {
85 for (const lang of LANGS) {
86 const hl = await ready([lang]);
87 expect([lang, hl !== null]).toEqual([lang, true]);
88 const out = tokenize(hl!, "x = 1 # a\n// b\n<a href='c'>d</a>", lang);
89 expect([lang, out?.length]).toEqual([lang, 3]);
90 }
91 }, 60_000);
92
93 it("falls back to plain text for unknown languages and oversized input", async () => {
94 expect(await highlightLines(["x"], null)).toBeNull();
95 expect(await highlightLines(Array.from({ length: LIMITS.lines + 1 }, () => "x"), "typescript")).toBeNull();
96 expect(await highlightLines(["x".repeat(LIMITS.bytes + 1)], "typescript")).toBeNull();
97 });
98
99 it("does not tokenize very long lines", async () => {
100 const long = `const s = "${"a".repeat(LIMITS.lineLength + 10)}";`;
101 const out = await highlightLines([long, "let b = 2;"], "typescript");
102 expect(out!.map(text)).toEqual([long, "let b = 2;"]);
103 expect(out![0]).toEqual([{ text: long }]);
104 expect(out![1]!.find((t) => t.text === "let")?.style).toBeTruthy();
105 });
106
107 it("highlights a 1,500-line TypeScript file quickly", async () => {
108 const chunk = [
109 "import { readFile } from \"node:fs/promises\";",
110 "/** A documented interface. */",
111 "export interface Item<T extends object = {}> { id: number; name: string; meta?: T }",
112 "export async function load(path: string): Promise<Item[]> {",
113 " const raw = await readFile(path, \"utf8\"); // read",
114 " return JSON.parse(raw).map((x: any, i: number) => ({ id: i, name: `item ${x.name}` }));",
115 "}",
116 "class Store { #items = new Map<number, Item>(); get size() { return this.#items.size; } }",
117 "const re = /^(?:[a-z]+)\\d*$/gi;",
118 "",
119 ];
120 const lines = Array.from({ length: 1500 }, (_, i) => chunk[i % chunk.length]!);
121 await ready(["typescript"]);
122 const t0 = performance.now();
123 const out = await highlightLines(lines, "typescript");
124 const ms = performance.now() - t0;
125 console.log(`highlight: 1,500-line TypeScript file in ${ms.toFixed(0)} ms`);
126 expect(out).toHaveLength(1500);
127 expect(ms).toBeLessThan(5000);
128 });
129});
130
131describe("paired GitHub themes", () => {
132 it("match Shiki's own dual-theme output", async () => {
133 const { createHighlighterCore } = await import("shiki/core");
134 const { createJavaScriptRegexEngine } = await import("shiki/engine/javascript");
135 const samples: Array<[Lang, string]> = [
136 ["typescript", 'import { a } from "b";\n/** doc @param x */\nexport class C<T> extends D { #x = 1; get y(): T { return `v${this.#x}` as T } }\nconst re = /a+/g;'],
137 ["markdown", "# Title\n\n*em* **bold** ~~gone~~ [link](https://x.y)\n\n> quote\n\n- item"],
138 ["python", "@dataclass\nclass A:\n def f(self, x: int = 2) -> str:\n return f\"{x!r}\" # c"],
139 ["diff", "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new"],
140 ["yaml", "on:\n push:\n branches: [main] # c\njobs: { a: 1 }"],
141 ];
142 const ref = await createHighlighterCore({ themes: [import("shiki/themes/github-light-default.mjs"), import("shiki/themes/github-dark-default.mjs")], langs: samples.map(([l]) => import(`shiki/langs/${l}.mjs`)), engine: createJavaScriptRegexEngine({ forgiving: true }) });
143 const hl = await ready(samples.map(([l]) => l));
144 for (const [lang, code] of samples) {
145 const { tokens: theirs, fg } = ref.codeToTokens(code, { lang, themes: { light: "github-light-default", dark: "github-dark-default" }, defaultColor: false });
146 const ours = tokenize(hl!, code, lang)!;
147 const flat = (lines: Array<Array<{ text: string; style?: string }>>) => lines.map((l) => l.flatMap((t) => [...t.text].map((ch) => `${ch}\t${t.style ?? ""}`)));
148 // Shiki spells out the default foreground and token backgrounds; ours leaves both to the site.
149 const style = (s: Record<string, string> = {}) => Object.entries(s).filter(([k]) => !k.endsWith("-bg")).map(([k, v]) => `${k}:${v}`).join(";");
150 const expected = theirs.map((l) => l.map((t) => ({ text: t.content, style: style(t.htmlStyle).toLowerCase() === fg!.toLowerCase() ? undefined : style(t.htmlStyle) })));
151 expect([lang, flat(ours)]).toEqual([lang, flat(expected)]);
152 }
153 });
154});
155
156describe("diff mapping", () => {
157 // Each token records the snippet it was tokenized from.
158 const fake = (code: string): TokLine[] => code.split("\n").map((l) => [{ text: l, style: code.replace(/\n/g, "|") }]);
159
160 it("maps old and new side tokens back to rows", () => {
161 const hunk = [" a", "-b", "-c", "+B", " d", "\\ No newline at end of file", "+e"];
162 const rows = diffLineTokens(hunk, fake);
163 expect(rows.map(text)).toEqual(["a", "b", "c", "B", "d", null, "e"]);
164 expect(rows.map((r) => r?.[0]?.style ?? null)).toEqual(["a|B|d|e", "a|b|c|d", "a|b|c|d", "a|B|d|e", "a|B|d|e", null, "a|B|d|e"]);
165 });
166
167 it("treats empty lines as context and fails soft", () => {
168 expect(diffLineTokens(["", "+x"], fake).map(text)).toEqual(["", "x"]);
169 expect(diffLineTokens([" a", "+b"], () => null)).toEqual([null, null]);
170 expect(diffLineTokens([" a", "+b"], () => [])).toEqual([null, null]);
171 });
172
173 it("carries grammar state across rows of a hunk", async () => {
174 const hl = await ready(["typescript"]);
175 const hunk = [" /* start of a", "-old comment", "+new comment", " end */ const x = 1;"];
176 const rows = diffLineTokens(hunk, (code) => tokenize(hl!, code, "typescript"));
177 expect(rows.map(text)).toEqual(["/* start of a", "old comment", "new comment", "end */ const x = 1;"]);
178 const comment = rows[0]![0]!.style;
179 expect(rows[1]![0]!.style).toBe(comment);
180 expect(rows[2]![0]!.style).toBe(comment);
181 expect(rows[3]!.find((t) => t.text.includes("const"))?.style).not.toBe(comment);
182 });
183});
184
185describe("markdown fences", () => {
186 it("highlights named fences and escapes code", async () => {
187 const md = "# T\n\n```ts\nconst a = \"<b>\" && 1;\n```\n\n```\nplain <i>\n```\n\n```mermaid\ngraph\n```\n";
188 expect(fenceLangs(md)).toEqual(["ts", "mermaid"]);
189 const html = renderMarkdown(md, { highlight: await fenceHighlighter(fenceLangs(md)) });
190 expect(html).toContain('<code class="language-ts sh">');
191 expect(html).toMatch(/<span style="--shiki-light:#[0-9A-F]{6};--shiki-dark:#[0-9A-F]{6}">const<\/span>/i);
192 expect(html).toContain("&quot;&lt;b&gt;&quot;");
193 expect(html).not.toContain("<b>");
194 expect(html).toContain("<pre><code>plain &lt;i&gt;</code></pre>");
195 expect(html).toContain('<code class="language-mermaid">graph</code>');
196 });
197
198 it("renders plain code without a highlighter", async () => {
199 expect(await fenceHighlighter(["text", "mermaid"])).toBeUndefined();
200 expect(renderMarkdown("```ts\nlet x\n```")).toBe('<pre><code class="language-ts">let x</code></pre>');
201 });
202});