v0.1.1 ebc8127

parse_test.go RawHistoryFolder

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

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