v0.1.0 3f267b5

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

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