1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "strings"
8 "testing"
9
10 "github.com/alecthomas/chroma/v2"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15)
16
17func TestHighlightLines(t *testing.T) {
18 t.Parallel()
19 v := highlight(context.Background(), "a.go", "package main\n\nfunc main() {}\n")
20 assert.Equal(t, 3, v.lines)
21 assert.Equal(t, "Go", v.lang)
22 h := string(v.html)
23 assert.Contains(t, h, `<span class="line" id="L1"><a class="ln" href="#L1">1</a><span class="kn">package</span>`)
24 assert.Contains(t, h, `<span class="line" id="L2"><a class="ln" href="#L2">2</a></span>`)
25 assert.NotContains(t, h, "style=")
26
27 // No final line feed, CRLF and lone CR are lines too.
28 assert.Equal(t, 2, highlight(context.Background(), "x.txt", "a\nb").lines)
29 assert.Equal(t, 3, highlight(context.Background(), "x.txt", "a\r\nb\rc").lines)
30 assert.Equal(t, 0, highlight(context.Background(), "x.txt", "").lines)
31}
32
33func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) {
34 t.Parallel()
35 v := highlight(context.Background(), "unknown.zzz", "<script>alert(1)</script> & \"q\"\n")
36 assert.Equal(t, "", v.lang)
37 h := string(v.html)
38 assert.Contains(t, h, "<script>alert(1)</script> & "q"")
39 assert.NotContains(t, h, "<script>")
40
41 // Invalid UTF-8 is replaced, not passed on.
42 assert.Contains(t, string(highlight(context.Background(), "x.txt", "a\xffb\n").html), "a\uFFFDb")
43}
44
45func TestHighlightMarksInvisibleCharacters(t *testing.T) {
46 t.Parallel()
47 for r, cp := range map[rune]string{
48 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF",
49 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F",
50 } {
51 h := string(highlight(context.Background(), "x.go", "a"+string(r)+"b\n").html)
52 assert.NotContains(t, h, string(r), cp)
53 assert.Contains(t, h, "<"+cp+"></span>", cp)
54 assert.Contains(t, h, `<span class="ctl" title="`+cp+" ", cp)
55 }
56 // A tab and ordinary non-ASCII text are left alone.
57 h := string(highlight(context.Background(), "x.txt", "\tcafé 日本\n").html)
58 assert.Contains(t, h, "\tcafé 日本")
59 assert.NotContains(t, h, "ctl")
60 // Inside a token of a lexer too.
61 h = string(highlight(context.Background(), "x.go", "x := \"a\u202eb\"\n").html)
62 assert.NotContains(t, h, "\u202e")
63 assert.Contains(t, h, "<U+202E>")
64}
65
66// The lexers do not lose or add characters: the text of the lines is the
67// source, whatever the language.
68func TestHighlightKeepsTheSource(t *testing.T) {
69 t.Parallel()
70 srcs := map[string]string{
71 "a.go": "package a\n\nvar s = `multi\nline`\n// end",
72 "b.py": "def f():\n '''doc\n string'''\n return 1\n",
73 "c.md": "# T\n\n```go\nx := 1\n```\n",
74 "d.json": "{\"a\": [1, 2, {\"b\": null}]}\n",
75 "e.sh": "echo \"$(date)\" | tr a b\n",
76 "f.yaml": "a:\n - b\n - c: d\n",
77 "Makefile": "all:\n\t@echo hi\n",
78 "g.rs": "fn main() { let _ = \"x\"; }\n",
79 "h.html": "<p class=\"a\">x & y</p>\n",
80 "i.templ": "templ A() {\n\t<p>x</p>\n}\n",
81 "Dockerfile": "FROM alpine\nRUN echo hi\n",
82 }
83 for name, src := range srcs {
84 v := highlight(context.Background(), name, src)
85 var text strings.Builder
86 for _, l := range strings.Split(string(v.html), "\n") {
87 l = strings.TrimSpace(l)
88 if l == "" {
89 continue
90 }
91 text.WriteString(stripTags(l) + "\n")
92 }
93 want := src
94 assert.Equal(t, strings.Count(strings.TrimSuffix(src, "\n"), "\n")+1, v.lines, name)
95 assert.Equal(t, normSpace(want), normSpace(unescape(text.String())), name)
96 }
97}
98
99func TestFirstLinesAndCount(t *testing.T) {
100 t.Parallel()
101 assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2))
102 assert.Equal(t, "a\nb", firstLines("a\nb", 5))
103 assert.Equal(t, 3, countLines([]byte("a\nb\nc")))
104 assert.Equal(t, 3, countLines([]byte("a\nb\nc\n")))
105 assert.Equal(t, 0, countLines(nil))
106}
107
108func TestHighlightCtxCancelled(t *testing.T) {
109 t.Parallel()
110 ctx, cancel := context.WithCancel(context.Background())
111 cancel()
112 v := highlightCtx(ctx, "a.go", "package a\n")
113 assert.Equal(t, 1, v.lines, "a cancelled context gives the plain text")
114 assert.Empty(t, v.lang, "the lexer stopped")
115}
116
117// panicLexer is a lexer that panics while lexing, as chroma lexers can.
118type panicLexer struct{ chroma.Lexer }
119
120func (panicLexer) Tokenise(*chroma.TokeniseOptions, string) (chroma.Iterator, error) {
121 return func() chroma.Token { panic("lexer bug") }, nil
122}
123
124func TestTokeniseRecoversFromALexerPanic(t *testing.T) {
125 t.Parallel()
126 toks, ok := tokenise(context.Background(), panicLexer{}, "x\n")
127 assert.False(t, ok)
128 assert.Nil(t, toks)
129}
130
131func TestSniffImage(t *testing.T) {
132 t.Parallel()
133 assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")))
134 assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a....")))
135 assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF")))
136 assert.Equal(t, "", sniffImage([]byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)))
137 assert.Equal(t, "", sniffImage([]byte(`<?xml version="1.0"?><svg/>`)))
138 assert.Equal(t, "", sniffImage([]byte("plain")))
139 assert.Equal(t, "", sniffImage(nil))
140}
141
142func TestLocate(t *testing.T) {
143 t.Parallel()
144 b, tag := domain.RefKindBranch, domain.RefKindTag
145 d := &repoData{
146 branches: []domain.Ref{ref(b, "main"), ref(b, "feature/x")},
147 tags: []domain.Ref{ref(tag, "v1/rc")},
148 }
149 d.head = &d.branches[0]
150 for _, tt := range []struct {
151 in, name, path string
152 detached, ok bool
153 }{
154 {"", "main", "", false, true},
155 {"main/a/b.go", "main", "a/b.go", false, true},
156 {"feature/x/dir/f", "feature/x", "dir/f", false, true},
157 {"v1/rc", "v1/rc", "", false, true},
158 {"0123abc/f.txt", "0123abc", "f.txt", true, true},
159 {"feature/y/f", "", "", false, false},
160 {"abc/f", "", "", false, false},
161 {"MAIN/f", "", "", false, false},
162 } {
163 got, ok := d.locate(tt.in)
164 assert.Equal(t, tt.ok, ok, tt.in)
165 if !ok {
166 continue
167 }
168 assert.Equal(t, tt.name, got.name, tt.in)
169 assert.Equal(t, tt.path, got.path, tt.in)
170 assert.Equal(t, tt.detached, got.detached, tt.in)
171 }
172 empty := &repoData{}
173 _, ok := empty.locate("")
174 assert.False(t, ok, "an empty repository has no head")
175 require.True(t, isCommitID(strings.Repeat("a", 40)))
176 assert.False(t, isCommitID(strings.Repeat("a", 41)))
177 assert.False(t, isCommitID("abcdef"))
178}
179
180func TestFileCrumbs(t *testing.T) {
181 t.Parallel()
182 cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
183 require.Len(t, cs, 5)
184 assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)
185 assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href)
186 assert.True(t, cs[4].Current)
187 cs = pathCrumbs("demo", "tree", target{name: "main", path: "src"})
188 assert.Equal(t, "/demo/tree/main/src", cs[3].Href)
189 assert.Equal(t, "a<U+202E>b", pathCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)
190}
191
192func stripTags(s string) string {
193 var b strings.Builder
194 in := false
195 for _, r := range s {
196 switch {
197 case r == '<':
198 in = true
199 case r == '>':
200 in = false
201 case !in:
202 b.WriteRune(r)
203 }
204 }
205 return b.String()
206}
207
208func unescape(s string) string {
209 return strings.NewReplacer("<", "<", ">", ">", "&", "&", """, `"`, "'", "'").Replace(s)
210}
211
212// normSpace drops the leading numbers of the gutter and the white space, which
213// the tests do not care about.
214func normSpace(s string) string {
215 var out []string
216 for _, l := range strings.Split(s, "\n") {
217 l = strings.TrimSpace(l)
218 if l == "" {
219 continue
220 }
221 i := 0
222 for i < len(l) && l[i] >= '0' && l[i] <= '9' {
223 i++
224 }
225 if l = strings.Join(strings.Fields(l[i:]), ""); l != "" {
226 out = append(out, l)
227 }
228 }
229 return strings.Join(out, "\n")
230}
231
232func TestTokensMatch(t *testing.T) {
233 t.Parallel()
234 toks := func(vs ...string) []chroma.Token {
235 var out []chroma.Token
236 for _, v := range vs {
237 out = append(out, chroma.Token{Type: chroma.Text, Value: v})
238 }
239 return out
240 }
241 for _, tt := range []struct {
242 name string
243 toks []chroma.Token
244 src string
245 want bool
246 }{
247 {"same pieces", toks("ab", "c\n", "d"), "abc\nd", true},
248 {"lexer added a final line feed", toks("a\n", "b", "\n"), "a\nb", true},
249 {"source ends with line feeds", toks("a", "\n"), "a\n\n", true},
250 {"empty", nil, "", true},
251 {"empty tokens", toks("", ""), "\n", true},
252 {"only line feeds", toks("\n\n"), "", true},
253 {"token differs", toks("a", "x"), "ab", false},
254 {"tokens stop short", toks("a"), "ab", false},
255 {"tokens go past the source", toks("abc"), "ab", false},
256 {"extra text after the source", toks("ab", "\nx"), "ab", false},
257 {"lexer dropped a line", toks("a\n", "c"), "a\nb\nc", false},
258 {"unicode", toks("é", "日本"), "é日本", true},
259 } {
260 t.Run(tt.name, func(t *testing.T) {
261 t.Parallel()
262 assert.Equal(t, tt.want, tokensMatch(tt.toks, tt.src))
263 })
264 }
265}