highlight.test.ts
| 1 | import { describe, test, expect, beforeAll } from "bun:test"; |
| 2 | import { detectLang, hasBinaryContent, highlightStartup } from "../src/services/highlight.ts"; |
| 3 | import { parseDiff, highlightFile } from "../src/services/diffHighlight.ts"; |
| 4 | |
| 5 | beforeAll(async () => { |
| 6 | await highlightStartup(); |
| 7 | }); |
| 8 | |
| 9 | describe("detectLang", () => { |
| 10 | // Extensions |
| 11 | test("detects TypeScript", () => expect(detectLang("foo.ts")).toBe("typescript")); |
| 12 | test("detects TSX", () => expect(detectLang("foo.tsx")).toBe("tsx")); |
| 13 | test("detects JavaScript", () => expect(detectLang("foo.js")).toBe("javascript")); |
| 14 | test("detects Python", () => expect(detectLang("foo.py")).toBe("python")); |
| 15 | test("detects Rust", () => expect(detectLang("foo.rs")).toBe("rust")); |
| 16 | test("detects Go", () => expect(detectLang("foo.go")).toBe("go")); |
| 17 | test("detects C", () => expect(detectLang("foo.c")).toBe("c")); |
| 18 | test("detects C++", () => expect(detectLang("foo.cpp")).toBe("cpp")); |
| 19 | test("detects C header", () => expect(detectLang("foo.h")).toBe("c")); |
| 20 | test("detects YAML", () => expect(detectLang("foo.yml")).toBe("yaml")); |
| 21 | test("detects TOML", () => expect(detectLang("foo.toml")).toBe("toml")); |
| 22 | test("detects JSON", () => expect(detectLang("foo.json")).toBe("json")); |
| 23 | test("detects Markdown", () => expect(detectLang("foo.md")).toBe("markdown")); |
| 24 | test("detects Shell", () => expect(detectLang("foo.sh")).toBe("shellscript")); |
| 25 | test("detects diff/patch", () => expect(detectLang("foo.patch")).toBe("diff")); |
| 26 | |
| 27 | // Specific filenames |
| 28 | test("detects Dockerfile by filename", () => expect(detectLang("Dockerfile")).toBe("docker")); |
| 29 | test("detects Makefile by filename", () => expect(detectLang("Makefile")).toBe("make")); |
| 30 | |
| 31 | // Path stripping |
| 32 | test("strips path before detecting", () => expect(detectLang("src/foo/bar.ts")).toBe("typescript")); |
| 33 | test("strips path for Dockerfile", () => expect(detectLang("infra/Dockerfile")).toBe("docker")); |
| 34 | |
| 35 | // Fallback |
| 36 | test("falls back to text for unknown extension", () => expect(detectLang("foo.xyz")).toBe("text")); |
| 37 | test("falls back to text for no extension", () => expect(detectLang("LICENSE")).toBe("text")); |
| 38 | }); |
| 39 | |
| 40 | describe("hasBinaryContent", () => { |
| 41 | test("detects NUL byte as binary", () => { |
| 42 | expect(hasBinaryContent(Buffer.from([0x68, 0x65, 0x00, 0x6c, 0x6f]))).toBe(true); |
| 43 | }); |
| 44 | test("detects NUL-only buffer as binary", () => { |
| 45 | expect(hasBinaryContent(Buffer.alloc(10, 0))).toBe(true); |
| 46 | }); |
| 47 | test("plain text is not binary", () => { |
| 48 | expect(hasBinaryContent(Buffer.from("hello world\n"))).toBe(false); |
| 49 | }); |
| 50 | test("CR LF text is not binary", () => { |
| 51 | expect(hasBinaryContent(Buffer.from("line1\r\nline2\r\n"))).toBe(false); |
| 52 | }); |
| 53 | test("other control chars without NUL are not binary", () => { |
| 54 | // \x01, \x07 (BEL), \x1b (ESC) — git treats these as text |
| 55 | expect(hasBinaryContent(Buffer.from([0x01, 0x07, 0x1b, 0x41]))).toBe(false); |
| 56 | }); |
| 57 | test("only checks first 8000 bytes", () => { |
| 58 | // NUL appears after the 8000-byte sample window — should not be detected |
| 59 | const buf = Buffer.alloc(8001, 0x41); |
| 60 | buf[8000] = 0x00; |
| 61 | expect(hasBinaryContent(buf)).toBe(false); |
| 62 | }); |
| 63 | test("detects NUL within the 8000-byte sample", () => { |
| 64 | const buf = Buffer.alloc(8001, 0x41); |
| 65 | buf[7999] = 0x00; |
| 66 | expect(hasBinaryContent(buf)).toBe(true); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | // Build a minimal unified diff string with the given added line(s). |
| 71 | function makeDiff(...lines: string[]): string { |
| 72 | return [ |
| 73 | "diff --git a/test.txt b/test.txt", |
| 74 | "index 0000000..1111111 100644", |
| 75 | "--- a/test.txt", |
| 76 | "+++ b/test.txt", |
| 77 | `@@ -0,0 +${lines.length} @@`, |
| 78 | ...lines.map((l) => `+${l}`), |
| 79 | ].join("\n"); |
| 80 | } |
| 81 | |
| 82 | describe("diff control character rendering", () => { |
| 83 | test("CR renders as ^M with diff-ctrl span", async () => { |
| 84 | const files = await parseDiff(makeDiff("hello\rworld")); |
| 85 | const rendered = await highlightFile(files[0]!); |
| 86 | const html = rendered.hunks[0]!.rows[0]!.html; |
| 87 | expect(html).toContain('<span class="diff-ctrl">^M</span>'); |
| 88 | }); |
| 89 | |
| 90 | test("trailing CR renders as ^M on every line, not just the last", async () => { |
| 91 | // Simulates a CRLF line-ending change where every line ends with \r. |
| 92 | // Shiki normalises \r\n → \n and would silently drop the \r from all |
| 93 | // but the last line without the pre-strip fix. |
| 94 | const files = await parseDiff(makeDiff("alpha\r", "beta\r", "gamma\r")); |
| 95 | const rendered = await highlightFile(files[0]!); |
| 96 | const rows = rendered.hunks[0]!.rows; |
| 97 | for (const row of rows) { |
| 98 | expect(row.html).toContain('<span class="diff-ctrl">^M</span>'); |
| 99 | } |
| 100 | }); |
| 101 | |
| 102 | test("BEL (\\x07) renders as ^G", async () => { |
| 103 | const files = await parseDiff(makeDiff("ring\x07bell")); |
| 104 | const rendered = await highlightFile(files[0]!); |
| 105 | const html = rendered.hunks[0]!.rows[0]!.html; |
| 106 | expect(html).toContain('<span class="diff-ctrl">^G</span>'); |
| 107 | }); |
| 108 | |
| 109 | test("ESC (\\x1b) renders as ^[", async () => { |
| 110 | const files = await parseDiff(makeDiff("\x1b[31mred\x1b[0m")); |
| 111 | const rendered = await highlightFile(files[0]!); |
| 112 | const html = rendered.hunks[0]!.rows[0]!.html; |
| 113 | expect(html).toContain('<span class="diff-ctrl">^[</span>'); |
| 114 | }); |
| 115 | |
| 116 | test("TAB does not render as a control char span", async () => { |
| 117 | const files = await parseDiff(makeDiff("\thello")); |
| 118 | const rendered = await highlightFile(files[0]!); |
| 119 | const html = rendered.hunks[0]!.rows[0]!.html; |
| 120 | expect(html).not.toContain("diff-ctrl"); |
| 121 | }); |
| 122 | |
| 123 | test("normal text has no control char spans", async () => { |
| 124 | const files = await parseDiff(makeDiff("hello world")); |
| 125 | const rendered = await highlightFile(files[0]!); |
| 126 | const html = rendered.hunks[0]!.rows[0]!.html; |
| 127 | expect(html).not.toContain("diff-ctrl"); |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | describe("lazy grammar loading", () => { |
| 132 | // codeToHtml emits `color:...;--shiki-dark:...`, codeToTokensWithThemes |
| 133 | // emits `--shiki-light:...;--shiki-dark:...`. Both carry --shiki-dark. |
| 134 | const shikiSpan = /<span style="[^"]*--shiki-dark:#/; |
| 135 | |
| 136 | test("startup preloads no grammars", async () => { |
| 137 | // A fresh highlighter carries only Shiki's built-in special langs. |
| 138 | const { getHighlighter } = await import("../src/services/highlight.ts"); |
| 139 | expect(getHighlighter().getLoadedLanguages().length).toBeLessThan(20); |
| 140 | }); |
| 141 | |
| 142 | test("ensureLang loads a real grammar and reports it", async () => { |
| 143 | const { ensureLang, getHighlighter } = await import( |
| 144 | "../src/services/highlight.ts" |
| 145 | ); |
| 146 | expect(await ensureLang("rust")).toBe("rust"); |
| 147 | expect(getHighlighter().getLoadedLanguages()).toContain("rust"); |
| 148 | }); |
| 149 | |
| 150 | test("ensureLang falls back to text for a language outside the bundle", async () => { |
| 151 | const { ensureLang } = await import("../src/services/highlight.ts"); |
| 152 | expect(await ensureLang("definitely-not-a-language")).toBe("text"); |
| 153 | }); |
| 154 | |
| 155 | test("ensureLang is idempotent", async () => { |
| 156 | const { ensureLang } = await import("../src/services/highlight.ts"); |
| 157 | expect(await ensureLang("python")).toBe("python"); |
| 158 | expect(await ensureLang("python")).toBe("python"); |
| 159 | }); |
| 160 | |
| 161 | test("serveFile actually highlights, for a grammar loaded on demand", async () => { |
| 162 | const { serveFile } = await import("../src/services/highlight.ts"); |
| 163 | const view = await serveFile( |
| 164 | Buffer.from("def greet():\n return 42\n"), |
| 165 | "sample.py", |
| 166 | "", |
| 167 | ); |
| 168 | expect(view.type).toBe("inline"); |
| 169 | if (view.type !== "inline") throw new Error("expected inline"); |
| 170 | // Regression guard: without ensureLang this silently degrades to the |
| 171 | // plain-text table, which has no per-token colour spans. |
| 172 | expect(view.html).toMatch(shikiSpan); |
| 173 | }); |
| 174 | |
| 175 | test("serveFile still renders an unknown extension as plain text", async () => { |
| 176 | const { serveFile } = await import("../src/services/highlight.ts"); |
| 177 | const view = await serveFile( |
| 178 | Buffer.from("just some words\n"), |
| 179 | "notes.xyz", |
| 180 | "", |
| 181 | ); |
| 182 | expect(view.type).toBe("inline"); |
| 183 | if (view.type !== "inline") throw new Error("expected inline"); |
| 184 | expect(view.html).toContain("just some words"); |
| 185 | }); |
| 186 | |
| 187 | test("highlightFile colours diff hunks for an on-demand grammar", async () => { |
| 188 | const diff = [ |
| 189 | "diff --git a/x.go b/x.go", |
| 190 | "index 111..222 100644", |
| 191 | "--- a/x.go", |
| 192 | "+++ b/x.go", |
| 193 | "@@ -1,2 +1,2 @@", |
| 194 | " package main", |
| 195 | "-const a = 1", |
| 196 | "+const a = 2", |
| 197 | ].join("\n"); |
| 198 | const [file] = await parseDiff(diff); |
| 199 | const rendered = await highlightFile(file!); |
| 200 | const html = rendered.hunks.flatMap((h) => h.rows.map((r) => r.html)).join(""); |
| 201 | expect(html).toMatch(shikiSpan); |
| 202 | }); |
| 203 | }); |
| 204 |