highlight_test.go
| 1 | package highlight |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func 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 | |
| 40 | func 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. |
| 68 | func 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 | |
| 82 | func 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 | |
| 91 | func 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 | |
| 113 | func 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 | |
| 127 | func 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 | |
| 162 | func 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 | |
| 178 | func 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 | |
| 192 | func 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 | |
| 224 | func 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 | |
| 232 | func 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. |
| 248 | func 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 | |
| 259 | func 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 |