v0.33.0 cf26365

diff_highlight_test.go RawHistoryFolder

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 &lt;b&gt; */</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], `&lt;U+202E&gt;`)
135	assert.Contains(t, rendered(t, f)[0], `&lt;script&gt;`)
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.Omitted = domain.OmitLarge
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 changed 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close