highlight_test.go
⎇
Raw
1package highlight
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7)
8
9func TestDetectLang(t *testing.T) {
10 cases := map[string]string{
11 "foo.ts": "TypeScript",
12 "foo.tsx": "TypeScript",
13 "foo.js": "JavaScript",
14 "foo.py": "Python",
15 "foo.rs": "Rust",
16 "foo.go": "Go",
17 "foo.c": "C",
18 "foo.cpp": "C++",
19 "foo.h": "C",
20 "foo.yml": "YAML",
21 "foo.toml": "TOML",
22 "foo.json": "JSON",
23 "foo.md": "markdown",
24 "foo.sh": "Bash",
25 "foo.patch": "Diff",
26 "Dockerfile": "Docker",
27 "Makefile": "Makefile",
28 "src/foo/bar.ts": "TypeScript",
29 "infra/Dockerfile": "Docker",
30 "foo.xyz": "",
31 "LICENSE": "",
32 }
33 for name, want := range cases {
34 if got := DetectLang(name); got != want {
35 t.Errorf("DetectLang(%q) = %q, want %q", name, got, want)
36 }
37 }
38}
39
40func TestHasBinaryContent(t *testing.T) {
41 nulAt := func(n, size int) []byte {
42 b := bytes.Repeat([]byte{'A'}, size)
43 b[n] = 0
44 return b
45 }
46 cases := []struct {
47 name string
48 in []byte
49 want bool
50 }{
51 {"NUL byte", []byte{0x68, 0x65, 0x00, 0x6c, 0x6f}, true},
52 {"all NUL", make([]byte, 10), true},
53 {"plain text", []byte("hello world\n"), false},
54 {"CRLF text", []byte("line1\r\nline2\r\n"), false},
55 // Git treats BEL and ESC as text.
56 {"other control chars", []byte{0x01, 0x07, 0x1b, 0x41}, false},
57 {"NUL past the sample window", nulAt(8000, 8001), false},
58 {"NUL inside the sample window", nulAt(7999, 8001), true},
59 }
60 for _, c := range cases {
61 if got := HasBinaryContent(c.in); got != c.want {
62 t.Errorf("%s: got %v, want %v", c.name, got, c.want)
63 }
64 }
65}
66
67// makeDiff builds a minimal unified diff that adds the given lines.
68func makeDiff(lines ...string) string {
69 out := []string{
70 "diff --git a/test.txt b/test.txt",
71 "index 0000000..1111111 100644",
72 "--- a/test.txt",
73 "+++ b/test.txt",
74 "@@ -0,0 +1 @@",
75 }
76 for _, l := range lines {
77 out = append(out, "+"+l)
78 }
79 return strings.Join(out, "\n")
80}
81
82func firstRowHTML(t *testing.T, h *Highlighter, diff string) string {
83 t.Helper()
84 files := parseDiff(diff, nil)
85 if len(files) != 1 {
86 t.Fatalf("parsed %d files, want 1", len(files))
87 }
88 return h.highlightFile(files[0]).Hunks[0].Rows[0].HTML
89}
90
91func TestDiffControlCharacters(t *testing.T) {
92 h := New(512 << 10)
93 cases := []struct {
94 name string
95 line string
96 want string
97 absent bool
98 }{
99 {name: "CR renders as ^M", line: "hello\rworld", want: `<span class="diff-ctrl">^M</span>`},
100 {name: "BEL renders as ^G", line: "ring\x07bell", want: `<span class="diff-ctrl">^G</span>`},
101 {name: "ESC renders as ^[", line: "\x1b[31mred\x1b[0m", want: `<span class="diff-ctrl">^[</span>`},
102 {name: "TAB is not a control span", line: "\thello", want: "diff-ctrl", absent: true},
103 {name: "normal text has no control spans", line: "hello world", want: "diff-ctrl", absent: true},
104 }
105 for _, c := range cases {
106 got := firstRowHTML(t, h, makeDiff(c.line))
107 if strings.Contains(got, c.want) == c.absent {
108 t.Errorf("%s: html %q, contains %q = %v", c.name, got, c.want, !c.absent)
109 }
110 }
111}
112
113func TestDiffTrailingCROnEveryLine(t *testing.T) {
114 h := New(512 << 10)
115 files := parseDiff(makeDiff("alpha\r", "beta\r", "gamma\r"), nil)
116 rows := h.highlightFile(files[0]).Hunks[0].Rows
117 if len(rows) != 3 {
118 t.Fatalf("got %d rows, want 3", len(rows))
119 }
120 for i, row := range rows {
121 if !strings.Contains(row.HTML, `<span class="diff-ctrl">^M</span>`) {
122 t.Errorf("row %d has no ^M: %q", i, row.HTML)
123 }
124 }
125}
126
127func TestDiffHighlightsKnownLanguage(t *testing.T) {
128 h := New(512 << 10)
129 diff := strings.Join([]string{
130 "diff --git a/x.go b/x.go",
131 "index 111..222 100644",
132 "--- a/x.go",
133 "+++ b/x.go",
134 "@@ -1,2 +1,2 @@",
135 " package main",
136 "-const a = 1",
137 "+const a = 2",
138 }, "\n")
139 files := parseDiff(diff, nil)
140 f := h.highlightFile(files[0])
141 if f.Added != 1 || f.Removed != 1 {
142 t.Errorf("added=%d removed=%d, want 1/1", f.Added, f.Removed)
143 }
144 var all strings.Builder
145 for _, hunk := range f.Hunks {
146 for _, row := range hunk.Rows {
147 all.WriteString(row.HTML)
148 }
149 }
150 if !strings.Contains(all.String(), `<span class="ch-`) {
151 t.Errorf("diff was not highlighted: %q", all.String())
152 }
153 rows := f.Hunks[0].Rows
154 if rows[0].OldLine != 1 || rows[0].NewLine != 1 {
155 t.Errorf("context row numbers = %d/%d, want 1/1", rows[0].OldLine, rows[0].NewLine)
156 }
157 if rows[1].NewLine != NoLine || rows[1].OldLine != 2 {
158 t.Errorf("del row numbers = %d/%d, want 2/%d", rows[1].OldLine, rows[1].NewLine, NoLine)
159 }
160}
161
162func TestParseDiffRename(t *testing.T) {
163 diff := strings.Join([]string{
164 "diff --git a/old.txt b/new.txt",
165 "similarity index 100%",
166 "rename from old.txt",
167 "rename to new.txt",
168 }, "\n")
169 files := parseDiff(diff, nil)
170 if len(files) != 1 {
171 t.Fatalf("got %d files, want 1", len(files))
172 }
173 if files[0].Status != StatusRenamed || files[0].OldPath != "old.txt" || files[0].NewPath != "new.txt" {
174 t.Errorf("got %+v", files[0])
175 }
176}
177
178func TestParseDiffBinaryLiteralSizes(t *testing.T) {
179 diff := strings.Join([]string{
180 "diff --git a/img.png b/img.png",
181 "index 111..222 100644",
182 "GIT binary patch",
183 "literal 1234",
184 "literal 99",
185 }, "\n")
186 files := parseDiff(diff, nil)
187 if !files[0].IsBinary || files[0].BinaryNewSize != 1234 || files[0].BinaryOldSize != 99 {
188 t.Errorf("got %+v", files[0])
189 }
190}
191
192func TestServeFile(t *testing.T) {
193 h := New(32)
194
195 view := h.ServeFile([]byte("def greet():\n return 42\n"), "sample.py", "")
196 if view.Type != "inline" {
197 t.Fatalf("type = %q, want inline", view.Type)
198 }
199 // Regression guard: an unhighlighted fallback has no token spans.
200 if !strings.Contains(view.HTML, `<span class="ch-`) {
201 t.Errorf("python was not highlighted: %q", view.HTML)
202 }
203 if !strings.Contains(view.HTML, `<tr id="L2"><td class="blob-ln"><a href="#L2">2</a>`) {
204 t.Errorf("missing line-number markup: %q", view.HTML)
205 }
206
207 view = h.ServeFile([]byte("just some words\n"), "notes.xyz", "")
208 if view.Type != "inline" || !strings.Contains(view.HTML, "just some words") {
209 t.Errorf("unknown extension: %+v", view)
210 }
211 if strings.Contains(view.HTML, "ch-") {
212 t.Errorf("unknown extension should not be highlighted: %q", view.HTML)
213 }
214
215 if view := h.ServeFile([]byte("a\x00b"), "blob.bin", ""); view.Type != "binary" {
216 t.Errorf("type = %q, want binary", view.Type)
217 }
218 big := bytes.Repeat([]byte("x"), 100)
219 if view := h.ServeFile(big, "big.txt", ""); view.Type != "download" || view.Size != 100 {
220 t.Errorf("got %+v, want download/100", view)
221 }
222}
223
224func TestServeFileEscapesHTML(t *testing.T) {
225 h := New(512 << 10)
226 view := h.ServeFile([]byte("<script>alert(1)</script>\n"), "notes.xyz", "")
227 if strings.Contains(view.HTML, "<script>") {
228 t.Errorf("html was not escaped: %q", view.HTML)
229 }
230}
231
232func TestCSS(t *testing.T) {
233 css := CSS()
234 for _, want := range []string{
235 "--code-bg:",
236 ".ch-k {",
237 `@media (prefers-color-scheme: dark) {`,
238 `:root:not([data-theme="light"]) .ch-k {`,
239 `:root[data-theme="dark"] .ch-k {`,
240 } {
241 if !strings.Contains(css, want) {
242 t.Errorf("CSS() is missing %q", want)
243 }
244 }
245}
246
247// A truncated header line used to be sliced with line[6:] and panicked.
248func TestParseDiffShortHeaderLines(t *testing.T) {
249 raw := "diff --git a/f b/f\n--- \n+++ \n@@ -1 +1 @@\n-a\n+b\n"
250 files := parseDiff(raw, nil)
251 if len(files) != 1 {
252 t.Fatalf("got %d files", len(files))
253 }
254 if files[0].OldPath != "" || files[0].NewPath != "" {
255 t.Fatalf("got %q %q", files[0].OldPath, files[0].NewPath)
256 }
257}
258
259func TestParseDiffStripsPathPrefixes(t *testing.T) {
260 raw := "diff --git a/x b/x\n--- a/old.txt\n+++ b/new.txt\n@@ -1 +1 @@\n-a\n+b\n"
261 files := parseDiff(raw, nil)
262 if len(files) != 1 || files[0].OldPath != "old.txt" || files[0].NewPath != "new.txt" {
263 t.Fatalf("got %+v", files)
264 }
265}
266