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}