main b4521f0

parse_test.go RawHistoryFolder

1package kvconf
2
3import (
4	"errors"
5	"reflect"
6	"slices"
7	"strconv"
8	"strings"
9	"testing"
10)
11
12func TestParse(t *testing.T) {
13	tests := []struct {
14		name     string
15		in       string
16		opts     Options
17		want     []Entry
18		warnings []string
19		err      string
20	}{
21		{name: "empty", in: "", want: []Entry{}},
22		{
23			name: "basic",
24			in:   "# comment\n\n  a = 1\nb=two words  \n",
25			want: []Entry{{"a", "1", 3}, {"b", "two words", 4}},
26		},
27		{
28			name: "CRLF and BOM",
29			in:   "\ufeffa = 1\r\nb = 2\r\n",
30			want: []Entry{{"a", "1", 1}, {"b", "2", 2}},
31		},
32		{
33			name: "empty value",
34			in:   "a =\n",
35			want: []Entry{{"a", "", 1}},
36		},
37		{
38			name: "value with equals",
39			in:   "cmd = env A=1 foo\n",
40			want: []Entry{{"cmd", "env A=1 foo", 1}},
41		},
42		{
43			name: "spaced comments keep colors",
44			in:   "color = #ff8800 # orange\nx = a#b\ny = a #b\n",
45			want: []Entry{{"color", "#ff8800", 1}, {"x", "a#b", 2}, {"y", "a", 3}},
46		},
47		{
48			name: "tab before comment",
49			in:   "a = 1\t# note\n",
50			want: []Entry{{"a", "1", 1}},
51		},
52		{
53			name: "line comments only",
54			in:   "a = 1 # kept\n# skipped = 1\n",
55			opts: Options{Comments: CommentLine},
56			want: []Entry{{"a", "1 # kept", 1}},
57		},
58		{
59			name: "any hash is a comment",
60			in:   "a = x#y\n",
61			opts: Options{Comments: CommentAny},
62			want: []Entry{{"a", "x", 1}},
63		},
64		{
65			name: "quotes",
66			in: `a = "x # y"   # c
67b = 'it''s'
68c = 'lit\n'
69d = "tab\t"
70e = "a" "b"
71f = "unterminated
72g = spawn "foo bar"
73`,
74			want: []Entry{
75				{"a", "x # y", 1},
76				{"b", "'it''s'", 2},
77				{"c", `lit\n`, 3},
78				{"d", "tab\t", 4},
79				{"e", `"a" "b"`, 5},
80				{"f", `"unterminated`, 6},
81				{"g", `spawn "foo bar"`, 7},
82			},
83		},
84		{
85			name: "apostrophe inside a word keeps the comment",
86			in:   "title = it's fine   # a note\nb = say \"x # y\" # c\n",
87			want: []Entry{{"title", "it's fine", 1}, {"b", `say "x # y"`, 2}},
88		},
89		{
90			name: "escape making invalid UTF-8 is kept as written",
91			in:   `a = "\200"` + "\n",
92			want: []Entry{{"a", `"\200"`, 1}},
93		},
94		{
95			name: "raw values",
96			in:   `a = "x"` + "\n",
97			opts: Options{RawValues: true},
98			want: []Entry{{"a", `"x"`, 1}},
99		},
100		{
101			name:     "duplicate warns, both kept",
102			in:       "a = 1\na = 2\n",
103			want:     []Entry{{"a", "1", 1}, {"a", "2", 2}},
104			warnings: []string{"line 2: a: overrides line 1"},
105		},
106		{
107			name:     "duplicates and bad lines in line order",
108			in:       "a = 1\nb = 1\nnope\na = 2\n= x\nb = 2\na = 3\n",
109			want:     []Entry{{"a", "1", 1}, {"b", "1", 2}, {"a", "2", 4}, {"b", "2", 6}, {"a", "3", 7}},
110			warnings: []string{"line 3: expected key = value", "line 4: a: overrides line 1", "line 5: missing key", "line 6: b: overrides line 2", "line 7: a: overrides line 4"},
111		},
112		{
113			name: "duplicates allowed",
114			in:   "a = 1\na = 2\n",
115			opts: Options{AllowDuplicates: true},
116			want: []Entry{{"a", "1", 1}, {"a", "2", 2}},
117		},
118		{
119			name:     "bad lines are skipped",
120			in:       "nope\n= 1\nok = 1\n",
121			want:     []Entry{{"ok", "1", 3}},
122			warnings: []string{"line 1: expected key = value", "line 2: missing key"},
123		},
124		{
125			name: "key with spaces",
126			in:   "layout.LG Electronics 27GR95UM.overflow  = scroll\n",
127			want: []Entry{{"layout.LG Electronics 27GR95UM.overflow", "scroll", 1}},
128		},
129		{
130			name:     "strict stops at first problem",
131			in:       "ok = 1\nnope\nbad\n",
132			opts:     Options{Strict: true},
133			warnings: []string{"line 2: expected key = value"},
134			err:      "line 2: expected key = value",
135		},
136		{
137			name:     "strict duplicate",
138			in:       "a = 1\na = 2\n",
139			opts:     Options{Strict: true},
140			err:      "line 2: a: overrides line 1",
141			warnings: []string{"line 2: a: overrides line 1"},
142		},
143		{
144			name:     "strict duplicate before a bad line",
145			in:       "a = 1\na = 2\nnope\n",
146			opts:     Options{Strict: true},
147			err:      "line 2: a: overrides line 1",
148			warnings: []string{"line 2: a: overrides line 1"},
149		},
150		{
151			name:     "strict bad line before a duplicate",
152			in:       "a = 1\nnope\na = 2\n",
153			opts:     Options{Strict: true},
154			err:      "line 2: expected key = value",
155			warnings: []string{"line 2: expected key = value"},
156		},
157		{
158			name:     "long line",
159			in:       "a = " + strings.Repeat("x", 20) + "\nb = 1\n",
160			opts:     Options{MaxLine: 10},
161			want:     []Entry{{"b", "1", 2}},
162			warnings: []string{"line 1: line longer than 10 bytes"},
163		},
164		{
165			name: "too large",
166			in:   "a = 1234567890\n",
167			opts: Options{MaxBytes: 8},
168			err:  ErrTooLarge.Error(),
169		},
170		{
171			name: "no size limit",
172			in:   "a = 1234567890\n",
173			opts: Options{MaxBytes: -1, MaxLine: -1},
174			want: []Entry{{"a", "1234567890", 1}},
175		},
176		{
177			name: "invalid UTF-8",
178			in:   "a = \xff\n",
179			err:  ErrNotUTF8.Error(),
180		},
181	}
182	for _, tt := range tests {
183		t.Run(tt.name, func(t *testing.T) {
184			got, warnings, err := Parse(strings.NewReader(tt.in), tt.opts)
185			if tt.err != "" {
186				if err == nil || err.Error() != tt.err {
187					t.Fatalf("err = %v, want %q", err, tt.err)
188				}
189			} else if err != nil {
190				t.Fatalf("err = %v", err)
191			} else if !reflect.DeepEqual(got, tt.want) {
192				t.Errorf("entries = %#v\nwant      %#v", got, tt.want)
193			}
194			if msgs := warningStrings(warnings); !reflect.DeepEqual(msgs, tt.warnings) {
195				t.Errorf("warnings = %q, want %q", msgs, tt.warnings)
196			}
197		})
198	}
199}
200
201func warningStrings(ws []Warning) []string {
202	var out []string
203	for _, w := range ws {
204		out = append(out, w.Error())
205	}
206	return out
207}
208
209func TestStrictErrorIsWarning(t *testing.T) {
210	_, _, err := Parse(strings.NewReader("bad\n"), Options{Strict: true})
211	var w Warning
212	if !errors.As(err, &w) || w.Line != 1 {
213		t.Fatalf("err = %#v, want a Warning on line 1", err)
214	}
215}
216
217func TestChanged(t *testing.T) {
218	tests := []struct {
219		name     string
220		old, cur map[string]string
221		want     []string
222	}{
223		{name: "same", old: map[string]string{"a": "1"}, cur: map[string]string{"a": "1"}},
224		{name: "added", old: map[string]string{}, cur: map[string]string{"b": "1", "a": "1"}, want: []string{"a", "b"}},
225		{name: "removed", old: map[string]string{"a": "1"}, cur: nil, want: []string{"a"}},
226		{name: "changed", old: map[string]string{"a": "1", "b": "1"}, cur: map[string]string{"a": "2", "b": "1"}, want: []string{"a"}},
227	}
228	for _, tt := range tests {
229		t.Run(tt.name, func(t *testing.T) {
230			if got := Changed(tt.old, tt.cur); !reflect.DeepEqual(got, tt.want) {
231				t.Errorf("Changed = %q, want %q", got, tt.want)
232			}
233		})
234	}
235}
236
237// FuzzDuplicates checks the duplicate warnings against a map.
238func FuzzDuplicates(f *testing.F) {
239	for _, s := range []string{"a=1\na=2\nb=1\na=3\n", "x\n=1\na=1\na=1\n"} {
240		f.Add(s)
241	}
242	f.Fuzz(func(t *testing.T, in string) {
243		all, syntax, err := ParseBytes([]byte(in), Options{AllowDuplicates: true})
244		if err != nil {
245			return
246		}
247		var want []string
248		seen := map[string]int{}
249		for _, e := range all {
250			if prev, ok := seen[e.Key]; ok {
251				want = append(want, Warning{Line: e.Line, Key: e.Key, Msg: "overrides line " + strconv.Itoa(prev)}.Error())
252			}
253			seen[e.Key] = e.Line
254		}
255		for _, w := range syntax {
256			want = append(want, w.Error())
257		}
258		_, got, _ := ParseBytes([]byte(in), Options{})
259		if !slices.IsSortedFunc(got, func(a, b Warning) int { return a.Line - b.Line }) {
260			t.Fatalf("warnings out of line order: %q", warningStrings(got))
261		}
262		// Strict fails on the first problem in line order.
263		_, _, err = ParseBytes([]byte(in), Options{Strict: true})
264		switch {
265		case len(got) == 0 && err != nil:
266			t.Fatalf("strict err = %v, lenient had no warning", err)
267		case len(got) > 0 && (err == nil || err.Error() != got[0].Error()):
268			t.Fatalf("strict err = %v, want %q", err, got[0].Error())
269		}
270		gotS := warningStrings(got)
271		slices.Sort(want)
272		slices.Sort(gotS)
273		if !slices.Equal(gotS, want) {
274			t.Fatalf("warnings = %q, want %q", gotS, want)
275		}
276	})
277}
278
279func FuzzParse(f *testing.F) {
280	for _, s := range []string{"a = 1\n", "a = \"x # y\" # c\n", "\ufeffa=b\r\n", "x\n=\n'", "\ufeff\ufeff=", `0="\200"`} {
281		f.Add(s)
282	}
283	f.Fuzz(func(t *testing.T, in string) {
284		entries, _, err := Parse(strings.NewReader(in), Options{})
285		if err != nil {
286			return
287		}
288		// Whatever was read can be written and read back the same, except a
289		// key that Set refuses to write.
290		if slices.ContainsFunc(entries, func(e Entry) bool { return !validKey(e.Key) }) {
291			return
292		}
293		var data []byte
294		for _, e := range entries {
295			data = append(data, e.Key+" = "+Quote(e.Value)+"\n"...)
296		}
297		again, _, err := ParseBytes(data, Options{AllowDuplicates: true})
298		if err != nil {
299			t.Fatalf("re-parse: %v\n%s", err, data)
300		}
301		if !slices.EqualFunc(entries, again, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value }) {
302			t.Fatalf("round trip:\n%#v\n%#v", entries, again)
303		}
304	})
305}

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 1

Tags 3

↑↓ moveenter openesc close