highlight.test.ts
Raw
1import { describe, test, expect, beforeAll } from "bun:test";
2import { detectLang, hasBinaryContent, highlightStartup } from "../src/services/highlight.ts";
3import { parseDiff, highlightFile } from "../src/services/diffHighlight.ts";
4
5beforeAll(async () => {
6 await highlightStartup();
7});
8
9describe("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
40describe("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).
71function 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
82describe("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