package kvconf import ( "errors" "reflect" "slices" "strconv" "strings" "testing" ) func TestParse(t *testing.T) { tests := []struct { name string in string opts Options want []Entry warnings []string err string }{ {name: "empty", in: "", want: []Entry{}}, { name: "basic", in: "# comment\n\n a = 1\nb=two words \n", want: []Entry{{"a", "1", 3}, {"b", "two words", 4}}, }, { name: "CRLF and BOM", in: "\ufeffa = 1\r\nb = 2\r\n", want: []Entry{{"a", "1", 1}, {"b", "2", 2}}, }, { name: "empty value", in: "a =\n", want: []Entry{{"a", "", 1}}, }, { name: "value with equals", in: "cmd = env A=1 foo\n", want: []Entry{{"cmd", "env A=1 foo", 1}}, }, { name: "spaced comments keep colors", in: "color = #ff8800 # orange\nx = a#b\ny = a #b\n", want: []Entry{{"color", "#ff8800", 1}, {"x", "a#b", 2}, {"y", "a", 3}}, }, { name: "tab before comment", in: "a = 1\t# note\n", want: []Entry{{"a", "1", 1}}, }, { name: "line comments only", in: "a = 1 # kept\n# skipped = 1\n", opts: Options{Comments: CommentLine}, want: []Entry{{"a", "1 # kept", 1}}, }, { name: "any hash is a comment", in: "a = x#y\n", opts: Options{Comments: CommentAny}, want: []Entry{{"a", "x", 1}}, }, { name: "quotes", in: `a = "x # y" # c b = 'it''s' c = 'lit\n' d = "tab\t" e = "a" "b" f = "unterminated g = spawn "foo bar" `, want: []Entry{ {"a", "x # y", 1}, {"b", "'it''s'", 2}, {"c", `lit\n`, 3}, {"d", "tab\t", 4}, {"e", `"a" "b"`, 5}, {"f", `"unterminated`, 6}, {"g", `spawn "foo bar"`, 7}, }, }, { name: "apostrophe inside a word keeps the comment", in: "title = it's fine # a note\nb = say \"x # y\" # c\n", want: []Entry{{"title", "it's fine", 1}, {"b", `say "x # y"`, 2}}, }, { name: "escape making invalid UTF-8 is kept as written", in: `a = "\200"` + "\n", want: []Entry{{"a", `"\200"`, 1}}, }, { name: "raw values", in: `a = "x"` + "\n", opts: Options{RawValues: true}, want: []Entry{{"a", `"x"`, 1}}, }, { name: "duplicate warns, both kept", in: "a = 1\na = 2\n", want: []Entry{{"a", "1", 1}, {"a", "2", 2}}, warnings: []string{"line 2: a: overrides line 1"}, }, { name: "duplicates and bad lines in line order", in: "a = 1\nb = 1\nnope\na = 2\n= x\nb = 2\na = 3\n", want: []Entry{{"a", "1", 1}, {"b", "1", 2}, {"a", "2", 4}, {"b", "2", 6}, {"a", "3", 7}}, 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"}, }, { name: "duplicates allowed", in: "a = 1\na = 2\n", opts: Options{AllowDuplicates: true}, want: []Entry{{"a", "1", 1}, {"a", "2", 2}}, }, { name: "bad lines are skipped", in: "nope\n= 1\nok = 1\n", want: []Entry{{"ok", "1", 3}}, warnings: []string{"line 1: expected key = value", "line 2: missing key"}, }, { name: "key with spaces", in: "layout.LG Electronics 27GR95UM.overflow = scroll\n", want: []Entry{{"layout.LG Electronics 27GR95UM.overflow", "scroll", 1}}, }, { name: "strict stops at first problem", in: "ok = 1\nnope\nbad\n", opts: Options{Strict: true}, warnings: []string{"line 2: expected key = value"}, err: "line 2: expected key = value", }, { name: "strict duplicate", in: "a = 1\na = 2\n", opts: Options{Strict: true}, err: "line 2: a: overrides line 1", warnings: []string{"line 2: a: overrides line 1"}, }, { name: "strict duplicate before a bad line", in: "a = 1\na = 2\nnope\n", opts: Options{Strict: true}, err: "line 2: a: overrides line 1", warnings: []string{"line 2: a: overrides line 1"}, }, { name: "strict bad line before a duplicate", in: "a = 1\nnope\na = 2\n", opts: Options{Strict: true}, err: "line 2: expected key = value", warnings: []string{"line 2: expected key = value"}, }, { name: "long line", in: "a = " + strings.Repeat("x", 20) + "\nb = 1\n", opts: Options{MaxLine: 10}, want: []Entry{{"b", "1", 2}}, warnings: []string{"line 1: line longer than 10 bytes"}, }, { name: "too large", in: "a = 1234567890\n", opts: Options{MaxBytes: 8}, err: ErrTooLarge.Error(), }, { name: "no size limit", in: "a = 1234567890\n", opts: Options{MaxBytes: -1, MaxLine: -1}, want: []Entry{{"a", "1234567890", 1}}, }, { name: "invalid UTF-8", in: "a = \xff\n", err: ErrNotUTF8.Error(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, warnings, err := Parse(strings.NewReader(tt.in), tt.opts) if tt.err != "" { if err == nil || err.Error() != tt.err { t.Fatalf("err = %v, want %q", err, tt.err) } } else if err != nil { t.Fatalf("err = %v", err) } else if !reflect.DeepEqual(got, tt.want) { t.Errorf("entries = %#v\nwant %#v", got, tt.want) } if msgs := warningStrings(warnings); !reflect.DeepEqual(msgs, tt.warnings) { t.Errorf("warnings = %q, want %q", msgs, tt.warnings) } }) } } func warningStrings(ws []Warning) []string { var out []string for _, w := range ws { out = append(out, w.Error()) } return out } func TestStrictErrorIsWarning(t *testing.T) { _, _, err := Parse(strings.NewReader("bad\n"), Options{Strict: true}) var w Warning if !errors.As(err, &w) || w.Line != 1 { t.Fatalf("err = %#v, want a Warning on line 1", err) } } func TestChanged(t *testing.T) { tests := []struct { name string old, cur map[string]string want []string }{ {name: "same", old: map[string]string{"a": "1"}, cur: map[string]string{"a": "1"}}, {name: "added", old: map[string]string{}, cur: map[string]string{"b": "1", "a": "1"}, want: []string{"a", "b"}}, {name: "removed", old: map[string]string{"a": "1"}, cur: nil, want: []string{"a"}}, {name: "changed", old: map[string]string{"a": "1", "b": "1"}, cur: map[string]string{"a": "2", "b": "1"}, want: []string{"a"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Changed(tt.old, tt.cur); !reflect.DeepEqual(got, tt.want) { t.Errorf("Changed = %q, want %q", got, tt.want) } }) } } // FuzzDuplicates checks the duplicate warnings against a map. func FuzzDuplicates(f *testing.F) { for _, s := range []string{"a=1\na=2\nb=1\na=3\n", "x\n=1\na=1\na=1\n"} { f.Add(s) } f.Fuzz(func(t *testing.T, in string) { all, syntax, err := ParseBytes([]byte(in), Options{AllowDuplicates: true}) if err != nil { return } var want []string seen := map[string]int{} for _, e := range all { if prev, ok := seen[e.Key]; ok { want = append(want, Warning{Line: e.Line, Key: e.Key, Msg: "overrides line " + strconv.Itoa(prev)}.Error()) } seen[e.Key] = e.Line } for _, w := range syntax { want = append(want, w.Error()) } _, got, _ := ParseBytes([]byte(in), Options{}) if !slices.IsSortedFunc(got, func(a, b Warning) int { return a.Line - b.Line }) { t.Fatalf("warnings out of line order: %q", warningStrings(got)) } // Strict fails on the first problem in line order. _, _, err = ParseBytes([]byte(in), Options{Strict: true}) switch { case len(got) == 0 && err != nil: t.Fatalf("strict err = %v, lenient had no warning", err) case len(got) > 0 && (err == nil || err.Error() != got[0].Error()): t.Fatalf("strict err = %v, want %q", err, got[0].Error()) } gotS := warningStrings(got) slices.Sort(want) slices.Sort(gotS) if !slices.Equal(gotS, want) { t.Fatalf("warnings = %q, want %q", gotS, want) } }) } func FuzzParse(f *testing.F) { for _, s := range []string{"a = 1\n", "a = \"x # y\" # c\n", "\ufeffa=b\r\n", "x\n=\n'", "\ufeff\ufeff=", `0="\200"`} { f.Add(s) } f.Fuzz(func(t *testing.T, in string) { entries, _, err := Parse(strings.NewReader(in), Options{}) if err != nil { return } // Whatever was read can be written and read back the same, except a // key that Set refuses to write. if slices.ContainsFunc(entries, func(e Entry) bool { return !validKey(e.Key) }) { return } var data []byte for _, e := range entries { data = append(data, e.Key+" = "+Quote(e.Value)+"\n"...) } again, _, err := ParseBytes(data, Options{AllowDuplicates: true}) if err != nil { t.Fatalf("re-parse: %v\n%s", err, data) } if !slices.EqualFunc(entries, again, func(x, y Entry) bool { return x.Key == y.Key && x.Value == y.Value }) { t.Fatalf("round trip:\n%#v\n%#v", entries, again) } }) }