v0.7.0 45bfc5b

highlight_test.go RawHistoryFolder

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, "&lt;script&gt;alert(1)&lt;/script&gt; &amp; &#34;q&#34;")
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, "&lt;"+cp+"&gt;</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, "&lt;U+202E&gt;")
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 &amp; 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("&lt;", "<", "&gt;", ">", "&amp;", "&", "&#34;", `"`, "&#39;", "'").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}

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 4

Tags 45

↑↓ moveenter openesc close