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}