1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "os"
9 "regexp"
10 "strings"
11 "testing"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17)
18
19// hunkOf is a modified file with one hunk of the lines given as "+text",
20// "-text" and " text".
21func hunkOf(name string, lines ...string) domain.FileDiff {
22 var ls []domain.Line
23 o, n := 0, 0
24 for _, s := range lines {
25 switch s[0] {
26 case '+':
27 n++
28 ls = append(ls, line(domain.LineAddition, 0, n, s[1:]))
29 case '-':
30 o++
31 ls = append(ls, line(domain.LineDeletion, o, 0, s[1:]))
32 default:
33 o++
34 n++
35 ls = append(ls, line(domain.LineContext, o, n, s[1:]))
36 }
37 }
38 return domain.FileDiff{
39 Status: domain.FileModified, OldPath: name, NewPath: name,
40 Hunks: []domain.Hunk{{OldStart: 1, OldLines: o, NewStart: 1, NewLines: n, Lines: ls}},
41 }
42}
43
44// rendered is the HTML of the code cell of every line of the first hunk.
45func rendered(t *testing.T, f DiffFile) []string {
46 t.Helper()
47 var out []string
48 for _, l := range f.Hunks[0].Lines {
49 var b strings.Builder
50 require.NoError(t, codeLine(l).Render(context.Background(), &b))
51 out = append(out, b.String())
52 }
53 return out
54}
55
56// uncachedFiles is the files of a diff coloured with no cache: every call
57// lexes.
58func uncachedFiles(ctx context.Context, files []domain.FileDiff) []DiffFile {
59 return (*hlCache)(nil).files(ctx, files)
60}
61
62func highlighted(t *testing.T, fd domain.FileDiff) DiffFile {
63 t.Helper()
64 return uncachedFiles(context.Background(), []domain.FileDiff{fd})[0]
65}
66
67// A block comment that spans lines is a comment on each of them: the hunk is
68// lexed as a sequence, not line by line.
69func TestHighlightMultiLineComment(t *testing.T) {
70 t.Parallel()
71 f := highlighted(t, hunkOf("a.go",
72 " package a",
73 "+/* first",
74 "+ second <b> */",
75 "+var x = 1",
76 ))
77 got := rendered(t, f)
78 assert.Equal(t, `<span class="kn">package</span> a`, got[0])
79 assert.Equal(t, `<span class="cm">/* first</span>`, got[1])
80 assert.Equal(t, `<span class="cm"> second <b> */</span>`, got[2])
81 assert.Contains(t, got[3], `<span class="kd">var</span>`)
82}
83
84// Each side is lexed on its own: a deleted line has the colours it had in
85// the old file, whatever the added lines around it open.
86func TestHighlightSidesApart(t *testing.T) {
87 t.Parallel()
88 f := highlighted(t, hunkOf("a.go",
89 "-x := 1 // old",
90 "-y := 2",
91 "+/* new",
92 "+y := 2 */",
93 ))
94 got := rendered(t, f)
95 assert.NotContains(t, got[1], `class="cm"`, "the old side has no comment open")
96 assert.Equal(t, `<span class="cm">/* new</span>`, got[2])
97 assert.Equal(t, `<span class="cm">y := 2 */</span>`, got[3])
98 assert.Contains(t, got[0], `<span class="c1">// old</span>`)
99}
100
101// Whatever is coloured, the text of the line is untouched and escaped,
102// invisible characters are markers, and the CR of a CRLF line is dropped.
103func TestHighlightKeepsText(t *testing.T) {
104 t.Parallel()
105 texts := []string{
106 `s := "<script>alert(1)</script>" // & "quoted"`,
107 "if x { // \u202eadmin \u200b",
108 "\tfunc() {}\r",
109 "",
110 `x := '<' + "é世"`,
111 }
112 var in []string
113 for _, s := range texts {
114 in = append(in, "+"+s)
115 }
116 f := highlighted(t, hunkOf("a.go", in...))
117 plain := diffFiles([]domain.FileDiff{hunkOf("a.go", in...)})[0]
118 for i, l := range f.Hunks[0].Lines {
119 var got, want strings.Builder
120 for _, s := range l.Segs {
121 got.WriteString(s.Text)
122 }
123 for _, s := range plain.Hunks[0].Lines[i].Segs {
124 want.WriteString(s.Text)
125 }
126 assert.Equal(t, want.String(), got.String(), "line %d", i)
127 }
128 for i, h := range rendered(t, f) {
129 assert.NotContains(t, h, "<script>", "line %d", i)
130 assert.NotContains(t, h, "\u202e")
131 assert.NotContains(t, h, "\u200b")
132 assert.NotContains(t, h, "style=")
133 }
134 assert.Contains(t, rendered(t, f)[1], `<U+202E>`)
135 assert.Contains(t, rendered(t, f)[0], `<script>`)
136}
137
138// Only the classes of the style sheet are ever written, and the sets agree.
139func TestStyledClassesMatchCSS(t *testing.T) {
140 t.Parallel()
141 css, err := os.ReadFile("static/css/56-chroma.css")
142 require.NoError(t, err)
143 got := map[string]bool{}
144 for _, m := range regexp.MustCompile(`(?m)^\s*\.chroma \.([a-z0-9]+) \{`).FindAllStringSubmatch(string(css), -1) {
145 if m[1] != "line" && m[1] != "ctl" {
146 got[m[1]] = true
147 }
148 }
149 assert.Equal(t, got, styledClasses)
150}
151
152// No language, a binary or a truncated file, a file over a limit, a line too
153// long, a lone CR: the text stays as the plain diff has it.
154func TestHighlightSkips(t *testing.T) {
155 t.Parallel()
156 plainOf := func(fd domain.FileDiff) []DiffFile { return diffFiles([]domain.FileDiff{fd}) }
157 big := make([]string, 0, 6000)
158 for range 6000 {
159 big = append(big, "+var longer_name_of_a_variable = 12345 // and a comment")
160 }
161 longLine := "+x := \"" + strings.Repeat("a", maxDiffHLLine) + "\""
162 binary := hunkOf("a.go", "+package a")
163 binary.Binary = true
164 truncated := hunkOf("a.go", "+package a")
165 truncated.Truncated = true
166 for name, fd := range map[string]domain.FileDiff{
167 "no extension": hunkOf("LICENSE.unknownext", "+package a", "+func f() {}"),
168 "plain text": hunkOf("notes.txt", "+package a"),
169 "binary": binary,
170 "truncated": truncated,
171 "over the limit": hunkOf("big.go", big...),
172 "long line": hunkOf("a.go", "+package a", longLine),
173 "lone cr": hunkOf("a.go", "+package a", "+x := 1\r// y"),
174 } {
175 got := uncachedFiles(context.Background(), []domain.FileDiff{fd})
176 assert.Equal(t, plainOf(fd), got, name)
177 }
178}
179
180// The files past the text budget of the request are plain. Small files and a
181// small budget: only the byte accounting decides, never the time. Not
182// parallel: it changes maxDiffHLTotalBytes.
183func TestHighlightRequestBudget(t *testing.T) {
184 saved := maxDiffHLTotalBytes
185 t.Cleanup(func() { maxDiffHLTotalBytes = saved })
186 line := "+var v = 1"
187 one := diffHLBytes(hunkOf("a.go", line))
188 maxDiffHLTotalBytes = 2 * one // two files fit, the third does not
189 var files []domain.FileDiff
190 for i := range 3 {
191 files = append(files, hunkOf(fmt.Sprintf("f%d.go", i), line))
192 }
193 out := uncachedFiles(context.Background(), files)
194 assert.Contains(t, rendered(t, out[0])[0], `class="kd"`)
195 assert.Contains(t, rendered(t, out[1])[0], `class="kd"`)
196 assert.NotContains(t, rendered(t, out[2])[0], `class=`, "over the budget of the request")
197}
198
199// What a cached entry weighs is what it holds: a file of plain lines keeps
200// no room reserved for runs it does not have.
201func TestHighlightDataSize(t *testing.T) {
202 t.Parallel()
203 var lines []domain.Line
204 for i := range 2000 {
205 lines = append(lines, domain.Line{Kind: domain.LineAddition, NewNumber: i + 1})
206 }
207 d, ok := computeFile(context.Background(), diffLexer("a.go"), []domain.Hunk{{Lines: lines}})
208 require.True(t, ok)
209 assert.LessOrEqual(t, cap(d.runs), len(d.runs)+len(d.runs)/4+16, "the reserve is given back")
210 assert.Equal(t, 64+4*cap(d.lineEnd)+4*cap(d.runs), d.size())
211 assert.Less(t, d.size(), 10_000, "about 4 bytes a line, not 20")
212}
213
214// With every lexing slot taken the diff is shown plain at once, not queued.
215func TestHighlightNoSlot(t *testing.T) {
216 for range cap(lexSlots) {
217 lexSlots <- struct{}{}
218 }
219 defer func() {
220 for range cap(lexSlots) {
221 <-lexSlots
222 }
223 }()
224 f := highlighted(t, hunkOf("a.go", "+package a"))
225 assert.Equal(t, "package a", rendered(t, f)[0])
226}
227
228// A cancelled request lexes nothing.
229func TestHighlightCancelled(t *testing.T) {
230 t.Parallel()
231 ctx, cancel := context.WithCancel(context.Background())
232 cancel()
233 fd := hunkOf("a.go", "+package a")
234 got := uncachedFiles(ctx, []domain.FileDiff{fd})
235 assert.Equal(t, diffFiles([]domain.FileDiff{fd}), got)
236}
237
238// The lines /context loads are coloured with the file lexed from its start:
239// a comment that opens above them colours them.
240func TestHighlightContext(t *testing.T) {
241 t.Parallel()
242 lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1", "func f() {}"}
243 segs := (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 2, 5)
244 require.Len(t, segs, 3)
245 assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, segs[0])
246 assert.Equal(t, []Seg{{Text: "closed */", Class: "cm"}}, segs[1])
247 assert.Equal(t, "var", segs[2][0].Text)
248 assert.Equal(t, "kd", segs[2][0].Class)
249
250 assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.txt", lines, 2, 5), "no language")
251 assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 2, 99), "past the end")
252 assert.Nil(t, (*hlCache)(nil).highlightContext(context.Background(), "", "a.go", lines, 3, 3), "nothing to show")
253}
254
255// goSource is n lines of Go with comments, strings and keywords.
256func goSource(n int) []string {
257 var out []string
258 for i := 0; len(out) < n; i++ {
259 out = append(out,
260 fmt.Sprintf("func handler%d(w http.ResponseWriter, r *http.Request) error {", i),
261 fmt.Sprintf("\t// serve the page number %d", i),
262 fmt.Sprintf("\tname := r.URL.Query().Get(%q)", fmt.Sprint("key", i)),
263 "\t/* a block",
264 "\t comment */",
265 "\tif name == \"\" {",
266 "\t\treturn fmt.Errorf(\"missing %s: %w\", name, errNotFound)",
267 "\t}",
268 "\treturn nil",
269 "}",
270 )
271 }
272 return out[:n]
273}
274
275// largeDiff is n files of 1,000 lines of Go with 250 lines changed in place
276// and 250 added, 1,500 lines a file (the limit of the diffs is 2,000 lines a
277// file and 20,000 in all, domain.DefaultDiffLimits); a file is about 36 KB.
278func largeDiff(n int) []domain.FileDiff {
279 var files []domain.FileDiff
280 for i := range n {
281 src := goSource(1000)
282 var ls []string
283 for j, s := range src {
284 switch j % 4 {
285 case 0:
286 ls = append(ls, "-"+s, "+"+s+" // changed")
287 case 1:
288 ls = append(ls, "+"+s)
289 default:
290 ls = append(ls, " "+s)
291 }
292 }
293 f := hunkOf(fmt.Sprintf("pkg/f%d.go", i), ls...)
294 f.OldID, f.NewID = fakeBlob(i, 0), fakeBlob(i, 1)
295 files = append(files, f)
296 }
297 return files
298}
299
300// blobID is a made-up blob id, a different one for each n and side.
301func fakeBlob(n, side int) domain.ObjectID {
302 id, err := domain.ParseObjectID(fmt.Sprintf("%038x%02x", n, side))
303 if err != nil {
304 panic(err)
305 }
306 return id
307}
308
309// BenchmarkDiffHighlight measures the cost of the colours on a large diff:
310// the conversion alone, the conversion with the colours, and the colours with
311// the page rendering of every line; "capped" is a diff twice the size the
312// request budget lexes, which shows the bound.
313func BenchmarkDiffHighlight(b *testing.B) {
314 files := largeDiff(6)
315 ctx := context.Background()
316 b.Run("plain", func(b *testing.B) {
317 for b.Loop() {
318 diffFiles(files)
319 }
320 })
321 b.Run("highlighted", func(b *testing.B) {
322 for b.Loop() {
323 uncachedFiles(ctx, files)
324 }
325 })
326 b.Run("highlighted+render", func(b *testing.B) {
327 var w strings.Builder
328 for b.Loop() {
329 w.Reset()
330 for _, f := range uncachedFiles(ctx, files) {
331 for _, l := range f.Hunks[0].Lines {
332 _ = codeLine(l).Render(ctx, &w)
333 }
334 }
335 }
336 })
337 b.Run("cached", func(b *testing.B) {
338 c := newHLCache(64 << 20)
339 c.files(ctx, files) // the first visitor lexes
340 for b.Loop() {
341 c.files(ctx, files)
342 }
343 })
344 b.Run("cached+render", func(b *testing.B) {
345 c := newHLCache(64 << 20)
346 c.files(ctx, files)
347 var w strings.Builder
348 for b.Loop() {
349 w.Reset()
350 for _, f := range c.files(ctx, files) {
351 for _, l := range f.Hunks[0].Lines {
352 _ = codeLine(l).Render(ctx, &w)
353 }
354 }
355 }
356 })
357 b.Run("capped", func(b *testing.B) {
358 over := largeDiff(14)
359 for b.Loop() {
360 uncachedFiles(ctx, over)
361 }
362 })
363 b.Run("plain+render", func(b *testing.B) {
364 var w strings.Builder
365 for b.Loop() {
366 w.Reset()
367 for _, f := range diffFiles(files) {
368 for _, l := range f.Hunks[0].Lines {
369 _ = codeLine(l).Render(ctx, &w)
370 }
371 }
372 }
373 })
374}