From 8b5e46f988ac1834279add7eeb0934bc6603785b Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:06:03 +0000 Subject: [PATCH 01/41] v2: module gopkg.in/ini.v2, O(1) duplicate checks for sections and keys Section.NewKey and File.NewSection scanned keyList and sectionList on every insert to find duplicates, which made loading quadratic in the number of keys per section (#377). Look them up in the maps that already exist instead. Loading 10,000 keys in one section drops from about 143 ms to 9 ms. inSlice has no callers left and is removed. --- file.go | 2 +- go.mod | 10 ++++++++-- go.sum | 9 --------- helper.go | 24 ------------------------ helper_test.go | 27 --------------------------- section.go | 2 +- 6 files changed, 10 insertions(+), 64 deletions(-) delete mode 100644 helper.go delete mode 100644 helper_test.go diff --git a/file.go b/file.go index 58beabc..317dcec 100644 --- a/file.go +++ b/file.go @@ -93,7 +93,7 @@ func (f *File) NewSection(name string) (*Section, error) { defer f.lock.Unlock() } - if !f.options.AllowNonUniqueSections && inSlice(name, f.sectionList) { + if _, ok := f.sections[name]; ok && !f.options.AllowNonUniqueSections { return f.sections[name][0], nil } diff --git a/go.mod b/go.mod index e3020b0..2871197 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,11 @@ -module gopkg.in/ini.v1 +module gopkg.in/ini.v2 -go 1.13 +go 1.22 require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 1cd876d..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,10 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helper.go b/helper.go deleted file mode 100644 index f9d80a6..0000000 --- a/helper.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2019 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package ini - -func inSlice(str string, s []string) bool { - for _, v := range s { - if str == v { - return true - } - } - return false -} diff --git a/helper_test.go b/helper_test.go deleted file mode 100644 index 439ad3b..0000000 --- a/helper_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2019 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package ini - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsInSlice(t *testing.T) { - ss := []string{"a", "b", "c"} - assert.True(t, inSlice("a", ss)) - assert.False(t, inSlice("d", ss)) -} diff --git a/section.go b/section.go index a3615d8..f885e57 100644 --- a/section.go +++ b/section.go @@ -75,7 +75,7 @@ func (s *Section) NewKey(name, val string) (*Key, error) { defer s.f.lock.Unlock() } - if inSlice(name, s.keyList) { + if _, ok := s.keys[name]; ok { if s.f.options.AllowShadows { if err := s.keys[name].addShadow(val); err != nil { return nil, err From c4b6828e82469ccbe3191c47a78f553b6cbfd09a Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:09:16 +0000 Subject: [PATCH 02/41] Replace the line parser with a single-pass lexer The lexer reads the whole input once and slices names, values and comments out of it, so a typical key line allocates nothing. One switch in lexer.next routes each line to a section, key, comment, raw body or nested value; readValue applies v1's value rules in v1's order, which its doc comment states as the contract. Behavior is unchanged with one exception: a Python-style multiline value is no longer cut at the reader buffer (4 KiB by default) and no longer fails with "key-value delimiter not found" (#210). Multi-line values are built in a strings.Builder instead of with += per line. parity_test.go loads every input with the released gopkg.in/ini.v1 and with this package and compares everything both stored, by reflection over the fields the two versions share. Its seeds cover testdata, the documented examples and the inputs from GitHub issues and pull requests; FuzzParseParity fuzzes inputs and options together. At 10,000 keys, Load allocates 4-5 times less than with the v1 parser. --- bench_test.go | 67 +++++++ go.mod | 5 +- go.sum | 11 ++ lexer.go | 451 ++++++++++++++++++++++++++++++++++++++++++ parity_test.go | 325 ++++++++++++++++++++++++++++++ parser.go | 528 ------------------------------------------------- 6 files changed, 858 insertions(+), 529 deletions(-) create mode 100644 lexer.go create mode 100644 parity_test.go delete mode 100644 parser.go diff --git a/bench_test.go b/bench_test.go index ad96633..ff2d2f6 100644 --- a/bench_test.go +++ b/bench_test.go @@ -15,7 +15,11 @@ package ini import ( + "bytes" + "fmt" "testing" + + v1 "gopkg.in/ini.v1" ) func newTestFile(block bool) *File { @@ -127,3 +131,66 @@ func Benchmark_Key_Strings_LargeArray(b *testing.B) { _ = key.Strings(",") } } + +// genKeys10k returns INI input with 10,000 keys: in one section, in 1,000 +// sections of 10 keys, and in one section using every value syntax. +func genKeys10k(layout string) []byte { + var b bytes.Buffer + b.WriteString("; generated\n[s]\n") + for i := 0; i < 10000; i++ { + switch layout { + case "flat": + fmt.Fprintf(&b, "key_%d = some value number %d ; trailing comment\n", i, i) + case "sections": + if i%10 == 0 { + fmt.Fprintf(&b, "\n# section %d\n[section_%d]\n", i/10, i/10) + } + fmt.Fprintf(&b, "key_%d = value %d\n", i%10, i) + case "mixed": + switch i % 8 { + case 0: + fmt.Fprintf(&b, "# comment for key %d\nkey_%d = plain value %d\n", i, i, i) + case 1: + fmt.Fprintf(&b, "key_%d = \"double quoted %d\"\n", i, i) + case 2: + fmt.Fprintf(&b, "key_%d = 'single quoted %d' # comment\n", i, i) + case 3: + fmt.Fprintf(&b, "key_%d = `raw #%d; value`\n", i, i) + case 4: + fmt.Fprintf(&b, "key_%d = \"\"\"multi\nline %d\nvalue\"\"\"\n", i, i) + case 5: + fmt.Fprintf(&b, "key_%d = first part \\\n\tsecond part %d\n", i, i) + case 6: + fmt.Fprintf(&b, "- = auto increment %d\n", i) + case 7: + fmt.Fprintf(&b, "\"quoted key %d\" : value\r\n", i) + } + } + } + return b.Bytes() +} + +// BenchmarkLoad10kKeys compares loading with v2 and with the released v1. +func BenchmarkLoad10kKeys(b *testing.B) { + loaders := []struct { + name string + load func([]byte) error + }{ + {"v2", func(data []byte) error { _, err := Load(data); return err }}, + {"v1", func(data []byte) error { _, err := v1.Load(data); return err }}, + } + for _, layout := range []string{"flat", "sections", "mixed"} { + data := genKeys10k(layout) + for _, l := range loaders { + b.Run(layout+"/"+l.name, func(b *testing.B) { + b.SetBytes(int64(len(data))) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if err := l.load(data); err != nil { + b.Fatal(err) + } + } + }) + } + } +} diff --git a/go.mod b/go.mod index 2871197..c9852c7 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module gopkg.in/ini.v2 go 1.22 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/stretchr/testify v1.11.1 + gopkg.in/ini.v1 v1.67.3 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index c4c1710..eeeaea6 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,21 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lexer.go b/lexer.go new file mode 100644 index 0000000..66c23b1 --- /dev/null +++ b/lexer.go @@ -0,0 +1,451 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "fmt" + "io" + "strconv" + "strings" + "unicode" +) + +type tokenKind uint8 + +const ( + tokEOF tokenKind = iota + tokSection // [name] ; comment + tokKey // name = value ; comment + tokBooleanKey // name + tokNestedValue // indented line under a key with an empty value + tokRawBody // body of an unparseable section +) + +type token struct { + kind tokenKind + name string + value string + comment string +} + +// lexer turns INI input into tokens. It follows the v1 parser rule for rule, +// including the order in which value rules apply; FuzzParseParity checks that +// v1 (gopkg.in/ini.v1) and v2 produce the same File. +type lexer struct { + src string + pos int // offset of the next unread byte + eof bool // the last line read had no '\n' + opts *LoadOptions + + comment []string // comment pieces for the next section or key + lastValueEmpty bool // the last key had an empty value, so indented lines are nested values + inRaw bool // inside an unparseable section + raw strings.Builder +} + +// next returns the next token, or tokEOF at the end of input. +func (l *lexer) next() (token, error) { + for !l.eof { + raw := l.readLine() + if l.opts.AllowNestedValues && l.lastValueEmpty && raw != "" && (raw[0] == ' ' || raw[0] == '\t') { + return token{kind: tokNestedValue, value: strings.TrimSpace(raw)}, nil + } + + line := strings.TrimLeftFunc(raw, unicode.IsSpace) + switch { + case line == "": + case line[0] == '#' || line[0] == ';': + // Keep the line break so comment lines stay on separate lines. + l.comment = append(l.comment, line) + case line[0] == '[': + if l.raw.Len() > 0 { + l.unread(raw) + return l.rawBody(), nil + } + return l.section(line) + case l.inRaw: + l.raw.WriteString(line) + default: + if tok, ok, err := l.key(line); ok || err != nil { + return tok, err + } + } + } + if l.raw.Len() > 0 { + return l.rawBody(), nil + } + return token{kind: tokEOF}, nil +} + +// readLine returns the next line including its '\n', if any. +func (l *lexer) readLine() string { + rest := l.src[l.pos:] + i := strings.IndexByte(rest, '\n') + if i < 0 { + l.pos, l.eof = len(l.src), true + return rest + } + l.pos += i + 1 + return rest[:i+1] +} + +// unread puts back the line just read. +func (l *lexer) unread(line string) { + l.pos -= len(line) + l.eof = false +} + +func (l *lexer) takeComment() string { + c := strings.TrimSpace(strings.Join(l.comment, "")) + l.comment = l.comment[:0] + return c +} + +func (l *lexer) rawBody() token { + t := token{kind: tokRawBody, value: l.raw.String()} + l.raw.Reset() + return t +} + +func (l *lexer) debug(format string, args ...interface{}) { + if l.opts.DebugFunc != nil { + l.opts.DebugFunc(fmt.Sprintf(format, args...)) + } +} + +// section reads a "[name] ; comment" line. The name ends at the last ']'. +func (l *lexer) section(line string) (token, error) { + closeIdx := strings.LastIndexByte(line, ']') + if closeIdx == -1 { + return token{}, fmt.Errorf("unclosed section: %s", line) + } + name := line[1:closeIdx] + if i := strings.IndexAny(line[closeIdx+1:], "#;"); i > -1 { + l.comment = append(l.comment, line[closeIdx+1+i:]) + } + + l.lastValueEmpty = false + l.inRaw = false + for _, s := range l.opts.UnparseableSections { + if s == name || ((l.opts.Insensitive || l.opts.InsensitiveSections) && strings.EqualFold(s, name)) { + l.inRaw = true + break + } + } + return token{kind: tokSection, name: name, comment: l.takeComment()}, nil +} + +// key reads a "name = value" line, or a boolean key when the line has no +// delimiter. ok is false for a line that SkipUnrecognizableLines skips. +func (l *lexer) key(line string) (_ token, ok bool, _ error) { + name, offset, err := readKeyName(l.opts.KeyValueDelimiters, line) + switch { + case err == nil: + value, err := l.readValue(line[offset:]) + if err != nil { + return token{}, false, err + } + l.lastValueEmpty = value == "" + return token{kind: tokKey, name: name, value: value, comment: l.takeComment()}, true, nil + + case IsErrDelimiterNotFound(err) && l.opts.AllowBooleanKeys: + // The whole line is the key name, read like a value. + name, err := l.readValue(line) + if err != nil { + return token{}, false, err + } + return token{kind: tokBooleanKey, name: name, comment: l.takeComment()}, true, nil + + case (IsErrDelimiterNotFound(err) || IsErrEmptyKeyName(err)) && l.opts.SkipUnrecognizableLines: + return token{}, false, nil + } + return token{}, false, err +} + +// readValue reads a value that starts in "in", the rest of the current line, +// and any following lines it spans. The rules apply in v1's order, which +// decides the result when they overlap: a value is either quoted, continued +// with a backslash, or plain; for a plain value the inline comment is cut +// before surrounding quotes are stripped, so `"a;b"` is `"a` with comment +// `;b"`. +func (l *lexer) readValue(in string) (string, error) { + line := strings.TrimLeftFunc(in, unicode.IsSpace) + if line == "" { + if l.opts.AllowPythonMultilineValues && strings.HasSuffix(in, "\n") { + return l.readPythonMultilines(""), nil + } + return "", nil + } + + if q := l.valueQuote(line); q != "" { + return l.readQuoted(line, q) + } + + endsLine := line[len(line)-1] == '\n' + line = strings.TrimSpace(line) + if !l.opts.IgnoreContinuation && strings.HasSuffix(line, `\`) { + return l.readContinuationLines(line[:len(line)-1]), nil + } + if !l.opts.IgnoreInlineComment { + line = l.cutInlineComment(line) + } + + switch { + case !l.opts.PreserveSurroundedQuote && (hasSurroundedQuote(line, '\'') || hasSurroundedQuote(line, '"')): + return line[1 : len(line)-1], nil + case l.opts.UnescapeValueCommentSymbols: + line = strings.ReplaceAll(line, `\;`, ";") + return strings.ReplaceAll(line, `\#`, "#"), nil + case l.opts.AllowPythonMultilineValues && endsLine: + return l.readPythonMultilines(line), nil + } + return line, nil +} + +// valueQuote returns the quote that opens line as a quoted value: `"""`, "`", +// or `"` with UnescapeValueDoubleQuotes. Single quotes and plain double +// quotes are handled later, as surrounding quotes. +func (l *lexer) valueQuote(line string) string { + switch { + case len(line) > 3 && line[:3] == `"""`: + return `"""` + case line[0] == '`': + return "`" + case l.opts.UnescapeValueDoubleQuotes && line[0] == '"': + return `"` + } + return "" +} + +// readQuoted reads a value that starts with quote q and ends at the last q on +// the same line or, failing that, the first later line containing q. +func (l *lexer) readQuoted(line, q string) (string, error) { + rest := line[len(q):] + if i := strings.LastIndex(rest, q); i > -1 { + if q == `"` { + return strings.ReplaceAll(rest[:i], `\"`, `"`), nil + } + return rest[:i], nil + } + + var b strings.Builder + b.WriteString(rest) + for { + next := l.readLine() + i := strings.LastIndex(next, q) + switch { + case i == -1: + b.WriteString(next) + if l.eof { + return "", fmt.Errorf("missing closing key quote from %q to %q", line, next) + } + case !l.opts.IgnoreContinuation && strings.HasSuffix(strings.TrimSpace(next[i+len(q):]), `\`): + // A backslash after the closing quote continues the value. + b.WriteString(next) + default: + b.WriteString(next[:i]) + if j := strings.IndexAny(next[i:], "#;"); j > -1 { + l.comment = append(l.comment, strings.TrimSpace(next[i+j:])) + } + return b.String(), nil + } + } +} + +// cutInlineComment moves a trailing "# ..." or "; ..." of a plain value to +// the comment. With SpaceBeforeInlineComment the symbol must follow a space, +// and " #" wins over an earlier " ;". +func (l *lexer) cutInlineComment(line string) string { + var i int + if l.opts.SpaceBeforeInlineComment { + if i = strings.Index(line, " #"); i == -1 { + i = strings.Index(line, " ;") + } + } else { + i = strings.IndexAny(line, "#;") + } + if i == -1 { + return line + } + l.comment = append(l.comment, line[i:]) + return strings.TrimSpace(line[:i]) +} + +// readContinuationLines appends trimmed following lines while they end with +// a backslash, up to the first line that doesn't or a blank line. val is the +// first line without its backslash. +func (l *lexer) readContinuationLines(val string) string { + var b strings.Builder + b.WriteString(val) + for { + next := strings.TrimSpace(l.readLine()) + if next == "" { + return b.String() + } + if !strings.HasSuffix(next, `\`) { + b.WriteString(next) + return b.String() + } + b.WriteString(next[:len(next)-1]) + } +} + +// readPythonMultilines appends the following lines that start with a space, +// tab or form feed, each on a new line with its indentation kept. +func (l *lexer) readPythonMultilines(line string) string { + var b strings.Builder + b.WriteString(line) + for l.pos < len(l.src) && strings.IndexByte(" \t\f", l.src[l.pos]) > -1 { + next := strings.TrimSuffix(l.readLine(), "\n") + l.debug("readPythonMultilines: parsing %q", next) + b.WriteByte('\n') + b.WriteString(next) + } + l.debug("readPythonMultilines: end of value, got: %q", b.String()) + return b.String() +} + +// skipBOM strips a UTF-8, UTF-16 LE or UTF-16 BE byte order mark. +// http://en.wikipedia.org/wiki/Byte_order_mark#Representations_of_byte_order_marks_by_encoding +func skipBOM(src string) string { + switch { + case len(src) >= 2 && (src[0] == 0xFE && src[1] == 0xFF || src[0] == 0xFF && src[1] == 0xFE): + return src[2:] + case len(src) >= 3 && src[0] == 0xEF && src[1] == 0xBB && src[2] == 0xBF: + return src[3:] + } + return src +} + +// parse reads all of reader and adds its sections and keys to f. +func (f *File) parse(reader io.Reader) error { + var b strings.Builder + if _, err := io.Copy(&b, reader); err != nil { + return err + } + l := &lexer{src: skipBOM(b.String()), opts: &f.options} + + // Ignore error because default section name is never empty string. + name := DefaultSection + if f.options.Insensitive || f.options.InsensitiveSections { + name = strings.ToLower(DefaultSection) + } + section, _ := f.NewSection(name) + + var lastRegularKey *Key + count := 1 // Counter for auto-increment key names ("-"), per section. + for { + tok, err := l.next() + if err != nil { + return err + } + + switch tok.kind { + case tokEOF: + return nil + + case tokSection: + if section, err = f.NewSection(tok.name); err != nil { + return err + } + section.Comment = tok.comment + count = 1 + + case tokRawBody: + section.isRawSection = true + section.rawBody += tok.value + + case tokNestedValue: + if err = lastRegularKey.addNestedValue(tok.value); err != nil { + return err + } + + case tokBooleanKey: + key, err := section.NewBooleanKey(tok.name) + if err != nil { + return err + } + key.Comment = tok.comment + + case tokKey: + name, isAutoIncr := tok.name, tok.name == "-" + if isAutoIncr { + name = "#" + strconv.Itoa(count) + count++ + } + key, err := section.NewKey(name, tok.value) + if err != nil { + return err + } + key.isAutoIncrement = isAutoIncr + key.Comment = tok.comment + lastRegularKey = key + } + } +} + +// readKeyName returns the key name at the start of line and the offset of +// the value after its delimiter. +func readKeyName(delimiters string, line string) (string, int, error) { + // Check if key name surrounded by quotes. + var keyQuote string + switch line[0] { + case '"': + if len(line) > 6 && line[0:3] == `"""` { + keyQuote = `"""` + } else { + keyQuote = `"` + } + case '`': + keyQuote = "`" + } + + // Get out key name + var endIdx int + if len(keyQuote) > 0 { + startIdx := len(keyQuote) + // FIXME: fail case -> """"""name"""=value + pos := strings.Index(line[startIdx:], keyQuote) + if pos == -1 { + return "", -1, fmt.Errorf("missing closing key quote: %s", line) + } + pos += startIdx + + // Find key-value delimiter + i := strings.IndexAny(line[pos+startIdx:], delimiters) + if i < 0 { + return "", -1, ErrDelimiterNotFound{line} + } + endIdx = pos + i + return strings.TrimSpace(line[startIdx:pos]), endIdx + startIdx + 1, nil + } + + endIdx = strings.IndexAny(line, delimiters) + if endIdx < 0 { + return "", -1, ErrDelimiterNotFound{line} + } + if endIdx == 0 { + return "", -1, ErrEmptyKeyName{line} + } + + return strings.TrimSpace(line[0:endIdx]), endIdx + 1, nil +} + +// hasSurroundedQuote reports whether in starts and ends with quote and has no +// other quote of the same kind. +func hasSurroundedQuote(in string, quote byte) bool { + return len(in) >= 2 && in[0] == quote && in[len(in)-1] == quote && + strings.IndexByte(in[1:], quote) == len(in)-2 +} diff --git a/parity_test.go b/parity_test.go new file mode 100644 index 0000000..327af3f --- /dev/null +++ b/parity_test.go @@ -0,0 +1,325 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + v1 "gopkg.in/ini.v1" +) + +// The parity tests load the same input with the released v1 module and with +// this package and compare everything both stored. v2 changes no parsing +// result unless an option that v1 doesn't have asks for it. + +// parityOptions maps bits of flags to v1 load options, so the fuzzer explores +// option combinations along with inputs. +func parityOptions(flags uint32) v1.LoadOptions { + bit := func(i uint) bool { return flags&(1<>17)&7] + return opts +} + +// v2Options returns the v2 equivalent of v1 load options. +func v2Options(o v1.LoadOptions) LoadOptions { + return LoadOptions{ + Insensitive: o.Insensitive, + InsensitiveSections: o.InsensitiveSections, + InsensitiveKeys: o.InsensitiveKeys, + IgnoreContinuation: o.IgnoreContinuation, + IgnoreInlineComment: o.IgnoreInlineComment, + SkipUnrecognizableLines: o.SkipUnrecognizableLines, + AllowBooleanKeys: o.AllowBooleanKeys, + AllowShadows: o.AllowShadows, + AllowNestedValues: o.AllowNestedValues, + AllowPythonMultilineValues: o.AllowPythonMultilineValues, + SpaceBeforeInlineComment: o.SpaceBeforeInlineComment, + UnescapeValueDoubleQuotes: o.UnescapeValueDoubleQuotes, + UnescapeValueCommentSymbols: o.UnescapeValueCommentSymbols, + PreserveSurroundedQuote: o.PreserveSurroundedQuote, + AllowNonUniqueSections: o.AllowNonUniqueSections, + AllowDuplicateShadowValues: o.AllowDuplicateShadowValues, + UnparseableSections: o.UnparseableSections, + KeyValueDelimiters: o.KeyValueDelimiters, + } +} + +// dumpFile describes everything stored in a v1 or v2 *File. It reads the +// unexported fields both versions share by reflection, so the comparison +// covers state the public API doesn't expose, such as empty shadow values. +func dumpFile(f interface{}) string { + v := reflect.ValueOf(f).Elem() + names, indexes, sections := v.FieldByName("sectionList"), v.FieldByName("sectionIndexes"), v.FieldByName("sections") + + var b strings.Builder + for i := 0; i < names.Len(); i++ { + name := names.Index(i) + s := sections.MapIndex(name).Index(int(indexes.Index(i).Int())).Elem() + fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.FieldByName("name").String(), + s.FieldByName("Comment").String(), s.FieldByName("isRawSection").Bool(), s.FieldByName("rawBody").String()) + keyList, keys := s.FieldByName("keyList"), s.FieldByName("keys") + for j := 0; j < keyList.Len(); j++ { + dumpKey(&b, " ", keys.MapIndex(keyList.Index(j)).Elem()) + } + } + return b.String() +} + +func dumpKey(b *strings.Builder, indent string, k reflect.Value) { + nested := k.FieldByName("nestedValues") + fmt.Fprintf(b, "%s%q=%q comment=%q auto=%v bool=%v shadow=%v nested=%d", + indent, k.FieldByName("name").String(), k.FieldByName("value").String(), k.FieldByName("Comment").String(), + k.FieldByName("isAutoIncrement").Bool(), k.FieldByName("isBooleanType").Bool(), k.FieldByName("isShadow").Bool(), nested.Len()) + for i := 0; i < nested.Len(); i++ { + fmt.Fprintf(b, " %q", nested.Index(i).String()) + } + b.WriteString("\n") + shadows := k.FieldByName("shadows") + for i := 0; i < shadows.Len(); i++ { + dumpKey(b, indent+" ", shadows.Index(i).Elem()) + } +} + +// loadResult describes the outcome of loading: the stored File or the error. +func loadResult(load func() (interface{}, error)) (out string) { + defer func() { + if r := recover(); r != nil { + out = fmt.Sprintf("panic: %v", r) + } + }() + f, err := load() + if err != nil { + return "error: " + err.Error() + } + return dumpFile(f) +} + +// checkParity loads data twice, as two sources of one File, with v1 and v2. +func checkParity(t *testing.T, data []byte, flags uint32) { + t.Helper() + opts := parityOptions(flags) + // Known difference: v1 looks ahead at most one reader buffer (>= 4 KiB) + // for Python-style multiline values; v2 has no such limit. + if opts.AllowPythonMultilineValues && len(data) > 4096 { + return + } + + want := loadResult(func() (interface{}, error) { return v1.LoadSources(opts, data, data) }) + got := loadResult(func() (interface{}, error) { return LoadSources(v2Options(opts), data, data) }) + if want != got { + t.Fatalf("v1 and v2 differ\ninput: %q\noptions: %+v\nv1:\n%s\nv2:\n%s", data, opts, want, got) + } +} + +// paritySeeds are inputs that exercise every parser feature, including the +// cases from the documentation and from GitHub issues and pull requests. +var paritySeeds = []string{ + "", + "\xEF\xBB\xBFk = v\n", + "\xFF\xFEk = v\n", + "\xFE\xFFk = v\n", + "\xEF\xBB", + "k = v", + "k = v\r\n[s]\r\nx = y\r\n", + "# c1\n; c2\nkey = v\n# sec comment\n[sec]\n", + "a = value # c\nb = value;c\nc = x#y\n", + "[sec1] some words\nk = v\n[sec2] # hash words\n[sec3] ; semi\n[sec] words # c\n", + "a = `v#1;2`\nb = \"\"\"v#1;2\"\"\"\nc = \"v#1\"\n", + "a = a#b ;c\nb = x;y # z\nc = p#q\n", + "a = x\\#y\\;z\n", + "foo = \"some value\"\nbar = 'some value'\nbaz = \"a\" \"b\"\nq = 'it''s'\n", + "create_repo=\"created repository %s\"\n", + "[advance]\nADDRESS = \"\"\"404 road,\nNotFound, State, 5000\nEarth\"\"\"\n", + "[advance]\ntwo_lines = how about \\\n\tcontinuation lines?\nlots_of_lines = 1 \\\n\t2 \\\n\t3 \\\n\t4\n", + "key1=a\\b\\\nkey2=c\\d\\\nkey3=value", + "k = `line1\nline2` \\\nmore` ; c\n", + "k = \"\"\"line1\nline2\"\"\" ; c\n", + "k = `unterminated\n", + "k = `unterminated", + "name=\"\"\"Unknwon", + "[long]\nlong_rsa_private_key = -----BEGIN RSA PRIVATE KEY-----\n foo\n bar\n\tbaz\r\n\f-----END RSA PRIVATE KEY-----\nnext = v\n", + "[s]\nk =\n line2\n line3\n\nnext = v\n", + "[profile development]\ns3 =\n max_concurrent_requests = 20\n \n\tmax_queue_size = 10000\nregion = us-west-2\n", + "- = a\n-: b\n[other]\n- = x\n-\n nested\n", + "[mysqld]\n...\nskip-host-cache\nskip-name-resolve # c\n\"quoted bool\"\n", + "[remote \"origin\"]\nurl = a\nurl = b\nurl = a\nurl =\nfetch = +refs/heads/*:refs/remotes/origin/*\n", + "[system]\nfoo = bar\n\n[system]\nbaz = gazonk\n[SYSTEM]\nfoo = 2\n", + "[COMMENTS]\n<1> fuel ; units\n indented\n# not body\n\n[raw]\nx\n[next]\nk = v\n", + "[raw]\nbody", + "GenerationDepth: 13\n\n################\nBiomeGroup(NormalBiomes, 3, 99\n= RainForest\n", + "\"\"\"\"\"\"name\"\"\"=value\n\"\"\"key\"\"\" = v\n`bk` = v\n\"dq k\" junk = v\n\"\"=1\n=\nname\n\"\"\"name\n\"\"\"name\"\"\"\n", + "a: 1\nb = 2\nc:d = 3\na ? 1\nk→v\n", + "[a] ; note ]\nk=v\n[\n", + "[]\n", + "\u00a0k\u2003=\u00a0v\u00a0\n\v[s]\v\n", + "k = %(x)s\nx = ${HOME}\n", + "k = v\\\n\n", + "k = \"\"\"\n\"\"\"", + "k = \"\"\"", + "k = \"x\"\"\"\n", + "k = `x` y `z`\n", + "k = \"quoted ; not comment\"\n", + "k=\xff\xfe = \xc3\n\t\xff\n", + + // From GitHub issues and pull requests. + "[background]\nprimary-color = '#e18477'\nsecondary-color = \"#000000\"\nhash-in-backticks = `#e18477`\nsemi-single = 'foo;bar'\nquoted-then-comment = '#e18477' ; trailing\n", // #393, #394 + "[background]\nurl = 'https://example.test/#fragment' ; trailing comment\nplain = value # actual comment\nk = don't # comment\n", // #411 + "#===== Start of the file =======\ntest_value=\"qwe#123\" \n#===== End of the file ======== \n", // #71 + "HOST = network=tcp,addr=:6379,password=`redis#$%12322`,db=15\nH2 = a=\"\"\"redis#1\"\"\",b\n", // #141 + "\n[author]\nNAME = U#n#k#n#w#o#n\nGITHUB = U;n;k;n;w;o;n\nkey=value_with_#_in_it\nkey2=value with # in it\nk3 = a\t#c\nk4 = #abc\nj = ;\n", // #146, #277 + "\nkey = test value more text\nk = \"a\\;b\"\n", // #129 + "; Comment before the section\n[comments] ; This is a comment for the section too\n; Comment before key\nkey = \"value\"\nkey2 = \"value2\" ; c\n", // #24 + "key1 = \"value\" ; comment\nkey2 = \"one\", \"two\", \"three\"\n", // #25 + "# BEGIN\n[profile test]\nsso_region = us-east-1\noutput = json\n# END\n", // #362 + "Name=\"bruce\"\nAddress=\"\"\nSingle=''\na=\"b=c\"\ntime=2025-01-04T16:25:00+08:00\"\nc = _\"x\"_\nd = \"a\"b\"\ne = '\"x\"'\n", // #56, #112, #363, #389 + "hello = \"!f() { \\\n echo \"hello world\"; \\\n};f\"", // #373 + "key = \"a \\\nb\"\n[access_log]\nreg_gsid = ` (\\d{39,41}) `\n", // #172, #229 + "KEY = \"\"\"VALUE\"\"\"\n[ANOTHER SECTION]\nKEYS=VALUES\n; \"\"\"\"\"\"\n`A pathological `c\"as\"`e` = v\n`KEY`=FAKE_VALUE ;\"`` = ACTUAL_VALUE\n", // #187 + "[Directories]\nSourceDir=C:\\App\\Source\\\nTargetDir=C:\\App\\Target\\\n[dirs]\ncert = Y:\\\\_1\\\\\nserial = 12345678\n", // #44, #347 + "[control]\naudio_path = \\\nvideo_path = \\\n\n\t\tA = C:\\\n\t\tB = D:\\\n\t\t", // #243 + "[git]\nsubmodules_disabled = \n .*secret.*\n fix.*\n", // #270 + "value1 = some text here\n\tsome more text here\n\t\n\tthere is an empty line above and below\n\t\n\nvalue2 = there is an empty line above\n that is not indented\n", // #210, #299 + "[long]\nnotes = first\n second\n\n third\nnext = x\n", // #205 + "value1 = some text here\n\tsome more text here 2", // #309 + "k = a\r\n b\r\nn = 1\r\nk = \"a\"\n b\n", // Python CRLF + "[profile testing]\nregion = us-west-2\ns3 =\n max_concurrent_requests=10\n max_queue_size=1000\n[section2]\nkey2 =\n[section3]\n key3 = value3\n", // #131, #306 + "[FILTER]\n name record_modifier\n whitelist_key _CMDLINE\n whitelist_key _EXE\n record filename systemd\n", // #367 + "keee:y=value\n\"keee:y\"=value\n\n[section]\nkey1?value1\nkey2!value2\n", // #166 + "[global]\nvfs objects = catia fruit streams_xattr\nfruit:metadata = stream\n`fruit:metadata` = stream\n", // #355 + "-key=a\n_key=b\n.key=c\n:key=a\n", // #183 + "[third_section]\nphpversion[] = \"5.0\"\nphpversion[] = \"5.3\"\n", // #315 + "[我]\n姓名 = 张三\n[1234]\n我=abc\n有支付宝语句 = a\\n支付宝:{支付宝}\\n[红包]\n", // #165, #244 + "[server]\ntimeout = {\n \"timeout\":1000\n}\n", // #344 + "key1=hello\nkey2\n#key3\nkey4\nkey5", // #53, #78 + "localhost ansible_connection=local\n\n[webservers]\nother1\nother2\nflag\nflag\n", // #159 + "[test]\n Key1=Key1Value1\n Key1=Key1Value2\n Key1=Key1Value1\n\n[Network]\nDNS=\nDNS=\"1.2.3.4\"\n", // #283, #284, #294 + "[features]\n\t-: Support read/write comments\n\t-: Support auto-increment of key names\n[Agents]\n- = a\n- = b", // #329, #240 + "NAME = Unknwon\n[DEFAULT]\nk1 = v1\n[default]\nk2 = v2\n", // #8, #37, #125, #198 + "[profile foo]\nMfa_Serial=xxxxxxx\n\n[profile FOO]\nmfa_serial=yyyyyyy\n", // #238 + "[foo]bar]\nk = v\n[sec] ; see [1]\n[my section]\n[ padded ]\n", // #46, #212 + "[Interface]\nPrivateKey = a\n# Peer 1\n[Peer]\nPublicKey = b\nAllowedIPs = c\n# Peer 2\n[Peer]\nPublicKey = d\n", // #190, #289 + "[Core]\n Lesson_Location = 87\n\n[CORE_LESSON]\nmy lesson state data – 1111\n[COMMENTS]\n<1> units \n", // #72 + "[raw]", // #291 + "[RAW]\n ; not a comment\n\n indented line\n[next]\n", + "[Version]\nSignature=\"$Windows NT$\"\n[Capture.2860.AddReg]\nHKR,,CLSID,,%ProxyVCap.CLSID%\nHKLM,\"%UNINSTKEY%\\%DRVNAME%\",\"caption\",0x0\n", // #173, #237 + "\xef\xbb\xbf\xef\xbb\xbf[author]\nE-MAIL = example@email.com", // #390 + "\xff\xfe[\x00c\x00o\x00n\x00f\x00]\x00\n\x00k\x00=\x00v\x00\n\x00", // #66, #212 + "; c1\r\n; c2\r\n[sec] ; sc\r\nkey = value\r\nq = \"quoted\"\r\nbt = `a\r\nb`\r\ncont = a \\\r\n b\r\nflag\r\na = 1\rb = 2\r", // #216 + "k =\u00a0v\u00a0\n\u00a0[s]\n", +} + +func FuzzParseParity(f *testing.F) { + inputs := append([]string{confData}, paritySeeds...) + files, _ := filepath.Glob("testdata/*.ini") + for _, name := range files { + data, err := os.ReadFile(name) + if err != nil { + f.Fatal(err) + } + inputs = append(inputs, string(data)) + } + for _, in := range inputs { + for _, flags := range []uint32{0, 1 << 9, 1<<8 | 1<<6, 1<<11 | 1<<10 | 1<<12, 1<<16 | 1<<7 | 1<<5, 1<<20 - 1} { + f.Add([]byte(in), flags) + } + } + f.Fuzz(func(t *testing.T, data []byte, flags uint32) { + checkParity(t, data, flags) + }) +} + +// TestParseParityRandom compares the parsers on documents assembled from +// fragments that sit on the boundaries between parser features. +func TestParseParityRandom(t *testing.T) { + indents := []string{"", "", "", " ", "\t", "\f", "\v", "\u00a0"} + fragments := []string{ + "[s]", "[raw]", "[COMMENTS]", "[S]", "[a] ; c ]", "[a] x", "[", "[]", + "k = v", "K = v2", "k=", "k :", "-", "- = x", "k : v", "\"q k\" = v", "`b k` = v", "\"\"\"tk\"\"\" = v", + "k = \"\"\"multi", "end\"\"\" ; c", "end\"\"\" \\", "k = `multi", "end` # c", "end`", + "k = a \\", "k = \\", "cont \\", "a long continued line that is well over fifty bytes in total \\", "k = 'x'", "k = \"x\"", "k = \"a\\\"b\"", "k = \"a\" ; c", + "k = x\\;y\\#z", "k = a #c", "k = a ;c", "k = a#b ;c", "k = a ;b #c", + "# comment", "; comment", "#", "bool", "\"bool q\"", "bool ; c", "nested = 1", + "k = `a` b `c`", "\"\"\"\"\"\"name\"\"\"=v", "k→v", "k = %(x)s", "k ? v", "= v", "k = '", + } + endings := []string{"\n", "\n", "\n", "\r\n", ""} + + rng := rand.New(rand.NewSource(1)) + for i := 0; i < 50000; i++ { + var b strings.Builder + for n := rng.Intn(10) + 1; n > 0; n-- { + b.WriteString(indents[rng.Intn(len(indents))]) + b.WriteString(fragments[rng.Intn(len(fragments))]) + if rng.Intn(4) == 0 { + b.WriteString(fragments[rng.Intn(len(fragments))]) + } + b.WriteString(endings[rng.Intn(len(endings))]) + } + checkParity(t, []byte(b.String()), rng.Uint32()) + } + for _, in := range paritySeeds { + for i := 0; i < 500; i++ { + checkParity(t, []byte(in), rng.Uint32()) + } + } +} + +// v1 reads a Python-style multiline value only as far as one reader buffer +// looks ahead (#210); the lexer reads it to the end however long it is. +func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { + var b strings.Builder + b.WriteString("[s]\nkey = first\n") + for i := 0; i < 200; i++ { + fmt.Fprintf(&b, " line %03d xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n", i) + } + b.WriteString("next = ok\n") + + f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(b.String())) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(f.Section("s").Key("key").Value(), "\n"); got != 200 { + t.Fatalf("value has %d continuation lines, want 200", got) + } + if got := f.Section("s").Key("next").Value(); got != "ok" { + t.Fatalf("next = %q, want ok", got) + } +} diff --git a/parser.go b/parser.go deleted file mode 100644 index 513b19e..0000000 --- a/parser.go +++ /dev/null @@ -1,528 +0,0 @@ -// Copyright 2015 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package ini - -import ( - "bufio" - "bytes" - "fmt" - "io" - "regexp" - "strconv" - "strings" - "unicode" -) - -const minReaderBufferSize = 4096 - -var pythonMultiline = regexp.MustCompile(`^([\t\f ]+)(.*)`) - -type parserOptions struct { - IgnoreContinuation bool - IgnoreInlineComment bool - AllowPythonMultilineValues bool - SpaceBeforeInlineComment bool - UnescapeValueDoubleQuotes bool - UnescapeValueCommentSymbols bool - PreserveSurroundedQuote bool - DebugFunc DebugFunc - ReaderBufferSize int -} - -type parser struct { - buf *bufio.Reader - options parserOptions - - isEOF bool - count int - comment *bytes.Buffer -} - -func (p *parser) debug(format string, args ...interface{}) { - if p.options.DebugFunc != nil { - p.options.DebugFunc(fmt.Sprintf(format, args...)) - } -} - -func newParser(r io.Reader, opts parserOptions) *parser { - size := opts.ReaderBufferSize - if size < minReaderBufferSize { - size = minReaderBufferSize - } - - return &parser{ - buf: bufio.NewReaderSize(r, size), - options: opts, - count: 1, - comment: &bytes.Buffer{}, - } -} - -// BOM handles header of UTF-8, UTF-16 LE and UTF-16 BE's BOM format. -// http://en.wikipedia.org/wiki/Byte_order_mark#Representations_of_byte_order_marks_by_encoding -func (p *parser) BOM() error { - mask, err := p.buf.Peek(2) - if err != nil && err != io.EOF { - return err - } else if len(mask) < 2 { - return nil - } - - switch { - case mask[0] == 254 && mask[1] == 255: - fallthrough - case mask[0] == 255 && mask[1] == 254: - _, err = p.buf.Read(mask) - if err != nil { - return err - } - case mask[0] == 239 && mask[1] == 187: - mask, err := p.buf.Peek(3) - if err != nil && err != io.EOF { - return err - } else if len(mask) < 3 { - return nil - } - if mask[2] == 191 { - _, err = p.buf.Read(mask) - if err != nil { - return err - } - } - } - return nil -} - -func (p *parser) readUntil(delim byte) ([]byte, error) { - data, err := p.buf.ReadBytes(delim) - if err != nil { - if err == io.EOF { - p.isEOF = true - } else { - return nil, err - } - } - return data, nil -} - -func cleanComment(in []byte) ([]byte, bool) { - i := bytes.IndexAny(in, "#;") - if i == -1 { - return nil, false - } - return in[i:], true -} - -func readKeyName(delimiters string, in []byte) (string, int, error) { - line := string(in) - - // Check if key name surrounded by quotes. - var keyQuote string - switch line[0] { - case '"': - if len(line) > 6 && line[0:3] == `"""` { - keyQuote = `"""` - } else { - keyQuote = `"` - } - case '`': - keyQuote = "`" - } - - // Get out key name - var endIdx int - if len(keyQuote) > 0 { - startIdx := len(keyQuote) - // FIXME: fail case -> """"""name"""=value - pos := strings.Index(line[startIdx:], keyQuote) - if pos == -1 { - return "", -1, fmt.Errorf("missing closing key quote: %s", line) - } - pos += startIdx - - // Find key-value delimiter - i := strings.IndexAny(line[pos+startIdx:], delimiters) - if i < 0 { - return "", -1, ErrDelimiterNotFound{line} - } - endIdx = pos + i - return strings.TrimSpace(line[startIdx:pos]), endIdx + startIdx + 1, nil - } - - endIdx = strings.IndexAny(line, delimiters) - if endIdx < 0 { - return "", -1, ErrDelimiterNotFound{line} - } - if endIdx == 0 { - return "", -1, ErrEmptyKeyName{line} - } - - return strings.TrimSpace(line[0:endIdx]), endIdx + 1, nil -} - -func (p *parser) readMultilines(line, val, valQuote string) (string, error) { - for { - data, err := p.readUntil('\n') - if err != nil { - return "", err - } - next := string(data) - - pos := strings.LastIndex(next, valQuote) - if pos > -1 { - // Check if the line ends with backslash continuation after the quote - restOfLine := strings.TrimRight(next[pos+len(valQuote):], "\r\n") - if !p.options.IgnoreContinuation && strings.HasSuffix(strings.TrimSpace(restOfLine), `\`) { - val += next - continue - } - - val += next[:pos] - - comment, has := cleanComment([]byte(next[pos:])) - if has { - p.comment.Write(bytes.TrimSpace(comment)) - } - break - } - val += next - if p.isEOF { - return "", fmt.Errorf("missing closing key quote from %q to %q", line, next) - } - } - return val, nil -} - -func (p *parser) readContinuationLines(val string) (string, error) { - for { - data, err := p.readUntil('\n') - if err != nil { - return "", err - } - next := strings.TrimSpace(string(data)) - - if len(next) == 0 { - break - } - val += next - if val[len(val)-1] != '\\' { - break - } - val = val[:len(val)-1] - } - return val, nil -} - -// hasSurroundedQuote check if and only if the first and last characters -// are quotes \" or \'. -// It returns false if any other parts also contain same kind of quotes. -func hasSurroundedQuote(in string, quote byte) bool { - return len(in) >= 2 && in[0] == quote && in[len(in)-1] == quote && - strings.IndexByte(in[1:], quote) == len(in)-2 -} - -func (p *parser) readValue(in []byte, bufferSize int) (string, error) { - - line := strings.TrimLeftFunc(string(in), unicode.IsSpace) - if len(line) == 0 { - if p.options.AllowPythonMultilineValues && len(in) > 0 && in[len(in)-1] == '\n' { - return p.readPythonMultilines(line, bufferSize) - } - return "", nil - } - - var valQuote string - if len(line) > 3 && line[0:3] == `"""` { - valQuote = `"""` - } else if line[0] == '`' { - valQuote = "`" - } else if p.options.UnescapeValueDoubleQuotes && line[0] == '"' { - valQuote = `"` - } - - if len(valQuote) > 0 { - startIdx := len(valQuote) - pos := strings.LastIndex(line[startIdx:], valQuote) - // Check for multi-line value - if pos == -1 { - return p.readMultilines(line, line[startIdx:], valQuote) - } - - if p.options.UnescapeValueDoubleQuotes && valQuote == `"` { - return strings.ReplaceAll(line[startIdx:pos+startIdx], `\"`, `"`), nil - } - return line[startIdx : pos+startIdx], nil - } - - lastChar := line[len(line)-1] - // Won't be able to reach here if value only contains whitespace - line = strings.TrimSpace(line) - trimmedLastChar := line[len(line)-1] - - // Check continuation lines when desired - if !p.options.IgnoreContinuation && trimmedLastChar == '\\' { - return p.readContinuationLines(line[:len(line)-1]) - } - - // Check if ignore inline comment - if !p.options.IgnoreInlineComment { - var i int - if p.options.SpaceBeforeInlineComment { - i = strings.Index(line, " #") - if i == -1 { - i = strings.Index(line, " ;") - } - - } else { - i = strings.IndexAny(line, "#;") - } - - if i > -1 { - p.comment.WriteString(line[i:]) - line = strings.TrimSpace(line[:i]) - } - - } - - // Trim single and double quotes - if (hasSurroundedQuote(line, '\'') || - hasSurroundedQuote(line, '"')) && !p.options.PreserveSurroundedQuote { - line = line[1 : len(line)-1] - } else if len(valQuote) == 0 && p.options.UnescapeValueCommentSymbols { - line = strings.ReplaceAll(line, `\;`, ";") - line = strings.ReplaceAll(line, `\#`, "#") - } else if p.options.AllowPythonMultilineValues && lastChar == '\n' { - return p.readPythonMultilines(line, bufferSize) - } - - return line, nil -} - -func (p *parser) readPythonMultilines(line string, bufferSize int) (string, error) { - parserBufferPeekResult, _ := p.buf.Peek(bufferSize) - peekBuffer := bytes.NewBuffer(parserBufferPeekResult) - - for { - peekData, peekErr := peekBuffer.ReadBytes('\n') - if peekErr != nil && peekErr != io.EOF { - p.debug("readPythonMultilines: failed to peek with error: %v", peekErr) - return "", peekErr - } - - p.debug("readPythonMultilines: parsing %q", string(peekData)) - - peekMatches := pythonMultiline.FindStringSubmatch(string(peekData)) - p.debug("readPythonMultilines: matched %d parts", len(peekMatches)) - for n, v := range peekMatches { - p.debug(" %d: %q", n, v) - } - - // Return if not a Python multiline value. - if len(peekMatches) != 3 { - p.debug("readPythonMultilines: end of value, got: %q", line) - return line, nil - } - - // Advance the parser reader (buffer) in-sync with the peek buffer. - _, err := p.buf.Discard(len(peekData)) - if err != nil { - p.debug("readPythonMultilines: failed to skip to the end, returning error") - return "", err - } - - line += "\n" + peekMatches[0] - } -} - -// parse parses data through an io.Reader. -func (f *File) parse(reader io.Reader) (err error) { - p := newParser(reader, parserOptions{ - IgnoreContinuation: f.options.IgnoreContinuation, - IgnoreInlineComment: f.options.IgnoreInlineComment, - AllowPythonMultilineValues: f.options.AllowPythonMultilineValues, - SpaceBeforeInlineComment: f.options.SpaceBeforeInlineComment, - UnescapeValueDoubleQuotes: f.options.UnescapeValueDoubleQuotes, - UnescapeValueCommentSymbols: f.options.UnescapeValueCommentSymbols, - PreserveSurroundedQuote: f.options.PreserveSurroundedQuote, - DebugFunc: f.options.DebugFunc, - ReaderBufferSize: f.options.ReaderBufferSize, - }) - if err = p.BOM(); err != nil { - return fmt.Errorf("BOM: %v", err) - } - - // Ignore error because default section name is never empty string. - name := DefaultSection - if f.options.Insensitive || f.options.InsensitiveSections { - name = strings.ToLower(DefaultSection) - } - section, _ := f.NewSection(name) - - // This "last" is not strictly equivalent to "previous one" if current key is not the first nested key - var isLastValueEmpty bool - var lastRegularKey *Key - - var line []byte - var inUnparseableSection bool - - // NOTE: Iterate and increase `currentPeekSize` until - // the size of the parser buffer is found. - // TODO(unknwon): When Golang 1.10 is the lowest version supported, replace with `parserBufferSize := p.buf.Size()`. - parserBufferSize := 0 - // NOTE: Peek 4kb at a time. - currentPeekSize := minReaderBufferSize - - if f.options.AllowPythonMultilineValues { - for { - peekBytes, _ := p.buf.Peek(currentPeekSize) - peekBytesLength := len(peekBytes) - - if parserBufferSize >= peekBytesLength { - break - } - - currentPeekSize *= 2 - parserBufferSize = peekBytesLength - } - } - - for !p.isEOF { - line, err = p.readUntil('\n') - if err != nil { - return err - } - - if f.options.AllowNestedValues && - isLastValueEmpty && len(line) > 0 { - if line[0] == ' ' || line[0] == '\t' { - err = lastRegularKey.addNestedValue(string(bytes.TrimSpace(line))) - if err != nil { - return err - } - continue - } - } - - line = bytes.TrimLeftFunc(line, unicode.IsSpace) - if len(line) == 0 { - continue - } - - // Comments - if line[0] == '#' || line[0] == ';' { - // Note: we do not care ending line break, - // it is needed for adding second line, - // so just clean it once at the end when set to value. - p.comment.Write(line) - continue - } - - // Section - if line[0] == '[' { - // Read to the next ']' (TODO: support quoted strings) - closeIdx := bytes.LastIndexByte(line, ']') - if closeIdx == -1 { - return fmt.Errorf("unclosed section: %s", line) - } - - name := string(line[1:closeIdx]) - section, err = f.NewSection(name) - if err != nil { - return err - } - - comment, has := cleanComment(line[closeIdx+1:]) - if has { - p.comment.Write(comment) - } - - section.Comment = strings.TrimSpace(p.comment.String()) - - // Reset auto-counter and comments - p.comment.Reset() - p.count = 1 - // Nested values can't span sections - isLastValueEmpty = false - - inUnparseableSection = false - for i := range f.options.UnparseableSections { - if f.options.UnparseableSections[i] == name || - ((f.options.Insensitive || f.options.InsensitiveSections) && strings.EqualFold(f.options.UnparseableSections[i], name)) { - inUnparseableSection = true - continue - } - } - continue - } - - if inUnparseableSection { - section.isRawSection = true - section.rawBody += string(line) - continue - } - - kname, offset, err := readKeyName(f.options.KeyValueDelimiters, line) - if err != nil { - switch { - // Treat as boolean key when desired, and whole line is key name. - case IsErrDelimiterNotFound(err): - switch { - case f.options.AllowBooleanKeys: - kname, err := p.readValue(line, parserBufferSize) - if err != nil { - return err - } - key, err := section.NewBooleanKey(kname) - if err != nil { - return err - } - key.Comment = strings.TrimSpace(p.comment.String()) - p.comment.Reset() - continue - - case f.options.SkipUnrecognizableLines: - continue - } - case IsErrEmptyKeyName(err) && f.options.SkipUnrecognizableLines: - continue - } - return err - } - - // Auto increment. - isAutoIncr := false - if kname == "-" { - isAutoIncr = true - kname = "#" + strconv.Itoa(p.count) - p.count++ - } - - value, err := p.readValue(line[offset:], parserBufferSize) - if err != nil { - return err - } - isLastValueEmpty = len(value) == 0 - - key, err := section.NewKey(kname, value) - if err != nil { - return err - } - key.isAutoIncrement = isAutoIncr - key.Comment = strings.TrimSpace(p.comment.String()) - p.comment.Reset() - lastRegularKey = key - } - return nil -} From 713c44679f7b12fca04c3197d2a013a815944201 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:10:21 +0000 Subject: [PATCH 03/41] Drop the per-section keysHash map Every section kept a second map that copied each value only to serve KeysHash, and growing it was the largest cost left when loading. Each Key now keeps that value in a hashValue field, updated where keysHash was: on creation, on overwrite in NewKey, and in SetValue. It can't reuse value, because Must* writes defaults into value but never changed KeysHash; the new test pins that. Loading 10,000 keys gets 25-33% faster. --- key.go | 20 ++++++++++++-------- section.go | 30 +++++++++++++----------------- section_test.go | 12 ++++++++++++ 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/key.go b/key.go index 800d04a..30293c1 100644 --- a/key.go +++ b/key.go @@ -25,10 +25,13 @@ import ( // Key represents a key under a section. type Key struct { - s *Section - Comment string - name string - value string + s *Section + Comment string + name string + value string + // hashValue is the value KeysHash reports. Must* defaults change value + // but not hashValue, as they never updated KeysHash in v1. + hashValue string isAutoIncrement bool isBooleanType bool @@ -41,9 +44,10 @@ type Key struct { // newKey simply return a key object with given values. func newKey(s *Section, name, val string) *Key { return &Key{ - s: s, - name: name, - value: val, + s: s, + name: name, + value: val, + hashValue: val, } } @@ -840,5 +844,5 @@ func (k *Key) SetValue(v string) { } k.value = v - k.s.keysHash[k.name] = v + k.hashValue = v } diff --git a/section.go b/section.go index f885e57..5683671 100644 --- a/section.go +++ b/section.go @@ -22,12 +22,11 @@ import ( // Section represents a config section. type Section struct { - f *File - Comment string - name string - keys map[string]*Key - keyList []string - keysHash map[string]string + f *File + Comment string + name string + keys map[string]*Key + keyList []string isRawSection bool rawBody string @@ -35,11 +34,10 @@ type Section struct { func newSection(f *File, name string) *Section { return &Section{ - f: f, - name: name, - keys: make(map[string]*Key), - keyList: make([]string, 0, 10), - keysHash: make(map[string]string), + f: f, + name: name, + keys: make(map[string]*Key), + keyList: make([]string, 0, 10), } } @@ -82,14 +80,13 @@ func (s *Section) NewKey(name, val string) (*Key, error) { } } else { s.keys[name].value = val - s.keysHash[name] = val + s.keys[name].hashValue = val } return s.keys[name], nil } s.keyList = append(s.keyList, name) s.keys[name] = newKey(s, name, val) - s.keysHash[name] = val return s.keys[name], nil } @@ -217,9 +214,9 @@ func (s *Section) KeysHash() map[string]string { defer s.f.lock.RUnlock() } - hash := make(map[string]string, len(s.keysHash)) - for key, value := range s.keysHash { - hash[key] = value + hash := make(map[string]string, len(s.keys)) + for name, k := range s.keys { + hash[name] = k.hashValue } return hash } @@ -235,7 +232,6 @@ func (s *Section) DeleteKey(name string) { if k == name { s.keyList = append(s.keyList[:i], s.keyList[i+1:]...) delete(s.keys, name) - delete(s.keysHash, name) return } } diff --git a/section_test.go b/section_test.go index 09ed366..bc45cbb 100644 --- a/section_test.go +++ b/section_test.go @@ -297,6 +297,18 @@ file = b.log assert.Equal(t, relation[k], v) } }) + + t.Run("reports values as loaded or set, not Must* defaults", func(t *testing.T) { + f, err := Load([]byte("empty =\nset = 1\ngone = 2\n")) + require.NoError(t, err) + sec := f.Section("") + + assert.Equal(t, "fallback", sec.Key("empty").MustString("fallback")) + sec.Key("set").SetValue("3") + sec.DeleteKey("gone") + + assert.Equal(t, map[string]string{"empty": "", "set": "3"}, sec.KeysHash()) + }) } func TestSection_DeleteKey(t *testing.T) { From 3857b9c71882caef68742ca4967f7a847d075579 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:23:23 +0000 Subject: [PATCH 04/41] Functional options for loading and writing; no package globals Load and LoadSources take ...LoadOption instead of a LoadOptions struct, and LooseLoad, InsensitiveLoad and ShadowLoad become the Loose, Insensitive and AllowShadows options. Without options, parsing is exactly v1's Load. Every v1 option keeps its name and meaning. ReaderBufferSize is gone: the lexer has no read buffer to size. The writer settings that were package variables (LineBreak, DefaultHeader, PrettySection, PrettyFormat, PrettyEqual, DefaultFormatLeft/Right) and KeyValueDelimiterOnWrite are per-call WriteOptions of File.Write and File.SaveTo, so one caller can no longer change another's output. WriteToIndent and SaveToIndent become the Indent option; WriteTo stays for io.WriterTo. New options that v1 had no way to express: - DefaultSectionName renames the default section per file, e.g. AWS's [default] (#287), replacing the DefaultSection variable. - CommentSymbols sets the comment characters (#350). The default "#;" keeps v1 parsing; the writer uses them for comments it adds. Parse errors are wrapped in *ParseError with the 1-based line and column where the failing line's content starts (#341). Its message is "line L, column C: " followed by v1's message, and IsErrDelimiterNotFound and IsErrEmptyKeyName see through it. The parity tests map v1 options to v2 options and compare v1's error messages with the error inside ParseError. --- deprecated.go | 22 ---- error.go | 23 +++- file.go | 121 ++++++++---------- file_test.go | 34 ++---- ini.go | 162 ++++--------------------- ini_test.go | 316 +++++++++++++++++------------------------------- key_test.go | 21 ++-- lexer.go | 85 +++++++++---- options.go | 276 ++++++++++++++++++++++++++++++++++++++++++ options_test.go | 104 ++++++++++++++++ parity_test.go | 60 +++++---- section_test.go | 6 +- struct.go | 33 ++--- struct_test.go | 24 ++-- 14 files changed, 732 insertions(+), 555 deletions(-) delete mode 100644 deprecated.go create mode 100644 options.go create mode 100644 options_test.go diff --git a/deprecated.go b/deprecated.go deleted file mode 100644 index 48b8e66..0000000 --- a/deprecated.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2019 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package ini - -var ( - // Deprecated: Use "DefaultSection" instead. - DEFAULT_SECTION = DefaultSection - // Deprecated: AllCapsUnderscore converts to format ALL_CAPS_UNDERSCORE. - AllCapsUnderscore = SnackCase -) diff --git a/error.go b/error.go index f66bc94..3bed5df 100644 --- a/error.go +++ b/error.go @@ -15,6 +15,7 @@ package ini import ( + "errors" "fmt" ) @@ -25,8 +26,7 @@ type ErrDelimiterNotFound struct { // IsErrDelimiterNotFound returns true if the given error is an instance of ErrDelimiterNotFound. func IsErrDelimiterNotFound(err error) bool { - _, ok := err.(ErrDelimiterNotFound) - return ok + return errors.As(err, new(ErrDelimiterNotFound)) } func (err ErrDelimiterNotFound) Error() string { @@ -40,10 +40,25 @@ type ErrEmptyKeyName struct { // IsErrEmptyKeyName returns true if the given error is an instance of ErrEmptyKeyName. func IsErrEmptyKeyName(err error) bool { - _, ok := err.(ErrEmptyKeyName) - return ok + return errors.As(err, new(ErrEmptyKeyName)) } func (err ErrEmptyKeyName) Error() string { return fmt.Sprintf("empty key name: %s", err.Line) } + +// ParseError reports where in a data source parsing failed. Err is the +// error at that position, such as ErrDelimiterNotFound. +type ParseError struct { + Line int // 1-based line number + Column int // 1-based column, in characters, where the line's content starts + Err error +} + +func (err *ParseError) Error() string { + return fmt.Sprintf("line %d, column %d: %v", err.Line, err.Column, err.Err) +} + +func (err *ParseError) Unwrap() error { + return err.Err +} diff --git a/file.go b/file.go index 317dcec..a6d3776 100644 --- a/file.go +++ b/file.go @@ -26,7 +26,7 @@ import ( // File represents a combination of one or more INI files in memory. type File struct { - options LoadOptions + options loadOptions dataSources []dataSource // Should make things safe, but sometimes doesn't matter. @@ -47,34 +47,19 @@ type File struct { } // newFile initializes File object with given data sources. -func newFile(dataSources []dataSource, opts LoadOptions) *File { - if len(opts.KeyValueDelimiters) == 0 { - opts.KeyValueDelimiters = "=:" - } - if len(opts.KeyValueDelimiterOnWrite) == 0 { - opts.KeyValueDelimiterOnWrite = "=" - } - if len(opts.ChildSectionDelimiter) == 0 { - opts.ChildSectionDelimiter = "." - } - +func newFile(dataSources []dataSource, opts []LoadOption) *File { return &File{ BlockMode: true, dataSources: dataSources, sections: make(map[string][]*Section), - options: opts, + options: newLoadOptions(opts), } } // Empty returns an empty file object. -func Empty(opts ...LoadOptions) *File { - var opt LoadOptions - if len(opts) > 0 { - opt = opts[0] - } - +func Empty(opts ...LoadOption) *File { // Ignore error here, we are sure our data is good. - f, _ := LoadSources(opt, []byte("")) + f, _ := Load([]byte(""), opts...) return f } @@ -84,7 +69,7 @@ func (f *File) NewSection(name string) (*Section, error) { return nil, errors.New("empty section name") } - if (f.options.Insensitive || f.options.InsensitiveSections) && name != DefaultSection { + if (f.options.Insensitive || f.options.InsensitiveSections) && name != f.options.DefaultSection { name = strings.ToLower(name) } @@ -150,7 +135,7 @@ func (f *File) HasSection(name string) bool { // SectionsByName returns all sections with given name. func (f *File) SectionsByName(name string) ([]*Section, error) { if len(name) == 0 { - name = DefaultSection + name = f.options.DefaultSection } if f.options.Insensitive || f.options.InsensitiveSections { name = strings.ToLower(name) @@ -174,7 +159,7 @@ func (f *File) Section(name string) *Section { sec, err := f.GetSection(name) if err != nil { if name == "" { - name = DefaultSection + name = f.options.DefaultSection } sec, _ = f.NewSection(name) return sec @@ -243,7 +228,7 @@ func (f *File) DeleteSectionWithIndex(name string, index int) error { } if len(name) == 0 { - name = DefaultSection + name = f.options.DefaultSection } if f.options.Insensitive || f.options.InsensitiveSections { name = strings.ToLower(name) @@ -332,12 +317,22 @@ func (f *File) Append(source interface{}, others ...interface{}) error { return f.Reload() } -func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { - equalSign := DefaultFormatLeft + f.options.KeyValueDelimiterOnWrite + DefaultFormatRight +func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { + o := newWriteOptions(opts) + indent, lineBreak := o.indent, o.lineBreak + equalSign := o.formatLeft + o.keyValueDelimiter + o.formatRight + if o.prettyFormat || o.prettyEqual { + equalSign = fmt.Sprintf(" %s ", o.keyValueDelimiter) + } - if PrettyFormat || PrettyEqual { - equalSign = fmt.Sprintf(" %s ", f.options.KeyValueDelimiterOnWrite) + // Comment lines that don't start with a comment symbol get one: ";" as in + // v1 when it is a comment symbol, otherwise the first one. + symbols := f.options.CommentSymbols + commentPrefix := ";" + if !strings.Contains(symbols, ";") && symbols != "" { + commentPrefix = symbols[:1] } + isComment := func(line string) bool { return strings.IndexByte(symbols, line[0]) > -1 } // Use buffer to make sure target is safe until finish encoding. buf := bytes.NewBuffer(nil) @@ -346,22 +341,22 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { sec := f.SectionWithIndex(sname, f.sectionIndexes[i]) if len(sec.Comment) > 0 { // Support multiline comments - lines := strings.Split(sec.Comment, LineBreak) + lines := strings.Split(sec.Comment, lineBreak) for i := range lines { - if lines[i][0] != '#' && lines[i][0] != ';' { - lines[i] = "; " + lines[i] + if !isComment(lines[i]) { + lines[i] = commentPrefix + " " + lines[i] } else { lines[i] = lines[i][:1] + " " + strings.TrimSpace(lines[i][1:]) } - if _, err := buf.WriteString(lines[i] + LineBreak); err != nil { + if _, err := buf.WriteString(lines[i] + lineBreak); err != nil { return nil, err } } } - if i > 0 || DefaultHeader || (i == 0 && strings.ToUpper(sec.name) != DefaultSection) { - if _, err := buf.WriteString("[" + sname + "]" + LineBreak); err != nil { + if i > 0 || o.defaultHeader || (i == 0 && strings.ToUpper(sec.name) != f.options.DefaultSection) { + if _, err := buf.WriteString("[" + sname + "]" + lineBreak); err != nil { return nil, err } } else { @@ -377,9 +372,9 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { return nil, err } - if PrettySection && !isLastSection { + if o.prettySection && !isLastSection { // Put a line between sections - if _, err := buf.WriteString(LineBreak); err != nil { + if _, err := buf.WriteString(lineBreak); err != nil { return nil, err } } @@ -390,7 +385,7 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { // longest key. Keys may be modified if they contain certain characters so // we need to take that into account in our calculation. alignLength := 0 - if PrettyFormat { + if o.prettyFormat { for _, kname := range sec.keyList { keyLength := len(kname) // First case will surround key by ` and second by """ @@ -411,26 +406,26 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { for _, kname := range sec.keyList { key := sec.Key(kname) if len(key.Comment) > 0 { - if len(indent) > 0 && sname != DefaultSection { + if len(indent) > 0 && sname != f.options.DefaultSection { buf.WriteString(indent) } // Support multiline comments - lines := strings.Split(key.Comment, LineBreak) + lines := strings.Split(key.Comment, lineBreak) for i := range lines { - if lines[i][0] != '#' && lines[i][0] != ';' { - lines[i] = "; " + strings.TrimSpace(lines[i]) + if !isComment(lines[i]) { + lines[i] = commentPrefix + " " + strings.TrimSpace(lines[i]) } else { lines[i] = lines[i][:1] + " " + strings.TrimSpace(lines[i][1:]) } - if _, err := buf.WriteString(lines[i] + LineBreak); err != nil { + if _, err := buf.WriteString(lines[i] + lineBreak); err != nil { return nil, err } } } - if len(indent) > 0 && sname != DefaultSection { + if len(indent) > 0 && sname != f.options.DefaultSection { buf.WriteString(indent) } @@ -449,24 +444,24 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { } if key.isBooleanType { - buf.WriteString(LineBreak) + buf.WriteString(lineBreak) return true, nil } // Write out alignment spaces before "=" sign - if PrettyFormat { + if o.prettyFormat { buf.Write(alignSpaces[:alignLength-len(kname)]) } // In case key value contains "\n", "`", "\"", "#" or ";" if strings.ContainsAny(val, "\n`") { val = `"""` + val + `"""` - } else if !f.options.IgnoreInlineComment && strings.ContainsAny(val, "#;") { + } else if !f.options.IgnoreInlineComment && strings.ContainsAny(val, symbols) { val = "`" + val + "`" } else if len(strings.TrimSpace(val)) != len(val) { val = `"` + val + `"` } - if _, err := buf.WriteString(equalSign + val + LineBreak); err != nil { + if _, err := buf.WriteString(equalSign + val + lineBreak); err != nil { return false, err } return false, nil @@ -489,15 +484,15 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { } for _, val := range key.nestedValues { - if _, err := buf.WriteString(indent + " " + val + LineBreak); err != nil { + if _, err := buf.WriteString(indent + " " + val + lineBreak); err != nil { return nil, err } } } - if PrettySection && !isLastSection { + if o.prettySection && !isLastSection { // Put a line between sections - if _, err := buf.WriteString(LineBreak); err != nil { + if _, err := buf.WriteString(lineBreak); err != nil { return nil, err } } @@ -506,35 +501,25 @@ func (f *File) writeToBuffer(indent string) (*bytes.Buffer, error) { return buf, nil } -// WriteToIndent writes content into io.Writer with given indention. -// If PrettyFormat has been set to be true, -// it will align "=" sign with spaces under each section. -func (f *File) WriteToIndent(w io.Writer, indent string) (int64, error) { - buf, err := f.writeToBuffer(indent) +// Write writes the file to w, formatted by opts. +func (f *File) Write(w io.Writer, opts ...WriteOption) (int64, error) { + buf, err := f.writeToBuffer(opts) if err != nil { return 0, err } return buf.WriteTo(w) } -// WriteTo writes file content into io.Writer. +// WriteTo writes the file to w with the default format. func (f *File) WriteTo(w io.Writer) (int64, error) { - return f.WriteToIndent(w, "") + return f.Write(w) } -// SaveToIndent writes content to file system with given value indention. -func (f *File) SaveToIndent(filename, indent string) error { - // Note: Because we are truncating with os.Create, - // so it's safer to save to a temporary file location and rename after done. - buf, err := f.writeToBuffer(indent) +// SaveTo writes the file to the named file, formatted by opts. +func (f *File) SaveTo(filename string, opts ...WriteOption) error { + buf, err := f.writeToBuffer(opts) if err != nil { return err } - return os.WriteFile(filename, buf.Bytes(), 0666) } - -// SaveTo writes content to file system. -func (f *File) SaveTo(filename string) error { - return f.SaveToIndent(filename, "") -} diff --git a/file_test.go b/file_test.go index 306c0c8..5c11a7b 100644 --- a/file_test.go +++ b/file_test.go @@ -64,9 +64,7 @@ func TestFile_NewSection(t *testing.T) { func TestFile_NonUniqueSection(t *testing.T) { t.Run("read and write non-unique sections", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNonUniqueSections: true, - }, []byte(`[Interface] + f, err := Load([]byte(`[Interface] Address = 192.168.2.1 PrivateKey = ListenPort = 51820 @@ -77,7 +75,7 @@ AllowedIPs = 192.168.2.2/32 [Peer] PublicKey = -AllowedIPs = 192.168.2.3/32`)) +AllowedIPs = 192.168.2.3/32`), AllowNonUniqueSections()) require.NoError(t, err) require.NotNil(t, f) @@ -112,9 +110,7 @@ AllowedIPs = 192.168.2.4/32 }) t.Run("delete non-unique section", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNonUniqueSections: true, - }, []byte(`[Interface] + f, err := Load([]byte(`[Interface] Address = 192.168.2.1 PrivateKey = ListenPort = 51820 @@ -131,7 +127,7 @@ AllowedIPs = 192.168.2.3/32 PublicKey = AllowedIPs = 192.168.2.4/32 -`)) +`), AllowNonUniqueSections()) require.NoError(t, err) require.NotNil(t, f) @@ -158,9 +154,7 @@ AllowedIPs = 192.168.2.4/32 }) t.Run("delete all sections", func(t *testing.T) { - f := Empty(LoadOptions{ - AllowNonUniqueSections: true, - }) + f := Empty(AllowNonUniqueSections()) require.NotNil(t, f) _ = f.NewSections("Interface", "Peer", "Peer") @@ -267,10 +261,10 @@ func TestFile_Section(t *testing.T) { }) t.Run("get default section in lower case with insensitive load", func(t *testing.T) { - f, err := InsensitiveLoad([]byte(` + f, err := Load([]byte(` [default] NAME = ini -VERSION = v1`)) +VERSION = v1`), Insensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -375,7 +369,7 @@ key1 = value1 }) t.Run("delete a section with InsensitiveSections", func(t *testing.T) { - f := Empty(LoadOptions{InsensitiveSections: true}) + f := Empty(InsensitiveSections()) require.NotNil(t, f) _ = f.NewSections("author", "package", "features") @@ -482,13 +476,11 @@ func TestFile_SaveTo(t *testing.T) { require.NotNil(t, f) assert.NoError(t, f.SaveTo("testdata/conf_out.ini")) - assert.NoError(t, f.SaveToIndent("testdata/conf_out.ini", "\t")) + assert.NoError(t, f.SaveTo("testdata/conf_out.ini", Indent("\t"))) } func TestFile_WriteToWithOutputDelimiter(t *testing.T) { - f, err := LoadSources(LoadOptions{ - KeyValueDelimiterOnWrite: "->", - }, []byte(`[Others] + f, err := Load([]byte(`[Others] Cities = HangZhou|Boston Visits = 1993-10-07T20:17:05Z, 1993-10-07T20:17:05Z Years = 1993,1994 @@ -513,7 +505,7 @@ Coordinates -> 192.168,10.11 Flags -> true,false Note -> Hello world! `) - _, err = f.WriteTo(&actual) + _, err = f.Write(&actual, KeyValueDelimiterOnWrite("->")) require.NoError(t, err) assert.Equal(t, expected, actual.Bytes()) @@ -521,12 +513,12 @@ Note -> Hello world! // Inspired by https://github.com/go-ini/ini/issues/207 func TestReloadAfterShadowLoad(t *testing.T) { - f, err := ShadowLoad([]byte(` + f, err := Load([]byte(` [slice] v = 1 v = 2 v = 3 -`)) +`), AllowShadows()) require.NoError(t, err) require.NotNil(t, f) diff --git a/ini.go b/ini.go index 99e7f86..528b97f 100644 --- a/ini.go +++ b/ini.go @@ -16,161 +16,43 @@ package ini import ( - "os" "regexp" - "runtime" - "strings" ) const ( - // Maximum allowed depth when recursively substituing variable names. - depthValues = 99 -) - -var ( - // DefaultSection is the name of default section. You can use this var or the string literal. - // In most of cases, an empty string is all you need to access the section. + // DefaultSection is the default name of the section that holds keys + // before the first section header. Pass "" to the section methods to + // refer to it whatever its name, and change the name with + // DefaultSectionName. DefaultSection = "DEFAULT" - // LineBreak is the delimiter to determine or compose a new line. - // This variable will be changed to "\r\n" automatically on Windows at package init time. - LineBreak = "\n" - - // Variable regexp pattern: %(variable)s - varPattern = regexp.MustCompile(`%\(([^)]+)\)s`) - - // DefaultHeader explicitly writes default section header. - DefaultHeader = false - - // PrettySection indicates whether to put a line between sections. - PrettySection = true - // PrettyFormat indicates whether to align "=" sign with spaces to produce pretty output - // or reduce all possible spaces for compact format. - PrettyFormat = true - // PrettyEqual places spaces around "=" sign even when PrettyFormat is false. - PrettyEqual = false - // DefaultFormatLeft places custom spaces on the left when PrettyFormat and PrettyEqual are both disabled. - DefaultFormatLeft = "" - // DefaultFormatRight places custom spaces on the right when PrettyFormat and PrettyEqual are both disabled. - DefaultFormatRight = "" + // Maximum allowed depth when recursively substituing variable names. + depthValues = 99 ) -var inTest = len(os.Args) > 0 && strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test") - -func init() { - if runtime.GOOS == "windows" && !inTest { - LineBreak = "\r\n" - } -} +// Variable regexp pattern: %(variable)s +var varPattern = regexp.MustCompile(`%\(([^)]+)\)s`) -// LoadOptions contains all customized options used for load data source(s). -type LoadOptions struct { - // Loose indicates whether the parser should ignore nonexistent files or return error. - Loose bool - // Insensitive indicates whether the parser forces all section and key names to lowercase. - Insensitive bool - // InsensitiveSections indicates whether the parser forces all section to lowercase. - InsensitiveSections bool - // InsensitiveKeys indicates whether the parser forces all key names to lowercase. - InsensitiveKeys bool - // IgnoreContinuation indicates whether to ignore continuation lines while parsing. - IgnoreContinuation bool - // IgnoreInlineComment indicates whether to ignore comments at the end of value and treat it as part of value. - IgnoreInlineComment bool - // SkipUnrecognizableLines indicates whether to skip unrecognizable lines that do not conform to key/value pairs. - SkipUnrecognizableLines bool - // ShortCircuit indicates whether to ignore other configuration sources after loaded the first available configuration source. - ShortCircuit bool - // AllowBooleanKeys indicates whether to allow boolean type keys or treat as value is missing. - // This type of keys are mostly used in my.cnf. - AllowBooleanKeys bool - // AllowShadows indicates whether to keep track of keys with same name under same section. - AllowShadows bool - // AllowNestedValues indicates whether to allow AWS-like nested values. - // Docs: http://docs.aws.amazon.com/cli/latest/topic/config-vars.html#nested-values - AllowNestedValues bool - // AllowPythonMultilineValues indicates whether to allow Python-like multi-line values. - // Docs: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure - // Relevant quote: Values can also span multiple lines, as long as they are indented deeper - // than the first line of the value. - AllowPythonMultilineValues bool - // SpaceBeforeInlineComment indicates whether to allow comment symbols (\# and \;) inside value. - // Docs: https://docs.python.org/2/library/configparser.html - // Quote: Comments may appear on their own in an otherwise empty line, or may be entered in lines holding values or section names. - // In the latter case, they need to be preceded by a whitespace character to be recognized as a comment. - SpaceBeforeInlineComment bool - // UnescapeValueDoubleQuotes indicates whether to unescape double quotes inside value to regular format - // when value is surrounded by double quotes, e.g. key="a \"value\"" => key=a "value" - UnescapeValueDoubleQuotes bool - // UnescapeValueCommentSymbols indicates to unescape comment symbols (\# and \;) inside value to regular format - // when value is NOT surrounded by any quotes. - // Note: UNSTABLE, behavior might change to only unescape inside double quotes but may noy necessary at all. - UnescapeValueCommentSymbols bool - // UnparseableSections stores a list of blocks that are allowed with raw content which do not otherwise - // conform to key/value pairs. Specify the names of those blocks here. - UnparseableSections []string - // KeyValueDelimiters is the sequence of delimiters that are used to separate key and value. By default, it is "=:". - KeyValueDelimiters string - // KeyValueDelimiterOnWrite is the delimiter that are used to separate key and value output. By default, it is "=". - KeyValueDelimiterOnWrite string - // ChildSectionDelimiter is the delimiter that is used to separate child sections. By default, it is ".". - ChildSectionDelimiter string - // PreserveSurroundedQuote indicates whether to preserve surrounded quote (single and double quotes). - PreserveSurroundedQuote bool - // DebugFunc is called to collect debug information (currently only useful to debug parsing Python-style multiline values). - DebugFunc DebugFunc - // ReaderBufferSize is the buffer size of the reader in bytes. - ReaderBufferSize int - // AllowNonUniqueSections indicates whether to allow sections with the same name multiple times. - AllowNonUniqueSections bool - // AllowDuplicateShadowValues indicates whether values for shadowed keys should be deduplicated. - AllowDuplicateShadowValues bool +// Load loads and parses an INI data source. A source is a file name +// (string), raw data ([]byte), or an io.Reader; an io.ReadCloser is closed +// after reading. +func Load(source any, opts ...LoadOption) (*File, error) { + return LoadSources([]any{source}, opts...) } -// DebugFunc is the type of function called to log parse events. -type DebugFunc func(message string) - -// LoadSources allows caller to apply customized options for loading from data source(s). -func LoadSources(opts LoadOptions, source interface{}, others ...interface{}) (_ *File, err error) { - sources := make([]dataSource, len(others)+1) - sources[0], err = parseDataSource(source) - if err != nil { - return nil, err - } - for i := range others { - sources[i+1], err = parseDataSource(others[i]) - if err != nil { +// LoadSources loads and parses several INI data sources into one File. +// Later sources add to earlier ones and overwrite keys they repeat. +func LoadSources(sources []any, opts ...LoadOption) (*File, error) { + dataSources := make([]dataSource, len(sources)) + for i := range sources { + var err error + if dataSources[i], err = parseDataSource(sources[i]); err != nil { return nil, err } } - f := newFile(sources, opts) - if err = f.Reload(); err != nil { + f := newFile(dataSources, opts) + if err := f.Reload(); err != nil { return nil, err } return f, nil } - -// Load loads and parses from INI data sources. -// Arguments can be mixed of file name with string type, or raw data in []byte. -// It will return error if list contains nonexistent files. -func Load(source interface{}, others ...interface{}) (*File, error) { - return LoadSources(LoadOptions{}, source, others...) -} - -// LooseLoad has exactly same functionality as Load function -// except it ignores nonexistent files instead of returning error. -func LooseLoad(source interface{}, others ...interface{}) (*File, error) { - return LoadSources(LoadOptions{Loose: true}, source, others...) -} - -// InsensitiveLoad has exactly same functionality as Load function -// except it forces all section and key names to be lowercased. -func InsensitiveLoad(source interface{}, others ...interface{}) (*File, error) { - return LoadSources(LoadOptions{Insensitive: true}, source, others...) -} - -// ShadowLoad has exactly same functionality as Load function -// except it allows have shadow keys. -func ShadowLoad(source interface{}, others ...interface{}) (*File, error) { - return LoadSources(LoadOptions{AllowShadows: true}, source, others...) -} diff --git a/ini_test.go b/ini_test.go index a3d3b4c..86215b9 100644 --- a/ini_test.go +++ b/ini_test.go @@ -54,12 +54,7 @@ var update = flag.Bool("update", false, "Update .golden files") func TestLoad(t *testing.T) { t.Run("load from good data sources", func(t *testing.T) { - f, err := Load( - "testdata/minimal.ini", - []byte("NAME = ini\nIMPORT_PATH = gopkg.in/%(NAME)s.%(VERSION)s"), - bytes.NewReader([]byte(`VERSION = v1`)), - io.NopCloser(bytes.NewReader([]byte("[author]\nNAME = Unknwon"))), - ) + f, err := LoadSources([]any{"testdata/minimal.ini", []byte("NAME = ini\nIMPORT_PATH = gopkg.in/%(NAME)s.%(VERSION)s"), bytes.NewReader([]byte(`VERSION = v1`)), io.NopCloser(bytes.NewReader([]byte("[author]\nNAME = Unknwon")))}) require.NoError(t, err) require.NotNil(t, f) @@ -114,7 +109,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- `)) require.Error(t, err) assert.Nil(t, f) - assert.Equal(t, "key-value delimiter not found: foo\n", err.Error()) + assert.Equal(t, "line 4, column 4: key-value delimiter not found: foo\n", err.Error()) }) t.Run("cannot parse big python-compatible INI files", func(t *testing.T) { @@ -221,12 +216,12 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- `)) require.Error(t, err) assert.Nil(t, f) - assert.Equal(t, "key-value delimiter not found: 1foo\n", err.Error()) + assert.Equal(t, "line 4, column 4: key-value delimiter not found: 1foo\n", err.Error()) }) } func TestLooseLoad(t *testing.T) { - f, err := LoadSources(LoadOptions{Loose: true}, notFoundConf, minimalConf) + f, err := LoadSources([]any{notFoundConf, minimalConf}, Loose()) require.NoError(t, err) require.NotNil(t, f) @@ -238,7 +233,7 @@ func TestLooseLoad(t *testing.T) { func TestInsensitiveLoad(t *testing.T) { t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := InsensitiveLoad(minimalConf) + f, err := Load(minimalConf, Insensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -266,11 +261,11 @@ e-mail = u@gogs.io // Ref: https://github.com/go-ini/ini/issues/198 t.Run("insensitive load with default section", func(t *testing.T) { - f, err := InsensitiveLoad([]byte(` + f, err := Load([]byte(` user = unknwon [profile] email = unknwon@local -`)) +`), Insensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -281,18 +276,18 @@ email = unknwon@local func TestLoadSources(t *testing.T) { t.Run("with true `AllowPythonMultilineValues`", func(t *testing.T) { t.Run("ignore nonexistent files", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true, Loose: true}, notFoundConf, minimalConf) + f, err := LoadSources([]any{notFoundConf, minimalConf}, AllowPythonMultilineValues(), Loose()) require.NoError(t, err) require.NotNil(t, f) t.Run("inverse case", func(t *testing.T) { - _, err = LoadSources(LoadOptions{AllowPythonMultilineValues: true}, notFoundConf) + _, err = Load(notFoundConf, AllowPythonMultilineValues()) require.Error(t, err) }) }) t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true, Insensitive: true}, minimalConf) + f, err := Load(minimalConf, AllowPythonMultilineValues(), Insensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -310,7 +305,7 @@ e-mail = u@gogs.io }) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, minimalConf) + f, err := Load(minimalConf, AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) @@ -319,7 +314,7 @@ e-mail = u@gogs.io }) t.Run("insensitive to sections and sensitive to key names", func(t *testing.T) { - f, err := LoadSources(LoadOptions{InsensitiveSections: true}, minimalConf) + f, err := Load(minimalConf, InsensitiveSections()) require.NoError(t, err) require.NotNil(t, f) @@ -337,7 +332,7 @@ E-MAIL = u@gogs.io }) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{}, minimalConf) + f, err := Load(minimalConf) require.NoError(t, err) require.NotNil(t, f) @@ -346,7 +341,7 @@ E-MAIL = u@gogs.io }) t.Run("sensitive to sections and insensitive to key names", func(t *testing.T) { - f, err := LoadSources(LoadOptions{InsensitiveKeys: true}, minimalConf) + f, err := Load(minimalConf, InsensitiveKeys()) require.NoError(t, err) require.NotNil(t, f) @@ -364,7 +359,7 @@ e-mail = u@gogs.io }) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{}, minimalConf) + f, err := Load(minimalConf) require.NoError(t, err) require.NotNil(t, f) @@ -373,13 +368,10 @@ e-mail = u@gogs.io }) t.Run("ignore continuation lines", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - IgnoreContinuation: true, - }, []byte(` + f, err := Load([]byte(` key1=a\b\ key2=c\d\ -key3=value`)) +key3=value`), AllowPythonMultilineValues(), IgnoreContinuation()) require.NoError(t, err) require.NotNil(t, f) @@ -388,9 +380,9 @@ key3=value`)) assert.Equal(t, "value", f.Section("").Key("key3").String()) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(` + f, err := Load([]byte(` key1=a\b\ -key2=c\d\`)) +key2=c\d\`), AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) @@ -399,13 +391,10 @@ key2=c\d\`)) }) t.Run("ignore inline comments", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - IgnoreInlineComment: true, - }, []byte(` + f, err := Load([]byte(` key1=value ;comment key2=value2 #comment2 -key3=val#ue #comment3`)) +key3=val#ue #comment3`), AllowPythonMultilineValues(), IgnoreInlineComment()) require.NoError(t, err) require.NotNil(t, f) @@ -414,9 +403,9 @@ key3=val#ue #comment3`)) assert.Equal(t, `val#ue #comment3`, f.Section("").Key("key3").String()) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(` + f, err := Load([]byte(` key1=value ;comment -key2=value2 #comment2`)) +key2=value2 #comment2`), AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) @@ -428,9 +417,7 @@ key2=value2 #comment2`)) }) t.Run("skip unrecognizable lines", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - SkipUnrecognizableLines: true, - }, []byte(` + f, err := Load([]byte(` GenerationDepth: 13 BiomeRarityScale: 100 @@ -443,7 +430,7 @@ BiomeGroup(NormalBiomes, 3, 99, RoofedForestEnchanted, ForestSakura, FloatingJun BiomeGroup(IceBiomes, 4, 85, Ice Plains) = RainForest -`)) +`), SkipUnrecognizableLines()) require.NoError(t, err) require.NotNil(t, f) @@ -453,13 +440,10 @@ BiomeGroup(IceBiomes, 4, 85, Ice Plains) }) t.Run("allow boolean type keys", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - AllowBooleanKeys: true, - }, []byte(` + f, err := Load([]byte(` key1=hello #key2 -key3`)) +key3`), AllowPythonMultilineValues(), AllowBooleanKeys()) require.NoError(t, err) require.NotNil(t, f) @@ -479,20 +463,20 @@ key3 }) t.Run("inverse case", func(t *testing.T) { - _, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(` + _, err := Load([]byte(` key1=hello #key2 -key3`)) +key3`), AllowPythonMultilineValues()) require.Error(t, err) }) }) t.Run("allow shadow keys", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowShadows: true, AllowPythonMultilineValues: true}, []byte(` + f, err := Load([]byte(` [remote "origin"] url = https://github.com/Antergone/test1.git url = https://github.com/Antergone/test2.git -fetch = +refs/heads/*:refs/remotes/origin/*`)) +fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows(), AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) @@ -521,10 +505,10 @@ fetch = +refs/heads/*:refs/remotes/origin/* }) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(` + f, err := Load([]byte(` [remote "origin"] url = https://github.com/Antergone/test1.git -url = https://github.com/Antergone/test2.git`)) +url = https://github.com/Antergone/test2.git`), AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) @@ -533,19 +517,16 @@ url = https://github.com/Antergone/test2.git`)) }) t.Run("unescape double quotes inside value", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - UnescapeValueDoubleQuotes: true, - }, []byte(` -create_repo="创建了仓库 %s"`)) + f, err := Load([]byte(` +create_repo="创建了仓库 %s"`), AllowPythonMultilineValues(), UnescapeValueDoubleQuotes()) require.NoError(t, err) require.NotNil(t, f) assert.Equal(t, `创建了仓库 %s`, f.Section("").Key("create_repo").String()) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(` -create_repo="创建了仓库 %s"`)) + f, err := Load([]byte(` +create_repo="创建了仓库 %s"`), AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) @@ -554,13 +535,9 @@ create_repo="创建了仓库 %s"`)) }) t.Run("unescape comment symbols inside value", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - IgnoreInlineComment: true, - UnescapeValueCommentSymbols: true, - }, []byte(` + f, err := Load([]byte(` key = test value more text -`)) +`), AllowPythonMultilineValues(), IgnoreInlineComment(), UnescapeValueCommentSymbols()) require.NoError(t, err) require.NotNil(t, f) @@ -568,11 +545,9 @@ key = test value more text }) t.Run("unescape double quotes with backslash continuation", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - UnescapeValueDoubleQuotes: true, - }, []byte(`hello = "!f() { \ + f, err := Load([]byte(`hello = "!f() { \ echo "hello world"; \ -};f"`)) +};f"`), UnescapeValueDoubleQuotes()) require.NoError(t, err) require.NotNil(t, f) @@ -582,22 +557,15 @@ key = test value more text assert.Equal(t, expected, f.Section("").Key("hello").String()) t.Run("inverse case", func(t *testing.T) { - _, err := LoadSources(LoadOptions{ - UnescapeValueDoubleQuotes: true, - IgnoreContinuation: true, - }, []byte(`hello = "!f() { \ + _, err := Load([]byte(`hello = "!f() { \ echo "hello world"; \ -};f"`)) +};f"`), UnescapeValueDoubleQuotes(), IgnoreContinuation()) require.Error(t, err) }) }) t.Run("can parse small python-compatible INI files", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - Insensitive: true, - UnparseableSections: []string{"core_lesson", "comments"}, - }, []byte(` + f, err := Load([]byte(` [long] long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- foo @@ -609,7 +577,7 @@ multiline_list = first second third -`)) +`), AllowPythonMultilineValues(), Insensitive(), UnparseableSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -618,11 +586,7 @@ multiline_list = }) t.Run("can parse big python-compatible INI files", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - Insensitive: true, - UnparseableSections: []string{"core_lesson", "comments"}, - }, []byte(` + f, err := Load([]byte(` [long] long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- 1foo @@ -722,7 +686,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- 95foobar 96barfoo -----END RSA PRIVATE KEY----- -`)) +`), AllowPythonMultilineValues(), Insensitive(), UnparseableSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -829,11 +793,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- }) t.Run("allow unparsable sections", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - Insensitive: true, - UnparseableSections: []string{"core_lesson", "comments"}, - }, []byte(` + f, err := Load([]byte(` Lesson_Location = 87 Lesson_Status = C Score = 3 @@ -845,7 +805,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [COMMENTS] <1> This slide has the fuel listed in the wrong units -`)) +`), AllowPythonMultilineValues(), Insensitive(), UnparseableSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -878,19 +838,17 @@ my lesson state data – 1111111111111111111000000000000000001110000 }) t.Run("inverse case", func(t *testing.T) { - _, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(` + _, err := Load([]byte(` [CORE_LESSON] my lesson state data – 1111111111111111111000000000000000001110000 -111111111111111111100000000000111000000000 – end my lesson state data`)) +111111111111111111100000000000111000000000 – end my lesson state data`), AllowPythonMultilineValues()) require.Error(t, err) }) }) t.Run("and false `SpaceBeforeInlineComment`", func(t *testing.T) { t.Run("cannot parse INI files containing `#` or `;` in value", func(t *testing.T) { - f, err := LoadSources( - LoadOptions{AllowPythonMultilineValues: false, SpaceBeforeInlineComment: false}, - []byte(` + f, err := Load([]byte(` [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n @@ -907,13 +865,11 @@ GITHUB = U;n;k;n;w;o;n t.Run("and true `SpaceBeforeInlineComment`", func(t *testing.T) { t.Run("can parse INI files containing `#` or `;` in value", func(t *testing.T) { - f, err := LoadSources( - LoadOptions{AllowPythonMultilineValues: false, SpaceBeforeInlineComment: true}, - []byte(` + f, err := Load([]byte(` [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n -`)) +`), SpaceBeforeInlineComment()) require.NoError(t, err) require.NotNil(t, f) sec := f.Section("author") @@ -927,26 +883,18 @@ GITHUB = U;n;k;n;w;o;n t.Run("with false `AllowPythonMultilineValues`", func(t *testing.T) { t.Run("ignore nonexistent files", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - Loose: true, - }, notFoundConf, minimalConf) + f, err := LoadSources([]any{notFoundConf, minimalConf}, Loose()) require.NoError(t, err) require.NotNil(t, f) t.Run("inverse case", func(t *testing.T) { - _, err = LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - }, notFoundConf) + _, err = Load(notFoundConf) require.Error(t, err) }) }) t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - Insensitive: true, - }, minimalConf) + f, err := Load(minimalConf, Insensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -964,9 +912,7 @@ e-mail = u@gogs.io }) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - }, minimalConf) + f, err := Load(minimalConf) require.NoError(t, err) require.NotNil(t, f) @@ -975,13 +921,10 @@ e-mail = u@gogs.io }) t.Run("ignore continuation lines", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - IgnoreContinuation: true, - }, []byte(` + f, err := Load([]byte(` key1=a\b\ key2=c\d\ -key3=value`)) +key3=value`), IgnoreContinuation()) require.NoError(t, err) require.NotNil(t, f) @@ -990,7 +933,7 @@ key3=value`)) assert.Equal(t, "value", f.Section("").Key("key3").String()) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + f, err := Load([]byte(` key1=a\b\ key2=c\d\`)) require.NoError(t, err) @@ -1001,13 +944,10 @@ key2=c\d\`)) }) t.Run("ignore inline comments", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - IgnoreInlineComment: true, - }, []byte(` + f, err := Load([]byte(` key1=value ;comment key2=value2 #comment2 -key3=val#ue #comment3`)) +key3=val#ue #comment3`), IgnoreInlineComment()) require.NoError(t, err) require.NotNil(t, f) @@ -1016,7 +956,7 @@ key3=val#ue #comment3`)) assert.Equal(t, `val#ue #comment3`, f.Section("").Key("key3").String()) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + f, err := Load([]byte(` key1=value ;comment key2=value2 #comment2`)) require.NoError(t, err) @@ -1030,13 +970,10 @@ key2=value2 #comment2`)) }) t.Run("allow boolean type keys", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - AllowBooleanKeys: true, - }, []byte(` + f, err := Load([]byte(` key1=hello #key2 -key3`)) +key3`), AllowBooleanKeys()) require.NoError(t, err) require.NotNil(t, f) @@ -1056,7 +993,7 @@ key3 }) t.Run("inverse case", func(t *testing.T) { - _, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + _, err := Load([]byte(` key1=hello #key2 key3`)) @@ -1065,11 +1002,11 @@ key3`)) }) t.Run("allow shadow keys", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false, AllowShadows: true}, []byte(` + f, err := Load([]byte(` [remote "origin"] url = https://github.com/Antergone/test1.git url = https://github.com/Antergone/test2.git -fetch = +refs/heads/*:refs/remotes/origin/*`)) +fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows()) require.NoError(t, err) require.NotNil(t, f) @@ -1098,7 +1035,7 @@ fetch = +refs/heads/*:refs/remotes/origin/* }) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + f, err := Load([]byte(` [remote "origin"] url = https://github.com/Antergone/test1.git url = https://github.com/Antergone/test2.git`)) @@ -1110,18 +1047,15 @@ url = https://github.com/Antergone/test2.git`)) }) t.Run("unescape double quotes inside value", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - UnescapeValueDoubleQuotes: true, - }, []byte(` -create_repo="创建了仓库 %s"`)) + f, err := Load([]byte(` +create_repo="创建了仓库 %s"`), UnescapeValueDoubleQuotes()) require.NoError(t, err) require.NotNil(t, f) assert.Equal(t, `创建了仓库 %s`, f.Section("").Key("create_repo").String()) t.Run("inverse case", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + f, err := Load([]byte(` create_repo="创建了仓库 %s"`)) require.NoError(t, err) require.NotNil(t, f) @@ -1131,13 +1065,9 @@ create_repo="创建了仓库 %s"`)) }) t.Run("unescape comment symbols inside value", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - IgnoreInlineComment: true, - UnescapeValueCommentSymbols: true, - }, []byte(` + f, err := Load([]byte(` key = test value more text -`)) +`), IgnoreInlineComment(), UnescapeValueCommentSymbols()) require.NoError(t, err) require.NotNil(t, f) @@ -1145,7 +1075,7 @@ key = test value more text }) t.Run("cannot parse small python-compatible INI files", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + f, err := Load([]byte(` [long] long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- foo @@ -1156,11 +1086,11 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- `)) require.Error(t, err) assert.Nil(t, f) - assert.Equal(t, "key-value delimiter not found: foo\n", err.Error()) + assert.Equal(t, "line 4, column 3: key-value delimiter not found: foo\n", err.Error()) }) t.Run("cannot parse big python-compatible INI files", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + f, err := Load([]byte(` [long] long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- 1foo @@ -1263,15 +1193,11 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- `)) require.Error(t, err) assert.Nil(t, f) - assert.Equal(t, "key-value delimiter not found: 1foo\n", err.Error()) + assert.Equal(t, "line 4, column 3: key-value delimiter not found: 1foo\n", err.Error()) }) t.Run("allow unparsable sections", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: false, - Insensitive: true, - UnparseableSections: []string{"core_lesson", "comments"}, - }, []byte(` + f, err := Load([]byte(` Lesson_Location = 87 Lesson_Status = C Score = 3 @@ -1283,7 +1209,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [COMMENTS] <1> This slide has the fuel listed in the wrong units -`)) +`), Insensitive(), UnparseableSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -1316,7 +1242,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 }) t.Run("inverse case", func(t *testing.T) { - _, err := LoadSources(LoadOptions{AllowPythonMultilineValues: false}, []byte(` + _, err := Load([]byte(` [CORE_LESSON] my lesson state data – 1111111111111111111000000000000000001110000 111111111111111111100000000000111000000000 – end my lesson state data`)) @@ -1326,13 +1252,11 @@ my lesson state data – 1111111111111111111000000000000000001110000 t.Run("and false `SpaceBeforeInlineComment`", func(t *testing.T) { t.Run("cannot parse INI files containing `#` or `;` in value", func(t *testing.T) { - f, err := LoadSources( - LoadOptions{AllowPythonMultilineValues: true, SpaceBeforeInlineComment: false}, - []byte(` + f, err := Load([]byte(` [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n -`)) +`), AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) sec := f.Section("author") @@ -1345,13 +1269,11 @@ GITHUB = U;n;k;n;w;o;n t.Run("and true `SpaceBeforeInlineComment`", func(t *testing.T) { t.Run("can parse INI files containing `#` or `;` in value", func(t *testing.T) { - f, err := LoadSources( - LoadOptions{AllowPythonMultilineValues: true, SpaceBeforeInlineComment: true}, - []byte(` + f, err := Load([]byte(` [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n -`)) +`), AllowPythonMultilineValues(), SpaceBeforeInlineComment()) require.NoError(t, err) require.NotNil(t, f) sec := f.Section("author") @@ -1365,7 +1287,7 @@ GITHUB = U;n;k;n;w;o;n t.Run("with `ChildSectionDelimiter` ':'", func(t *testing.T) { t.Run("get all keys of parent sections", func(t *testing.T) { - f := Empty(LoadOptions{ChildSectionDelimiter: ":"}) + f := Empty(ChildSectionDelimiter(":")) require.NotNil(t, f) k, err := f.Section("package").NewKey("NAME", "ini") @@ -1387,7 +1309,7 @@ GITHUB = U;n;k;n;w;o;n }) t.Run("getting and setting values", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ChildSectionDelimiter: ":"}, fullConf) + f, err := Load(fullConf, ChildSectionDelimiter(":")) require.NoError(t, err) require.NotNil(t, f) @@ -1406,13 +1328,13 @@ GITHUB = U;n;k;n;w;o;n }) t.Run("get child sections by parent name", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ChildSectionDelimiter: ":"}, []byte(` + f, err := Load([]byte(` [node] [node:biz1] [node:biz2] [node.biz3] [node.bizN] -`)) +`), ChildSectionDelimiter(":")) require.NoError(t, err) require.NotNil(t, f) @@ -1427,7 +1349,7 @@ GITHUB = U;n;k;n;w;o;n t.Run("ShortCircuit", func(t *testing.T) { t.Run("load the first available configuration, ignore other configuration", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ShortCircuit: true}, minimalConf, []byte(`key1 = value1`)) + f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}, ShortCircuit()) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1441,13 +1363,13 @@ E-MAIL = u@gogs.io }) t.Run("return an error when fail to load", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ShortCircuit: true}, notFoundConf, minimalConf) + f, err := LoadSources([]any{notFoundConf, minimalConf}, ShortCircuit()) assert.Nil(t, f) require.Error(t, err) }) t.Run("used with Loose to ignore errors that the file does not exist", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ShortCircuit: true, Loose: true}, notFoundConf, minimalConf) + f, err := LoadSources([]any{notFoundConf, minimalConf}, ShortCircuit(), Loose()) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1461,7 +1383,7 @@ E-MAIL = u@gogs.io }) t.Run("ensure all sources are loaded without ShortCircuit", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ShortCircuit: false}, minimalConf, []byte(`key1 = value1`)) + f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1480,13 +1402,11 @@ E-MAIL = u@gogs.io func Test_KeyValueDelimiters(t *testing.T) { t.Run("custom key-value delimiters", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - KeyValueDelimiters: "?!", - }, []byte(` + f, err := Load([]byte(` [section] key1?value1 key2!value2 -`)) +`), KeyValueDelimiters("?!")) require.NoError(t, err) require.NotNil(t, f) @@ -1497,13 +1417,11 @@ key2!value2 func Test_PreserveSurroundedQuote(t *testing.T) { t.Run("preserve surrounded quote test", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - PreserveSurroundedQuote: true, - }, []byte(` + f, err := Load([]byte(` [section] key1 = "value1" key2 = value2 -`)) +`), PreserveSurroundedQuote()) require.NoError(t, err) require.NotNil(t, f) @@ -1512,9 +1430,7 @@ key2 = value2 }) t.Run("preserve surrounded quote test inverse test", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - PreserveSurroundedQuote: false, - }, []byte(` + f, err := Load([]byte(` [section] key1 = "value1" key2 = value2 @@ -1539,10 +1455,7 @@ func TestPythonMultiline(t *testing.T) { } path := filepath.Join("testdata", "multiline.ini") - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - ReaderBufferSize: 64 * 1024, - }, path) + f, err := Load(path, AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) assert.Len(t, f.Sections(), 1) @@ -1588,10 +1501,7 @@ func TestPythonMultiline_EOF(t *testing.T) { } path := filepath.Join("testdata", "multiline_eof.ini") - f, err := LoadSources(LoadOptions{ - AllowPythonMultilineValues: true, - ReaderBufferSize: 64 * 1024, - }, path) + f, err := Load(path, AllowPythonMultilineValues()) require.NoError(t, err) require.NotNil(t, f) assert.Len(t, f.Sections(), 1) @@ -1607,14 +1517,12 @@ func TestPythonMultiline_EOF(t *testing.T) { func Test_NestedValuesSpanningSections(t *testing.T) { t.Run("basic nested value", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNestedValues: true, - }, []byte(` + f, err := Load([]byte(` [section] key1 = value1 key2 = nested1 = nestedvalue1 -`)) +`), AllowNestedValues()) require.NoError(t, err) require.NotNil(t, f) @@ -1624,13 +1532,11 @@ key2 = }) t.Run("no nested values", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNestedValues: true, - }, []byte(` + f, err := Load([]byte(` [section] key1 = value1 key2 = -`)) +`), AllowNestedValues()) require.NoError(t, err) require.NotNil(t, f) @@ -1639,16 +1545,14 @@ key2 = }) t.Run("no nested values and following sections", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNestedValues: true, - }, []byte(` + f, err := Load([]byte(` [section] key1 = value1 key2 = [section2] key3 = value3 -`)) +`), AllowNestedValues()) require.NoError(t, err) require.NotNil(t, f) @@ -1658,16 +1562,14 @@ key3 = value3 }) t.Run("no nested values and following sections with indentation", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNestedValues: true, - }, []byte(` + f, err := Load([]byte(` [section] key1 = value1 key2 = [section2] key3 = value3 -`)) +`), AllowNestedValues()) require.NoError(t, err) require.NotNil(t, f) diff --git a/key_test.go b/key_test.go index e57a7bd..12e2f38 100644 --- a/key_test.go +++ b/key_test.go @@ -28,9 +28,9 @@ import ( func TestKey_AddShadow(t *testing.T) { t.Run("add shadow to a key", func(t *testing.T) { - f, err := ShadowLoad([]byte(` + f, err := Load([]byte(` [notes] --: note1`)) +-: note1`), AllowShadows()) require.NoError(t, err) require.NotNil(t, f) @@ -67,10 +67,7 @@ func TestKey_AddShadow(t *testing.T) { }) t.Run("allow duplicate shadowed values", func(t *testing.T) { - f := Empty(LoadOptions{ - AllowShadows: true, - AllowDuplicateShadowValues: true, - }) + f := Empty(AllowShadows(), AllowDuplicateShadowValues()) require.NotNil(t, f) k, err := f.Section("").NewKey("NAME", "ini") @@ -531,10 +528,10 @@ func TestKey_Helpers(t *testing.T) { func TestKey_ValueWithShadows(t *testing.T) { t.Run("", func(t *testing.T) { - f, err := ShadowLoad([]byte(` + f, err := Load([]byte(` keyName = value1 keyName = value2 -`)) +`), AllowShadows()) require.NoError(t, err) require.NotNil(t, f) @@ -550,7 +547,7 @@ keyName = value2 func TestKey_StringsWithShadows(t *testing.T) { t.Run("get strings of shadows of a key", func(t *testing.T) { - f, err := ShadowLoad([]byte("")) + f, err := Load([]byte(""), AllowShadows()) require.NoError(t, err) require.NotNil(t, f) @@ -582,15 +579,13 @@ func TestKey_SetValue(t *testing.T) { func TestKey_NestedValues(t *testing.T) { t.Run("read and write nested values", func(t *testing.T) { - f, err := LoadSources(LoadOptions{ - AllowNestedValues: true, - }, []byte(` + f, err := Load([]byte(` aws_access_key_id = foo aws_secret_access_key = bar region = us-west-2 s3 = max_concurrent_requests=10 - max_queue_size=1000`)) + max_queue_size=1000`), AllowNestedValues()) require.NoError(t, err) require.NotNil(t, f) diff --git a/lexer.go b/lexer.go index 66c23b1..ccfbc76 100644 --- a/lexer.go +++ b/lexer.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" ) type tokenKind uint8 @@ -34,10 +35,16 @@ const ( ) type token struct { - kind tokenKind - name string - value string - comment string + kind tokenKind + name string + value string + comment string + line, column int // where the token starts, both from 1 +} + +// errorf reports err at the position of the token. +func (t token) errorf(err error) error { + return &ParseError{Line: t.line, Column: t.column, Err: err} } // lexer turns INI input into tokens. It follows the v1 parser rule for rule, @@ -47,7 +54,8 @@ type lexer struct { src string pos int // offset of the next unread byte eof bool // the last line read had no '\n' - opts *LoadOptions + opts *loadOptions + line int // number of the last line read, from 1 comment []string // comment pieces for the next section or key lastValueEmpty bool // the last key had an empty value, so indented lines are nested values @@ -59,29 +67,43 @@ type lexer struct { func (l *lexer) next() (token, error) { for !l.eof { raw := l.readLine() + lineNum := l.line + column := func(s string) int { return utf8.RuneCountInString(raw[:len(raw)-len(s)]) + 1 } if l.opts.AllowNestedValues && l.lastValueEmpty && raw != "" && (raw[0] == ' ' || raw[0] == '\t') { - return token{kind: tokNestedValue, value: strings.TrimSpace(raw)}, nil + value := strings.TrimSpace(raw) + return token{kind: tokNestedValue, value: value, line: lineNum, column: column(strings.TrimLeftFunc(raw, unicode.IsSpace))}, nil } line := strings.TrimLeftFunc(raw, unicode.IsSpace) + var tok token + var err error switch { case line == "": - case line[0] == '#' || line[0] == ';': + continue + case l.isCommentSymbol(line[0]): // Keep the line break so comment lines stay on separate lines. l.comment = append(l.comment, line) + continue case line[0] == '[': if l.raw.Len() > 0 { l.unread(raw) return l.rawBody(), nil } - return l.section(line) + tok, err = l.section(line) case l.inRaw: l.raw.WriteString(line) + continue default: - if tok, ok, err := l.key(line); ok || err != nil { - return tok, err + var ok bool + if tok, ok, err = l.key(line); !ok && err == nil { + continue } } + if err != nil { + return token{}, &ParseError{Line: lineNum, Column: column(line), Err: err} + } + tok.line, tok.column = lineNum, column(line) + return tok, nil } if l.raw.Len() > 0 { return l.rawBody(), nil @@ -95,9 +117,11 @@ func (l *lexer) readLine() string { i := strings.IndexByte(rest, '\n') if i < 0 { l.pos, l.eof = len(l.src), true + l.line++ return rest } l.pos += i + 1 + l.line++ return rest[:i+1] } @@ -105,6 +129,11 @@ func (l *lexer) readLine() string { func (l *lexer) unread(line string) { l.pos -= len(line) l.eof = false + l.line-- +} + +func (l *lexer) isCommentSymbol(c byte) bool { + return strings.IndexByte(l.opts.CommentSymbols, c) > -1 } func (l *lexer) takeComment() string { @@ -132,7 +161,7 @@ func (l *lexer) section(line string) (token, error) { return token{}, fmt.Errorf("unclosed section: %s", line) } name := line[1:closeIdx] - if i := strings.IndexAny(line[closeIdx+1:], "#;"); i > -1 { + if i := strings.IndexAny(line[closeIdx+1:], l.opts.CommentSymbols); i > -1 { l.comment = append(l.comment, line[closeIdx+1+i:]) } @@ -206,8 +235,12 @@ func (l *lexer) readValue(in string) (string, error) { case !l.opts.PreserveSurroundedQuote && (hasSurroundedQuote(line, '\'') || hasSurroundedQuote(line, '"')): return line[1 : len(line)-1], nil case l.opts.UnescapeValueCommentSymbols: - line = strings.ReplaceAll(line, `\;`, ";") - return strings.ReplaceAll(line, `\#`, "#"), nil + // Last symbol first, as v1 unescaped \; before \#. + for i := len(l.opts.CommentSymbols) - 1; i >= 0; i-- { + c := l.opts.CommentSymbols[i : i+1] + line = strings.ReplaceAll(line, `\`+c, c) + } + return line, nil case l.opts.AllowPythonMultilineValues && endsLine: return l.readPythonMultilines(line), nil } @@ -256,7 +289,7 @@ func (l *lexer) readQuoted(line, q string) (string, error) { b.WriteString(next) default: b.WriteString(next[:i]) - if j := strings.IndexAny(next[i:], "#;"); j > -1 { + if j := strings.IndexAny(next[i:], l.opts.CommentSymbols); j > -1 { l.comment = append(l.comment, strings.TrimSpace(next[i+j:])) } return b.String(), nil @@ -266,15 +299,17 @@ func (l *lexer) readQuoted(line, q string) (string, error) { // cutInlineComment moves a trailing "# ..." or "; ..." of a plain value to // the comment. With SpaceBeforeInlineComment the symbol must follow a space, -// and " #" wins over an earlier " ;". +// and symbols are tried in order, so " #" wins over an earlier " ;". func (l *lexer) cutInlineComment(line string) string { - var i int + i := -1 if l.opts.SpaceBeforeInlineComment { - if i = strings.Index(line, " #"); i == -1 { - i = strings.Index(line, " ;") + for _, c := range []byte(l.opts.CommentSymbols) { + if i = strings.Index(line, " "+string(c)); i > -1 { + break + } } } else { - i = strings.IndexAny(line, "#;") + i = strings.IndexAny(line, l.opts.CommentSymbols) } if i == -1 { return line @@ -338,9 +373,9 @@ func (f *File) parse(reader io.Reader) error { l := &lexer{src: skipBOM(b.String()), opts: &f.options} // Ignore error because default section name is never empty string. - name := DefaultSection + name := f.options.DefaultSection if f.options.Insensitive || f.options.InsensitiveSections { - name = strings.ToLower(DefaultSection) + name = strings.ToLower(name) } section, _ := f.NewSection(name) @@ -358,7 +393,7 @@ func (f *File) parse(reader io.Reader) error { case tokSection: if section, err = f.NewSection(tok.name); err != nil { - return err + return tok.errorf(err) } section.Comment = tok.comment count = 1 @@ -369,13 +404,13 @@ func (f *File) parse(reader io.Reader) error { case tokNestedValue: if err = lastRegularKey.addNestedValue(tok.value); err != nil { - return err + return tok.errorf(err) } case tokBooleanKey: key, err := section.NewBooleanKey(tok.name) if err != nil { - return err + return tok.errorf(err) } key.Comment = tok.comment @@ -387,7 +422,7 @@ func (f *File) parse(reader io.Reader) error { } key, err := section.NewKey(name, tok.value) if err != nil { - return err + return tok.errorf(err) } key.isAutoIncrement = isAutoIncr key.Comment = tok.comment diff --git a/options.go b/options.go new file mode 100644 index 0000000..8341091 --- /dev/null +++ b/options.go @@ -0,0 +1,276 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "os" + "runtime" + "strings" +) + +// LoadOption configures how data sources are loaded and parsed. Without +// options, parsing behaves exactly as v1's Load. When two options set the +// same thing, the last one wins. +type LoadOption func(*loadOptions) + +// loadOptions holds the settings that LoadOptions apply. A File keeps them, +// so Reload and Append parse the same way as the first load. +type loadOptions struct { + Loose bool + Insensitive bool + InsensitiveSections bool + InsensitiveKeys bool + IgnoreContinuation bool + IgnoreInlineComment bool + SkipUnrecognizableLines bool + ShortCircuit bool + AllowBooleanKeys bool + AllowShadows bool + AllowNestedValues bool + AllowPythonMultilineValues bool + SpaceBeforeInlineComment bool + UnescapeValueDoubleQuotes bool + UnescapeValueCommentSymbols bool + UnparseableSections []string + KeyValueDelimiters string + ChildSectionDelimiter string + PreserveSurroundedQuote bool + DebugFunc DebugFunc + AllowNonUniqueSections bool + AllowDuplicateShadowValues bool + DefaultSection string + CommentSymbols string +} + +func newLoadOptions(opts []LoadOption) loadOptions { + o := loadOptions{ + KeyValueDelimiters: "=:", + ChildSectionDelimiter: ".", + DefaultSection: DefaultSection, + CommentSymbols: "#;", + } + for _, opt := range opts { + opt(&o) + } + return o +} + +// DebugFunc is the type of function called to log parse events. +type DebugFunc func(message string) + +// Loose ignores data sources that are files which don't exist, instead of +// returning an error. +func Loose() LoadOption { return func(o *loadOptions) { o.Loose = true } } + +// Insensitive lowercases all section and key names. +func Insensitive() LoadOption { return func(o *loadOptions) { o.Insensitive = true } } + +// InsensitiveSections lowercases all section names. +func InsensitiveSections() LoadOption { return func(o *loadOptions) { o.InsensitiveSections = true } } + +// InsensitiveKeys lowercases all key names. +func InsensitiveKeys() LoadOption { return func(o *loadOptions) { o.InsensitiveKeys = true } } + +// IgnoreContinuation treats a backslash at the end of a line as part of the +// value instead of joining the next line. +func IgnoreContinuation() LoadOption { return func(o *loadOptions) { o.IgnoreContinuation = true } } + +// IgnoreInlineComment keeps comment symbols after a value as part of it. +// Comments on lines of their own still work. +func IgnoreInlineComment() LoadOption { return func(o *loadOptions) { o.IgnoreInlineComment = true } } + +// SkipUnrecognizableLines skips lines that are not sections, keys or +// comments instead of returning an error. +func SkipUnrecognizableLines() LoadOption { + return func(o *loadOptions) { o.SkipUnrecognizableLines = true } +} + +// ShortCircuit stops loading after the first data source that loads. +func ShortCircuit() LoadOption { return func(o *loadOptions) { o.ShortCircuit = true } } + +// AllowBooleanKeys reads a line without a delimiter as a key whose value is +// "true", as in my.cnf. +func AllowBooleanKeys() LoadOption { return func(o *loadOptions) { o.AllowBooleanKeys = true } } + +// AllowShadows keeps every value of a key that repeats in a section, +// instead of only the last one. +func AllowShadows() LoadOption { return func(o *loadOptions) { o.AllowShadows = true } } + +// AllowNestedValues reads indented lines under a key with an empty value as +// AWS-style nested values. +// Docs: http://docs.aws.amazon.com/cli/latest/topic/config-vars.html#nested-values +func AllowNestedValues() LoadOption { return func(o *loadOptions) { o.AllowNestedValues = true } } + +// AllowPythonMultilineValues continues a value on the following lines that +// are indented. +// Docs: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure +func AllowPythonMultilineValues() LoadOption { + return func(o *loadOptions) { o.AllowPythonMultilineValues = true } +} + +// SpaceBeforeInlineComment starts an inline comment only at a comment symbol +// that follows a space, so values can contain comment symbols. +func SpaceBeforeInlineComment() LoadOption { + return func(o *loadOptions) { o.SpaceBeforeInlineComment = true } +} + +// UnescapeValueDoubleQuotes unescapes \" inside values surrounded by double +// quotes, e.g. key="a \"value\"" is `a "value"`. +func UnescapeValueDoubleQuotes() LoadOption { + return func(o *loadOptions) { o.UnescapeValueDoubleQuotes = true } +} + +// UnescapeValueCommentSymbols unescapes \# and \; in values that are not +// surrounded by quotes. +func UnescapeValueCommentSymbols() LoadOption { + return func(o *loadOptions) { o.UnescapeValueCommentSymbols = true } +} + +// UnparseableSections keeps the bodies of the named sections as raw text, +// available from Section.Body. +func UnparseableSections(names ...string) LoadOption { + return func(o *loadOptions) { o.UnparseableSections = names } +} + +// KeyValueDelimiters sets the characters that separate a key from its value. +// The default is "=:"; an empty string keeps the default. +func KeyValueDelimiters(delimiters string) LoadOption { + return func(o *loadOptions) { + if delimiters != "" { + o.KeyValueDelimiters = delimiters + } + } +} + +// ChildSectionDelimiter sets the separator between parent and child section +// names. The default is "."; an empty string keeps the default. +func ChildSectionDelimiter(delimiter string) LoadOption { + return func(o *loadOptions) { + if delimiter != "" { + o.ChildSectionDelimiter = delimiter + } + } +} + +// PreserveSurroundedQuote keeps single and double quotes around values. +func PreserveSurroundedQuote() LoadOption { + return func(o *loadOptions) { o.PreserveSurroundedQuote = true } +} + +// Debug calls fn with debug information while parsing, currently about +// Python-style multiline values. +func Debug(fn DebugFunc) LoadOption { return func(o *loadOptions) { o.DebugFunc = fn } } + +// AllowNonUniqueSections keeps sections that repeat a name as separate +// sections, instead of merging them. +func AllowNonUniqueSections() LoadOption { + return func(o *loadOptions) { o.AllowNonUniqueSections = true } +} + +// AllowDuplicateShadowValues keeps shadow values that repeat an earlier +// value of the key. By default they are dropped. +func AllowDuplicateShadowValues() LoadOption { + return func(o *loadOptions) { o.AllowDuplicateShadowValues = true } +} + +// DefaultSectionName names the section that holds keys before the first +// section header, such as "default" for AWS config files. The default is +// DefaultSection; an empty string keeps the default. +func DefaultSectionName(name string) LoadOption { + return func(o *loadOptions) { + if name != "" { + o.DefaultSection = name + } + } +} + +// CommentSymbols sets the characters that start a comment. The default is +// "#;"; an empty string means no line is a comment. +func CommentSymbols(symbols string) LoadOption { + return func(o *loadOptions) { o.CommentSymbols = symbols } +} + +// WriteOption configures how a File is written. +type WriteOption func(*writeOptions) + +type writeOptions struct { + indent string + lineBreak string + keyValueDelimiter string + defaultHeader bool + prettySection bool + prettyFormat bool + prettyEqual bool + formatLeft string + formatRight string +} + +// inTest keeps "\n" line breaks in this package's tests on Windows. +var inTest = len(os.Args) > 0 && strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test") + +func newWriteOptions(opts []WriteOption) writeOptions { + o := writeOptions{ + lineBreak: "\n", + keyValueDelimiter: "=", + prettySection: true, + prettyFormat: true, + } + if runtime.GOOS == "windows" && !inTest { + o.lineBreak = "\r\n" + } + for _, opt := range opts { + opt(&o) + } + return o +} + +// Indent puts indent before the keys and comments of every section except +// the default one. +func Indent(indent string) WriteOption { return func(o *writeOptions) { o.indent = indent } } + +// LineBreak sets the line break to write. The default is "\r\n" on Windows +// and "\n" elsewhere. +func LineBreak(lineBreak string) WriteOption { + return func(o *writeOptions) { o.lineBreak = lineBreak } +} + +// KeyValueDelimiterOnWrite sets the delimiter written between keys and +// values. The default is "=". +func KeyValueDelimiterOnWrite(delimiter string) WriteOption { + return func(o *writeOptions) { o.keyValueDelimiter = delimiter } +} + +// DefaultHeader writes the header of the default section. +func DefaultHeader() WriteOption { return func(o *writeOptions) { o.defaultHeader = true } } + +// PrettySection sets whether to put an empty line between sections. The +// default is true. +func PrettySection(pretty bool) WriteOption { + return func(o *writeOptions) { o.prettySection = pretty } +} + +// PrettyFormat sets whether to align the delimiters of a section with +// spaces. The default is true. +func PrettyFormat(pretty bool) WriteOption { return func(o *writeOptions) { o.prettyFormat = pretty } } + +// PrettyEqual puts spaces around the delimiter even when PrettyFormat is +// false. +func PrettyEqual() WriteOption { return func(o *writeOptions) { o.prettyEqual = true } } + +// DelimiterSpaces puts left and right around the delimiter when neither +// PrettyFormat nor PrettyEqual applies. +func DelimiterSpaces(left, right string) WriteOption { + return func(o *writeOptions) { o.formatLeft, o.formatRight = left, right } +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..8e1de15 --- /dev/null +++ b/options_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteOptions(t *testing.T) { + f, err := Load([]byte("a = 1\n[s]\nbb = 2\n")) + require.NoError(t, err) + + // Expected output is what v1 wrote with the same package globals set. + var b bytes.Buffer + _, err = f.Write(&b, PrettyFormat(false), PrettySection(false), DelimiterSpaces("", " "), LineBreak("\r\n"), DefaultHeader()) + require.NoError(t, err) + assert.Equal(t, "[DEFAULT]\r\na= 1\r\n[s]\r\nbb= 2\r\n", b.String()) + + // Options apply to one call only. + b.Reset() + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "a = 1\n\n[s]\nbb = 2\n", b.String()) +} + +func TestDefaultSectionName(t *testing.T) { + f, err := Load([]byte("a = 1\n[default]\nb = 2\n[profile x]\nc = 3\n"), DefaultSectionName("default")) + require.NoError(t, err) + + assert.Equal(t, []string{"default", "profile x"}, f.SectionStrings()) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, f.Section("").KeysHash()) + + var b bytes.Buffer + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "[default]\na = 1\nb = 2\n\n[profile x]\nc = 3\n", b.String()) +} + +func TestCommentSymbols(t *testing.T) { + f, err := Load([]byte("// header\n[s] // section\nurl = http://x/#a;b // note\nk = a\\/\\/b\n"), + CommentSymbols("/"), UnescapeValueCommentSymbols(), SpaceBeforeInlineComment()) + require.NoError(t, err) + + sec := f.Section("s") + assert.Equal(t, "// header\n// section", sec.Comment) + assert.Equal(t, "http://x/#a;b", sec.Key("url").String()) + assert.Equal(t, "// note", sec.Key("url").Comment) + assert.Equal(t, "a//b", sec.Key("k").String()) + + t.Run("written comments use the configured symbol", func(t *testing.T) { + f := Empty(CommentSymbols("#")) + k, err := f.Section("").NewKey("k", "v;w") + require.NoError(t, err) + k.Comment = "note" + + var b bytes.Buffer + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "# note\nk = v;w\n", b.String()) + }) +} + +func TestParseErrorPosition(t *testing.T) { + tests := []struct { + name string + data string + line, column int + is func(error) bool + }{ + {"missing delimiter", "a = 1\n[s]\n\u00a0\u00a0été x\n", 3, 3, IsErrDelimiterNotFound}, + {"empty key name", "a = 1\n\n= 2\n", 3, 1, IsErrEmptyKeyName}, + {"unclosed section", "a = 1\n\t[s\n", 2, 2, nil}, + {"unclosed quote", "a = 1\nk = `x\ny\n", 2, 1, nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := Load([]byte(test.data)) + var perr *ParseError + require.True(t, errors.As(err, &perr), "error %v", err) + assert.Equal(t, test.line, perr.Line) + assert.Equal(t, test.column, perr.Column) + if test.is != nil { + assert.True(t, test.is(err)) + } + }) + } +} diff --git a/parity_test.go b/parity_test.go index 327af3f..badabb8 100644 --- a/parity_test.go +++ b/parity_test.go @@ -15,6 +15,7 @@ package ini import ( + "errors" "fmt" "math/rand" "os" @@ -60,27 +61,37 @@ func parityOptions(flags uint32) v1.LoadOptions { } // v2Options returns the v2 equivalent of v1 load options. -func v2Options(o v1.LoadOptions) LoadOptions { - return LoadOptions{ - Insensitive: o.Insensitive, - InsensitiveSections: o.InsensitiveSections, - InsensitiveKeys: o.InsensitiveKeys, - IgnoreContinuation: o.IgnoreContinuation, - IgnoreInlineComment: o.IgnoreInlineComment, - SkipUnrecognizableLines: o.SkipUnrecognizableLines, - AllowBooleanKeys: o.AllowBooleanKeys, - AllowShadows: o.AllowShadows, - AllowNestedValues: o.AllowNestedValues, - AllowPythonMultilineValues: o.AllowPythonMultilineValues, - SpaceBeforeInlineComment: o.SpaceBeforeInlineComment, - UnescapeValueDoubleQuotes: o.UnescapeValueDoubleQuotes, - UnescapeValueCommentSymbols: o.UnescapeValueCommentSymbols, - PreserveSurroundedQuote: o.PreserveSurroundedQuote, - AllowNonUniqueSections: o.AllowNonUniqueSections, - AllowDuplicateShadowValues: o.AllowDuplicateShadowValues, - UnparseableSections: o.UnparseableSections, - KeyValueDelimiters: o.KeyValueDelimiters, +func v2Options(o v1.LoadOptions) []LoadOption { + var opts []LoadOption + for _, flag := range []struct { + set bool + opt func() LoadOption + }{ + {o.Insensitive, Insensitive}, + {o.InsensitiveSections, InsensitiveSections}, + {o.InsensitiveKeys, InsensitiveKeys}, + {o.IgnoreContinuation, IgnoreContinuation}, + {o.IgnoreInlineComment, IgnoreInlineComment}, + {o.SkipUnrecognizableLines, SkipUnrecognizableLines}, + {o.AllowBooleanKeys, AllowBooleanKeys}, + {o.AllowShadows, AllowShadows}, + {o.AllowNestedValues, AllowNestedValues}, + {o.AllowPythonMultilineValues, AllowPythonMultilineValues}, + {o.SpaceBeforeInlineComment, SpaceBeforeInlineComment}, + {o.UnescapeValueDoubleQuotes, UnescapeValueDoubleQuotes}, + {o.UnescapeValueCommentSymbols, UnescapeValueCommentSymbols}, + {o.PreserveSurroundedQuote, PreserveSurroundedQuote}, + {o.AllowNonUniqueSections, AllowNonUniqueSections}, + {o.AllowDuplicateShadowValues, AllowDuplicateShadowValues}, + } { + if flag.set { + opts = append(opts, flag.opt()) + } + } + if o.UnparseableSections != nil { + opts = append(opts, UnparseableSections(o.UnparseableSections...)) } + return append(opts, KeyValueDelimiters(o.KeyValueDelimiters)) } // dumpFile describes everything stored in a v1 or v2 *File. It reads the @@ -127,6 +138,11 @@ func loadResult(load func() (interface{}, error)) (out string) { } }() f, err := load() + // v2 adds the position to parse errors; compare the error under it. + var perr *ParseError + if errors.As(err, &perr) { + err = perr.Err + } if err != nil { return "error: " + err.Error() } @@ -144,7 +160,7 @@ func checkParity(t *testing.T, data []byte, flags uint32) { } want := loadResult(func() (interface{}, error) { return v1.LoadSources(opts, data, data) }) - got := loadResult(func() (interface{}, error) { return LoadSources(v2Options(opts), data, data) }) + got := loadResult(func() (interface{}, error) { return LoadSources([]any{data, data}, v2Options(opts)...) }) if want != got { t.Fatalf("v1 and v2 differ\ninput: %q\noptions: %+v\nv1:\n%s\nv2:\n%s", data, opts, want, got) } @@ -312,7 +328,7 @@ func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { } b.WriteString("next = ok\n") - f, err := LoadSources(LoadOptions{AllowPythonMultilineValues: true}, []byte(b.String())) + f, err := Load([]byte(b.String()), AllowPythonMultilineValues()) if err != nil { t.Fatal(err) } diff --git a/section_test.go b/section_test.go index bc45cbb..ecfa658 100644 --- a/section_test.go +++ b/section_test.go @@ -75,7 +75,7 @@ func TestSection_NewKey(t *testing.T) { }) t.Run("create keys with same name and allow shadow", func(t *testing.T) { - f, err := ShadowLoad([]byte("")) + f, err := Load([]byte(""), AllowShadows()) require.NoError(t, err) require.NotNil(t, f) @@ -272,7 +272,7 @@ func TestSection_KeyStrings(t *testing.T) { func TestSection_KeyHash(t *testing.T) { t.Run("get clone of key hash", func(t *testing.T) { - f, err := Load([]byte(` + f, err := LoadSources([]any{[]byte(` key = one [log] name = app @@ -282,7 +282,7 @@ key = two [log] name = app2 file = b.log -`)) +`)}) require.NoError(t, err) require.NotNil(t, f) diff --git a/struct.go b/struct.go index 819a6e5..ec227d5 100644 --- a/struct.go +++ b/struct.go @@ -423,9 +423,10 @@ func (f *File) StrictMapTo(v interface{}) error { return f.Section("").StrictMapTo(v) } -// MapToWithMapper maps data sources to given struct with name mapper. -func MapToWithMapper(v interface{}, mapper NameMapper, source interface{}, others ...interface{}) error { - cfg, err := Load(source, others...) +// MapToWithMapper loads a data source with opts and maps it to the given +// struct with the name mapper. +func MapToWithMapper(v any, mapper NameMapper, source any, opts ...LoadOption) error { + cfg, err := Load(source, opts...) if err != nil { return err } @@ -433,10 +434,11 @@ func MapToWithMapper(v interface{}, mapper NameMapper, source interface{}, other return cfg.MapTo(v) } -// StrictMapToWithMapper maps data sources to given struct with name mapper in strict mode, -// which returns all possible error including value parsing error. -func StrictMapToWithMapper(v interface{}, mapper NameMapper, source interface{}, others ...interface{}) error { - cfg, err := Load(source, others...) +// StrictMapToWithMapper loads a data source with opts and maps it to the +// given struct with the name mapper in strict mode, which returns all +// possible error including value parsing error. +func StrictMapToWithMapper(v any, mapper NameMapper, source any, opts ...LoadOption) error { + cfg, err := Load(source, opts...) if err != nil { return err } @@ -444,15 +446,16 @@ func StrictMapToWithMapper(v interface{}, mapper NameMapper, source interface{}, return cfg.StrictMapTo(v) } -// MapTo maps data sources to given struct. -func MapTo(v, source interface{}, others ...interface{}) error { - return MapToWithMapper(v, nil, source, others...) +// MapTo loads a data source with opts and maps it to the given struct. +func MapTo(v, source any, opts ...LoadOption) error { + return MapToWithMapper(v, nil, source, opts...) } -// StrictMapTo maps data sources to given struct in strict mode, -// which returns all possible error including value parsing error. -func StrictMapTo(v, source interface{}, others ...interface{}) error { - return StrictMapToWithMapper(v, nil, source, others...) +// StrictMapTo loads a data source with opts and maps it to the given struct +// in strict mode, which returns all possible error including value parsing +// error. +func StrictMapTo(v, source any, opts ...LoadOption) error { + return StrictMapToWithMapper(v, nil, source, opts...) } // reflectSliceWithProperType does the opposite thing as setSliceWithProperType. @@ -687,7 +690,7 @@ func (s *Section) ReflectFrom(v interface{}) error { typ := reflect.TypeOf(v) val := reflect.ValueOf(v) - if s.name != DefaultSection && s.f.options.AllowNonUniqueSections && + if s.name != s.f.options.DefaultSection && s.f.options.AllowNonUniqueSections && (typ.Kind() == reflect.Slice || typ.Kind() == reflect.Pointer) { // Clear sections to make sure none exists before adding the new ones s.f.DeleteSection(s.name) diff --git a/struct_test.go b/struct_test.go index ad6ad04..50c6ad3 100644 --- a/struct_test.go +++ b/struct_test.go @@ -315,7 +315,7 @@ func Test_MapToStruct(t *testing.T) { }) t.Run("map with shadows", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowShadows: true}, []byte(confDataStruct)) + f, err := Load([]byte(confDataStruct), AllowShadows()) require.NoError(t, err) ts := new(testStruct) assert.NoError(t, f.MapTo(ts)) @@ -388,7 +388,7 @@ names=alice, bruce`)) func Test_MapToStructNonUniqueSections(t *testing.T) { t.Run("map to struct non unique", func(t *testing.T) { t.Run("map file to struct non unique", func(t *testing.T) { - f, err := LoadSources(LoadOptions{AllowNonUniqueSections: true}, []byte(confNonUniqueSectionDataStruct)) + f, err := Load([]byte(confNonUniqueSectionDataStruct), AllowNonUniqueSections()) require.NoError(t, err) ts := new(testNonUniqueSectionsStruct) @@ -413,7 +413,7 @@ func Test_MapToStructNonUniqueSections(t *testing.T) { newPeer := new(testPeer) newPeerSlice := make([]testPeer, 0) - f, err := LoadSources(LoadOptions{AllowNonUniqueSections: true}, []byte(confNonUniqueSectionDataStruct)) + f, err := Load([]byte(confNonUniqueSectionDataStruct), AllowNonUniqueSections()) require.NoError(t, err) // try only first one @@ -437,7 +437,7 @@ func Test_MapToStructNonUniqueSections(t *testing.T) { }) t.Run("map non unique sections with subsections to struct", func(t *testing.T) { - iniFile, err := LoadSources(LoadOptions{AllowNonUniqueSections: true}, strings.NewReader(` + iniFile, err := Load(strings.NewReader(` [Section] FieldInSubSection = 1 FieldInSubSection2 = 2 @@ -447,7 +447,7 @@ FieldInSection = 3 FieldInSubSection = 4 FieldInSubSection2 = 5 FieldInSection = 6 -`)) +`), AllowNonUniqueSections()) require.NoError(t, err) type SubSection struct { @@ -649,9 +649,7 @@ func Test_ReflectFromStructNonUniqueSections(t *testing.T) { }, } - cfg := Empty(LoadOptions{ - AllowNonUniqueSections: true, - }) + cfg := Empty(AllowNonUniqueSections()) assert.NoError(t, ReflectFrom(cfg, nonUnique)) @@ -731,12 +729,10 @@ func TestMapToAndReflectFromStructWithShadows(t *testing.T) { Paths []string `ini:"path,omitempty,allowshadow"` } - cfg, err := LoadSources(LoadOptions{ - AllowShadows: true, - }, []byte(` + cfg, err := Load([]byte(` [include] path = /tmp/gpm-profiles/test5.profile -path = /tmp/gpm-profiles/test1.profile`)) +path = /tmp/gpm-profiles/test1.profile`), AllowShadows()) require.NoError(t, err) sec := cfg.Section("include") @@ -758,9 +754,7 @@ path = /tmp/gpm-profiles/test1.profile ) t.Run("reflect from struct with shadows", func(t *testing.T) { - cfg := Empty(LoadOptions{ - AllowShadows: true, - }) + cfg := Empty(AllowShadows()) type ShadowStruct struct { StringArray []string `ini:"sa,allowshadow"` EmptyStringArrat []string `ini:"empty,omitempty,allowshadow"` From 941bf9584b4ae32e583f5e34834cc1a1e9a12a2d Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:23:53 +0000 Subject: [PATCH 05/41] Add Key.IsBooleanType and the KeepEmptyShadowValues option IsBooleanType reports whether a key was read as a boolean key (#279). KeepEmptyShadowValues keeps empty values in ValueWithShadows and when writing shadow keys (#366, #342). In systemd units an empty assignment resets the values before it, which ValueWithShadows dropped since #316. Without the option the result is unchanged. --- key.go | 23 ++++++++++++----------- options.go | 8 ++++++++ options_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/key.go b/key.go index 30293c1..2809638 100644 --- a/key.go +++ b/key.go @@ -114,28 +114,29 @@ func (k *Key) Value() string { return k.value } -// ValueWithShadows returns raw values of key and its shadows if any. Shadow -// keys with empty values are ignored from the returned list. +// ValueWithShadows returns raw values of key and its shadows if any. Empty +// values are left out, unless the file was loaded with +// KeepEmptyShadowValues. func (k *Key) ValueWithShadows() []string { - if len(k.shadows) == 0 { - if k.value == "" { - return []string{} - } - return []string{k.value} - } - + keepEmpty := k.s.f.options.KeepEmptyShadowValues vals := make([]string, 0, len(k.shadows)+1) - if k.value != "" { + if keepEmpty || k.value != "" { vals = append(vals, k.value) } for _, s := range k.shadows { - if s.value != "" { + if keepEmpty || s.value != "" { vals = append(vals, s.value) } } return vals } +// IsBooleanType reports whether the key was read as a boolean key, a line +// without a delimiter loaded with AllowBooleanKeys. +func (k *Key) IsBooleanType() bool { + return k.isBooleanType +} + // NestedValues returns nested values stored in the key. // It is possible returned value is nil if no nested values stored in the key. func (k *Key) NestedValues() []string { diff --git a/options.go b/options.go index 8341091..bb719ff 100644 --- a/options.go +++ b/options.go @@ -52,6 +52,7 @@ type loadOptions struct { AllowDuplicateShadowValues bool DefaultSection string CommentSymbols string + KeepEmptyShadowValues bool } func newLoadOptions(opts []LoadOption) loadOptions { @@ -185,6 +186,13 @@ func AllowDuplicateShadowValues() LoadOption { return func(o *loadOptions) { o.AllowDuplicateShadowValues = true } } +// KeepEmptyShadowValues keeps empty values in Key.ValueWithShadows and when +// writing shadow keys. An empty value can be meaningful: in systemd units it +// resets the values before it. +func KeepEmptyShadowValues() LoadOption { + return func(o *loadOptions) { o.KeepEmptyShadowValues = true } +} + // DefaultSectionName names the section that holds keys before the first // section header, such as "default" for AWS config files. The default is // DefaultSection; an empty string keeps the default. diff --git a/options_test.go b/options_test.go index 8e1de15..a3bb746 100644 --- a/options_test.go +++ b/options_test.go @@ -102,3 +102,27 @@ func TestParseErrorPosition(t *testing.T) { }) } } + +func TestKeepEmptyShadowValues(t *testing.T) { + data := []byte("[Service]\nExecStart=/bin/a\nExecStart=\nExecStart=/bin/b\n") + + f, err := Load(data, AllowShadows()) + require.NoError(t, err) + assert.Equal(t, []string{"/bin/a", "/bin/b"}, f.Section("Service").Key("ExecStart").ValueWithShadows()) + + f, err = Load(data, AllowShadows(), KeepEmptyShadowValues()) + require.NoError(t, err) + assert.Equal(t, []string{"/bin/a", "", "/bin/b"}, f.Section("Service").Key("ExecStart").ValueWithShadows()) + + var b bytes.Buffer + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "[Service]\nExecStart = /bin/a\nExecStart = \nExecStart = /bin/b\n", b.String()) +} + +func TestKey_IsBooleanType(t *testing.T) { + f, err := Load([]byte("[mysqld]\nskip-name-resolve\nuser = mysql\n"), AllowBooleanKeys()) + require.NoError(t, err) + assert.True(t, f.Section("mysqld").Key("skip-name-resolve").IsBooleanType()) + assert.False(t, f.Section("mysqld").Key("user").IsBooleanType()) +} From 00074604461e35c249c4d0d0b48e3375fc42d3f3 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:24:38 +0000 Subject: [PATCH 06/41] Add Marshal and Unmarshal They follow encoding/json's shape (#337): Unmarshal maps INI data to a struct as MapTo does, and Marshal encodes a struct, or a pointer to one, as ReflectFrom and Write do. --- struct.go | 25 +++++++++++++++++++++++++ struct_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/struct.go b/struct.go index ec227d5..b9b88bc 100644 --- a/struct.go +++ b/struct.go @@ -748,3 +748,28 @@ func ReflectFromWithMapper(cfg *File, v interface{}, mapper NameMapper) error { func ReflectFrom(cfg *File, v interface{}) error { return ReflectFromWithMapper(cfg, v, nil) } + +// Unmarshal parses INI data with opts and maps it to the struct v points to, +// as File.MapTo does. +func Unmarshal(data []byte, v any, opts ...LoadOption) error { + return MapTo(v, data, opts...) +} + +// Marshal returns the INI encoding of v, a struct or a pointer to one, as +// File.ReflectFrom and File.Write produce it. +func Marshal(v any, opts ...WriteOption) ([]byte, error) { + if val := reflect.ValueOf(v); val.Kind() == reflect.Struct { + ptr := reflect.New(val.Type()) + ptr.Elem().Set(val) + v = ptr.Interface() + } + f := Empty() + if err := f.ReflectFrom(v); err != nil { + return nil, err + } + var buf bytes.Buffer + if _, err := f.Write(&buf, opts...); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/struct_test.go b/struct_test.go index 50c6ad3..3154867 100644 --- a/struct_test.go +++ b/struct_test.go @@ -904,3 +904,27 @@ Title = Consultant Engineer ) }) } + +func TestMarshalUnmarshal(t *testing.T) { + type server struct { + Host string `ini:"host"` + Ports []int `ini:"ports"` + Tags []string `ini:"tags" delim:"|"` + } + type config struct { + Name string `ini:"name"` + Server server `ini:"server"` + } + in := config{Name: "app", Server: server{Host: "localhost", Ports: []int{80, 443}, Tags: []string{"a", "b"}}} + + data, err := Marshal(in) + require.NoError(t, err) + assert.Equal(t, "name = app\n\n[server]\nhost = localhost\nports = 80,443\ntags = a|b\n", string(data)) + + var out config + require.NoError(t, Unmarshal(data, &out)) + assert.Equal(t, in, out) + + err = Unmarshal([]byte("name"), &out) + assert.True(t, IsErrDelimiterNotFound(err)) +} From dc2c2e2752508d643a631d1832160c2a9598f633 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:26:35 +0000 Subject: [PATCH 07/41] README: v2 installation and migration from v1 --- README.md | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ab20a64..0a71e9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # INI -[![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/github.com/go-ini/ini?tab=doc) +[![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/gopkg.in/ini.v2?tab=doc) ![](https://avatars0.githubusercontent.com/u/10216035?v=3&s=200) @@ -21,17 +21,37 @@ Package ini provides INI file read and write functionality in Go. ## Installation -The minimum requirement of Go is **1.13**. +The minimum requirement of Go is **1.22**. ```sh -$ go get gopkg.in/ini.v1@latest +$ go get gopkg.in/ini.v2@latest ``` -> [!NOTE] -> If you previously used `github.com/go-ini/ini` as the import path in your project, without updating all of your code, you can use the following command to replace the import path in your `go.mod`: -> ```zsh -> go mod edit -replace github.com/go-ini/ini=gopkg.in/ini.v1@latest -> ``` +## Migrating from v1 + +Without options, v2 parses every input exactly as v1's `Load` does. The parity tests check this against the released `gopkg.in/ini.v1`. The exception: a Python-style multiline value longer than 4 KiB is read in full instead of failing. The API changes are: + +| v1 | v2 | +|---|---| +| `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | +| `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows())` | +| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.Loose()`, `ini.Insensitive()`, `ini.AllowShadows()` | +| `LoadOptions.DebugFunc` | `ini.Debug(fn)` | +| `LoadOptions.ReaderBufferSize` | removed; values are never cut at a buffer size | +| `ini.DefaultSection = "default"` | `ini.DefaultSectionName("default")` | +| `ini.LineBreak`, `PrettyFormat`, `PrettySection`, `PrettyEqual`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo` | +| `LoadOptions.KeyValueDelimiterOnWrite` | `ini.KeyValueDelimiterOnWrite(d)` write option | +| `WriteToIndent(w, i)`, `SaveToIndent(f, i)` | `Write(w, ini.Indent(i))`, `SaveTo(f, ini.Indent(i))` | +| `MapTo(v, a, b)` | `MapTo(v, a, opts...)`; one source | +| `DEFAULT_SECTION`, `AllCapsUnderscore` | `DefaultSection`, `SnackCase` | + +New in v2: + +- Parse errors are `*ini.ParseError` with 1-based `Line` and `Column`. +- `ini.CommentSymbols(s)` sets the comment characters. +- `ini.KeepEmptyShadowValues()` keeps empty values of shadow keys. +- `Key.IsBooleanType()`, and `ini.Marshal`/`ini.Unmarshal`. +- Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. ## Getting Help From 6305b64a4bed42ee600c9bacf64d2dcd6e9c88c1 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:39:28 +0000 Subject: [PATCH 08/41] Use the unknwon.dev/ini/v2 module path The v2 module is served from unknwon.dev/ini/v2 instead of gopkg.in/ini.v2, so its versions come from v2.x.y tags rather than a gopkg.in branch. v1 stays at gopkg.in/ini.v1. --- README.md | 5 +++-- go.mod | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0a71e9a..461eae2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # INI -[![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/gopkg.in/ini.v2?tab=doc) +[![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://pkg.go.dev/unknwon.dev/ini/v2?tab=doc) ![](https://avatars0.githubusercontent.com/u/10216035?v=3&s=200) @@ -24,7 +24,7 @@ Package ini provides INI file read and write functionality in Go. The minimum requirement of Go is **1.22**. ```sh -$ go get gopkg.in/ini.v2@latest +$ go get unknwon.dev/ini/v2@latest ``` ## Migrating from v1 @@ -33,6 +33,7 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | v1 | v2 | |---|---| +| `import "gopkg.in/ini.v1"` | `import "unknwon.dev/ini/v2"` (package name is still `ini`) | | `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | | `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows())` | | `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.Loose()`, `ini.Insensitive()`, `ini.AllowShadows()` | diff --git a/go.mod b/go.mod index c9852c7..d7cdffe 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gopkg.in/ini.v2 +module unknwon.dev/ini/v2 go 1.22 From fbec78063f9b7a0cabce821f9ab9cb5da22242de Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:49:26 +0000 Subject: [PATCH 09/41] Remove codecov.yml CI doesn't upload coverage to Codecov, so the config has no effect. --- codecov.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index e02ec84..0000000 --- a/codecov.yml +++ /dev/null @@ -1,16 +0,0 @@ -coverage: - range: "60...95" - status: - project: - default: - threshold: 1% - informational: true - patch: - defualt: - only_pulls: true - informational: true - -comment: - layout: 'diff' - -github_checks: false From e81af5827f2e9f489bceae95fe46624d55ae7fde Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 19:51:02 +0000 Subject: [PATCH 10/41] Require Go 1.26 and test on 1.26 and 1.27 Go supports its latest two releases, 1.26 and 1.27; the module now requires the older of them instead of 1.22, which CI never tested. --- .github/workflows/go.yml | 2 +- README.md | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 2e2b9fe..fb682fe 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -37,7 +37,7 @@ jobs: name: Test strategy: matrix: - go-version: [1.26.x] + go-version: [1.26.x, 1.27.x] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: diff --git a/README.md b/README.md index 461eae2..aaed477 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Package ini provides INI file read and write functionality in Go. ## Installation -The minimum requirement of Go is **1.22**. +The minimum requirement of Go is **1.26**. ```sh $ go get unknwon.dev/ini/v2@latest diff --git a/go.mod b/go.mod index d7cdffe..b2026d2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module unknwon.dev/ini/v2 -go 1.22 +go 1.26 require ( github.com/stretchr/testify v1.11.1 From 44355e53e89a28f844611dc08d14248fff5d737e Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 20:04:09 +0000 Subject: [PATCH 11/41] Fix six bugs found in review - CommentSymbols compared the first byte of a line with the symbols' bytes, so a key whose first character shared a lead byte with a multi-byte symbol was read as a comment and lost. Symbols are now compared as characters everywhere: line starts, SpaceBeforeInlineComment, UnescapeValueCommentSymbols, and the comment prefix the writer adds, which was invalid UTF-8. - With CommentSymbols(""), the writer put ";" before comments, which read back as keys. Writing a comment now returns an error instead. - Marshal dropped all but one value of allowshadow fields and all but one section of nonunique fields; it now reflects into a file that allows both. - KeepEmptyShadowValues still deduplicated repeated empty values, losing every reset after the first. Empty values it keeps are never treated as duplicates. - A renamed default section (DefaultSectionName) was written as an empty header when it had no keys. It is now skipped like the DEFAULT section; with keys, its header is still written, as v1 did. - Marshal and Unmarshal panicked on nil and on pointers to non-structs, as ReflectFrom and MapTo did. They now return an error, and a nil struct-pointer field writes no section. --- file.go | 43 ++++++++++++++++++++++++++----------- key.go | 6 ++++-- lexer.go | 19 +++++++++++------ options_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ struct.go | 21 ++++++++++++------ struct_test.go | 44 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 27 deletions(-) diff --git a/file.go b/file.go index a6d3776..50cf545 100644 --- a/file.go +++ b/file.go @@ -22,6 +22,7 @@ import ( "os" "strings" "sync" + "unicode/utf8" ) // File represents a combination of one or more INI files in memory. @@ -326,13 +327,29 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { } // Comment lines that don't start with a comment symbol get one: ";" as in - // v1 when it is a comment symbol, otherwise the first one. + // v1 when it is a comment symbol, otherwise the first one. Without + // comment symbols, a comment can't be written so that it reads back. symbols := f.options.CommentSymbols commentPrefix := ";" if !strings.Contains(symbols, ";") && symbols != "" { - commentPrefix = symbols[:1] + r, _ := utf8.DecodeRuneInString(symbols) + commentPrefix = string(r) + } + // formatComment puts the comment symbol of line, or commentPrefix, and a + // space before its text. + formatComment := func(line string, trim bool) (string, error) { + if symbols == "" { + return "", errors.New("cannot write comment: no comment symbols set") + } + if !startsWithAny(line, symbols) { + if trim { + line = strings.TrimSpace(line) + } + return commentPrefix + " " + line, nil + } + _, n := utf8.DecodeRuneInString(line) + return line[:n] + " " + strings.TrimSpace(line[n:]), nil } - isComment := func(line string) bool { return strings.IndexByte(symbols, line[0]) > -1 } // Use buffer to make sure target is safe until finish encoding. buf := bytes.NewBuffer(nil) @@ -343,10 +360,9 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // Support multiline comments lines := strings.Split(sec.Comment, lineBreak) for i := range lines { - if !isComment(lines[i]) { - lines[i] = commentPrefix + " " + lines[i] - } else { - lines[i] = lines[i][:1] + " " + strings.TrimSpace(lines[i][1:]) + var err error + if lines[i], err = formatComment(lines[i], false); err != nil { + return nil, err } if _, err := buf.WriteString(lines[i] + lineBreak); err != nil { @@ -355,7 +371,11 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { } } - if i > 0 || o.defaultHeader || (i == 0 && strings.ToUpper(sec.name) != f.options.DefaultSection) { + // v1 left out the header of the default section only when its name was + // upper case, so a renamed default section keeps its header. It is + // skipped like any default section when it has no keys. + emptyDefault := i == 0 && len(sec.keyList) == 0 && strings.EqualFold(sec.name, f.options.DefaultSection) + if i > 0 || o.defaultHeader || (strings.ToUpper(sec.name) != f.options.DefaultSection && !emptyDefault) { if _, err := buf.WriteString("[" + sname + "]" + lineBreak); err != nil { return nil, err } @@ -413,10 +433,9 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // Support multiline comments lines := strings.Split(key.Comment, lineBreak) for i := range lines { - if !isComment(lines[i]) { - lines[i] = commentPrefix + " " + strings.TrimSpace(lines[i]) - } else { - lines[i] = lines[i][:1] + " " + strings.TrimSpace(lines[i][1:]) + var err error + if lines[i], err = formatComment(lines[i], true); err != nil { + return nil, err } if _, err := buf.WriteString(lines[i] + lineBreak); err != nil { diff --git a/key.go b/key.go index 2809638..d677ffe 100644 --- a/key.go +++ b/key.go @@ -58,8 +58,10 @@ func (k *Key) addShadow(val string) error { return errors.New("cannot add shadow to auto-increment or boolean key") } - if !k.s.f.options.AllowDuplicateShadowValues { - // Deduplicate shadows based on their values. + // Deduplicate shadows based on their values. Empty values kept with + // KeepEmptyShadowValues each reset the values before them, so they stay. + keptEmpty := val == "" && k.s.f.options.KeepEmptyShadowValues + if !k.s.f.options.AllowDuplicateShadowValues && !keptEmpty { if k.value == val { return nil } diff --git a/lexer.go b/lexer.go index ccfbc76..6327807 100644 --- a/lexer.go +++ b/lexer.go @@ -80,7 +80,7 @@ func (l *lexer) next() (token, error) { switch { case line == "": continue - case l.isCommentSymbol(line[0]): + case l.startsWithCommentSymbol(line): // Keep the line break so comment lines stay on separate lines. l.comment = append(l.comment, line) continue @@ -132,8 +132,14 @@ func (l *lexer) unread(line string) { l.line-- } -func (l *lexer) isCommentSymbol(c byte) bool { - return strings.IndexByte(l.opts.CommentSymbols, c) > -1 +func (l *lexer) startsWithCommentSymbol(line string) bool { + return startsWithAny(line, l.opts.CommentSymbols) +} + +// startsWithAny reports whether the first character of s is one of chars. +func startsWithAny(s, chars string) bool { + r, _ := utf8.DecodeRuneInString(s) + return r != utf8.RuneError && strings.ContainsRune(chars, r) } func (l *lexer) takeComment() string { @@ -236,8 +242,9 @@ func (l *lexer) readValue(in string) (string, error) { return line[1 : len(line)-1], nil case l.opts.UnescapeValueCommentSymbols: // Last symbol first, as v1 unescaped \; before \#. - for i := len(l.opts.CommentSymbols) - 1; i >= 0; i-- { - c := l.opts.CommentSymbols[i : i+1] + symbols := []rune(l.opts.CommentSymbols) + for i := len(symbols) - 1; i >= 0; i-- { + c := string(symbols[i]) line = strings.ReplaceAll(line, `\`+c, c) } return line, nil @@ -303,7 +310,7 @@ func (l *lexer) readQuoted(line, q string) (string, error) { func (l *lexer) cutInlineComment(line string) string { i := -1 if l.opts.SpaceBeforeInlineComment { - for _, c := range []byte(l.opts.CommentSymbols) { + for _, c := range l.opts.CommentSymbols { if i = strings.Index(line, " "+string(c)); i > -1 { break } diff --git a/options_test.go b/options_test.go index a3bb746..7a80152 100644 --- a/options_test.go +++ b/options_test.go @@ -51,6 +51,16 @@ func TestDefaultSectionName(t *testing.T) { _, err = f.WriteTo(&b) require.NoError(t, err) assert.Equal(t, "[default]\na = 1\nb = 2\n\n[profile x]\nc = 3\n", b.String()) + + t.Run("empty default section is not written", func(t *testing.T) { + f, err := Load([]byte("[s]\nk = v\n"), DefaultSectionName("default")) + require.NoError(t, err) + + var b bytes.Buffer + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "[s]\nk = v\n", b.String()) + }) } func TestCommentSymbols(t *testing.T) { @@ -64,6 +74,46 @@ func TestCommentSymbols(t *testing.T) { assert.Equal(t, "// note", sec.Key("url").Comment) assert.Equal(t, "a//b", sec.Key("k").String()) + t.Run("multi-byte symbols", func(t *testing.T) { + f, err := Load([]byte("€ head\n→ arrow = 1\nk = a €c\nj = x\\€y\nu = \\→\n"), + CommentSymbols("€"), SpaceBeforeInlineComment(), UnescapeValueCommentSymbols()) + require.NoError(t, err) + + sec := f.Section("") + assert.Equal(t, []string{"→ arrow", "k", "j", "u"}, sec.KeyStrings()) + assert.Equal(t, "€ head", sec.Key("→ arrow").Comment) + assert.Equal(t, "a", sec.Key("k").String()) + assert.Equal(t, "€c", sec.Key("k").Comment) + assert.Equal(t, "x€y", sec.Key("j").String()) + assert.Equal(t, `\→`, sec.Key("u").String()) + + f, err = Load([]byte("§ head\nk = v\n"), CommentSymbols("§")) + require.NoError(t, err) + _, err = f.Section("").NewKey("n", "1") + require.NoError(t, err) + f.Section("").Key("n").Comment = "note" + var b bytes.Buffer + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "§ head\nk = v\n§ note\nn = 1\n", b.String()) + }) + + t.Run("no symbols", func(t *testing.T) { + f := Empty(CommentSymbols("")) + k, err := f.Section("").NewKey("k", "v") + require.NoError(t, err) + + var b bytes.Buffer + _, err = f.WriteTo(&b) + require.NoError(t, err) + assert.Equal(t, "k = v\n", b.String()) + + // A comment can't be written so that it reads back as a comment. + k.Comment = "set x = y" + _, err = f.WriteTo(&b) + assert.Error(t, err) + }) + t.Run("written comments use the configured symbol", func(t *testing.T) { f := Empty(CommentSymbols("#")) k, err := f.Section("").NewKey("k", "v;w") @@ -118,6 +168,13 @@ func TestKeepEmptyShadowValues(t *testing.T) { _, err = f.WriteTo(&b) require.NoError(t, err) assert.Equal(t, "[Service]\nExecStart = /bin/a\nExecStart = \nExecStart = /bin/b\n", b.String()) + + t.Run("every empty value is kept", func(t *testing.T) { + f, err := Load([]byte("[u]\nExecStart=\nExecStart=a\nExecStart=\nExecStart=b\nExecStart=a\n"), + AllowShadows(), KeepEmptyShadowValues()) + require.NoError(t, err) + assert.Equal(t, []string{"", "a", "", "b"}, f.Section("u").Key("ExecStart").ValueWithShadows()) + }) } func TestKey_IsBooleanType(t *testing.T) { diff --git a/struct.go b/struct.go index b9b88bc..0fd6873 100644 --- a/struct.go +++ b/struct.go @@ -381,7 +381,7 @@ func (s *Section) mapToSlice(secName string, val reflect.Value, isStrict bool) ( func (s *Section) mapTo(v interface{}, isStrict bool) error { typ := reflect.TypeOf(v) val := reflect.ValueOf(v) - if typ.Kind() == reflect.Pointer { + if typ != nil && typ.Kind() == reflect.Pointer && !val.IsNil() { typ = typ.Elem() val = val.Elem() } else { @@ -609,6 +609,11 @@ func (s *Section) reflectFrom(val reflect.Value) error { continue } + // A nil struct pointer has nothing to write. + if field.Kind() == reflect.Pointer && field.IsNil() && tpField.Type.Elem().Kind() == reflect.Struct { + continue + } + if extends && tpField.Anonymous && (tpField.Type.Kind() == reflect.Pointer || tpField.Type.Kind() == reflect.Struct) { if err := s.reflectFrom(field); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) @@ -689,6 +694,9 @@ func (s *Section) reflectFrom(val reflect.Value) error { func (s *Section) ReflectFrom(v interface{}) error { typ := reflect.TypeOf(v) val := reflect.ValueOf(v) + if typ == nil || (typ.Kind() == reflect.Pointer && val.IsNil()) { + return errors.New("not a pointer to a struct") + } if s.name != s.f.options.DefaultSection && s.f.options.AllowNonUniqueSections && (typ.Kind() == reflect.Slice || typ.Kind() == reflect.Pointer) { @@ -724,13 +732,10 @@ func (s *Section) ReflectFrom(v interface{}) error { return nil } - if typ.Kind() == reflect.Pointer { - val = val.Elem() - } else { + if typ.Kind() != reflect.Pointer || typ.Elem().Kind() != reflect.Struct { return errors.New("not a pointer to a struct") } - - return s.reflectFrom(val) + return s.reflectFrom(val.Elem()) } // ReflectFrom reflects file from given struct. @@ -763,7 +768,9 @@ func Marshal(v any, opts ...WriteOption) ([]byte, error) { ptr.Elem().Set(val) v = ptr.Interface() } - f := Empty() + // Shadows and non-unique sections let allowshadow and nonunique fields + // write all their values. + f := Empty(AllowShadows(), AllowNonUniqueSections()) if err := f.ReflectFrom(v); err != nil { return nil, err } diff --git a/struct_test.go b/struct_test.go index 3154867..52eeb5f 100644 --- a/struct_test.go +++ b/struct_test.go @@ -928,3 +928,47 @@ func TestMarshalUnmarshal(t *testing.T) { err = Unmarshal([]byte("name"), &out) assert.True(t, IsErrDelimiterNotFound(err)) } + +func TestMarshal_ShadowsAndNonUniqueSections(t *testing.T) { + type peer struct { + Key string `ini:"key"` + } + type config struct { + Paths []string `ini:"path,,allowshadow"` + Peers []peer `ini:"Peer,,,nonunique"` + } + in := config{Paths: []string{"a", "b", "c"}, Peers: []peer{{"x"}, {"y"}}} + + data, err := Marshal(in) + require.NoError(t, err) + assert.Equal(t, "path = a\npath = b\npath = c\n\n[Peer]\nkey = x\n\n[Peer]\nkey = y\n", string(data)) + + var out config + require.NoError(t, Unmarshal(data, &out, AllowShadows(), AllowNonUniqueSections())) + assert.Equal(t, in, out) +} + +func TestMarshalUnmarshal_Nil(t *testing.T) { + type peer struct { + Key string `ini:"key"` + } + type config struct { + Name string `ini:"name"` + Peer *peer `ini:"peer"` + } + + _, err := Marshal(nil) + assert.Error(t, err) + _, err = Marshal((*config)(nil)) + assert.Error(t, err) + n := 1 + _, err = Marshal(&n) + assert.Error(t, err) + assert.Error(t, Unmarshal([]byte("name = x"), nil)) + assert.Error(t, Unmarshal([]byte("name = x"), (*config)(nil))) + + // A nil section pointer writes nothing for that section. + data, err := Marshal(config{Name: "n"}) + require.NoError(t, err) + assert.Equal(t, "name = n\n", string(data)) +} From 241036a28f60f1b708b4921fe8e83406ae946af5 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 21:05:41 +0000 Subject: [PATCH 12/41] Make Write output load back to the same file Write now checks every key line with the lexer and picks a quoting that reads back to the same name and value, starting with v1's choice so ordinary output is unchanged. When no quoting works it returns an error instead of writing a file that loads differently. Also fixed in the writer: - Empty shadow values are written, so systemd-style resets survive. - A key written on several lines gets its comment before its last line, where the parser looks for it. - Nested values follow an empty value line, and boolean keys that also have values are written as they were read. - With Insensitive, an explicit [DEFAULT] section writes its own keys. - A default section with a comment keeps its header. - A raw body without a final line break no longer runs into the next section header. - Indent returns an error where an indented line would join the value before it. FuzzWriteRoundTrip and TestWriteRoundTripRandom load, write and reload files under random option sets and compare everything the File holds. --- README.md | 1 + file.go | 276 ++++++++++++++---- options.go | 5 +- roundtrip_test.go | 164 +++++++++++ .../fuzz/FuzzWriteRoundTrip/0af50e1fca39c522 | 3 + .../fuzz/FuzzWriteRoundTrip/d59fd92c55425bc2 | 3 + .../fuzz/FuzzWriteRoundTrip/f6a35f13bb953479 | 3 + 7 files changed, 390 insertions(+), 65 deletions(-) create mode 100644 roundtrip_test.go create mode 100644 testdata/fuzz/FuzzWriteRoundTrip/0af50e1fca39c522 create mode 100644 testdata/fuzz/FuzzWriteRoundTrip/d59fd92c55425bc2 create mode 100644 testdata/fuzz/FuzzWriteRoundTrip/f6a35f13bb953479 diff --git a/README.md b/README.md index aaed477..3b2aac0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ New in v2: - `ini.CommentSymbols(s)` sets the comment characters. - `ini.KeepEmptyShadowValues()` keeps empty values of shadow keys. - `Key.IsBooleanType()`, and `ini.Marshal`/`ini.Unmarshal`. +- `File.Write` writes files that load back to the same sections, keys, values and comments: it picks a quoting the parser reads back, writes empty shadow values, and returns an error instead of writing a file that loads differently. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. ## Getting Help diff --git a/file.go b/file.go index 50cf545..d7f453d 100644 --- a/file.go +++ b/file.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "os" + "slices" "strings" "sync" "unicode/utf8" @@ -341,21 +342,93 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { if symbols == "" { return "", errors.New("cannot write comment: no comment symbols set") } + if trim { + line = strings.TrimSpace(line) + } if !startsWithAny(line, symbols) { - if trim { - line = strings.TrimSpace(line) - } return commentPrefix + " " + line, nil } _, n := utf8.DecodeRuneInString(line) return line[:n] + " " + strings.TrimSpace(line[n:]), nil } + // keyLine returns the line that writes val as a value of key, written as + // form, or key itself for a boolean key, followed by nested values. + // v1's quoting comes first; when the lexer reads that back differently, + // other quotings and a bare delimiter are tried, and when none reads + // back, it's an error rather than a file that loads differently. A file + // read with delimiters that don't include the written one is meant for + // other readers, and is written as v1 did. + verify := strings.ContainsAny(o.keyValueDelimiter, f.options.KeyValueDelimiters) + keyLine := func(key *Key, boolean bool, name, form, eq, val string, nested []string) (string, error) { + var after string + for _, nv := range nested { + after += indent + " " + nv + lineBreak + } + var line string + switch { + case boolean: + line = form + lineBreak + if !f.options.AllowBooleanKeys { + return line, nil + } + case strings.ContainsAny(val, "\n`"): + line = form + eq + `"""` + val + `"""` + lineBreak + case !f.options.IgnoreInlineComment && strings.ContainsAny(val, symbols): + line = form + eq + "`" + val + "`" + lineBreak + case len(strings.TrimSpace(val)) != len(val): + line = form + eq + `"` + val + `"` + lineBreak + default: + line = form + eq + val + lineBreak + } + if !verify || f.readsBack(line+after, boolean, name, val, nested) { + return line + after, nil + } + + quotings := func(s string) []string { + q := []string{s, "`" + s + "`", `"""` + s + `"""`, `"` + s + `"`} + if f.options.UnescapeValueDoubleQuotes { + q = append(q, `"`+strings.ReplaceAll(s, `"`, `\"`)+`"`) + } + return q + } + forms := []string{"-"} + if !key.isAutoIncrement { + forms = quotings(name) + } + if boolean { + for _, k := range forms { + if line = k + lineBreak; f.readsBack(line, boolean, name, "", nil) { + return line, nil + } + } + } else { + for _, e := range []string{eq, o.keyValueDelimiter} { + for _, k := range forms { + for _, v := range quotings(val) { + if line = k + e + v + lineBreak + after; f.readsBack(line, boolean, name, val, nested) { + return line, nil + } + } + } + } + } + return "", fmt.Errorf("cannot write key %q with value %q in section %q so that it reads back", key.name, val, key.s.name) + } + // Use buffer to make sure target is safe until finish encoding. buf := bytes.NewBuffer(nil) + // Per-key buffers, reused for every key. + var vals, lines []string + var empty []bool lastSectionIdx := len(f.sectionList) - 1 for i, sname := range f.sectionList { - sec := f.SectionWithIndex(sname, f.sectionIndexes[i]) + // Not SectionWithIndex: with Insensitive, a "[DEFAULT]" header makes a + // section that lookups by name don't find. + sec := f.sections[sname][f.sectionIndexes[i]] + if strings.Contains(sname, "\n") { + return nil, fmt.Errorf("cannot write section %q: its name has a line break", sname) + } if len(sec.Comment) > 0 { // Support multiline comments lines := strings.Split(sec.Comment, lineBreak) @@ -375,7 +448,8 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // upper case, so a renamed default section keeps its header. It is // skipped like any default section when it has no keys. emptyDefault := i == 0 && len(sec.keyList) == 0 && strings.EqualFold(sec.name, f.options.DefaultSection) - if i > 0 || o.defaultHeader || (strings.ToUpper(sec.name) != f.options.DefaultSection && !emptyDefault) { + // A comment needs the header to stay on the section. + if i > 0 || o.defaultHeader || len(sec.Comment) > 0 || (strings.ToUpper(sec.name) != f.options.DefaultSection && !emptyDefault) { if _, err := buf.WriteString("[" + sname + "]" + lineBreak); err != nil { return nil, err } @@ -391,6 +465,10 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { if _, err := buf.WriteString(sec.rawBody); err != nil { return nil, err } + if !isLastSection && sec.rawBody != "" && !strings.HasSuffix(sec.rawBody, "\n") { + // The next section header starts on its own line. + buf.WriteString(lineBreak) + } if o.prettySection && !isLastSection { // Put a line between sections @@ -422,91 +500,136 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { } alignSpaces := bytes.Repeat([]byte(" "), alignLength) - KeyList: - for _, kname := range sec.keyList { - key := sec.Key(kname) - if len(key.Comment) > 0 { - if len(indent) > 0 && sname != f.options.DefaultSection { - buf.WriteString(indent) - } - - // Support multiline comments - lines := strings.Split(key.Comment, lineBreak) - for i := range lines { - var err error - if lines[i], err = formatComment(lines[i], true); err != nil { - return nil, err - } - - if _, err := buf.WriteString(lines[i] + lineBreak); err != nil { - return nil, err - } - } - } - - if len(indent) > 0 && sname != f.options.DefaultSection { - buf.WriteString(indent) + keyIndent := "" + if sname != f.options.DefaultSection { + keyIndent = indent + } + // swallows is set when the lexer would read an indented line as part + // of the last value: always in Python multiline values, and after an + // empty value with nested values. + swallows := false + writeIndented := func(line string) error { + if keyIndent != "" && swallows { + return fmt.Errorf("cannot indent line %q in section %q: it would be read as part of the value before it", line, sname) } + buf.WriteString(keyIndent + line) + return nil + } + for _, kname := range sec.keyList { + key := sec.Key(kname) + name, form := kname, kname switch { case key.isAutoIncrement: - kname = "-" + name, form = "-", "-" case strings.Contains(kname, "\"") || strings.ContainsAny(kname, f.options.KeyValueDelimiters): - kname = "`" + kname + "`" + form = "`" + kname + "`" case strings.Contains(kname, "`"): - kname = `"""` + kname + `"""` + form = `"""` + kname + `"""` } - writeKeyValue := func(val string) (bool, error) { - if _, err := buf.WriteString(kname); err != nil { - return false, err + pad := "" + if o.prettyFormat { + pad = string(alignSpaces[:max(0, alignLength-len(form))]) + } + // lines are the lines of the key, each with the nested values + // that follow it, and empty tells whether its value is empty. + lines, empty = lines[:0], empty[:0] + addLine := func(boolean bool, val string, nested []string) error { + eq := pad + equalSign + if boolean { + eq = "" } + line, err := keyLine(key, boolean, name, form, eq, val, nested) + lines = append(lines, line) + empty = append(empty, !boolean && val == "") + return err + } - if key.isBooleanType { - buf.WriteString(lineBreak) - return true, nil - } + // All values are written, empty ones too: an empty value resets + // the values before it in systemd units, and nested values + // follow an empty value. + vals = append(vals[:0], key.value) + for _, s := range key.shadows { + vals = append(vals, s.value) + } - // Write out alignment spaces before "=" sign - if o.prettyFormat { - buf.Write(alignSpaces[:alignLength-len(kname)]) + // regular are the values written on key lines. A boolean line + // after a key of the same name makes it a boolean key with the + // value "true" added; a value set after the boolean line + // replaces "true". + regular, boolFirst := vals, false + if key.isBooleanType { + switch { + case !f.options.AllowBooleanKeys: + regular = nil + case len(key.shadows) > 0: + if vals[len(vals)-1] == "true" { + regular = vals[:len(vals)-1] + } + case key.value != "true": + boolFirst = true + default: + regular = nil } + } + if len(key.nestedValues) > 0 && !slices.Contains(regular, "") { + // Nested values only follow an empty value. Without shadows + // the value after it still wins. + regular = append([]string{""}, regular...) + } - // In case key value contains "\n", "`", "\"", "#" or ";" - if strings.ContainsAny(val, "\n`") { - val = `"""` + val + `"""` - } else if !f.options.IgnoreInlineComment && strings.ContainsAny(val, symbols) { - val = "`" + val + "`" - } else if len(strings.TrimSpace(val)) != len(val) { - val = `"` + val + `"` + if key.isBooleanType && boolFirst { + if err := addLine(true, "", nil); err != nil { + return nil, err } - if _, err := buf.WriteString(equalSign + val + lineBreak); err != nil { - return false, err + } + nested := key.nestedValues + for _, val := range regular { + if val != "" { + if err := addLine(false, val, nil); err != nil { + return nil, err + } + continue + } + if err := addLine(false, val, nested); err != nil { + return nil, err } - return false, nil + nested = nil } - - shadows := key.ValueWithShadows() - if len(shadows) == 0 { - if _, err := writeKeyValue(""); err != nil { + if key.isBooleanType && !boolFirst { + if err := addLine(true, "", nil); err != nil { return nil, err } } - for _, val := range shadows { - exitLoop, err := writeKeyValue(val) - if err != nil { + // The lexer gives a key the comment before its last line, so a + // key written on several lines has its comment there. + last := len(lines) - 1 + for i, line := range lines[:last] { + if err := writeIndented(line); err != nil { return nil, err - } else if exitLoop { - continue KeyList } + swallows = f.options.AllowPythonMultilineValues || (f.options.AllowNestedValues && empty[i]) } - - for _, val := range key.nestedValues { - if _, err := buf.WriteString(indent + " " + val + lineBreak); err != nil { + if len(key.Comment) > 0 { + if err := writeIndented(""); err != nil { return nil, err } + // Support multiline comments + comments := strings.Split(key.Comment, lineBreak) + for i := range comments { + var err error + if comments[i], err = formatComment(comments[i], true); err != nil { + return nil, err + } + buf.WriteString(comments[i] + lineBreak) + } + } + if err := writeIndented(lines[last]); err != nil { + return nil, err } + swallows = f.options.AllowPythonMultilineValues || (f.options.AllowNestedValues && empty[last]) } if o.prettySection && !isLastSection { @@ -520,6 +643,31 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { return buf, nil } +// readsBack reports whether the lexer reads text as one key, or boolean key, +// named name with value val and no comment, followed by nested values. +func (f *File) readsBack(text string, boolean bool, name, val string, nested []string) bool { + // A byte order mark is skipped at the start of a file. + l := lexer{src: skipBOM(text), opts: &f.options} + tok, err := l.next() + if err != nil { + return false + } + kind := tokKey + if boolean { + kind = tokBooleanKey + } + if tok.kind != kind || tok.name != name || tok.value != val || tok.comment != "" { + return false + } + for _, nv := range nested { + if tok, err = l.next(); err != nil || tok.kind != tokNestedValue || tok.value != nv { + return false + } + } + tok, err = l.next() + return err == nil && tok.kind == tokEOF && len(l.comment) == 0 +} + // Write writes the file to w, formatted by opts. func (f *File) Write(w io.Writer, opts ...WriteOption) (int64, error) { buf, err := f.writeToBuffer(opts) diff --git a/options.go b/options.go index bb719ff..3c1e8a6 100644 --- a/options.go +++ b/options.go @@ -245,7 +245,10 @@ func newWriteOptions(opts []WriteOption) writeOptions { } // Indent puts indent before the keys and comments of every section except -// the default one. +// the default one. Write returns an error when an indented line would read +// back as part of the value before it: after any value with +// AllowPythonMultilineValues, and after an empty value with +// AllowNestedValues. func Indent(indent string) WriteOption { return func(o *writeOptions) { o.indent = indent } } // LineBreak sets the line break to write. The default is "\r\n" on Windows diff --git a/roundtrip_test.go b/roundtrip_test.go new file mode 100644 index 0000000..e4a10c0 --- /dev/null +++ b/roundtrip_test.go @@ -0,0 +1,164 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "bytes" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" +) + +// The round-trip tests write a loaded File and load the output again with +// the same options. Whatever was loaded must come back: section and key +// names, values, shadows, nested values, raw bodies, flags and comments. + +// describeFile lists everything a File holds that writing must preserve. +// Comments are compared after the normalization the writer applies. +func describeFile(f *File) string { + var b strings.Builder + for _, s := range f.Sections() { + fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.Comment), s.isRawSection, s.rawBody) + for _, k := range s.Keys() { + vals := []string{k.value} + for _, s := range k.shadows { + vals = append(vals, s.value) + } + fmt.Fprintf(&b, " %q=%q comment=%q auto=%v bool=%v nested=%q\n", + k.name, vals, normalizeComment(k.Comment), k.isAutoIncrement, k.isBooleanType, k.nestedValues) + } + } + return b.String() +} + +// normalizeComment trims every line and puts one space after its comment +// symbol, as the writer does. +func normalizeComment(c string) string { + lines := strings.Split(c, "\n") + for i, line := range lines { + if line = strings.TrimSpace(line); line != "" { + lines[i] = line[:1] + " " + strings.TrimSpace(line[1:]) + } + } + return strings.Join(lines, "\n") +} + +func checkRoundTrip(t *testing.T, data []byte, opts []LoadOption, wopts ...WriteOption) { + t.Helper() + f, err := Load(data, opts...) + if err != nil { + return + } + if !strings.Contains(f.options.KeyValueDelimiters, "=") { + // Written with "=" for other readers, as v1 did. + return + } + want := describeFile(f) + + var out bytes.Buffer + if _, err = f.Write(&out, wopts...); err != nil { + // Some files can't be written; that must be reported, not + // written wrongly. + return + } + g, err := Load(out.Bytes(), opts...) + if err != nil { + t.Fatalf("written file doesn't load: %v\ninput: %q\nwritten: %q", err, data, out.String()) + } + if got := describeFile(g); got != want { + t.Fatalf("written file loads differently\ninput: %q\nwritten: %q\nloaded:\n%s\nreloaded:\n%s", data, out.String(), want, got) + } +} + +func FuzzWriteRoundTrip(f *testing.F) { + inputs := append([]string{confData}, paritySeeds...) + files, _ := filepath.Glob("testdata/*.ini") + for _, name := range files { + data, err := os.ReadFile(name) + if err != nil { + f.Fatal(err) + } + inputs = append(inputs, string(data)) + } + for _, in := range inputs { + for _, flags := range []uint32{0, 1 << 9, 1<<8 | 1<<6, 1<<11 | 1<<10 | 1<<12, 1<<16 | 1<<7 | 1<<5, 1<<20 - 1} { + f.Add([]byte(in), flags) + } + } + f.Fuzz(func(t *testing.T, data []byte, flags uint32) { + checkRoundTrip(t, data, v2Options(parityOptions(flags))) + }) +} + +func TestWriteRoundTripRandom(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for _, in := range paritySeeds { + for i := 0; i < 200; i++ { + checkRoundTrip(t, []byte(in), v2Options(parityOptions(rng.Uint32()))) + } + } +} + +// v1 wrote each of these files so that they loaded differently. +func TestWriteReadsBack(t *testing.T) { + for _, tc := range []struct { + data string + opts []LoadOption + }{ + {"a = 'x\\'\nb = 1\n", nil}, // value ending in a backslash + {"k = `a\"\"\"\nb`\n", nil}, // value containing """ + {"k=`= v`\n", []LoadOption{KeyValueDelimiters(" =")}}, // value starting with a delimiter + {"[u]\nExecStart=\nExecStart=a\n", []LoadOption{AllowShadows()}}, // empty value resets + {"k = a\n; c\nk = b\n", []LoadOption{AllowShadows()}}, // comment of a shadowed key + {"k =\n n\nk\n", []LoadOption{AllowNestedValues(), AllowBooleanKeys()}}, + {"k = \"\"\n n\n", []LoadOption{AllowNestedValues(), AllowPythonMultilineValues()}}, + {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{Insensitive(), AllowNonUniqueSections()}}, + {"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section + } { + f, err := Load([]byte(tc.data), tc.opts...) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(io.Discard); err != nil { + t.Fatalf("%q: %v", tc.data, err) + } + checkRoundTrip(t, []byte(tc.data), tc.opts) + } + + t.Run("raw body without line break", func(t *testing.T) { + f := Empty(UnparseableSections("raw")) + _, _ = f.NewRawSection("raw", "body") + _, _ = f.Section("s").NewKey("k", "v") + var b bytes.Buffer + if _, err := f.Write(&b); err != nil { + t.Fatal(err) + } + if want := "[raw]\nbody\n\n[s]\nk = v\n"; b.String() != want { + t.Fatalf("got %q, want %q", b.String(), want) + } + }) + + t.Run("unwritable key", func(t *testing.T) { + f := Empty() + _, _ = f.Section("").NewKey("a\nb", "v") + if _, err := f.Write(io.Discard); err == nil { + t.Fatal("want an error for a key name with a line break") + } + }) +} diff --git a/testdata/fuzz/FuzzWriteRoundTrip/0af50e1fca39c522 b/testdata/fuzz/FuzzWriteRoundTrip/0af50e1fca39c522 new file mode 100644 index 0000000..a25f286 --- /dev/null +++ b/testdata/fuzz/FuzzWriteRoundTrip/0af50e1fca39c522 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("0=\n 0\n0") +uint32(471) diff --git a/testdata/fuzz/FuzzWriteRoundTrip/d59fd92c55425bc2 b/testdata/fuzz/FuzzWriteRoundTrip/d59fd92c55425bc2 new file mode 100644 index 0000000..178aca5 --- /dev/null +++ b/testdata/fuzz/FuzzWriteRoundTrip/d59fd92c55425bc2 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("0=\"\"\n ") +uint32(7105) diff --git a/testdata/fuzz/FuzzWriteRoundTrip/f6a35f13bb953479 b/testdata/fuzz/FuzzWriteRoundTrip/f6a35f13bb953479 new file mode 100644 index 0000000..12ab8e7 --- /dev/null +++ b/testdata/fuzz/FuzzWriteRoundTrip/f6a35f13bb953479 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("#\n0=0 #") +uint32(1048488) From 6343dbf6efff47ca035fba4d53ed4d424a27f75a Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 21:08:40 +0000 Subject: [PATCH 13/41] Make Reload replace contents and Append read only new sources Reload parses every source into a fresh file and moves the result in, so keys and sections removed from a source are gone (#418) and shadows don't pile up (#273). Sections and keys that still exist keep their identity, so pointers callers hold see the new values. On error the file is unchanged. Append reads only the sources it adds, so changes made in memory stay and earlier sources aren't parsed twice. Readers are read once and kept, so a later Append or Reload doesn't fail on a used-up reader (#324). --- README.md | 1 + data_source.go | 21 +++++++++++--- file.go | 76 ++++++++++++++++++++++++++++++++++++++++---------- file_test.go | 63 +++++++++++++++++++++++++++++++++++++++++ ini.go | 2 +- 5 files changed, 143 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3b2aac0..6b80491 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `WriteToIndent(w, i)`, `SaveToIndent(f, i)` | `Write(w, ini.Indent(i))`, `SaveTo(f, ini.Indent(i))` | | `MapTo(v, a, b)` | `MapTo(v, a, opts...)`; one source | | `DEFAULT_SECTION`, `AllCapsUnderscore` | `DefaultSection`, `SnackCase` | +| `Reload` merged sources into the current contents; `Append` re-read every source | `Reload` replaces the contents with the sources (kept `*Section`/`*Key` pointers see new values; on error nothing changes); `Append` reads only the new sources; readers are buffered so they can be reloaded | New in v2: diff --git a/data_source.go b/data_source.go index 6e2572b..40a5fb8 100644 --- a/data_source.go +++ b/data_source.go @@ -50,13 +50,26 @@ func (s *sourceData) ReadCloser() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(s.data)), nil } -// sourceReadCloser represents an input stream with Close method. +// sourceReadCloser represents an input stream with Close method. A stream +// can be read only once, so its data is kept for reloading. type sourceReadCloser struct { reader io.ReadCloser + data []byte + err error } func (s *sourceReadCloser) ReadCloser() (io.ReadCloser, error) { - return s.reader, nil + if s.reader != nil { + s.data, s.err = io.ReadAll(s.reader) + if err := s.reader.Close(); s.err == nil { + s.err = err + } + s.reader = nil + } + if s.err != nil { + return nil, s.err + } + return io.NopCloser(bytes.NewReader(s.data)), nil } func parseDataSource(source interface{}) (dataSource, error) { @@ -66,9 +79,9 @@ func parseDataSource(source interface{}) (dataSource, error) { case []byte: return &sourceData{s}, nil case io.ReadCloser: - return &sourceReadCloser{s}, nil + return &sourceReadCloser{reader: s}, nil case io.Reader: - return &sourceReadCloser{io.NopCloser(s)}, nil + return &sourceReadCloser{reader: io.NopCloser(s)}, nil default: return nil, fmt.Errorf("error parsing data source: unknown type %q", s) } diff --git a/file.go b/file.go index d7f453d..0a5fa02 100644 --- a/file.go +++ b/file.go @@ -284,10 +284,53 @@ func (f *File) reload(s dataSource) error { return f.parse(r) } -// Reload reloads and parses all data sources. -func (f *File) Reload() (err error) { - for _, s := range f.dataSources { - if err = f.reload(s); err != nil { +// Reload reads all data sources again and replaces the contents of the file +// with them: keys and sections no longer in the sources are gone, and +// shadows don't pile up. Sections and keys that are still there stay the +// same values, so pointers to them see the new contents. On error the file +// is unchanged. +func (f *File) Reload() error { + next := &File{options: f.options, sections: make(map[string][]*Section)} + if err := next.load(f.dataSources); err != nil { + return err + } + + if f.BlockMode { + f.lock.Lock() + defer f.lock.Unlock() + } + for name, secs := range next.sections { + for i, sec := range secs { + if old := f.sections[name]; i < len(old) { + // Keep the section and keys callers may hold. + o := old[i] + for kname, k := range sec.keys { + if ok := o.keys[kname]; ok != nil { + *ok = *k + sec.keys[kname] = ok + } + } + o.Comment, o.keys, o.keyList = sec.Comment, sec.keys, sec.keyList + o.isRawSection, o.rawBody = sec.isRawSection, sec.rawBody + secs[i], sec = o, o + } + sec.f = f + for _, k := range sec.keys { + k.s = sec + for _, s := range k.shadows { + s.s = sec + } + } + } + } + f.sectionList, f.sectionIndexes, f.sections = next.sectionList, next.sectionIndexes, next.sections + return nil +} + +// load parses sources into f, in order. +func (f *File) load(sources []dataSource) error { + for _, s := range sources { + if err := f.reload(s); err != nil { // In loose mode, we create an empty default section for nonexistent files. if os.IsNotExist(err) && f.options.Loose { _ = f.parse(bytes.NewBuffer(nil)) @@ -302,21 +345,24 @@ func (f *File) Reload() (err error) { return nil } -// Append appends one or more data sources and reloads automatically. -func (f *File) Append(source interface{}, others ...interface{}) error { - ds, err := parseDataSource(source) - if err != nil { - return err - } - f.dataSources = append(f.dataSources, ds) - for _, s := range others { - ds, err = parseDataSource(s) +// Append adds one or more data sources and reads them into the file. Their +// sections and keys add to the current ones and overwrite keys they repeat; +// changes made to the file in memory stay. +func (f *File) Append(source any, others ...any) error { + sources := make([]dataSource, 0, 1+len(others)) + for _, s := range append([]any{source}, others...) { + ds, err := parseDataSource(s) if err != nil { return err } - f.dataSources = append(f.dataSources, ds) + sources = append(sources, ds) + } + f.dataSources = append(f.dataSources, sources...) + if f.options.ShortCircuit { + // Only the first source that loads counts. + return f.Reload() } - return f.Reload() + return f.load(sources) } func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { diff --git a/file_test.go b/file_test.go index 5c11a7b..98f8d61 100644 --- a/file_test.go +++ b/file_test.go @@ -17,8 +17,10 @@ package ini import ( "bytes" "os" + "path/filepath" "runtime" "sort" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -527,3 +529,64 @@ v = 3 require.NoError(t, f.Reload()) assert.Equal(t, []string{"1", "2", "3"}, f.Section("slice").Key("v").ValueWithShadows()) } + +func TestFile_Reload(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.ini") + write := func(s string) { require.NoError(t, os.WriteFile(path, []byte(s), 0o600)) } + + // https://github.com/go-ini/ini/issues/418 + t.Run("removed keys and sections are gone", func(t *testing.T) { + write("[app]\nold = 1\nkeep = 2\n[gone]\nk = v\n") + f, err := Load(path) + require.NoError(t, err) + sec, key := f.Section("app"), f.Section("app").Key("keep") + + write("[app]\nkeep = 3\n") + require.NoError(t, f.Reload()) + assert.False(t, f.Section("app").HasKey("old")) + assert.False(t, f.HasSection("gone")) + assert.Equal(t, map[string]string{"keep": "3"}, f.Section("app").KeysHash()) + // Pointers taken before the reload see the new contents. + assert.Equal(t, "3", key.String()) + assert.Same(t, sec, f.Section("app")) + }) + + // https://github.com/go-ini/ini/issues/273 + t.Run("shadows don't pile up", func(t *testing.T) { + write("[log]\nlevel = info\nlevel = warn\n") + f, err := Load(path, AllowShadows(), AllowDuplicateShadowValues()) + require.NoError(t, err) + + write("[log]\nlevel = debug\n") + require.NoError(t, f.Reload()) + assert.Equal(t, []string{"debug"}, f.Section("log").Key("level").ValueWithShadows()) + }) + + t.Run("error leaves the file unchanged", func(t *testing.T) { + write("[app]\nk = 1\n") + f, err := Load(path) + require.NoError(t, err) + + write("[app]\nk = 2\nbroken\n") + require.Error(t, f.Reload()) + assert.Equal(t, "1", f.Section("app").Key("k").String()) + }) +} + +// https://github.com/go-ini/ini/issues/324 +func TestFile_AppendReaders(t *testing.T) { + f, err := Load(strings.NewReader("[a]\nk = 1\n")) + require.NoError(t, err) + f.Section("a").Key("edited").SetValue("yes") + + require.NoError(t, f.Append(strings.NewReader("[b]\nk = 2\n"))) + require.NoError(t, f.Append(strings.NewReader("[a]\nk = 3\n"))) + assert.Equal(t, "3", f.Section("a").Key("k").String()) + assert.Equal(t, "2", f.Section("b").Key("k").String()) + assert.Equal(t, "yes", f.Section("a").Key("edited").String(), "Append keeps changes made in memory") + + // Readers are read once and kept for reloading. + require.NoError(t, f.Reload()) + assert.Equal(t, "3", f.Section("a").Key("k").String()) + assert.False(t, f.Section("a").HasKey("edited")) +} diff --git a/ini.go b/ini.go index 528b97f..5a97ff0 100644 --- a/ini.go +++ b/ini.go @@ -51,7 +51,7 @@ func LoadSources(sources []any, opts ...LoadOption) (*File, error) { } } f := newFile(dataSources, opts) - if err := f.Reload(); err != nil { + if err := f.load(dataSources); err != nil { return nil, err } return f, nil From f79bd1de2ffe9afb50ce76e5da1b71db672200f1 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 21:10:39 +0000 Subject: [PATCH 14/41] Stop %(name)s expansion on reference cycles and runaway growth Two keys referencing each other overflowed the stack in Key.String, a fatal error recover can't catch (#409). Expansion now tracks how deep references nest (99, as before per level), how many substitutions it makes (10,000) and how many bytes they produce (1 MiB), and stops at any limit, returning the value expanded so far. --- ini.go | 3 +++ key.go | 28 +++++++++++++++++++++++++--- key_test.go | 19 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/ini.go b/ini.go index 5a97ff0..0a48ab0 100644 --- a/ini.go +++ b/ini.go @@ -28,6 +28,9 @@ const ( // Maximum allowed depth when recursively substituing variable names. depthValues = 99 + // Limits of one %(name)s expansion; see Key.expand. + maxSubstitutions = 10000 + maxExpandedLen = 1 << 20 ) // Variable regexp pattern: %(variable)s diff --git a/key.go b/key.go index d677ffe..21bd746 100644 --- a/key.go +++ b/key.go @@ -147,6 +147,22 @@ func (k *Key) NestedValues() []string { // transformValue takes a raw value and transforms to its final string. func (k *Key) transformValue(val string) string { + return k.expand(val, 0, new(expansion)) +} + +// expansion counts the work of one %(name)s expansion. +type expansion struct { + substitutions int + bytes int // total length of the values substitutions made +} + +// expand applies the ValueMapper and replaces %(name)s references in val +// with the expanded values of those keys. References nest at most +// depthValues deep, and one expansion makes at most maxSubstitutions +// substitutions producing at most maxExpandedLen bytes in total, so +// reference cycles and values that double at every level stop instead of +// overflowing the stack or using up time and memory. +func (k *Key) expand(val string, depth int, e *expansion) string { if k.s.f.ValueMapper != nil { val = k.s.f.ValueMapper(val) } @@ -155,7 +171,7 @@ func (k *Key) transformValue(val string) string { if !strings.Contains(val, "%") { return val } - for i := 0; i < depthValues; i++ { + for i := 0; i < depthValues && depth < depthValues && e.substitutions < maxSubstitutions; i++ { vr := varPattern.FindString(val) if len(vr) == 0 { break @@ -176,8 +192,14 @@ func (k *Key) transformValue(val string) string { } } - // Substitute by new value and take off leading '%(' and trailing ')s'. - val = strings.ReplaceAll(val, vr, nk.String()) + e.substitutions++ + sub := nk.expand(nk.value, depth+1, e) + n := len(val) + strings.Count(val, vr)*(len(sub)-len(vr)) + if e.bytes+n > maxExpandedLen { + break + } + e.bytes += n + val = strings.ReplaceAll(val, vr, sub) } return val } diff --git a/key_test.go b/key_test.go index 12e2f38..30db7f4 100644 --- a/key_test.go +++ b/key_test.go @@ -621,6 +621,25 @@ expires = %(expires)s`)) assert.Equal(t, "yes", f.Section("package").Key("expires").String()) }) + // https://github.com/go-ini/ini/issues/409 + t.Run("reference cycles and doubling values stop", func(t *testing.T) { + for _, data := range []string{ + "[s]\na = %(b)s\nb = %(a)s\n", + "[s]\na = %(b)s%(b)s\nb = %(a)s%(a)s\n", + } { + f, err := Load([]byte(data)) + require.NoError(t, err) + done := make(chan string) + go func() { done <- f.Section("s").Key("a").String() }() + select { + case v := <-done: + assert.LessOrEqual(t, len(v), maxExpandedLen) + case <-time.After(5 * time.Second): + t.Fatalf("%q: String didn't return", data) + } + } + }) + t.Run("recursive value with no target found", func(t *testing.T) { f, err := Load([]byte(` [foo] From 803179da5c80d4cffd07e39b88e26f8a97b66dd2 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 21:33:35 +0000 Subject: [PATCH 15/41] Fix panics, hangs and lost values in File, Section and Key methods - Key.Strings with an empty delimiter returned only after running out of memory; it now returns the whole value. - A negative index panicked in SectionWithIndex and corrupted the section list in DeleteSectionWithIndex, which now returns an error. SectionWithIndex("", n) no longer returns nil. - With Insensitive, Section("") after deleting the default section created a new section on every call; DeleteKey ignored the case. - Section.Key("") returns a key that isn't added instead of nil. - ChildSections listed non-unique sections once per occurrence. - A default-section key referencing itself returns its raw value. --- file.go | 30 ++++++++++++++++++++++----- file_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ key.go | 9 +++++--- section.go | 14 ++++++++----- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/file.go b/file.go index 0a5fa02..bfb1166 100644 --- a/file.go +++ b/file.go @@ -65,6 +65,21 @@ func Empty(opts ...LoadOption) *File { return f } +// isDefaultSection reports whether name, as stored, is the default +// section's name. Loading with Insensitive or InsensitiveSections stores it +// lower-cased. +func (f *File) isDefaultSection(name string) bool { + return name == f.options.DefaultSection || name == f.defaultSectionName() +} + +// defaultSectionName returns the name the default section is stored under. +func (f *File) defaultSectionName() string { + if f.options.Insensitive || f.options.InsensitiveSections { + return strings.ToLower(f.options.DefaultSection) + } + return f.options.DefaultSection +} + // NewSection creates a new section. func (f *File) NewSection(name string) (*Section, error) { if len(name) == 0 { @@ -161,7 +176,8 @@ func (f *File) Section(name string) *Section { sec, err := f.GetSection(name) if err != nil { if name == "" { - name = f.options.DefaultSection + // The name the lookup used, so the next lookup finds it. + name = f.defaultSectionName() } sec, _ = f.NewSection(name) return sec @@ -171,10 +187,11 @@ func (f *File) Section(name string) *Section { // SectionWithIndex assumes named section exists and returns a new section when not. func (f *File) SectionWithIndex(name string, index int) *Section { + if name == "" { + name = f.defaultSectionName() + } secs, err := f.SectionsByName(name) - if err != nil || len(secs) <= index { - // NOTE: It's OK here because the only possible error is empty section name, - // but if it's empty, this piece of code won't be executed. + if err != nil || index < 0 || len(secs) <= index { newSec, _ := f.NewSection(name) return newSec } @@ -225,6 +242,9 @@ func (f *File) DeleteSection(name string) { // DeleteSectionWithIndex deletes a section with given name and index. func (f *File) DeleteSectionWithIndex(name string, index int) error { + if index < 0 { + return fmt.Errorf("delete section with negative index %d", index) + } if !f.options.AllowNonUniqueSections && index != 0 { return fmt.Errorf("delete section with non-zero index is only allowed when non-unique sections is enabled") } @@ -547,7 +567,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { alignSpaces := bytes.Repeat([]byte(" "), alignLength) keyIndent := "" - if sname != f.options.DefaultSection { + if !f.isDefaultSection(sname) { keyIndent = indent } // swallows is set when the lexer would read an indented line as part diff --git a/file_test.go b/file_test.go index 98f8d61..9b51090 100644 --- a/file_test.go +++ b/file_test.go @@ -590,3 +590,61 @@ func TestFile_AppendReaders(t *testing.T) { assert.Equal(t, "3", f.Section("a").Key("k").String()) assert.False(t, f.Section("a").HasKey("edited")) } + +// Misuse and option combinations that panicked, hung or lost data in v1. +func TestFile_EdgeCases(t *testing.T) { + t.Run("self-reference in the default section", func(t *testing.T) { + f, err := Load([]byte("a = %(a)s\n")) + require.NoError(t, err) + assert.Equal(t, "%(a)s", f.Section("").Key("a").String()) + }) + + t.Run("Strings with an empty delimiter", func(t *testing.T) { + f, err := Load([]byte("a = x,y\n")) + require.NoError(t, err) + assert.Equal(t, []string{"x,y"}, f.Section("").Key("a").Strings("")) + }) + + t.Run("negative and out-of-range section indexes", func(t *testing.T) { + f, err := Load([]byte("a = 1\n[x]\nb = 2\n"), AllowNonUniqueSections()) + require.NoError(t, err) + assert.NotNil(t, f.SectionWithIndex("x", -1)) + assert.Error(t, f.DeleteSectionWithIndex("x", -1)) + assert.NotNil(t, f.SectionWithIndex("", 1)) + assert.NotPanics(t, func() { _ = f.Sections() }) + }) + + t.Run("default section after deleting it, case-insensitive", func(t *testing.T) { + f, err := Load([]byte("a = 1\n"), Insensitive(), AllowNonUniqueSections()) + require.NoError(t, err) + f.DeleteSection("") + f.Section("").Key("k").SetValue("v") + assert.Equal(t, "v", f.Section("").Key("k").String()) + assert.Len(t, f.Sections(), 1) + }) + + // https://github.com/go-ini/ini/issues/340 + t.Run("DeleteKey with Insensitive", func(t *testing.T) { + f, err := Load([]byte("TestKey = 1\n"), Insensitive()) + require.NoError(t, err) + f.Section("").DeleteKey("TestKey") + assert.False(t, f.Section("").HasKey("TestKey")) + }) + + // https://github.com/go-ini/ini/issues/358 + t.Run("key with an empty name", func(t *testing.T) { + f := Empty() + require.NotNil(t, f.Section("").Key("")) + assert.Equal(t, "", f.Section("").Key("").String()) + assert.Empty(t, f.Section("").Keys()) + }) + + t.Run("child sections with non-unique names", func(t *testing.T) { + f, err := Load([]byte("[a]\n[a.b]\nx = 1\n[a.b]\nx = 2\n"), AllowNonUniqueSections()) + require.NoError(t, err) + children := f.Section("a").ChildSections() + require.Len(t, children, 2) + assert.Equal(t, "1", children[0].Key("x").String()) + assert.Equal(t, "2", children[1].Key("x").String()) + }) +} diff --git a/key.go b/key.go index 21bd746..97f967f 100644 --- a/key.go +++ b/key.go @@ -185,9 +185,9 @@ func (k *Key) expand(val string, depth int, e *expansion) string { nk, err := k.s.GetKey(noption) if err != nil || k == nk { nk, _ = k.s.f.Section("").GetKey(noption) - if nk == nil { - // Stop when no results found in the default section, - // and returns the value as-is. + if nk == nil || nk == k { + // Stop when no results found in the default section, or only + // the key itself, and returns the value as-is. break } } @@ -523,6 +523,9 @@ func (k *Key) Strings(delim string) []string { if len(str) == 0 { return []string{} } + if delim == "" { + return []string{strings.TrimSpace(str)} + } maxParts := strings.Count(str, delim) + 1 vals := make([]string, 0, maxParts) diff --git a/section.go b/section.go index 5683671..10f4459 100644 --- a/section.go +++ b/section.go @@ -163,9 +163,10 @@ func (s *Section) HasValue(value string) bool { func (s *Section) Key(name string) *Key { key, err := s.GetKey(name) if err != nil { - // It's OK here because the only possible error is empty key name, - // but if it's empty, this piece of code won't be executed. - key, _ = s.NewKey(name, "") + if key, err = s.NewKey(name, ""); err != nil { + // An empty name can't be added; return a key that isn't. + return newKey(s, name, "") + } return key } return key @@ -223,6 +224,9 @@ func (s *Section) KeysHash() map[string]string { // DeleteKey deletes a key from section. func (s *Section) DeleteKey(name string) { + if s.f.options.Insensitive || s.f.options.InsensitiveKeys { + name = strings.ToLower(name) + } if s.f.BlockMode { s.f.lock.Lock() defer s.f.lock.Unlock() @@ -243,9 +247,9 @@ func (s *Section) DeleteKey(name string) { func (s *Section) ChildSections() []*Section { prefix := s.name + s.f.options.ChildSectionDelimiter children := make([]*Section, 0, 3) - for _, name := range s.f.sectionList { + for i, name := range s.f.sectionList { if strings.HasPrefix(name, prefix) { - children = append(children, s.f.sections[name]...) + children = append(children, s.f.sections[name][s.f.sectionIndexes[i]]) } } return children From 7ca295a41a79ad2fd71f1e3dddf39a3911a6140b Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 21:33:36 +0000 Subject: [PATCH 16/41] Make struct mapping return errors instead of panicking MapTo, StrictMapTo, Unmarshal, ReflectFrom and Marshal panicked on struct, named, pointer and nil types, nil targets, non-unique slices of pointers, and recursive struct types, and silently read or wrote wrong values in several cases. Values now go through one parser and one formatter, time.Time is recognised by its type, and recursive types are followed one level deep. Also fixed: overflowing numbers wrapped, StrictMapTo ignored invalid slice elements, zero and negative durations were dropped, *time.Time was written as a section, sub-second times were truncated, slice elements with the delimiter or a trailing backslash didn't read back, nil pointers were written as empty keys, a nonunique or reflector field stopped later fields from being written, allowshadow lost the comment and dropped values, uint8 didn't map, embedded pointers were replaced, and a nonunique field without sections failed the whole MapTo. The README lists the changes valid input can show. --- README.md | 10 + struct.go | 594 +++++++++++++++++++++++++++---------------------- struct_test.go | 368 ++++++++++++++++++++++++++++-- 3 files changed, 695 insertions(+), 277 deletions(-) diff --git a/README.md b/README.md index 6b80491..dfbd06a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,16 @@ New in v2: - `ini.KeepEmptyShadowValues()` keeps empty values of shadow keys. - `Key.IsBooleanType()`, and `ini.Marshal`/`ini.Unmarshal`. - `File.Write` writes files that load back to the same sections, keys, values and comments: it picks a quoting the parser reads back, writes empty shadow values, and returns an error instead of writing a file that loads differently. +- `MapTo`, `StrictMapTo`, `Unmarshal`, `ReflectFrom` and `Marshal` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Differences from v1 that valid input can show: + - Out-of-range numbers (int8 `300`) are an error in `StrictMapTo` and leave the field unchanged in `MapTo`; v1 wrapped them. + - `StrictMapTo` reports invalid slice elements. + - Durations of `0` and negative durations are applied. + - uint8 fields map. + - Times keep sub-second precision when written. + - Slice elements containing the delimiter, or ending in a backslash, are escaped so they read back as one element. + - Nil pointer fields aren't written. + - Fields after an empty `nonunique` slice or a `StructReflector` field are written. + - A `nonunique` field with no sections is left empty instead of failing. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. ## Getting Help diff --git a/struct.go b/struct.go index 0fd6873..7d39805 100644 --- a/struct.go +++ b/struct.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "reflect" + "strconv" "strings" "time" "unicode" @@ -75,10 +76,143 @@ func parseDelim(actual string) string { return "," } -var reflectTime = reflect.TypeOf(time.Now()).Kind() +var timeType = reflect.TypeOf(time.Time{}) + +// isStructType reports whether t is a struct type that maps to a section, +// which is any struct type but time.Time. +func isStructType(t reflect.Type) bool { + return t.Kind() == reflect.Struct && t != timeType +} + +// isValueType reports whether a key value can be parsed as or formatted from +// a value of type t. +func isValueType(t reflect.Type) bool { + if t == timeType { + return true + } + switch t.Kind() { + case reflect.String, reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + } + return false +} + +// isSliceElemType reports whether slices of t are supported. +func isSliceElemType(t reflect.Type) bool { + if t == timeType { + return true + } + switch t.Kind() { + case reflect.String, reflect.Bool, reflect.Int, reflect.Int64, reflect.Uint, reflect.Uint64, reflect.Float64: + return true + } + return false +} + +// parseValue parses str as a value of type t, for which isValueType is true. +func parseValue(t reflect.Type, str string) (reflect.Value, error) { + v := reflect.New(t).Elem() + if t == timeType { + ti, err := time.Parse(time.RFC3339, str) + if err != nil { + return v, err + } + v.Set(reflect.ValueOf(ti)) + return v, nil + } + + switch t.Kind() { + case reflect.String: + v.SetString(str) + case reflect.Bool: + b, err := parseBool(str) + if err != nil { + return v, err + } + v.SetBool(b) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, err := strconv.ParseInt(str, 0, 64) + // A duration can also be given as a number of nanoseconds. + if t.Name() == "Duration" { + if d, derr := time.ParseDuration(str); derr == nil { + i, err = int64(d), nil + } + } + if err != nil { + return v, err + } + if v.OverflowInt(i) { + return v, fmt.Errorf("value %q overflows %s", str, t) + } + v.SetInt(i) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u, err := strconv.ParseUint(str, 0, 64) + if err != nil { + return v, err + } + if v.OverflowUint(u) { + return v, fmt.Errorf("value %q overflows %s", str, t) + } + v.SetUint(u) + case reflect.Float32, reflect.Float64: + f, err := strconv.ParseFloat(str, 64) + if err != nil { + return v, err + } + if v.OverflowFloat(f) { + return v, fmt.Errorf("value %q overflows %s", str, t) + } + v.SetFloat(f) + default: + return v, fmt.Errorf("unsupported type %q", t) + } + return v, nil +} + +// formatValue does the opposite thing as parseValue. +func formatValue(v reflect.Value) string { + if v.Type() == timeType { + return v.Interface().(time.Time).Format(time.RFC3339Nano) + } + switch v.Kind() { + case reflect.String: + return v.String() + case reflect.Bool: + return fmt.Sprint(v.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return fmt.Sprint(v.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return fmt.Sprint(v.Uint()) + case reflect.Float32, reflect.Float64: + return fmt.Sprint(v.Float()) + } + return "" +} + +// tooDeep reports whether path, the struct types being mapped or reflected +// from the outermost one, already holds t twice. Section names are not +// nested, so a recursive struct type would use the same sections forever: it +// is followed one level deep only. +func tooDeep(path []reflect.Type, t reflect.Type) bool { + n := 0 + for _, p := range path { + if p == t { + n++ + } + } + return n >= 2 +} // setSliceWithProperType sets proper values to slice based on its type. func setSliceWithProperType(key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { + elemType := field.Type().Elem() + if !isSliceElemType(elemType) { + return fmt.Errorf("unsupported type '[]%s'", elemType) + } + var strs []string if allowShadow { strs = key.StringsWithShadows(delim) @@ -91,54 +225,17 @@ func setSliceWithProperType(key *Key, field reflect.Value, delim string, allowSh return nil } - var vals interface{} - var err error - - sliceOf := field.Type().Elem().Kind() - switch sliceOf { - case reflect.String: - vals = strs - case reflect.Int: - vals, err = key.parseInts(strs, true, false) - case reflect.Int64: - vals, err = key.parseInt64s(strs, true, false) - case reflect.Uint: - vals, err = key.parseUints(strs, true, false) - case reflect.Uint64: - vals, err = key.parseUint64s(strs, true, false) - case reflect.Float64: - vals, err = key.parseFloat64s(strs, true, false) - case reflect.Bool: - vals, err = key.parseBools(strs, true, false) - case reflectTime: - vals, err = key.parseTimesFormat(time.RFC3339, strs, true, false) - default: - return fmt.Errorf("unsupported type '[]%s'", sliceOf) - } - if err != nil && isStrict { - return err - } - slice := reflect.MakeSlice(field.Type(), numVals, numVals) - for i := 0; i < numVals; i++ { - switch sliceOf { - case reflect.String: - slice.Index(i).Set(reflect.ValueOf(vals.([]string)[i])) - case reflect.Int: - slice.Index(i).Set(reflect.ValueOf(vals.([]int)[i])) - case reflect.Int64: - slice.Index(i).Set(reflect.ValueOf(vals.([]int64)[i])) - case reflect.Uint: - slice.Index(i).Set(reflect.ValueOf(vals.([]uint)[i])) - case reflect.Uint64: - slice.Index(i).Set(reflect.ValueOf(vals.([]uint64)[i])) - case reflect.Float64: - slice.Index(i).Set(reflect.ValueOf(vals.([]float64)[i])) - case reflect.Bool: - slice.Index(i).Set(reflect.ValueOf(vals.([]bool)[i])) - case reflectTime: - slice.Index(i).Set(reflect.ValueOf(vals.([]time.Time)[i])) + for i, str := range strs { + val, err := parseValue(elemType, str) + if err != nil { + if isStrict { + return err + } + // An invalid element is left as the zero value. + continue } + slice.Index(i).Set(val) } field.Set(slice) return nil @@ -155,111 +252,34 @@ func wrapStrictError(err error, isStrict bool) error { // but it does not return error for failing parsing, // because we want to use default value that is already assigned to struct. func setWithProperType(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { + if t.Kind() == reflect.Slice { + return setSliceWithProperType(key, field, delim, allowShadow, isStrict) + } + vt := t isPtr := t.Kind() == reflect.Pointer if isPtr { vt = t.Elem() } - switch vt.Kind() { - case reflect.String: - stringVal := key.String() - if isPtr { - field.Set(reflect.ValueOf(&stringVal)) - } else if len(stringVal) > 0 { - field.SetString(key.String()) - } - case reflect.Bool: - boolVal, err := key.Bool() - if err != nil { - return wrapStrictError(err, isStrict) - } - if isPtr { - field.Set(reflect.ValueOf(&boolVal)) - } else { - field.SetBool(boolVal) - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - // ParseDuration will not return err for `0`, so check the type name - if vt.Name() == "Duration" { - durationVal, err := key.Duration() - if err != nil { - if intVal, err := key.Int64(); err == nil { - field.SetInt(intVal) - return nil - } - return wrapStrictError(err, isStrict) - } - if isPtr { - field.Set(reflect.ValueOf(&durationVal)) - } else if int64(durationVal) > 0 { - field.Set(reflect.ValueOf(durationVal)) - } - return nil - } - - intVal, err := key.Int64() - if err != nil { - return wrapStrictError(err, isStrict) - } - if isPtr { - pv := reflect.New(t.Elem()) - pv.Elem().SetInt(intVal) - field.Set(pv) - } else { - field.SetInt(intVal) - } - // byte is an alias for uint8, so supporting uint8 breaks support for byte - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - durationVal, err := key.Duration() - // Skip zero value - if err == nil && uint64(durationVal) > 0 { - if isPtr { - field.Set(reflect.ValueOf(&durationVal)) - } else { - field.Set(reflect.ValueOf(durationVal)) - } - return nil - } - - uintVal, err := key.Uint64() - if err != nil { - return wrapStrictError(err, isStrict) - } - if isPtr { - pv := reflect.New(t.Elem()) - pv.Elem().SetUint(uintVal) - field.Set(pv) - } else { - field.SetUint(uintVal) - } - - case reflect.Float32, reflect.Float64: - floatVal, err := key.Float64() - if err != nil { - return wrapStrictError(err, isStrict) - } - if isPtr { - pv := reflect.New(t.Elem()) - pv.Elem().SetFloat(floatVal) - field.Set(pv) - } else { - field.SetFloat(floatVal) - } - case reflectTime: - timeVal, err := key.Time() - if err != nil { - return wrapStrictError(err, isStrict) - } - if isPtr { - field.Set(reflect.ValueOf(&timeVal)) - } else { - field.Set(reflect.ValueOf(timeVal)) - } - case reflect.Slice: - return setSliceWithProperType(key, field, delim, allowShadow, isStrict) - default: + if !isValueType(vt) { return fmt.Errorf("unsupported type %q", t) } + + str := key.String() + // An empty string keeps the default value of a string field. + if vt.Kind() == reflect.String && len(str) == 0 && !isPtr { + return nil + } + val, err := parseValue(vt, str) + if err != nil { + return wrapStrictError(err, isStrict) + } + if isPtr { + ptr := reflect.New(vt) + ptr.Elem().Set(val) + val = ptr + } + field.Set(val) return nil } @@ -277,11 +297,13 @@ func parseTagOptions(tag string) (rawName string, omitEmpty bool, allowShadow bo // mapToField maps the given value to the matching field of the given section. // The sectionIndex is the index (if non unique sections are enabled) to which the value should be added. -func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, sectionName string) error { +// The path holds the struct types being mapped, see tooDeep. +func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, sectionName string, path []reflect.Type) error { if val.Kind() == reflect.Pointer { val = val.Elem() } typ := val.Type() + path = append(path, typ) for i := 0; i < typ.NumField(); i++ { field := val.Field(i) @@ -298,16 +320,20 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, continue } - isStruct := tpField.Type.Kind() == reflect.Struct - isStructPtr := tpField.Type.Kind() == reflect.Pointer && tpField.Type.Elem().Kind() == reflect.Struct - isAnonymousPtr := tpField.Type.Kind() == reflect.Pointer && tpField.Anonymous - if isAnonymousPtr { - field.Set(reflect.New(tpField.Type.Elem())) + isStruct := isStructType(tpField.Type) + isStructPtr := tpField.Type.Kind() == reflect.Pointer && isStructType(tpField.Type.Elem()) + isAnonymousPtr := isStructPtr && tpField.Anonymous + structType := tpField.Type + if isStructPtr { + structType = tpField.Type.Elem() + } + if isAnonymousPtr && field.IsNil() && !tooDeep(path, structType) { + field.Set(reflect.New(structType)) } if extends && (isAnonymousPtr || (isStruct && tpField.Anonymous)) { - if isStructPtr && field.IsNil() { - field.Set(reflect.New(tpField.Type.Elem())) + if tooDeep(path, structType) { + continue } fieldSection := s if rawName != "" { @@ -316,20 +342,24 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, fieldSection = secs[sectionIndex] } } - if err := fieldSection.mapToField(field, isStrict, sectionIndex, sectionName); err != nil { + if err := fieldSection.mapToField(field, isStrict, sectionIndex, sectionName, path); err != nil { return fmt.Errorf("map to field %q: %v", fieldName, err) } - } else if isAnonymousPtr || isStruct || isStructPtr { + continue + } else if isStruct || isStructPtr { if secs, err := s.f.SectionsByName(fieldName); err == nil { + if tooDeep(path, structType) { + continue + } if len(secs) <= sectionIndex { return fmt.Errorf("there are not enough sections (%d <= %d) for the field %q", len(secs), sectionIndex, fieldName) } // Only set the field to non-nil struct value if we have a section for it. // Otherwise, we end up with a non-nil struct ptr even though there is no data. if isStructPtr && field.IsNil() { - field.Set(reflect.New(tpField.Type.Elem())) + field.Set(reflect.New(structType)) } - if err = secs[sectionIndex].mapToField(field, isStrict, sectionIndex, fieldName); err != nil { + if err = secs[sectionIndex].mapToField(field, isStrict, sectionIndex, fieldName, path); err != nil { return fmt.Errorf("map to field %q: %v", fieldName, err) } continue @@ -338,7 +368,7 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, // Map non-unique sections if allowNonUnique && tpField.Type.Kind() == reflect.Slice { - newField, err := s.mapToSlice(fieldName, field, isStrict) + newField, err := s.mapToSlice(fieldName, field, isStrict, path) if err != nil { return fmt.Errorf("map to slice %q: %v", fieldName, err) } @@ -358,38 +388,62 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, } // mapToSlice maps all sections with the same name and returns the new value. -// The type of the Value must be a slice. -func (s *Section) mapToSlice(secName string, val reflect.Value, isStrict bool) (reflect.Value, error) { +// The type of the Value must be a slice of structs or of pointers to structs. +func (s *Section) mapToSlice(secName string, val reflect.Value, isStrict bool, path []reflect.Type) (reflect.Value, error) { + elemType := val.Type().Elem() + isPtr := elemType.Kind() == reflect.Pointer + structType := elemType + if isPtr { + structType = elemType.Elem() + } + if !isStructType(structType) { + return reflect.Value{}, fmt.Errorf("unsupported type %q for non-unique sections", val.Type()) + } + secs, err := s.f.SectionsByName(secName) - if err != nil { - return reflect.Value{}, err + if err != nil || tooDeep(path, structType) { + // The slice is left as it is when there are no sections. + return val, nil } - typ := val.Type().Elem() for i, sec := range secs { - elem := reflect.New(typ) - if err = sec.mapToField(elem, isStrict, i, sec.name); err != nil { + elem := reflect.New(structType) + if err = sec.mapToField(elem, isStrict, i, sec.name, path); err != nil { return reflect.Value{}, fmt.Errorf("map to field from section %q: %v", secName, err) } - - val = reflect.Append(val, elem.Elem()) + if !isPtr { + elem = elem.Elem() + } + val = reflect.Append(val, elem) } return val, nil } // mapTo maps a section to object v. func (s *Section) mapTo(v interface{}, isStrict bool) error { - typ := reflect.TypeOf(v) val := reflect.ValueOf(v) - if typ != nil && typ.Kind() == reflect.Pointer && !val.IsNil() { + if val.Kind() != reflect.Pointer || val.IsNil() { + return errors.New("not a pointer to a struct") + } + typ := val.Type().Elem() + for typ.Kind() == reflect.Pointer { typ = typ.Elem() - val = val.Elem() - } else { + } + if typ.Kind() != reflect.Struct && typ.Kind() != reflect.Slice { return errors.New("not a pointer to a struct") } + // Allocate the nil pointers v points through. + val = val.Elem() + for val.Kind() == reflect.Pointer { + if val.IsNil() { + val.Set(reflect.New(val.Type().Elem())) + } + val = val.Elem() + } + if typ.Kind() == reflect.Slice { - newField, err := s.mapToSlice(s.name, val, isStrict) + newField, err := s.mapToSlice(s.name, val, isStrict, nil) if err != nil { return err } @@ -398,7 +452,7 @@ func (s *Section) mapTo(v interface{}, isStrict bool) error { return nil } - return s.mapToField(val, isStrict, 0, s.name) + return s.mapToField(val, isStrict, 0, s.name, nil) } // MapTo maps section to given struct. @@ -460,88 +514,73 @@ func StrictMapTo(v, source any, opts ...LoadOption) error { // reflectSliceWithProperType does the opposite thing as setSliceWithProperType. func reflectSliceWithProperType(key *Key, field reflect.Value, delim string, allowShadow bool) error { - slice := field.Slice(0, field.Len()) if field.Len() == 0 { return nil } - sliceOf := field.Type().Elem().Kind() + elemType := field.Type().Elem() + if !isSliceElemType(elemType) { + return fmt.Errorf("unsupported type '[]%s'", elemType) + } - if allowShadow { - var keyWithShadows *Key - for i := 0; i < field.Len(); i++ { - var val string - switch sliceOf { - case reflect.String: - val = slice.Index(i).String() - case reflect.Int, reflect.Int64: - val = fmt.Sprint(slice.Index(i).Int()) - case reflect.Uint, reflect.Uint64: - val = fmt.Sprint(slice.Index(i).Uint()) - case reflect.Float64: - val = fmt.Sprint(slice.Index(i).Float()) - case reflect.Bool: - val = fmt.Sprint(slice.Index(i).Bool()) - case reflectTime: - val = slice.Index(i).Interface().(time.Time).Format(time.RFC3339) - default: - return fmt.Errorf("unsupported type '[]%s'", sliceOf) - } + vals := make([]string, field.Len()) + for i := range vals { + vals[i] = formatValue(field.Index(i)) + } - if i == 0 { - keyWithShadows = newKey(key.s, key.name, val) - } else { - _ = keyWithShadows.AddShadow(val) + if allowShadow { + keyWithShadows := newKey(key.s, key.name, vals[0]) + keyWithShadows.Comment = key.Comment + for _, val := range vals[1:] { + if err := keyWithShadows.AddShadow(val); err != nil { + return err } } *key = *keyWithShadows return nil } - var buf bytes.Buffer - for i := 0; i < field.Len(); i++ { - switch sliceOf { - case reflect.String: - buf.WriteString(slice.Index(i).String()) - case reflect.Int, reflect.Int64: - fmt.Fprint(&buf, slice.Index(i).Int()) - case reflect.Uint, reflect.Uint64: - fmt.Fprint(&buf, slice.Index(i).Uint()) - case reflect.Float64: - fmt.Fprint(&buf, slice.Index(i).Float()) - case reflect.Bool: - fmt.Fprint(&buf, slice.Index(i).Bool()) - case reflectTime: - buf.WriteString(slice.Index(i).Interface().(time.Time).Format(time.RFC3339)) + for i := range vals { + vals[i] = escapeSliceElem(vals[i], delim) + } + key.SetValue(strings.Join(vals, delim)) + return nil +} + +// escapeSliceElem escapes s so that Key.Strings reads it back as one element +// even when it contains delim. Key.Strings keeps a backslash unless it comes +// before another backslash, delim or the end, so only those are doubled and +// paths like C:\dir are written as they are. +func escapeSliceElem(s, delim string) string { + if !strings.Contains(s, `\`) && !strings.Contains(s, delim) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + rest := s[i:] + switch { + case strings.HasPrefix(rest, delim): + b.WriteString(`\` + delim) + i += len(delim) - 1 + case s[i] == '\\' && (i+1 == len(s) || s[i+1] == '\\' || strings.HasPrefix(s[i+1:], delim)): + b.WriteString(`\\`) default: - return fmt.Errorf("unsupported type '[]%s'", sliceOf) + b.WriteByte(s[i]) } - buf.WriteString(delim) } - key.SetValue(buf.String()[:buf.Len()-len(delim)]) - return nil + return b.String() } // reflectWithProperType does the opposite thing as setWithProperType. func reflectWithProperType(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow bool) error { - switch t.Kind() { - case reflect.String: - key.SetValue(field.String()) - case reflect.Bool: - key.SetValue(fmt.Sprint(field.Bool())) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - key.SetValue(fmt.Sprint(field.Int())) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - key.SetValue(fmt.Sprint(field.Uint())) - case reflect.Float32, reflect.Float64: - key.SetValue(fmt.Sprint(field.Float())) - case reflectTime: - key.SetValue(fmt.Sprint(field.Interface().(time.Time).Format(time.RFC3339))) - case reflect.Slice: + switch { + case t.Kind() == reflect.Slice: return reflectSliceWithProperType(key, field, delim, allowShadow) - case reflect.Pointer: + case t.Kind() == reflect.Pointer: if !field.IsNil() { return reflectWithProperType(t.Elem(), key, field.Elem(), delim, allowShadow) } + case isValueType(t): + key.SetValue(formatValue(field)) default: return fmt.Errorf("unsupported type %q", t) } @@ -551,6 +590,9 @@ func reflectWithProperType(t reflect.Type, key *Key, field reflect.Value, delim // CR: copied from encoding/json/encode.go with modifications of time.Time support. // TODO: add more test coverage. func isEmptyValue(v reflect.Value) bool { + if v.Type() == timeType { + return v.Interface().(time.Time).IsZero() + } switch v.Kind() { case reflect.Array, reflect.Map, reflect.Slice, reflect.String: return v.Len() == 0 @@ -564,9 +606,6 @@ func isEmptyValue(v reflect.Value) bool { return v.Float() == 0 case reflect.Interface, reflect.Pointer: return v.IsNil() - case reflectTime: - t, ok := v.Interface().(time.Time) - return ok && t.IsZero() } return false } @@ -576,11 +615,14 @@ type StructReflector interface { ReflectINIStruct(*File) error } -func (s *Section) reflectFrom(val reflect.Value) error { +// reflectFrom reflects the section from val. The path holds the struct types +// being reflected from, see tooDeep. +func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { if val.Kind() == reflect.Pointer { val = val.Elem() } typ := val.Type() + path = append(path, typ) for i := 0; i < typ.NumField(); i++ { if !val.Field(i).CanInterface() { @@ -601,7 +643,10 @@ func (s *Section) reflectFrom(val reflect.Value) error { } if r, ok := field.Interface().(StructReflector); ok { - return r.ReflectINIStruct(s.f) + if err := r.ReflectINIStruct(s.f); err != nil { + return err + } + continue } fieldName := s.parseFieldName(tpField.Name, rawName) @@ -609,20 +654,32 @@ func (s *Section) reflectFrom(val reflect.Value) error { continue } - // A nil struct pointer has nothing to write. - if field.Kind() == reflect.Pointer && field.IsNil() && tpField.Type.Elem().Kind() == reflect.Struct { + // A nil pointer has nothing to write. + if field.Kind() == reflect.Pointer && field.IsNil() { continue } - if extends && tpField.Anonymous && (tpField.Type.Kind() == reflect.Pointer || tpField.Type.Kind() == reflect.Struct) { - if err := s.reflectFrom(field); err != nil { + isStruct := isStructType(tpField.Type) + isStructPtr := tpField.Type.Kind() == reflect.Pointer && isStructType(tpField.Type.Elem()) + structType := tpField.Type + if isStructPtr { + structType = tpField.Type.Elem() + } + + if extends && tpField.Anonymous && (isStruct || isStructPtr) { + if tooDeep(path, structType) { + continue + } + if err := s.reflectFrom(field, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) } continue } - if (tpField.Type.Kind() == reflect.Pointer && tpField.Type.Elem().Kind() == reflect.Struct) || - (tpField.Type.Kind() == reflect.Struct && tpField.Type.Name() != "Time") { + if isStruct || isStructPtr { + if tooDeep(path, structType) { + continue + } // Note: The only error here is section doesn't exist. sec, err := s.f.GetSection(fieldName) if err != nil { @@ -635,22 +692,31 @@ func (s *Section) reflectFrom(val reflect.Value) error { sec.Comment = tpField.Tag.Get("comment") } - if err = sec.reflectFrom(field); err != nil { + if err = sec.reflectFrom(field, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) } continue } if allowNonUnique && tpField.Type.Kind() == reflect.Slice { - slice := field.Slice(0, field.Len()) if field.Len() == 0 { - return nil + continue + } + structType = tpField.Type.Elem() + if structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if !isStructType(structType) { + return fmt.Errorf("field %q is not a slice of pointer or struct", fieldName) + } + if tooDeep(path, structType) { + continue } - sliceOf := field.Type().Elem().Kind() for i := 0; i < field.Len(); i++ { - if sliceOf != reflect.Struct && sliceOf != reflect.Pointer { - return fmt.Errorf("field %q is not a slice of pointer or struct", fieldName) + elem := field.Index(i) + if elem.Kind() == reflect.Pointer && elem.IsNil() { + continue } sec, err := s.f.NewSection(fieldName) @@ -663,7 +729,7 @@ func (s *Section) reflectFrom(val reflect.Value) error { sec.Comment = tpField.Tag.Get("comment") } - if err := sec.reflectFrom(slice.Index(i)); err != nil { + if err := sec.reflectFrom(elem, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) } } @@ -698,8 +764,15 @@ func (s *Section) ReflectFrom(v interface{}) error { return errors.New("not a pointer to a struct") } - if s.name != s.f.options.DefaultSection && s.f.options.AllowNonUniqueSections && + if !s.f.isDefaultSection(s.name) && s.f.options.AllowNonUniqueSections && (typ.Kind() == reflect.Slice || typ.Kind() == reflect.Pointer) { + if typ.Kind() == reflect.Pointer && !isStructType(typ.Elem()) { + return errors.New("not a pointer to a struct") + } + if typ.Kind() == reflect.Slice && (typ.Elem().Kind() != reflect.Pointer || !isStructType(typ.Elem().Elem())) { + return errors.New("not a slice of pointers to structs") + } + // Clear sections to make sure none exists before adding the new ones s.f.DeleteSection(s.name) @@ -708,22 +781,21 @@ func (s *Section) ReflectFrom(v interface{}) error { if err != nil { return err } - return sec.reflectFrom(val.Elem()) + return sec.reflectFrom(val.Elem(), nil) } - slice := val.Slice(0, val.Len()) - sliceOf := val.Type().Elem().Kind() - if sliceOf != reflect.Pointer { - return fmt.Errorf("not a slice of pointers") - } + for i := 0; i < val.Len(); i++ { + elem := val.Index(i) + if elem.IsNil() { + continue + } - for i := 0; i < slice.Len(); i++ { sec, err := s.f.NewSection(s.name) if err != nil { return err } - err = sec.reflectFrom(slice.Index(i)) + err = sec.reflectFrom(elem, nil) if err != nil { return fmt.Errorf("reflect from %dth field: %v", i, err) } @@ -735,7 +807,7 @@ func (s *Section) ReflectFrom(v interface{}) error { if typ.Kind() != reflect.Pointer || typ.Elem().Kind() != reflect.Struct { return errors.New("not a pointer to a struct") } - return s.reflectFrom(val.Elem()) + return s.reflectFrom(val.Elem(), nil) } // ReflectFrom reflects file from given struct. diff --git a/struct_test.go b/struct_test.go index 52eeb5f..48c5309 100644 --- a/struct_test.go +++ b/struct_test.go @@ -171,18 +171,19 @@ PresharedKey = psKey2 AllowedIPs = 10.2.0.3/32,fd00:2::3/128 ` +// complex128 is supported neither as a key value nor as a section. type unsupport struct { - Byte byte + Byte complex128 } type unsupport2 struct { Others struct { - Cities byte + Cities complex128 } } type Unsupport3 struct { - Cities byte + Cities complex128 } type unsupport4 struct { @@ -528,14 +529,13 @@ func Test_ReflectFromStruct(t *testing.T) { var buf bytes.Buffer _, err = cfg.WriteTo(&buf) require.NoError(t, err) - assert.Equal(t, `NAME = Unknwon -Male = true -Optional = + assert.Equal(t, `NAME = Unknwon +Male = true ; Author's age -Age = 21 -Height = 100 -GPA = 2.8 -Date = 1993-10-07T20:17:05Z +Age = 21 +Height = 100 +GPA = 2.8 +Date = 1993-10-07T20:17:05Z ; Embeded section [infos] @@ -639,12 +639,12 @@ func Test_ReflectFromStructNonUniqueSections(t *testing.T) { { PublicKey: "pubClientKey", PresharedKey: "psKey", - AllowedIPs: []string{"10.2.0.2/32,fd00:2::2/128"}, + AllowedIPs: []string{"10.2.0.2/32", "fd00:2::2/128"}, }, { PublicKey: "pubClientKey2", PresharedKey: "psKey2", - AllowedIPs: []string{"10.2.0.3/32,fd00:2::3/128"}, + AllowedIPs: []string{"10.2.0.3/32", "fd00:2::3/128"}, }, }, } @@ -685,12 +685,12 @@ PrivateKey = privServerKey [Peer] PublicKey = pubClientKey3 PresharedKey = psKey3 -AllowedIPs = 10.2.0.4/32,fd00:2::4/128 +AllowedIPs = 10.2.0.4/32\,fd00:2::4/128 [Peer] PublicKey = pubClientKey4 PresharedKey = psKey4 -AllowedIPs = 10.2.0.5/32,fd00:2::5/128 +AllowedIPs = 10.2.0.5/32\,fd00:2::5/128 `, buf.String(), ) @@ -715,7 +715,7 @@ PrivateKey = privServerKey [Peer] PublicKey = pubClientKey5 PresharedKey = psKey5 -AllowedIPs = 10.2.0.6/32,fd00:2::6/128 +AllowedIPs = 10.2.0.6/32\,fd00:2::6/128 `, buf.String(), ) @@ -794,7 +794,7 @@ path = /tmp/gpm-profiles/test1.profile sa = s2 allowshadow = s3 allowshadow = s4 -Dates = 2020-09-12T00:00:00Z +Dates = 2020-09-12T00:00:00.651387237Z Places = HangZhou Places = Boston Years = 1993 @@ -972,3 +972,339 @@ func TestMarshalUnmarshal_Nil(t *testing.T) { require.NoError(t, err) assert.Equal(t, "name = n\n", string(data)) } + +func TestMapTo_UintFieldWithDuration(t *testing.T) { + type cfg struct { + U uint + P *uint64 + } + c := cfg{U: 7} + assert.NoError(t, MapTo(&c, []byte("U = 1s\nP = 1s\n"))) + assert.Equal(t, uint(7), c.U) + assert.Nil(t, c.P) + assert.Error(t, StrictMapTo(&cfg{}, []byte("U = 1s\n"))) + + assert.NoError(t, MapTo(&c, []byte("U = 3\nP = 4\n"))) + assert.Equal(t, uint(3), c.U) + require.NotNil(t, c.P) + assert.Equal(t, uint64(4), *c.P) +} + +func TestMapTo_DurationPointerWithNumber(t *testing.T) { + var c struct{ D *time.Duration } + require.NoError(t, MapTo(&c, []byte("D = 5\n"))) + require.NotNil(t, c.D) + assert.Equal(t, time.Duration(5), *c.D) +} + +func TestMapTo_StructFieldsFromKeys(t *testing.T) { + type sub struct{ A int } + var c1 struct{ Sub sub } + assert.Error(t, MapTo(&c1, []byte("Sub = 2006-01-02T15:04:05Z\n"))) + var c2 struct{ Sub *sub } + assert.Error(t, MapTo(&c2, []byte("Sub = 2006-01-02T15:04:05Z\n"))) + var c3 struct{ Items []sub } + assert.Error(t, MapTo(&c3, []byte("Items = x\n"))) +} + +type testTag string + +type testBool bool + +func TestMapTo_NamedTypes(t *testing.T) { + var c struct { + Tag testTag + Tags []testTag + TagP *testTag + BoolP *testBool + Durs []time.Duration + } + require.NoError(t, MapTo(&c, []byte("Tag = t\nTags = a,b\nTagP = p\nBoolP = true\nDurs = 1s,2s,3\n"))) + assert.Equal(t, testTag("t"), c.Tag) + assert.Equal(t, []testTag{"a", "b"}, c.Tags) + require.NotNil(t, c.TagP) + assert.Equal(t, testTag("p"), *c.TagP) + require.NotNil(t, c.BoolP) + assert.Equal(t, testBool(true), *c.BoolP) + assert.Equal(t, []time.Duration{time.Second, 2 * time.Second, 3}, c.Durs) +} + +func TestMapTo_PointerTargets(t *testing.T) { + type cfg struct{ Name string } + f, err := Load([]byte("Name = x\n")) + require.NoError(t, err) + + var c *cfg + require.NoError(t, f.MapTo(&c)) + require.NotNil(t, c) + assert.Equal(t, "x", c.Name) + + var cc **cfg + require.NoError(t, f.MapTo(&cc)) + require.NotNil(t, cc) + assert.Equal(t, "x", (*cc).Name) + + var i int + assert.Error(t, f.MapTo(&i)) + var ip *int + assert.Error(t, f.MapTo(&ip)) + assert.Nil(t, ip) +} + +func TestMapTo_NonUniqueSlices(t *testing.T) { + type peer struct{ Key string } + type cfg struct { + Peers []*peer `ini:"Peer,nonunique"` + Other []peer `ini:"Other,nonunique"` + After string + } + var c cfg + require.NoError(t, MapTo(&c, []byte("After = a\n[Peer]\nKey = x\n[Peer]\nKey = y\n"), AllowNonUniqueSections())) + require.Len(t, c.Peers, 2) + assert.Equal(t, "x", c.Peers[0].Key) + assert.Equal(t, "y", c.Peers[1].Key) + assert.Empty(t, c.Other, "no [Other] sections leaves the slice empty") + assert.Equal(t, "a", c.After, "fields after a nonunique field without sections are mapped") + + var s struct { + Names []string `ini:"Name,nonunique"` + } + assert.Error(t, MapTo(&s, []byte("[Name]\nk = v\n"), AllowNonUniqueSections())) +} + +type testNode struct { + X int + Child *testNode + Kids []testNode `ini:"Kid,nonunique"` +} + +func TestMapToReflectFrom_RecursiveType(t *testing.T) { + var n testNode + require.NoError(t, MapTo(&n, []byte("X = 1\n[Child]\nX = 2\n[Kid]\nX = 3\n[Kid]\nX = 4\n"), AllowNonUniqueSections())) + assert.Equal(t, 1, n.X) + require.NotNil(t, n.Child) + assert.Equal(t, 2, n.Child.X) + assert.Nil(t, n.Child.Child, "the same section is not mapped again one level deeper") + require.Len(t, n.Kids, 2) + assert.Equal(t, 3, n.Kids[0].X) + assert.Empty(t, n.Kids[0].Kids) + + // A cyclic value is written one level deep. + cyclic := &testNode{X: 1} + cyclic.Child = cyclic + data, err := Marshal(cyclic) + require.NoError(t, err) + assert.Equal(t, "X = 1\n\n[Child]\nX = 1\n", string(data)) +} + +func TestReflectFrom_StructSlice(t *testing.T) { + type sub struct{ A int } + _, err := Marshal(struct{ Items []sub }{Items: []sub{{1}}}) + assert.Error(t, err) +} + +func TestReflectFrom_NonUniqueNilAndNonStruct(t *testing.T) { + type peer struct{ Key string } + data, err := Marshal(struct { + Peers []*peer `ini:"Peer,nonunique"` + }{Peers: []*peer{nil, {"x"}, nil}}) + require.NoError(t, err) + assert.Equal(t, "[Peer]\nKey = x\n", string(data)) + + n := 1 + _, err = Marshal(struct { + Ints []*int `ini:"Int,nonunique"` + }{Ints: []*int{&n}}) + assert.Error(t, err) + + f := Empty(AllowNonUniqueSections()) + require.NoError(t, f.Section("Peer").ReflectFrom([]*peer{nil, {"y"}})) + secs, err := f.SectionsByName("Peer") + require.NoError(t, err) + require.Len(t, secs, 1) + assert.Equal(t, "y", secs[0].Key("Key").String()) + + assert.Error(t, f.Section("Peer").ReflectFrom([]*int{&n})) + assert.Error(t, f.Section("Peer").ReflectFrom(&n)) + assert.True(t, f.HasSection("Peer"), "a rejected value does not delete the sections") +} + +func TestMapTo_Overflow(t *testing.T) { + type cfg struct { + I8 int8 + U16 uint16 + F32 float32 + Is []int + } + src := []byte("I8 = 300\nU16 = 70000\nF32 = 1e300\n") + c := cfg{I8: 1, U16: 2, F32: 3} + require.NoError(t, MapTo(&c, src)) + assert.Equal(t, cfg{I8: 1, U16: 2, F32: 3}, c) + + for _, line := range []string{"I8 = 300", "I8 = -129", "U16 = 70000", "F32 = 1e300"} { + assert.Error(t, StrictMapTo(&cfg{}, []byte(line)), line) + } + require.NoError(t, StrictMapTo(&c, []byte("I8 = -128\nU16 = 65535\nF32 = 1.5\n"))) + assert.Equal(t, cfg{I8: -128, U16: 65535, F32: 1.5}, c) +} + +func TestStrictMapTo_InvalidSliceElement(t *testing.T) { + var c struct{ Is []int } + require.NoError(t, MapTo(&c, []byte("Is = 1,x,3\n"))) + assert.Equal(t, []int{1, 0, 3}, c.Is) + assert.Error(t, StrictMapTo(&c, []byte("Is = 1,x,3\n"))) +} + +func TestMapTo_ZeroAndNegativeDuration(t *testing.T) { + c := struct{ D, Z time.Duration }{D: time.Hour, Z: time.Hour} + require.NoError(t, MapTo(&c, []byte("D = -5s\nZ = 0\n"))) + assert.Equal(t, -5*time.Second, c.D) + assert.Equal(t, time.Duration(0), c.Z) +} + +func TestMarshalUnmarshal_TimePointer(t *testing.T) { + type cfg struct { + T *time.Time + TV time.Time + } + ti := time.Date(2020, 9, 12, 1, 2, 3, 0, time.UTC) + data, err := Marshal(cfg{T: &ti, TV: ti}) + require.NoError(t, err) + assert.Equal(t, "T = 2020-09-12T01:02:03Z\nTV = 2020-09-12T01:02:03Z\n", string(data)) + + var out cfg + require.NoError(t, Unmarshal(data, &out)) + require.NotNil(t, out.T) + assert.True(t, ti.Equal(*out.T)) + assert.True(t, ti.Equal(out.TV)) +} + +func TestMarshalUnmarshal_SubSecondTime(t *testing.T) { + type cfg struct { + T time.Time + Ts []time.Time + } + ti := time.Date(2020, 9, 12, 0, 0, 0, 651387237, time.UTC) + in := cfg{T: ti, Ts: []time.Time{ti, ti.Add(time.Second)}} + data, err := Marshal(in) + require.NoError(t, err) + assert.Equal(t, "T = 2020-09-12T00:00:00.651387237Z\nTs = 2020-09-12T00:00:00.651387237Z,2020-09-12T00:00:01.651387237Z\n", string(data)) + + var out cfg + require.NoError(t, Unmarshal(data, &out)) + assert.Equal(t, in, out) +} + +func TestMarshalUnmarshal_EscapedSliceElements(t *testing.T) { + type cfg struct { + S []string + P []string `delim:"|"` + } + in := cfg{S: []string{"a", "b, c", `d\e`, `f\`}, P: []string{"x|y", "z,w"}} + data, err := Marshal(in) + require.NoError(t, err) + assert.Equal(t, "S = `a,b\\, c,d\\e,f\\\\`\nP = x\\|y|z,w\n", string(data)) + + var out cfg + require.NoError(t, Unmarshal(data, &out)) + assert.Equal(t, in, out) + + // Key.Strings drops an empty last element, so a slice ending in an empty + // string cannot round trip; empty elements before the last one do. + data, err = Marshal(cfg{S: []string{"", "a"}, P: []string{"", ""}}) + require.NoError(t, err) + assert.Equal(t, "S = ,a\nP = |\n", string(data)) + out = cfg{} + require.NoError(t, Unmarshal(data, &out)) + assert.Equal(t, []string{"", "a"}, out.S) +} + +func TestMarshalUnmarshal_NilPointerFields(t *testing.T) { + type cfg struct { + PS *string + PI *int + Name string + } + data, err := Marshal(cfg{Name: "n"}) + require.NoError(t, err) + assert.Equal(t, "Name = n\n", string(data)) + + var out cfg + require.NoError(t, Unmarshal(data, &out)) + assert.Nil(t, out.PS) + assert.Nil(t, out.PI) +} + +func TestReflectFrom_FieldsAfterEmptyNonUniqueAndReflector(t *testing.T) { + type peer struct{ Key string } + data, err := Marshal(struct { + Peers []peer `ini:"Peer,nonunique"` + After string + }{After: "a"}) + require.NoError(t, err) + assert.Equal(t, "After = a\n", string(data)) + + data, err = Marshal(struct { + Employer Employers + After string + }{Employer: Employers{{Name: "E", Title: "T"}}, After: "a"}) + require.NoError(t, err) + assert.Equal(t, "After = a\n\n[E]\nTitle = T\n", string(data)) +} + +func TestReflectFrom_AllowShadow(t *testing.T) { + type cfg struct { + Paths []string `ini:"path,allowshadow" comment:"search paths"` + } + data, err := Marshal(cfg{Paths: []string{"a", "b"}}) + require.NoError(t, err) + // The comment of a key goes before its last line, as the lexer reads it. + assert.Equal(t, "path = a\n; search paths\npath = b\n", string(data)) + + assert.Error(t, ReflectFrom(Empty(), &cfg{Paths: []string{"a", "b"}}), "values must not be dropped silently") + f := Empty() + require.NoError(t, ReflectFrom(f, &cfg{Paths: []string{"a"}})) + assert.Equal(t, "a", f.Section("").Key("path").String()) +} + +func TestReflectFrom_InsensitiveDefaultSection(t *testing.T) { + for _, opt := range []LoadOption{Insensitive(), InsensitiveSections()} { + f, err := Load([]byte("a=1\n[x]\nb=2\n"), opt, AllowNonUniqueSections()) + require.NoError(t, err) + require.NoError(t, f.ReflectFrom(&struct{ C int }{3})) + assert.Equal(t, "1", f.Section("").Key("a").String()) + assert.Equal(t, "3", f.Section("").Key("C").String()) + } +} + +// https://github.com/go-ini/ini/issues/302 +func TestMarshalUnmarshal_Uint8(t *testing.T) { + type cfg struct { + B uint8 + BP *byte + } + var out cfg + require.NoError(t, Unmarshal([]byte("B = 200\nBP = 7\n"), &out)) + assert.Equal(t, uint8(200), out.B) + require.NotNil(t, out.BP) + assert.Equal(t, byte(7), *out.BP) + + data, err := Marshal(out) + require.NoError(t, err) + assert.Equal(t, "B = 200\nBP = 7\n", string(data)) +} + +// https://github.com/go-ini/ini/issues/274 +func TestMapTo_EmbeddedPointerKeepsValues(t *testing.T) { + type Base struct{ A, B string } + type cfg struct { + *Base `ini:"base"` + } + c := cfg{Base: &Base{A: "keep", B: "old"}} + require.NoError(t, MapTo(&c, []byte("[base]\nB = new\n"))) + assert.Equal(t, Base{A: "keep", B: "new"}, *c.Base) + + var nilBase cfg + require.NoError(t, MapTo(&nilBase, []byte("X = 1\n"))) + assert.NotNil(t, nilBase.Base, "a nil embedded pointer is still allocated") +} From edf75ee26f4ef3ffafbf29474147e12b943c10df Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 21:49:53 +0000 Subject: [PATCH 17/41] Make concurrent use race-free and Marshal output read back With BlockMode, reads of keys, values, sections and bodies, and all of Write, took no lock, and Append parsed into the shared file, so reading while another goroutine reloaded, appended, set values or reflected was a data race. Public reads now take the read lock, mutations the write lock, and the parser and internal code use lock-free helpers, so Load and Reload parse a private file and Append holds the lock. Reload no longer rewrites the fields readers use to find the lock. Loading is slightly faster; a locked read costs one RLock pair. FuzzStructMapping loads arbitrary input under random options, calls every accessor, maps it, and checks that Unmarshal reads back what Marshal writes. It found that allowshadow slices lost repeated, empty and delimiter-containing elements, and that nonunique sections were merged on reading. Marshal now keeps repeated shadow values and escapes elements; Unmarshal enables the options allowshadow and nonunique fields need, and splits shadow values the way Key.Strings does. --- README.md | 2 + api_fuzz_test.go | 206 ++++++++++++++++++ file.go | 114 +++++++--- file_test.go | 83 +++++++ key.go | 56 +++-- lexer.go | 11 +- section.go | 35 ++- struct.go | 84 +++++-- struct_test.go | 14 ++ .../fuzz/FuzzStructMapping/714f26f75d68ee6f | 3 + 10 files changed, 535 insertions(+), 73 deletions(-) create mode 100644 api_fuzz_test.go create mode 100644 testdata/fuzz/FuzzStructMapping/714f26f75d68ee6f diff --git a/README.md b/README.md index dfbd06a..d2eeb6a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ New in v2: - Nil pointer fields aren't written. - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - A `nonunique` field with no sections is left empty instead of failing. +- With `BlockMode` (the default) a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. `Comment` fields are plain fields: set them before sharing the `File`. +- `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated, empty or delimiter-containing elements and `nonunique` sections. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. ## Getting Help diff --git a/api_fuzz_test.go b/api_fuzz_test.go new file mode 100644 index 0000000..2b46d52 --- /dev/null +++ b/api_fuzz_test.go @@ -0,0 +1,206 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +type fzName string + +type fzSub struct { + A int + B string + T time.Time +} + +type fzNode struct { + X int + Child *fzNode +} + +type fzEmbed struct { + E string +} + +// fzCfg has a field of every kind struct mapping supports. +type fzCfg struct { + S string + I int + I8 int8 + U uint + U8 uint8 + F32 float32 + F float64 + B bool + D time.Duration + T time.Time + + PS *string + PI *int + PT *time.Time + PD *time.Duration + + SS []string + SI []int + SU []uint64 + SF []float64 + SB []bool + ST []time.Time + SP []string `delim:"|"` + + Named fzName + PNamed *fzName + + Sub fzSub + PSub *fzSub + Shadow []string `ini:"shadow,,allowshadow"` + Peers []fzSub `ini:"Peer,,,nonunique"` + PPeers []*fzSub `ini:"PPeer,,,nonunique"` + Node *fzNode + fzEmbed `ini:",extends"` + Skip string `ini:"-"` +} + +// FuzzStructMapping loads arbitrary input under random options, reads it +// with every Key accessor and maps it into fzCfg. Nothing may panic or hang, +// and a value written by Marshal must read back the same. +func FuzzStructMapping(f *testing.F) { + inputs := append([]string{confData}, paritySeeds...) + files, _ := filepath.Glob("testdata/*.ini") + for _, name := range files { + data, err := os.ReadFile(name) + if err != nil { + f.Fatal(err) + } + inputs = append(inputs, string(data)) + } + inputs = append(inputs, + "S = s\nI = -3\nI8 = 127\nU = 4\nU8 = 255\nF32 = 1.5\nF = 2.5\nB = true\nD = 1s\nT = 2020-01-02T03:04:05.5Z\n"+ + "PS = p\nPI = 7\nPT = 2020-01-02T03:04:05Z\nPD = 3s\nSS = a,b\\, c,d\nSI = 1,2\nSU = 3,4\nSF = 1.5,2\nSB = true,false\n"+ + "ST = 2020-01-02T03:04:05Z\nSP = x|y\nNamed = n\nPNamed = m\nshadow = a\nshadow = b\nE = e\n"+ + "[Sub]\nA = 1\nB = b\n[PSub]\nA = 2\n[Peer]\nA = 3\n[Peer]\nA = 4\n[PPeer]\nB = q\n[Node]\nX = 1\n[Child]\nX = 2\n", + "I8 = 300\nU8 = -1\nF32 = 1e300\nD = -5s\nSI = 1,x,3\nT = 2006-01-02T15:04:05.123456789+07:00\n", + "a = %(b)s\nb = %(a)s\nS = %(S)s%(S)s\n", + ) + for _, in := range inputs { + for _, flags := range []uint32{0, 1 << 7, 1<<14 | 1<<7, 1<<0 | 1<<14, 1<<20 - 1} { + f.Add([]byte(in), flags) + } + } + + f.Fuzz(func(t *testing.T, data []byte, flags uint32) { + opts := v2Options(parityOptions(flags)) + file, err := Load(data, opts...) + if err != nil { + return + } + + for _, sec := range file.Sections() { + _ = sec.ChildSections() + _ = sec.ParentKeys() + _ = sec.KeysHash() + for _, k := range sec.Keys() { + _ = k.String() + for _, delim := range []string{",", "", "|", "\\", "::"} { + _ = k.Strings(delim) + } + _ = k.StringsWithShadows(",") + _ = k.Ints(",") + _ = k.Uint64s(",") + _ = k.Float64s(",") + _ = k.Bools(",") + _ = k.Times(",") + _, _ = k.Duration() + _, _ = k.Time() + _ = k.MustInt(1) + _ = k.In("a", []string{"a", "b"}) + _ = k.ValueWithShadows() + _ = k.NestedValues() + } + } + _ = file.SectionWithIndex("", 1) + _ = file.SectionWithIndex("Peer", -1) + + var cfg fzCfg + _ = file.MapTo(&cfg) + var strict fzCfg + _ = file.StrictMapTo(&strict) + + // What Marshal writes, Unmarshal reads back the same, except + // for the limits of the format that are checked below. + out, err := Marshal(cfg) + if err != nil { + return + } + var back fzCfg + if err := Unmarshal(out, &back); err != nil { + t.Fatalf("Unmarshal of Marshal output failed: %v\nmarshaled: %q", err, out) + } + normalizeFz(&cfg) + normalizeFz(&back) + if !reflect.DeepEqual(cfg, back) { + t.Fatalf("Marshal/Unmarshal changed the value\nbefore: %+v\nafter: %+v\nmarshaled: %q", cfg, back, out) + } + + // ReflectFrom into the loaded file must not panic and must write. + if err := file.ReflectFrom(&cfg); err == nil { + var buf bytes.Buffer + _, _ = file.Write(&buf) + } + }) +} + +// normalizeFz clears what the format can't carry: empty slices and nil +// versus empty, monotonic clock readings and locations of times. +func normalizeFz(c *fzCfg) { + v := reflect.ValueOf(c).Elem() + normalizeFzValue(v) +} + +func normalizeFzValue(v reflect.Value) { + switch v.Kind() { + case reflect.Pointer: + if !v.IsNil() { + normalizeFzValue(v.Elem()) + } + case reflect.Struct: + if v.Type() == timeType { + if v.CanSet() { + t := v.Interface().(time.Time) + v.Set(reflect.ValueOf(time.Unix(t.Unix(), int64(t.Nanosecond())).UTC())) + } + return + } + for i := 0; i < v.NumField(); i++ { + if v.Field(i).CanSet() { + normalizeFzValue(v.Field(i)) + } + } + case reflect.Slice: + if v.Len() == 0 { + v.Set(reflect.Zero(v.Type())) + return + } + for i := 0; i < v.Len(); i++ { + normalizeFzValue(v.Index(i)) + } + } +} diff --git a/file.go b/file.go index bfb1166..a92a886 100644 --- a/file.go +++ b/file.go @@ -80,8 +80,42 @@ func (f *File) defaultSectionName() string { return f.options.DefaultSection } +// The lock helpers guard the file's sections, keys and values when +// BlockMode is set. Unexported methods that change them expect the caller to +// hold the lock, or the file not to be shared yet, as while parsing. +func (f *File) rlock() { + if f.BlockMode { + f.lock.RLock() + } +} + +func (f *File) runlock() { + if f.BlockMode { + f.lock.RUnlock() + } +} + +func (f *File) wlock() { + if f.BlockMode { + f.lock.Lock() + } +} + +func (f *File) wunlock() { + if f.BlockMode { + f.lock.Unlock() + } +} + // NewSection creates a new section. func (f *File) NewSection(name string) (*Section, error) { + f.wlock() + defer f.wunlock() + return f.addSection(name) +} + +// addSection is NewSection for a caller that holds the lock. +func (f *File) addSection(name string) (*Section, error) { if len(name) == 0 { return nil, errors.New("empty section name") } @@ -90,11 +124,6 @@ func (f *File) NewSection(name string) (*Section, error) { name = strings.ToLower(name) } - if f.BlockMode { - f.lock.Lock() - defer f.lock.Unlock() - } - if _, ok := f.sections[name]; ok && !f.options.AllowNonUniqueSections { return f.sections[name][0], nil } @@ -113,7 +142,9 @@ func (f *File) NewSection(name string) (*Section, error) { // NewRawSection creates a new section with an unparseable body. func (f *File) NewRawSection(name, body string) (*Section, error) { - section, err := f.NewSection(name) + f.wlock() + defer f.wunlock() + section, err := f.addSection(name) if err != nil { return nil, err } @@ -135,12 +166,21 @@ func (f *File) NewSections(names ...string) (err error) { // GetSection returns section by given name. func (f *File) GetSection(name string) (*Section, error) { - secs, err := f.SectionsByName(name) + return f.sectionWithIndex(name, 0) +} + +// sectionWithIndex returns the index-th section with the given name. +func (f *File) sectionWithIndex(name string, index int) (*Section, error) { + f.rlock() + defer f.runlock() + secs, err := f.sectionsByName(name) if err != nil { return nil, err } - - return secs[0], err + if index < 0 || index >= len(secs) { + return nil, fmt.Errorf("section %q has no index %d", name, index) + } + return secs[index], nil } // HasSection returns true if the file contains a section with given name. @@ -151,6 +191,15 @@ func (f *File) HasSection(name string) bool { // SectionsByName returns all sections with given name. func (f *File) SectionsByName(name string) ([]*Section, error) { + f.rlock() + defer f.runlock() + secs, err := f.sectionsByName(name) + // A copy, as deleting a section shifts the ones after it in place. + return slices.Clone(secs), err +} + +// sectionsByName is SectionsByName for a caller that holds the lock. +func (f *File) sectionsByName(name string) ([]*Section, error) { if len(name) == 0 { name = f.options.DefaultSection } @@ -158,16 +207,10 @@ func (f *File) SectionsByName(name string) ([]*Section, error) { name = strings.ToLower(name) } - if f.BlockMode { - f.lock.RLock() - defer f.lock.RUnlock() - } - secs := f.sections[name] if len(secs) == 0 { return nil, fmt.Errorf("section %q does not exist", name) } - return secs, nil } @@ -190,13 +233,12 @@ func (f *File) SectionWithIndex(name string, index int) *Section { if name == "" { name = f.defaultSectionName() } - secs, err := f.SectionsByName(name) - if err != nil || index < 0 || len(secs) <= index { + sec, err := f.sectionWithIndex(name, index) + if err != nil { newSec, _ := f.NewSection(name) return newSec } - - return secs[index] + return sec } // Sections returns a list of Section stored in the current instance. @@ -220,6 +262,8 @@ func (f *File) ChildSections(name string) []*Section { // SectionStrings returns list of section names. func (f *File) SectionStrings() []string { + f.rlock() + defer f.runlock() list := make([]string, len(f.sectionList)) copy(list, f.sectionList) return list @@ -310,33 +354,40 @@ func (f *File) reload(s dataSource) error { // same values, so pointers to them see the new contents. On error the file // is unchanged. func (f *File) Reload() error { + f.rlock() + sources := f.dataSources + f.runlock() next := &File{options: f.options, sections: make(map[string][]*Section)} - if err := next.load(f.dataSources); err != nil { + if err := next.load(sources); err != nil { return err } - if f.BlockMode { - f.lock.Lock() - defer f.lock.Unlock() - } + f.wlock() + defer f.wunlock() for name, secs := range next.sections { for i, sec := range secs { if old := f.sections[name]; i < len(old) { - // Keep the section and keys callers may hold. + // Keep the section and keys callers may hold. Their s and + // f stay, as readers use them to find the lock. o := old[i] for kname, k := range sec.keys { if ok := o.keys[kname]; ok != nil { - *ok = *k + ok.Comment, ok.value, ok.hashValue = k.Comment, k.value, k.hashValue + ok.isAutoIncrement, ok.isBooleanType = k.isAutoIncrement, k.isBooleanType + ok.shadows, ok.nestedValues = k.shadows, k.nestedValues sec.keys[kname] = ok } } o.Comment, o.keys, o.keyList = sec.Comment, sec.keys, sec.keyList o.isRawSection, o.rawBody = sec.isRawSection, sec.rawBody secs[i], sec = o, o + } else { + sec.f = f } - sec.f = f for _, k := range sec.keys { - k.s = sec + if k.s != sec { + k.s = sec + } for _, s := range k.shadows { s.s = sec } @@ -377,15 +428,20 @@ func (f *File) Append(source any, others ...any) error { } sources = append(sources, ds) } + f.wlock() f.dataSources = append(f.dataSources, sources...) if f.options.ShortCircuit { + f.wunlock() // Only the first source that loads counts. return f.Reload() } + defer f.wunlock() return f.load(sources) } func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { + f.rlock() + defer f.runlock() o := newWriteOptions(opts) indent, lineBreak := o.indent, o.lineBreak equalSign := o.formatLeft + o.keyValueDelimiter + o.formatRight @@ -583,7 +639,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { } for _, kname := range sec.keyList { - key := sec.Key(kname) + key := sec.keys[kname] name, form := kname, kname switch { case key.isAutoIncrement: diff --git a/file_test.go b/file_test.go index 9b51090..39db907 100644 --- a/file_test.go +++ b/file_test.go @@ -16,12 +16,16 @@ package ini import ( "bytes" + "io" "os" "path/filepath" "runtime" "sort" + "strconv" "strings" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -648,3 +652,82 @@ func TestFile_EdgeCases(t *testing.T) { assert.Equal(t, "2", children[1].Key("x").String()) }) } + +// With BlockMode, the default, a File can be read while other goroutines +// change it. Run with -race. +func TestFile_Concurrency(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.ini") + require.NoError(t, os.WriteFile(path, []byte("name = %(base)s/x\nbase = /srv\n[s]\nk = a\nk = b\n[s.child]\nc = 1\n[raw]\nbody\n"), 0o600)) + f, err := Load(path, AllowShadows(), UnparseableSections("raw")) + require.NoError(t, err) + + type cfg struct { + Name string `ini:"name"` + S struct { + K []string `ini:"k,,allowshadow"` + } `ini:"s"` + } + + stop := make(chan struct{}) + var wg sync.WaitGroup + run := func(fn func(i int)) { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; ; i++ { + select { + case <-stop: + return + default: + fn(i) + } + } + }() + } + + // Readers. + run(func(int) { + for _, sec := range f.Sections() { + _ = sec.Body() + _ = sec.KeyStrings() + _ = sec.KeysHash() + _ = sec.ChildSections() + for _, k := range sec.Keys() { + _ = k.String() + _ = k.Value() + _ = k.ValueWithShadows() + _ = k.IsBooleanType() + _ = k.NestedValues() + } + } + }) + run(func(int) { + _, _ = f.Write(io.Discard) + _ = f.SectionStrings() + var c cfg + _ = f.MapTo(&c) + }) + + // Writers. + run(func(int) { _ = f.Reload() }) + run(func(i int) { + sec := f.Section("s") + sec.Key("k").SetValue(strconv.Itoa(i)) + _ = sec.Key("k").AddShadow("z") + _ = sec.Key("m").MustInt(3) + _, _ = f.Section("new").NewKey("n", "v") + f.Section("new").DeleteKey("n") + f.DeleteSection("new") + f.Section("raw").SetBody("other") + _ = f.ReflectFrom(&cfg{Name: "n"}) + }) + run(func(i int) { + if i%50 == 0 { + _ = f.Append([]byte("[more]\nx = 1\n")) + } + }) + + time.Sleep(time.Second) + close(stop) + wg.Wait() +} diff --git a/key.go b/key.go index 97f967f..fcd34d5 100644 --- a/key.go +++ b/key.go @@ -83,6 +83,8 @@ func (k *Key) AddShadow(val string) error { if !k.s.f.options.AllowShadows { return errors.New("shadow key is not allowed") } + k.s.f.wlock() + defer k.s.f.wunlock() return k.addShadow(val) } @@ -100,6 +102,8 @@ func (k *Key) AddNestedValue(val string) error { if !k.s.f.options.AllowNestedValues { return errors.New("nested value is not allowed") } + k.s.f.wlock() + defer k.s.f.wunlock() return k.addNestedValue(val) } @@ -113,13 +117,22 @@ func (k *Key) Name() string { // Value returns raw value of key for performance purpose. func (k *Key) Value() string { - return k.value + f := k.s.f + if !f.BlockMode { + return k.value + } + f.lock.RLock() + v := k.value + f.lock.RUnlock() + return v } // ValueWithShadows returns raw values of key and its shadows if any. Empty // values are left out, unless the file was loaded with // KeepEmptyShadowValues. func (k *Key) ValueWithShadows() []string { + k.s.f.rlock() + defer k.s.f.runlock() keepEmpty := k.s.f.options.KeepEmptyShadowValues vals := make([]string, 0, len(k.shadows)+1) if keepEmpty || k.value != "" { @@ -136,12 +149,16 @@ func (k *Key) ValueWithShadows() []string { // IsBooleanType reports whether the key was read as a boolean key, a line // without a delimiter loaded with AllowBooleanKeys. func (k *Key) IsBooleanType() bool { + k.s.f.rlock() + defer k.s.f.runlock() return k.isBooleanType } // NestedValues returns nested values stored in the key. // It is possible returned value is nil if no nested values stored in the key. func (k *Key) NestedValues() []string { + k.s.f.rlock() + defer k.s.f.runlock() return k.nestedValues } @@ -193,7 +210,7 @@ func (k *Key) expand(val string, depth int, e *expansion) string { } e.substitutions++ - sub := nk.expand(nk.value, depth+1, e) + sub := nk.expand(nk.Value(), depth+1, e) n := len(val) + strings.Count(val, vr)*(len(sub)-len(vr)) if e.bytes+n > maxExpandedLen { break @@ -206,7 +223,15 @@ func (k *Key) expand(val string, depth int, e *expansion) string { // String returns string representation of value. func (k *Key) String() string { - return k.transformValue(k.value) + return k.transformValue(k.Value()) +} + +// setDefault sets the value a Must method falls back to. Unlike SetValue, +// it leaves KeysHash as it was, as in v1. +func (k *Key) setDefault(v string) { + k.s.f.wlock() + defer k.s.f.wunlock() + k.value = v } // Validate accepts a validate function which can @@ -281,7 +306,7 @@ func (k *Key) Time() (time.Time, error) { func (k *Key) MustString(defaultVal string) string { val := k.String() if len(val) == 0 { - k.value = defaultVal + k.setDefault(defaultVal) return defaultVal } return val @@ -292,7 +317,7 @@ func (k *Key) MustString(defaultVal string) string { func (k *Key) MustBool(defaultVal ...bool) bool { val, err := k.Bool() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatBool(defaultVal[0]) + k.setDefault(strconv.FormatBool(defaultVal[0])) return defaultVal[0] } return val @@ -303,7 +328,7 @@ func (k *Key) MustBool(defaultVal ...bool) bool { func (k *Key) MustFloat64(defaultVal ...float64) float64 { val, err := k.Float64() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatFloat(defaultVal[0], 'f', -1, 64) + k.setDefault(strconv.FormatFloat(defaultVal[0], 'f', -1, 64)) return defaultVal[0] } return val @@ -314,7 +339,7 @@ func (k *Key) MustFloat64(defaultVal ...float64) float64 { func (k *Key) MustInt(defaultVal ...int) int { val, err := k.Int() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatInt(int64(defaultVal[0]), 10) + k.setDefault(strconv.FormatInt(int64(defaultVal[0]), 10)) return defaultVal[0] } return val @@ -325,7 +350,7 @@ func (k *Key) MustInt(defaultVal ...int) int { func (k *Key) MustInt64(defaultVal ...int64) int64 { val, err := k.Int64() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatInt(defaultVal[0], 10) + k.setDefault(strconv.FormatInt(defaultVal[0], 10)) return defaultVal[0] } return val @@ -336,7 +361,7 @@ func (k *Key) MustInt64(defaultVal ...int64) int64 { func (k *Key) MustUint(defaultVal ...uint) uint { val, err := k.Uint() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatUint(uint64(defaultVal[0]), 10) + k.setDefault(strconv.FormatUint(uint64(defaultVal[0]), 10)) return defaultVal[0] } return val @@ -347,7 +372,7 @@ func (k *Key) MustUint(defaultVal ...uint) uint { func (k *Key) MustUint64(defaultVal ...uint64) uint64 { val, err := k.Uint64() if len(defaultVal) > 0 && err != nil { - k.value = strconv.FormatUint(defaultVal[0], 10) + k.setDefault(strconv.FormatUint(defaultVal[0], 10)) return defaultVal[0] } return val @@ -358,7 +383,7 @@ func (k *Key) MustUint64(defaultVal ...uint64) uint64 { func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { val, err := k.Duration() if len(defaultVal) > 0 && err != nil { - k.value = defaultVal[0].String() + k.setDefault(defaultVal[0].String()) return defaultVal[0] } return val @@ -369,7 +394,7 @@ func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { func (k *Key) MustTimeFormat(format string, defaultVal ...time.Time) time.Time { val, err := k.TimeFormat(format) if len(defaultVal) > 0 && err != nil { - k.value = defaultVal[0].Format(format) + k.setDefault(defaultVal[0].Format(format)) return defaultVal[0] } return val @@ -519,7 +544,12 @@ func (k *Key) RangeTime(defaultVal, min, max time.Time) time.Time { // Strings returns list of string divided by given delimiter. func (k *Key) Strings(delim string) []string { - str := k.String() + return splitList(k.String(), delim) +} + +// splitList splits str at delim into trimmed elements. A backslash escapes +// delim and another backslash; before anything else it is kept. +func splitList(str, delim string) []string { if len(str) == 0 { return []string{} } diff --git a/lexer.go b/lexer.go index 6327807..1bbff87 100644 --- a/lexer.go +++ b/lexer.go @@ -371,7 +371,8 @@ func skipBOM(src string) string { return src } -// parse reads all of reader and adds its sections and keys to f. +// parse reads all of reader and adds its sections and keys to f. The caller +// holds the lock, or f isn't shared yet. func (f *File) parse(reader io.Reader) error { var b strings.Builder if _, err := io.Copy(&b, reader); err != nil { @@ -384,7 +385,7 @@ func (f *File) parse(reader io.Reader) error { if f.options.Insensitive || f.options.InsensitiveSections { name = strings.ToLower(name) } - section, _ := f.NewSection(name) + section, _ := f.addSection(name) var lastRegularKey *Key count := 1 // Counter for auto-increment key names ("-"), per section. @@ -399,7 +400,7 @@ func (f *File) parse(reader io.Reader) error { return nil case tokSection: - if section, err = f.NewSection(tok.name); err != nil { + if section, err = f.addSection(tok.name); err != nil { return tok.errorf(err) } section.Comment = tok.comment @@ -415,7 +416,7 @@ func (f *File) parse(reader io.Reader) error { } case tokBooleanKey: - key, err := section.NewBooleanKey(tok.name) + key, err := section.addBooleanKey(tok.name) if err != nil { return tok.errorf(err) } @@ -427,7 +428,7 @@ func (f *File) parse(reader io.Reader) error { name = "#" + strconv.Itoa(count) count++ } - key, err := section.NewKey(name, tok.value) + key, err := section.addKey(name, tok.value) if err != nil { return tok.errorf(err) } diff --git a/section.go b/section.go index 10f4459..4706ad0 100644 --- a/section.go +++ b/section.go @@ -49,11 +49,15 @@ func (s *Section) Name() string { // Body returns rawBody of Section if the section was marked as unparseable. // It still follows the other rules of the INI format surrounding leading/trailing whitespace. func (s *Section) Body() string { + s.f.rlock() + defer s.f.runlock() return strings.TrimSpace(s.rawBody) } // SetBody updates body content only if section is raw. func (s *Section) SetBody(body string) { + s.f.wlock() + defer s.f.wunlock() if !s.isRawSection { return } @@ -62,17 +66,19 @@ func (s *Section) SetBody(body string) { // NewKey creates a new key to given section. func (s *Section) NewKey(name, val string) (*Key, error) { + s.f.wlock() + defer s.f.wunlock() + return s.addKey(name, val) +} + +// addKey is NewKey for a caller that holds the lock. +func (s *Section) addKey(name, val string) (*Key, error) { if len(name) == 0 { return nil, errors.New("error creating new key: empty key name") } else if s.f.options.Insensitive || s.f.options.InsensitiveKeys { name = strings.ToLower(name) } - if s.f.BlockMode { - s.f.lock.Lock() - defer s.f.lock.Unlock() - } - if _, ok := s.keys[name]; ok { if s.f.options.AllowShadows { if err := s.keys[name].addShadow(val); err != nil { @@ -92,7 +98,14 @@ func (s *Section) NewKey(name, val string) (*Key, error) { // NewBooleanKey creates a new boolean type key to given section. func (s *Section) NewBooleanKey(name string) (*Key, error) { - key, err := s.NewKey(name, "true") + s.f.wlock() + defer s.f.wunlock() + return s.addBooleanKey(name) +} + +// addBooleanKey is NewBooleanKey for a caller that holds the lock. +func (s *Section) addBooleanKey(name string) (*Key, error) { + key, err := s.addKey(name, "true") if err != nil { return nil, err } @@ -174,9 +187,11 @@ func (s *Section) Key(name string) *Key { // Keys returns list of keys of section. func (s *Section) Keys() []*Key { + s.f.rlock() + defer s.f.runlock() keys := make([]*Key, len(s.keyList)) - for i := range s.keyList { - keys[i] = s.Key(s.keyList[i]) + for i, name := range s.keyList { + keys[i] = s.keys[name] } return keys } @@ -203,6 +218,8 @@ func (s *Section) ParentKeys() []*Key { // KeyStrings returns list of key names of section. func (s *Section) KeyStrings() []string { + s.f.rlock() + defer s.f.runlock() list := make([]string, len(s.keyList)) copy(list, s.keyList) return list @@ -245,6 +262,8 @@ func (s *Section) DeleteKey(name string) { // For example, "[parent.child1]" and "[parent.child12]" are child sections // of section "[parent]". func (s *Section) ChildSections() []*Section { + s.f.rlock() + defer s.f.runlock() prefix := s.name + s.f.options.ChildSectionDelimiter children := make([]*Section, 0, 3) for i, name := range s.f.sectionList { diff --git a/struct.go b/struct.go index 7d39805..1789266 100644 --- a/struct.go +++ b/struct.go @@ -192,6 +192,15 @@ func formatValue(v reflect.Value) string { return "" } +// defaultComment sets *c to comment if it is empty, under the file's lock. +func (f *File) defaultComment(c *string, comment string) { + f.wlock() + defer f.wunlock() + if *c == "" { + *c = comment + } +} + // tooDeep reports whether path, the struct types being mapped or reflected // from the outermost one, already holds t twice. Section names are not // nested, so a recursive struct type would use the same sections forever: it @@ -215,7 +224,21 @@ func setSliceWithProperType(key *Key, field reflect.Value, delim string, allowSh var strs []string if allowShadow { - strs = key.StringsWithShadows(delim) + // Each value is split as Key.Strings splits one, so that escaped + // elements read back as Marshal wrote them. An empty value, kept + // with KeepEmptyShadowValues, is an empty element, unless it is the + // only one: an empty slice is written as one empty value. + vals := key.ValueWithShadows() + if len(vals) == 1 && vals[0] == "" { + vals = nil + } + for _, v := range vals { + if v == "" { + strs = append(strs, "") + continue + } + strs = append(strs, splitList(key.transformValue(v), delim)...) + } } else { strs = key.Strings(delim) } @@ -527,21 +550,21 @@ func reflectSliceWithProperType(key *Key, field reflect.Value, delim string, all vals[i] = formatValue(field.Index(i)) } + for i := range vals { + vals[i] = escapeSliceElem(vals[i], delim) + } if allowShadow { keyWithShadows := newKey(key.s, key.name, vals[0]) - keyWithShadows.Comment = key.Comment for _, val := range vals[1:] { if err := keyWithShadows.AddShadow(val); err != nil { return err } } - *key = *keyWithShadows + key.s.f.wlock() + key.value, key.hashValue, key.shadows = keyWithShadows.value, keyWithShadows.hashValue, keyWithShadows.shadows + key.s.f.wunlock() return nil } - - for i := range vals { - vals[i] = escapeSliceElem(vals[i], delim) - } key.SetValue(strings.Join(vals, delim)) return nil } @@ -688,9 +711,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { } // Add comment from comment tag - if len(sec.Comment) == 0 { - sec.Comment = tpField.Tag.Get("comment") - } + s.f.defaultComment(&sec.Comment, tpField.Tag.Get("comment")) if err = sec.reflectFrom(field, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) @@ -725,9 +746,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { } // Add comment from comment tag - if len(sec.Comment) == 0 { - sec.Comment = tpField.Tag.Get("comment") - } + s.f.defaultComment(&sec.Comment, tpField.Tag.Get("comment")) if err := sec.reflectFrom(elem, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) @@ -743,9 +762,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { } // Add comment from comment tag - if len(key.Comment) == 0 { - key.Comment = tpField.Tag.Get("comment") - } + s.f.defaultComment(&key.Comment, tpField.Tag.Get("comment")) delim := parseDelim(tpField.Tag.Get("delim")) if err = reflectWithProperType(tpField.Type, key, field, delim, allowShadow); err != nil { @@ -827,11 +844,42 @@ func ReflectFrom(cfg *File, v interface{}) error { } // Unmarshal parses INI data with opts and maps it to the struct v points to, -// as File.MapTo does. +// as File.MapTo does. It reads what Marshal writes: when v has allowshadow +// fields it loads with AllowShadows, AllowDuplicateShadowValues and +// KeepEmptyShadowValues, and when it has nonunique fields with +// AllowNonUniqueSections, as those tags need. func Unmarshal(data []byte, v any, opts ...LoadOption) error { + if v != nil { + shadows, nonUnique := tagOptions(reflect.TypeOf(v), map[reflect.Type]bool{}) + if shadows { + opts = append([]LoadOption{AllowShadows(), AllowDuplicateShadowValues(), KeepEmptyShadowValues()}, opts...) + } + if nonUnique { + opts = append([]LoadOption{AllowNonUniqueSections()}, opts...) + } + } return MapTo(v, data, opts...) } +// tagOptions reports whether t, or a struct type it holds, has allowshadow +// or nonunique fields. +func tagOptions(t reflect.Type, seen map[reflect.Type]bool) (shadows, nonUnique bool) { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { + t = t.Elem() + } + if !isStructType(t) || seen[t] { + return false, false + } + seen[t] = true + for i := 0; i < t.NumField(); i++ { + _, _, allowShadow, allowNonUnique, _ := parseTagOptions(t.Field(i).Tag.Get("ini")) + s, n := tagOptions(t.Field(i).Type, seen) + shadows = shadows || allowShadow || s + nonUnique = nonUnique || allowNonUnique || n + } + return shadows, nonUnique +} + // Marshal returns the INI encoding of v, a struct or a pointer to one, as // File.ReflectFrom and File.Write produce it. func Marshal(v any, opts ...WriteOption) ([]byte, error) { @@ -842,7 +890,7 @@ func Marshal(v any, opts ...WriteOption) ([]byte, error) { } // Shadows and non-unique sections let allowshadow and nonunique fields // write all their values. - f := Empty(AllowShadows(), AllowNonUniqueSections()) + f := Empty(AllowShadows(), AllowDuplicateShadowValues(), AllowNonUniqueSections()) if err := f.ReflectFrom(v); err != nil { return nil, err } diff --git a/struct_test.go b/struct_test.go index 48c5309..d39dc45 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1308,3 +1308,17 @@ func TestMapTo_EmbeddedPointerKeepsValues(t *testing.T) { require.NoError(t, MapTo(&nilBase, []byte("X = 1\n"))) assert.NotNil(t, nilBase.Base, "a nil embedded pointer is still allocated") } + +func TestMarshalUnmarshal_AllowShadowElements(t *testing.T) { + type cfg struct { + Sh []string `ini:"sh,,allowshadow"` + N []int `ini:"n,,allowshadow"` + } + in := cfg{Sh: []string{"a", "a", "b,c", "", `d\`}, N: []int{1, 1, 2}} + data, err := Marshal(in) + require.NoError(t, err) + + var out cfg + require.NoError(t, Unmarshal(data, &out)) + assert.Equal(t, in, out, "written: %q", data) +} diff --git a/testdata/fuzz/FuzzStructMapping/714f26f75d68ee6f b/testdata/fuzz/FuzzStructMapping/714f26f75d68ee6f new file mode 100644 index 0000000..140f29a --- /dev/null +++ b/testdata/fuzz/FuzzStructMapping/714f26f75d68ee6f @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("0=00000000000000000000000\nshAdow=A\nshAdow=1") +uint32(16527) From e336a7f04c12aea78f413124de702614665f3a25 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 22:07:30 +0000 Subject: [PATCH 18/41] Write nested values before a later boolean line of the same key A key with nested values that later became a boolean key was written with the boolean line first, and the parser can't add nested values to a boolean key, so the output failed to load. The nested values now go before the boolean line, after an empty value. --- file.go | 21 ++++++++++++------- roundtrip_test.go | 4 +++- .../fuzz/FuzzWriteRoundTrip/1fbbbbd3e80fc3e3 | 3 +++ .../fuzz/FuzzWriteRoundTrip/a00aa54ba8684c04 | 3 +++ 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 testdata/fuzz/FuzzWriteRoundTrip/1fbbbbd3e80fc3e3 create mode 100644 testdata/fuzz/FuzzWriteRoundTrip/a00aa54ba8684c04 diff --git a/file.go b/file.go index a92a886..c0c1835 100644 --- a/file.go +++ b/file.go @@ -695,18 +695,25 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { regular = nil } } - if len(key.nestedValues) > 0 && !slices.Contains(regular, "") { - // Nested values only follow an empty value. Without shadows - // the value after it still wins. - regular = append([]string{""}, regular...) - } - + nested := key.nestedValues if key.isBooleanType && boolFirst { + if len(nested) > 0 { + // Nested values can't follow a boolean line, so they + // go before it, after an empty value. + if err := addLine(false, "", nested); err != nil { + return nil, err + } + nested = nil + } if err := addLine(true, "", nil); err != nil { return nil, err } } - nested := key.nestedValues + if len(nested) > 0 && !slices.Contains(regular, "") { + // Nested values only follow an empty value. Without shadows + // the value after it still wins. + regular = append([]string{""}, regular...) + } for _, val := range regular { if val != "" { if err := addLine(false, val, nil); err != nil { diff --git a/roundtrip_test.go b/roundtrip_test.go index e4a10c0..d0b9317 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -34,7 +34,9 @@ import ( func describeFile(f *File) string { var b strings.Builder for _, s := range f.Sections() { - fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.Comment), s.isRawSection, s.rawBody) + // A raw body gets a final line break when a section follows it; + // Body trims it either way. + fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.Comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n")) for _, k := range s.Keys() { vals := []string{k.value} for _, s := range k.shadows { diff --git a/testdata/fuzz/FuzzWriteRoundTrip/1fbbbbd3e80fc3e3 b/testdata/fuzz/FuzzWriteRoundTrip/1fbbbbd3e80fc3e3 new file mode 100644 index 0000000..581f833 --- /dev/null +++ b/testdata/fuzz/FuzzWriteRoundTrip/1fbbbbd3e80fc3e3 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("[Comments]\n[0]\n[Comments]\n0") +uint32(65703) diff --git a/testdata/fuzz/FuzzWriteRoundTrip/a00aa54ba8684c04 b/testdata/fuzz/FuzzWriteRoundTrip/a00aa54ba8684c04 new file mode 100644 index 0000000..5321c7e --- /dev/null +++ b/testdata/fuzz/FuzzWriteRoundTrip/a00aa54ba8684c04 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("0=\n\n \n0\n0=") +uint32(7039) From a7aa7523cd7206f11a3b0b120ef8083fb8a28dca Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Mon, 9 Nov 2026 22:31:45 +0000 Subject: [PATCH 19/41] Rename the API for v2 and replace typed accessors with generics Renames: SnakeCase, SectionNames, KeyNames, KeyValues, Values, IsBoolean, CaseInsensitive*, IgnoreMissingFiles, FirstSourceOnly, RawSections and WriteDelimiter. Section.Haskey is removed. File.BlockMode becomes the NoLocking load option, so locking can't be switched off while the file is in use. The error structs and IsErr* functions become sentinel errors for errors.Is, with the same messages. The *WithMapper functions become a NameMapping load option, and the package-level ReflectFrom functions are dropped in favor of File.ReflectFrom. interface{} is any throughout. The In*, Range*, typed list, Valid* and Strict* list methods of Key, 37 in all, become ini.In, ini.Range and ini.List[T]. They parse with the struct mapping parser, so every Scalar type works, including sized and named types, with overflow checks. The eight list parsers and the exported Parser type that boxed every element are gone. Internal helpers are named for what they do: decodeValue, decodeSlice, decodeStruct, decodeSections, encodeValue, encodeSlice, encodeStruct, newDataSource, loadSource and expandValue. --- README.md | 22 ++- api_fuzz_test.go | 20 ++- bench_test.go | 7 +- data_source.go | 2 +- error.go | 35 +--- file.go | 41 +++-- file_test.go | 62 +++---- generic.go | 82 +++++++++ ini.go | 2 +- ini_test.go | 46 ++--- key.go | 439 ++-------------------------------------------- key_test.go | 300 +++++++------------------------ lexer.go | 13 +- options.go | 45 +++-- options_test.go | 24 +-- parity_test.go | 16 +- roundtrip_test.go | 4 +- section.go | 23 +-- section_test.go | 8 +- struct.go | 123 ++++++------- struct_test.go | 22 +-- 21 files changed, 409 insertions(+), 927 deletions(-) create mode 100644 generic.go diff --git a/README.md b/README.md index d2eeb6a..6b5e553 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,27 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `import "gopkg.in/ini.v1"` | `import "unknwon.dev/ini/v2"` (package name is still `ini`) | | `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | | `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows())` | -| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.Loose()`, `ini.Insensitive()`, `ini.AllowShadows()` | +| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.IgnoreMissingFiles()`, `ini.CaseInsensitive()`, `ini.AllowShadows()` | | `LoadOptions.DebugFunc` | `ini.Debug(fn)` | | `LoadOptions.ReaderBufferSize` | removed; values are never cut at a buffer size | | `ini.DefaultSection = "default"` | `ini.DefaultSectionName("default")` | | `ini.LineBreak`, `PrettyFormat`, `PrettySection`, `PrettyEqual`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo` | -| `LoadOptions.KeyValueDelimiterOnWrite` | `ini.KeyValueDelimiterOnWrite(d)` write option | +| `LoadOptions.KeyValueDelimiterOnWrite` | `ini.WriteDelimiter(d)` write option | | `WriteToIndent(w, i)`, `SaveToIndent(f, i)` | `Write(w, ini.Indent(i))`, `SaveTo(f, ini.Indent(i))` | | `MapTo(v, a, b)` | `MapTo(v, a, opts...)`; one source | -| `DEFAULT_SECTION`, `AllCapsUnderscore` | `DefaultSection`, `SnackCase` | +| `DEFAULT_SECTION`, `AllCapsUnderscore`, `SnackCase` | `DefaultSection`, `SnakeCase` | +| `LoadOptions.Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `ini.CaseInsensitive()`, `ini.CaseInsensitiveSections()`, `ini.CaseInsensitiveKeys()` | +| `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections` | `ini.IgnoreMissingFiles()`, `ini.FirstSourceOnly()`, `ini.RawSections(names...)` | +| `File.BlockMode = false` | `ini.NoLocking()` | +| `MapToWithMapper(v, m, src)`, `StrictMapToWithMapper` | `MapTo(v, src, ini.NameMapping(m))`, `StrictMapTo(v, src, ini.NameMapping(m))` | +| `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`; set `f.NameMapper = m` or load with `ini.NameMapping(m)` | +| `IsErrDelimiterNotFound(err)`, `IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)`, `errors.Is(err, ini.ErrEmptyKeyName)` | +| `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues` | +| `Key.ValueWithShadows`, `Key.IsBooleanType`, `Section.Haskey` | `Values`, `IsBoolean`, `HasKey` | +| `k.Ints(",")`, `k.StrictInts(",")`, `k.ValidInts(",")` and the same for the other types | `ini.List[int](k, ",")`, which returns an error for an invalid element; any `Scalar` type works, such as `int8`, `time.Duration` or a named string type | +| `k.InInt(def, cands)`, `k.In(def, cands)` and the others | `ini.In(k, def, cands)` | +| `k.RangeInt(def, lo, hi)` and the others | `ini.Range(k, def, lo, hi)`; type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)`. Times compare with `Key.Time` | +| `TimesFormat`, `InTimeFormat`, `RangeTimeFormat` | `Strings` with `time.Parse`; `TimeFormat` and `MustTimeFormat` remain | | `Reload` merged sources into the current contents; `Append` re-read every source | `Reload` replaces the contents with the sources (kept `*Section`/`*Key` pointers see new values; on error nothing changes); `Append` reads only the new sources; readers are buffered so they can be reloaded | New in v2: @@ -52,7 +64,7 @@ New in v2: - Parse errors are `*ini.ParseError` with 1-based `Line` and `Column`. - `ini.CommentSymbols(s)` sets the comment characters. - `ini.KeepEmptyShadowValues()` keeps empty values of shadow keys. -- `Key.IsBooleanType()`, and `ini.Marshal`/`ini.Unmarshal`. +- `Key.IsBoolean()`, and `ini.Marshal`/`ini.Unmarshal`. - `File.Write` writes files that load back to the same sections, keys, values and comments: it picks a quoting the parser reads back, writes empty shadow values, and returns an error instead of writing a file that loads differently. - `MapTo`, `StrictMapTo`, `Unmarshal`, `ReflectFrom` and `Marshal` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Differences from v1 that valid input can show: - Out-of-range numbers (int8 `300`) are an error in `StrictMapTo` and leave the field unchanged in `MapTo`; v1 wrapped them. @@ -64,7 +76,7 @@ New in v2: - Nil pointer fields aren't written. - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - A `nonunique` field with no sections is left empty instead of failing. -- With `BlockMode` (the default) a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. `Comment` fields are plain fields: set them before sharing the `File`. +- Unless loaded with `NoLocking()`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. `Comment` fields are plain fields: set them before sharing the `File`. - `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated, empty or delimiter-containing elements and `nonunique` sections. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. diff --git a/api_fuzz_test.go b/api_fuzz_test.go index 2b46d52..fc31b38 100644 --- a/api_fuzz_test.go +++ b/api_fuzz_test.go @@ -116,23 +116,27 @@ func FuzzStructMapping(f *testing.F) { for _, sec := range file.Sections() { _ = sec.ChildSections() _ = sec.ParentKeys() - _ = sec.KeysHash() + _ = sec.KeyValues() for _, k := range sec.Keys() { _ = k.String() for _, delim := range []string{",", "", "|", "\\", "::"} { _ = k.Strings(delim) } _ = k.StringsWithShadows(",") - _ = k.Ints(",") - _ = k.Uint64s(",") - _ = k.Float64s(",") - _ = k.Bools(",") - _ = k.Times(",") + _, _ = List[int](k, ",") + _, _ = List[uint64](k, ",") + _, _ = List[float32](k, ",") + _, _ = List[bool](k, ",") + _, _ = List[time.Time](k, ",") + _, _ = List[time.Duration](k, ",") _, _ = k.Duration() _, _ = k.Time() _ = k.MustInt(1) - _ = k.In("a", []string{"a", "b"}) - _ = k.ValueWithShadows() + _ = In(k, "a", []string{"a", "b"}) + _ = In(k, int8(1), []int8{1, 2}) + _ = Range(k, 1.5, -1e9, 1e9) + _ = Range(k, uint16(1), 0, 100) + _ = k.Values() _ = k.NestedValues() } } diff --git a/bench_test.go b/bench_test.go index ff2d2f6..c1b9dda 100644 --- a/bench_test.go +++ b/bench_test.go @@ -23,8 +23,11 @@ import ( ) func newTestFile(block bool) *File { - c, _ := Load([]byte(confData)) - c.BlockMode = block + var opts []LoadOption + if !block { + opts = append(opts, NoLocking()) + } + c, _ := Load([]byte(confData), opts...) return c } diff --git a/data_source.go b/data_source.go index 40a5fb8..9737892 100644 --- a/data_source.go +++ b/data_source.go @@ -72,7 +72,7 @@ func (s *sourceReadCloser) ReadCloser() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(s.data)), nil } -func parseDataSource(source interface{}) (dataSource, error) { +func newDataSource(source any) (dataSource, error) { switch s := source.(type) { case string: return sourceFile{s}, nil diff --git a/error.go b/error.go index 3bed5df..fb2e04e 100644 --- a/error.go +++ b/error.go @@ -19,33 +19,14 @@ import ( "fmt" ) -// ErrDelimiterNotFound indicates the error type of no delimiter is found which there should be one. -type ErrDelimiterNotFound struct { - Line string -} - -// IsErrDelimiterNotFound returns true if the given error is an instance of ErrDelimiterNotFound. -func IsErrDelimiterNotFound(err error) bool { - return errors.As(err, new(ErrDelimiterNotFound)) -} - -func (err ErrDelimiterNotFound) Error() string { - return fmt.Sprintf("key-value delimiter not found: %s", err.Line) -} - -// ErrEmptyKeyName indicates the error type of no key name is found which there should be one. -type ErrEmptyKeyName struct { - Line string -} - -// IsErrEmptyKeyName returns true if the given error is an instance of ErrEmptyKeyName. -func IsErrEmptyKeyName(err error) bool { - return errors.As(err, new(ErrEmptyKeyName)) -} - -func (err ErrEmptyKeyName) Error() string { - return fmt.Sprintf("empty key name: %s", err.Line) -} +// Errors a ParseError wraps, for errors.Is. +var ( + // ErrDelimiterNotFound: a line that should be a key has no key-value + // delimiter. + ErrDelimiterNotFound = errors.New("key-value delimiter not found") + // ErrEmptyKeyName: a line starts with its key-value delimiter. + ErrEmptyKeyName = errors.New("empty key name") +) // ParseError reports where in a data source parsing failed. Err is the // error at that position, such as ErrDelimiterNotFound. diff --git a/file.go b/file.go index c0c1835..23d542f 100644 --- a/file.go +++ b/file.go @@ -31,9 +31,9 @@ type File struct { options loadOptions dataSources []dataSource - // Should make things safe, but sometimes doesn't matter. - BlockMode bool - lock sync.RWMutex + // locking guards the contents with lock, unless NoLocking is set. + locking bool + lock sync.RWMutex // To keep data in order. sectionList []string @@ -50,11 +50,13 @@ type File struct { // newFile initializes File object with given data sources. func newFile(dataSources []dataSource, opts []LoadOption) *File { + o := newLoadOptions(opts) return &File{ - BlockMode: true, + locking: !o.NoLocking, dataSources: dataSources, sections: make(map[string][]*Section), - options: newLoadOptions(opts), + options: o, + NameMapper: o.NameMapper, } } @@ -66,7 +68,7 @@ func Empty(opts ...LoadOption) *File { } // isDefaultSection reports whether name, as stored, is the default -// section's name. Loading with Insensitive or InsensitiveSections stores it +// section's name. Loading with CaseInsensitive or CaseInsensitiveSections stores it // lower-cased. func (f *File) isDefaultSection(name string) bool { return name == f.options.DefaultSection || name == f.defaultSectionName() @@ -81,28 +83,28 @@ func (f *File) defaultSectionName() string { } // The lock helpers guard the file's sections, keys and values when -// BlockMode is set. Unexported methods that change them expect the caller to +// locking is set. Unexported methods that change them expect the caller to // hold the lock, or the file not to be shared yet, as while parsing. func (f *File) rlock() { - if f.BlockMode { + if f.locking { f.lock.RLock() } } func (f *File) runlock() { - if f.BlockMode { + if f.locking { f.lock.RUnlock() } } func (f *File) wlock() { - if f.BlockMode { + if f.locking { f.lock.Lock() } } func (f *File) wunlock() { - if f.BlockMode { + if f.locking { f.lock.Unlock() } } @@ -243,7 +245,7 @@ func (f *File) SectionWithIndex(name string, index int) *Section { // Sections returns a list of Section stored in the current instance. func (f *File) Sections() []*Section { - if f.BlockMode { + if f.locking { f.lock.RLock() defer f.lock.RUnlock() } @@ -260,8 +262,8 @@ func (f *File) ChildSections(name string) []*Section { return f.Section(name).ChildSections() } -// SectionStrings returns list of section names. -func (f *File) SectionStrings() []string { +// SectionNames returns list of section names. +func (f *File) SectionNames() []string { f.rlock() defer f.runlock() list := make([]string, len(f.sectionList)) @@ -300,7 +302,7 @@ func (f *File) DeleteSectionWithIndex(name string, index int) error { name = strings.ToLower(name) } - if f.BlockMode { + if f.locking { f.lock.Lock() defer f.lock.Unlock() } @@ -338,7 +340,8 @@ func (f *File) DeleteSectionWithIndex(name string, index int) error { return nil } -func (f *File) reload(s dataSource) error { +// loadSource parses one data source into f. +func (f *File) loadSource(s dataSource) error { r, err := s.ReadCloser() if err != nil { return err @@ -401,7 +404,7 @@ func (f *File) Reload() error { // load parses sources into f, in order. func (f *File) load(sources []dataSource) error { for _, s := range sources { - if err := f.reload(s); err != nil { + if err := f.loadSource(s); err != nil { // In loose mode, we create an empty default section for nonexistent files. if os.IsNotExist(err) && f.options.Loose { _ = f.parse(bytes.NewBuffer(nil)) @@ -422,7 +425,7 @@ func (f *File) load(sources []dataSource) error { func (f *File) Append(source any, others ...any) error { sources := make([]dataSource, 0, 1+len(others)) for _, s := range append([]any{source}, others...) { - ds, err := parseDataSource(s) + ds, err := newDataSource(s) if err != nil { return err } @@ -545,7 +548,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { var empty []bool lastSectionIdx := len(f.sectionList) - 1 for i, sname := range f.sectionList { - // Not SectionWithIndex: with Insensitive, a "[DEFAULT]" header makes a + // Not SectionWithIndex: with CaseInsensitive, a "[DEFAULT]" header makes a // section that lookups by name don't find. sec := f.sections[sname][f.sectionIndexes[i]] if strings.Contains(sname, "\n") { diff --git a/file_test.go b/file_test.go index 39db907..20f6a9e 100644 --- a/file_test.go +++ b/file_test.go @@ -51,7 +51,7 @@ func TestFile_NewSection(t *testing.T) { require.NotNil(t, sec) assert.Equal(t, "author", sec.Name()) - assert.Equal(t, []string{DefaultSection, "author"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "author"}, f.SectionNames()) t.Run("with duplicated name", func(t *testing.T) { sec, err := f.NewSection("author") @@ -59,7 +59,7 @@ func TestFile_NewSection(t *testing.T) { require.NotNil(t, sec) // Does nothing if section already exists - assert.Equal(t, []string{DefaultSection, "author"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "author"}, f.SectionNames()) }) t.Run("with empty string", func(t *testing.T) { @@ -164,9 +164,9 @@ AllowedIPs = 192.168.2.4/32 require.NotNil(t, f) _ = f.NewSections("Interface", "Peer", "Peer") - assert.Equal(t, []string{DefaultSection, "Interface", "Peer", "Peer"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "Interface", "Peer", "Peer"}, f.SectionNames()) f.DeleteSection("Peer") - assert.Equal(t, []string{DefaultSection, "Interface"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "Interface"}, f.SectionNames()) }) } @@ -180,7 +180,7 @@ func TestFile_NewRawSection(t *testing.T) { require.NotNil(t, sec) assert.Equal(t, "comments", sec.Name()) - assert.Equal(t, []string{DefaultSection, "comments"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "comments"}, f.SectionNames()) assert.Equal(t, `1111111111111111111000000000000000001110000 111111111111111111100000000000111000000000`, f.Section("comments").Body()) @@ -188,7 +188,7 @@ func TestFile_NewRawSection(t *testing.T) { sec, err := f.NewRawSection("comments", `1111111111111111111000000000000000001110000`) require.NoError(t, err) require.NotNil(t, sec) - assert.Equal(t, []string{DefaultSection, "comments"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "comments"}, f.SectionNames()) // Overwrite previous existed section assert.Equal(t, `1111111111111111111000000000000000001110000`, f.Section("comments").Body()) @@ -205,13 +205,13 @@ func TestFile_NewSections(t *testing.T) { require.NotNil(t, f) assert.NoError(t, f.NewSections("package", "author")) - assert.Equal(t, []string{DefaultSection, "package", "author"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "package", "author"}, f.SectionNames()) t.Run("with duplicated name", func(t *testing.T) { assert.NoError(t, f.NewSections("author", "features")) // Ignore section already exists - assert.Equal(t, []string{DefaultSection, "package", "author", "features"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "package", "author", "features"}, f.SectionNames()) }) t.Run("with empty string", func(t *testing.T) { @@ -270,7 +270,7 @@ func TestFile_Section(t *testing.T) { f, err := Load([]byte(` [default] NAME = ini -VERSION = v1`), Insensitive()) +VERSION = v1`), CaseInsensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -285,7 +285,7 @@ VERSION = v1`), Insensitive()) require.NoError(t, err) require.NotNil(t, f) - sectionNames := f.SectionStrings() + sectionNames := f.SectionNames() sort.Strings(sectionNames) assert.Equal(t, []string{DefaultSection, "RANDOM"}, sectionNames) @@ -342,7 +342,7 @@ func TestFile_SectionStrings(t *testing.T) { require.NoError(t, err) require.NotNil(t, f) - assert.Equal(t, []string{DefaultSection, "author", "package", "package.sub", "features", "types", "array", "note", "comments", "string escapes", "advance"}, f.SectionStrings()) + assert.Equal(t, []string{DefaultSection, "author", "package", "package.sub", "features", "types", "array", "note", "comments", "string escapes", "advance"}, f.SectionNames()) } func TestFile_DeleteSection(t *testing.T) { @@ -353,7 +353,7 @@ func TestFile_DeleteSection(t *testing.T) { _ = f.NewSections("author", "package", "features") f.DeleteSection("features") f.DeleteSection("") - assert.Equal(t, []string{"author", "package"}, f.SectionStrings()) + assert.Equal(t, []string{"author", "package"}, f.SectionNames()) }) t.Run("delete default section", func(t *testing.T) { @@ -363,7 +363,7 @@ func TestFile_DeleteSection(t *testing.T) { f.Section("").Key("foo").SetValue("bar") f.Section("section1").Key("key1").SetValue("value1") f.DeleteSection("") - assert.Equal(t, []string{"section1"}, f.SectionStrings()) + assert.Equal(t, []string{"section1"}, f.SectionNames()) var buf bytes.Buffer _, err := f.WriteTo(&buf) @@ -374,14 +374,14 @@ key1 = value1 `, buf.String()) }) - t.Run("delete a section with InsensitiveSections", func(t *testing.T) { - f := Empty(InsensitiveSections()) + t.Run("delete a section with CaseInsensitiveSections", func(t *testing.T) { + f := Empty(CaseInsensitiveSections()) require.NotNil(t, f) _ = f.NewSections("author", "package", "features") f.DeleteSection("FEATURES") f.DeleteSection("") - assert.Equal(t, []string{"author", "package"}, f.SectionStrings()) + assert.Equal(t, []string{"author", "package"}, f.SectionNames()) }) } @@ -511,7 +511,7 @@ Coordinates -> 192.168,10.11 Flags -> true,false Note -> Hello world! `) - _, err = f.Write(&actual, KeyValueDelimiterOnWrite("->")) + _, err = f.Write(&actual, WriteDelimiter("->")) require.NoError(t, err) assert.Equal(t, expected, actual.Bytes()) @@ -528,10 +528,10 @@ v = 3 require.NoError(t, err) require.NotNil(t, f) - assert.Equal(t, []string{"1", "2", "3"}, f.Section("slice").Key("v").ValueWithShadows()) + assert.Equal(t, []string{"1", "2", "3"}, f.Section("slice").Key("v").Values()) require.NoError(t, f.Reload()) - assert.Equal(t, []string{"1", "2", "3"}, f.Section("slice").Key("v").ValueWithShadows()) + assert.Equal(t, []string{"1", "2", "3"}, f.Section("slice").Key("v").Values()) } func TestFile_Reload(t *testing.T) { @@ -549,7 +549,7 @@ func TestFile_Reload(t *testing.T) { require.NoError(t, f.Reload()) assert.False(t, f.Section("app").HasKey("old")) assert.False(t, f.HasSection("gone")) - assert.Equal(t, map[string]string{"keep": "3"}, f.Section("app").KeysHash()) + assert.Equal(t, map[string]string{"keep": "3"}, f.Section("app").KeyValues()) // Pointers taken before the reload see the new contents. assert.Equal(t, "3", key.String()) assert.Same(t, sec, f.Section("app")) @@ -563,7 +563,7 @@ func TestFile_Reload(t *testing.T) { write("[log]\nlevel = debug\n") require.NoError(t, f.Reload()) - assert.Equal(t, []string{"debug"}, f.Section("log").Key("level").ValueWithShadows()) + assert.Equal(t, []string{"debug"}, f.Section("log").Key("level").Values()) }) t.Run("error leaves the file unchanged", func(t *testing.T) { @@ -619,7 +619,7 @@ func TestFile_EdgeCases(t *testing.T) { }) t.Run("default section after deleting it, case-insensitive", func(t *testing.T) { - f, err := Load([]byte("a = 1\n"), Insensitive(), AllowNonUniqueSections()) + f, err := Load([]byte("a = 1\n"), CaseInsensitive(), AllowNonUniqueSections()) require.NoError(t, err) f.DeleteSection("") f.Section("").Key("k").SetValue("v") @@ -628,8 +628,8 @@ func TestFile_EdgeCases(t *testing.T) { }) // https://github.com/go-ini/ini/issues/340 - t.Run("DeleteKey with Insensitive", func(t *testing.T) { - f, err := Load([]byte("TestKey = 1\n"), Insensitive()) + t.Run("DeleteKey with CaseInsensitive", func(t *testing.T) { + f, err := Load([]byte("TestKey = 1\n"), CaseInsensitive()) require.NoError(t, err) f.Section("").DeleteKey("TestKey") assert.False(t, f.Section("").HasKey("TestKey")) @@ -653,12 +653,12 @@ func TestFile_EdgeCases(t *testing.T) { }) } -// With BlockMode, the default, a File can be read while other goroutines +// Unless loaded with NoLocking, a File can be read while other goroutines // change it. Run with -race. func TestFile_Concurrency(t *testing.T) { path := filepath.Join(t.TempDir(), "app.ini") require.NoError(t, os.WriteFile(path, []byte("name = %(base)s/x\nbase = /srv\n[s]\nk = a\nk = b\n[s.child]\nc = 1\n[raw]\nbody\n"), 0o600)) - f, err := Load(path, AllowShadows(), UnparseableSections("raw")) + f, err := Load(path, AllowShadows(), RawSections("raw")) require.NoError(t, err) type cfg struct { @@ -689,21 +689,21 @@ func TestFile_Concurrency(t *testing.T) { run(func(int) { for _, sec := range f.Sections() { _ = sec.Body() - _ = sec.KeyStrings() - _ = sec.KeysHash() + _ = sec.KeyNames() + _ = sec.KeyValues() _ = sec.ChildSections() for _, k := range sec.Keys() { _ = k.String() _ = k.Value() - _ = k.ValueWithShadows() - _ = k.IsBooleanType() + _ = k.Values() + _ = k.IsBoolean() _ = k.NestedValues() } } }) run(func(int) { _, _ = f.Write(io.Discard) - _ = f.SectionStrings() + _ = f.SectionNames() var c cfg _ = f.MapTo(&c) }) diff --git a/generic.go b/generic.go new file mode 100644 index 0000000..581306f --- /dev/null +++ b/generic.go @@ -0,0 +1,82 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "cmp" + "reflect" + "slices" + "time" +) + +// Scalar is a type a value can be parsed as, as struct mapping parses +// fields: strings, booleans, integers and floats of any size, named types of +// those, time.Duration (as "1s" or nanoseconds) and time.Time (RFC 3339). +// A number that doesn't fit the type is an error. +type Scalar interface { + ~string | ~bool | + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | time.Time +} + +// parse parses s as a T. +func parse[T Scalar](s string) (T, error) { + v, err := parseValue(reflect.TypeFor[T](), s) + if err != nil { + var zero T + return zero, err + } + return v.Interface().(T), nil +} + +// List splits the value of k at delim, as Key.Strings does, and parses each +// element as a T. It returns an error for the first element that doesn't +// parse. +func List[T Scalar](k *Key, delim string) ([]T, error) { + strs := k.Strings(delim) + vals := make([]T, len(strs)) + for i, s := range strs { + v, err := parse[T](s) + if err != nil { + return nil, err + } + vals[i] = v + } + return vals, nil +} + +// In returns the value of k parsed as a T when it is one of candidates, and +// defaultVal otherwise. T comes from candidates, as in +// In(k, "info", []string{"debug", "info"}). +func In[T Scalar](k *Key, defaultVal T, candidates []T) T { + if v, err := parse[T](k.String()); err == nil && slices.Contains(candidates, v) { + return v + } + return defaultVal +} + +// Range returns the value of k parsed as a T when it is between lo and hi +// inclusive, and defaultVal otherwise. Untyped constants make T int, so give +// another type explicitly: Range(k, 0.5, 0, 1) or Range(k, uint8(0), 0, 9). +func Range[T interface { + Scalar + cmp.Ordered +}](k *Key, defaultVal, lo, hi T) T { + if v, err := parse[T](k.String()); err == nil && v >= lo && v <= hi { + return v + } + return defaultVal +} diff --git a/ini.go b/ini.go index 0a48ab0..806d141 100644 --- a/ini.go +++ b/ini.go @@ -49,7 +49,7 @@ func LoadSources(sources []any, opts ...LoadOption) (*File, error) { dataSources := make([]dataSource, len(sources)) for i := range sources { var err error - if dataSources[i], err = parseDataSource(sources[i]); err != nil { + if dataSources[i], err = newDataSource(sources[i]); err != nil { return nil, err } } diff --git a/ini_test.go b/ini_test.go index 86215b9..6967669 100644 --- a/ini_test.go +++ b/ini_test.go @@ -221,7 +221,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- } func TestLooseLoad(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, Loose()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, IgnoreMissingFiles()) require.NoError(t, err) require.NotNil(t, f) @@ -233,7 +233,7 @@ func TestLooseLoad(t *testing.T) { func TestInsensitiveLoad(t *testing.T) { t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := Load(minimalConf, Insensitive()) + f, err := Load(minimalConf, CaseInsensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -265,7 +265,7 @@ e-mail = u@gogs.io user = unknwon [profile] email = unknwon@local -`), Insensitive()) +`), CaseInsensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -276,7 +276,7 @@ email = unknwon@local func TestLoadSources(t *testing.T) { t.Run("with true `AllowPythonMultilineValues`", func(t *testing.T) { t.Run("ignore nonexistent files", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, AllowPythonMultilineValues(), Loose()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, AllowPythonMultilineValues(), IgnoreMissingFiles()) require.NoError(t, err) require.NotNil(t, f) @@ -287,7 +287,7 @@ func TestLoadSources(t *testing.T) { }) t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := Load(minimalConf, AllowPythonMultilineValues(), Insensitive()) + f, err := Load(minimalConf, AllowPythonMultilineValues(), CaseInsensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -314,7 +314,7 @@ e-mail = u@gogs.io }) t.Run("insensitive to sections and sensitive to key names", func(t *testing.T) { - f, err := Load(minimalConf, InsensitiveSections()) + f, err := Load(minimalConf, CaseInsensitiveSections()) require.NoError(t, err) require.NotNil(t, f) @@ -341,7 +341,7 @@ E-MAIL = u@gogs.io }) t.Run("sensitive to sections and insensitive to key names", func(t *testing.T) { - f, err := Load(minimalConf, InsensitiveKeys()) + f, err := Load(minimalConf, CaseInsensitiveKeys()) require.NoError(t, err) require.NotNil(t, f) @@ -447,7 +447,7 @@ key3`), AllowPythonMultilineValues(), AllowBooleanKeys()) require.NoError(t, err) require.NotNil(t, f) - assert.Equal(t, []string{"key1", "key3"}, f.Section("").KeyStrings()) + assert.Equal(t, []string{"key1", "key3"}, f.Section("").KeyNames()) assert.True(t, f.Section("").Key("key3").MustBool(false)) t.Run("write out", func(t *testing.T) { @@ -487,7 +487,7 @@ fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows(), AllowPythonMultil "https://github.com/Antergone/test1.git", "https://github.com/Antergone/test2.git", }, - f.Section(`remote "origin"`).Key("url").ValueWithShadows(), + f.Section(`remote "origin"`).Key("url").Values(), ) assert.Equal(t, "+refs/heads/*:refs/remotes/origin/*", f.Section(`remote "origin"`).Key("fetch").String()) @@ -577,7 +577,7 @@ multiline_list = first second third -`), AllowPythonMultilineValues(), Insensitive(), UnparseableSections("core_lesson", "comments")) +`), AllowPythonMultilineValues(), CaseInsensitive(), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -686,7 +686,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- 95foobar 96barfoo -----END RSA PRIVATE KEY----- -`), AllowPythonMultilineValues(), Insensitive(), UnparseableSections("core_lesson", "comments")) +`), AllowPythonMultilineValues(), CaseInsensitive(), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -805,7 +805,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [COMMENTS] <1> This slide has the fuel listed in the wrong units -`), AllowPythonMultilineValues(), Insensitive(), UnparseableSections("core_lesson", "comments")) +`), AllowPythonMultilineValues(), CaseInsensitive(), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -883,7 +883,7 @@ GITHUB = U;n;k;n;w;o;n t.Run("with false `AllowPythonMultilineValues`", func(t *testing.T) { t.Run("ignore nonexistent files", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, Loose()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, IgnoreMissingFiles()) require.NoError(t, err) require.NotNil(t, f) @@ -894,7 +894,7 @@ GITHUB = U;n;k;n;w;o;n }) t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := Load(minimalConf, Insensitive()) + f, err := Load(minimalConf, CaseInsensitive()) require.NoError(t, err) require.NotNil(t, f) @@ -977,7 +977,7 @@ key3`), AllowBooleanKeys()) require.NoError(t, err) require.NotNil(t, f) - assert.Equal(t, []string{"key1", "key3"}, f.Section("").KeyStrings()) + assert.Equal(t, []string{"key1", "key3"}, f.Section("").KeyNames()) assert.True(t, f.Section("").Key("key3").MustBool(false)) t.Run("write out", func(t *testing.T) { @@ -1017,7 +1017,7 @@ fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows()) "https://github.com/Antergone/test1.git", "https://github.com/Antergone/test2.git", }, - f.Section(`remote "origin"`).Key("url").ValueWithShadows(), + f.Section(`remote "origin"`).Key("url").Values(), ) assert.Equal(t, "+refs/heads/*:refs/remotes/origin/*", f.Section(`remote "origin"`).Key("fetch").String()) @@ -1209,7 +1209,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [COMMENTS] <1> This slide has the fuel listed in the wrong units -`), Insensitive(), UnparseableSections("core_lesson", "comments")) +`), CaseInsensitive(), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -1347,9 +1347,9 @@ GITHUB = U;n;k;n;w;o;n }) }) - t.Run("ShortCircuit", func(t *testing.T) { + t.Run("FirstSourceOnly", func(t *testing.T) { t.Run("load the first available configuration, ignore other configuration", func(t *testing.T) { - f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}, ShortCircuit()) + f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}, FirstSourceOnly()) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1363,13 +1363,13 @@ E-MAIL = u@gogs.io }) t.Run("return an error when fail to load", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, ShortCircuit()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, FirstSourceOnly()) assert.Nil(t, f) require.Error(t, err) }) - t.Run("used with Loose to ignore errors that the file does not exist", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, ShortCircuit(), Loose()) + t.Run("used with IgnoreMissingFiles to ignore errors that the file does not exist", func(t *testing.T) { + f, err := LoadSources([]any{notFoundConf, minimalConf}, FirstSourceOnly(), IgnoreMissingFiles()) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1382,7 +1382,7 @@ E-MAIL = u@gogs.io ) }) - t.Run("ensure all sources are loaded without ShortCircuit", func(t *testing.T) { + t.Run("ensure all sources are loaded without FirstSourceOnly", func(t *testing.T) { f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}) require.NotNil(t, f) require.NoError(t, err) diff --git a/key.go b/key.go index fcd34d5..8a5756e 100644 --- a/key.go +++ b/key.go @@ -29,8 +29,8 @@ type Key struct { Comment string name string value string - // hashValue is the value KeysHash reports. Must* defaults change value - // but not hashValue, as they never updated KeysHash in v1. + // hashValue is the value KeyValues reports. Must* defaults change value + // but not hashValue, as they never updated KeyValues in v1. hashValue string isAutoIncrement bool isBooleanType bool @@ -118,7 +118,7 @@ func (k *Key) Name() string { // Value returns raw value of key for performance purpose. func (k *Key) Value() string { f := k.s.f - if !f.BlockMode { + if !f.locking { return k.value } f.lock.RLock() @@ -127,10 +127,10 @@ func (k *Key) Value() string { return v } -// ValueWithShadows returns raw values of key and its shadows if any. Empty +// Values returns raw values of key and its shadows if any. Empty // values are left out, unless the file was loaded with // KeepEmptyShadowValues. -func (k *Key) ValueWithShadows() []string { +func (k *Key) Values() []string { k.s.f.rlock() defer k.s.f.runlock() keepEmpty := k.s.f.options.KeepEmptyShadowValues @@ -146,9 +146,9 @@ func (k *Key) ValueWithShadows() []string { return vals } -// IsBooleanType reports whether the key was read as a boolean key, a line +// IsBoolean reports whether the key was read as a boolean key, a line // without a delimiter loaded with AllowBooleanKeys. -func (k *Key) IsBooleanType() bool { +func (k *Key) IsBoolean() bool { k.s.f.rlock() defer k.s.f.runlock() return k.isBooleanType @@ -162,8 +162,8 @@ func (k *Key) NestedValues() []string { return k.nestedValues } -// transformValue takes a raw value and transforms to its final string. -func (k *Key) transformValue(val string) string { +// expandValue takes a raw value and transforms to its final string. +func (k *Key) expandValue(val string) string { return k.expand(val, 0, new(expansion)) } @@ -223,11 +223,11 @@ func (k *Key) expand(val string, depth int, e *expansion) string { // String returns string representation of value. func (k *Key) String() string { - return k.transformValue(k.Value()) + return k.expandValue(k.Value()) } // setDefault sets the value a Must method falls back to. Unlike SetValue, -// it leaves KeysHash as it was, as in v1. +// it leaves KeyValues as it was, as in v1. func (k *Key) setDefault(v string) { k.s.f.wlock() defer k.s.f.wunlock() @@ -406,142 +406,6 @@ func (k *Key) MustTime(defaultVal ...time.Time) time.Time { return k.MustTimeFormat(time.RFC3339, defaultVal...) } -// In always returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) In(defaultVal string, candidates []string) string { - val := k.String() - for _, cand := range candidates { - if val == cand { - return val - } - } - return defaultVal -} - -// InFloat64 always returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InFloat64(defaultVal float64, candidates []float64) float64 { - val := k.MustFloat64() - for _, cand := range candidates { - if val == cand { - return val - } - } - return defaultVal -} - -// InInt always returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InInt(defaultVal int, candidates []int) int { - val := k.MustInt() - for _, cand := range candidates { - if val == cand { - return val - } - } - return defaultVal -} - -// InInt64 always returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InInt64(defaultVal int64, candidates []int64) int64 { - val := k.MustInt64() - for _, cand := range candidates { - if val == cand { - return val - } - } - return defaultVal -} - -// InUint always returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InUint(defaultVal uint, candidates []uint) uint { - val := k.MustUint() - for _, cand := range candidates { - if val == cand { - return val - } - } - return defaultVal -} - -// InUint64 always returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InUint64(defaultVal uint64, candidates []uint64) uint64 { - val := k.MustUint64() - for _, cand := range candidates { - if val == cand { - return val - } - } - return defaultVal -} - -// InTimeFormat always parses with given format and returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InTimeFormat(format string, defaultVal time.Time, candidates []time.Time) time.Time { - val := k.MustTimeFormat(format) - for _, cand := range candidates { - if val.Equal(cand) { - return val - } - } - return defaultVal -} - -// InTime always parses with RFC3339 format and returns value without error, -// it returns default value if error occurs or doesn't fit into candidates. -func (k *Key) InTime(defaultVal time.Time, candidates []time.Time) time.Time { - return k.InTimeFormat(time.RFC3339, defaultVal, candidates) -} - -// RangeFloat64 checks if value is in given range inclusively, -// and returns default value if it's not. -func (k *Key) RangeFloat64(defaultVal, min, max float64) float64 { - val := k.MustFloat64() - if val < min || val > max { - return defaultVal - } - return val -} - -// RangeInt checks if value is in given range inclusively, -// and returns default value if it's not. -func (k *Key) RangeInt(defaultVal, min, max int) int { - val := k.MustInt() - if val < min || val > max { - return defaultVal - } - return val -} - -// RangeInt64 checks if value is in given range inclusively, -// and returns default value if it's not. -func (k *Key) RangeInt64(defaultVal, min, max int64) int64 { - val := k.MustInt64() - if val < min || val > max { - return defaultVal - } - return val -} - -// RangeTimeFormat checks if value with given format is in given range inclusively, -// and returns default value if it's not. -func (k *Key) RangeTimeFormat(format string, defaultVal, min, max time.Time) time.Time { - val := k.MustTimeFormat(format) - if val.Unix() < min.Unix() || val.Unix() > max.Unix() { - return defaultVal - } - return val -} - -// RangeTime checks if value with RFC3339 format is in given range inclusively, -// and returns default value if it's not. -func (k *Key) RangeTime(defaultVal, min, max time.Time) time.Time { - return k.RangeTimeFormat(time.RFC3339, defaultVal, min, max) -} - // Strings returns list of string divided by given delimiter. func (k *Key) Strings(delim string) []string { return splitList(k.String(), delim) @@ -603,7 +467,7 @@ func splitList(str, delim string) []string { // StringsWithShadows returns list of string divided by given delimiter. // Shadows will also be appended if any. func (k *Key) StringsWithShadows(delim string) []string { - vals := k.ValueWithShadows() + vals := k.Values() results := make([]string, 0, len(vals)*2) for i := range vals { if len(vals) == 0 { @@ -614,289 +478,14 @@ func (k *Key) StringsWithShadows(delim string) []string { } for i := range results { - results[i] = k.transformValue(strings.TrimSpace(results[i])) + results[i] = k.expandValue(strings.TrimSpace(results[i])) } return results } -// Float64s returns list of float64 divided by given delimiter. Any invalid input will be treated as zero value. -func (k *Key) Float64s(delim string) []float64 { - vals, _ := k.parseFloat64s(k.Strings(delim), true, false) - return vals -} - -// Ints returns list of int divided by given delimiter. Any invalid input will be treated as zero value. -func (k *Key) Ints(delim string) []int { - vals, _ := k.parseInts(k.Strings(delim), true, false) - return vals -} - -// Int64s returns list of int64 divided by given delimiter. Any invalid input will be treated as zero value. -func (k *Key) Int64s(delim string) []int64 { - vals, _ := k.parseInt64s(k.Strings(delim), true, false) - return vals -} - -// Uints returns list of uint divided by given delimiter. Any invalid input will be treated as zero value. -func (k *Key) Uints(delim string) []uint { - vals, _ := k.parseUints(k.Strings(delim), true, false) - return vals -} - -// Uint64s returns list of uint64 divided by given delimiter. Any invalid input will be treated as zero value. -func (k *Key) Uint64s(delim string) []uint64 { - vals, _ := k.parseUint64s(k.Strings(delim), true, false) - return vals -} - -// Bools returns list of bool divided by given delimiter. Any invalid input will be treated as zero value. -func (k *Key) Bools(delim string) []bool { - vals, _ := k.parseBools(k.Strings(delim), true, false) - return vals -} - -// TimesFormat parses with given format and returns list of time.Time divided by given delimiter. -// Any invalid input will be treated as zero value (0001-01-01 00:00:00 +0000 UTC). -func (k *Key) TimesFormat(format, delim string) []time.Time { - vals, _ := k.parseTimesFormat(format, k.Strings(delim), true, false) - return vals -} - -// Times parses with RFC3339 format and returns list of time.Time divided by given delimiter. -// Any invalid input will be treated as zero value (0001-01-01 00:00:00 +0000 UTC). -func (k *Key) Times(delim string) []time.Time { - return k.TimesFormat(time.RFC3339, delim) -} - -// ValidFloat64s returns list of float64 divided by given delimiter. If some value is not float, then -// it will not be included to result list. -func (k *Key) ValidFloat64s(delim string) []float64 { - vals, _ := k.parseFloat64s(k.Strings(delim), false, false) - return vals -} - -// ValidInts returns list of int divided by given delimiter. If some value is not integer, then it will -// not be included to result list. -func (k *Key) ValidInts(delim string) []int { - vals, _ := k.parseInts(k.Strings(delim), false, false) - return vals -} - -// ValidInt64s returns list of int64 divided by given delimiter. If some value is not 64-bit integer, -// then it will not be included to result list. -func (k *Key) ValidInt64s(delim string) []int64 { - vals, _ := k.parseInt64s(k.Strings(delim), false, false) - return vals -} - -// ValidUints returns list of uint divided by given delimiter. If some value is not unsigned integer, -// then it will not be included to result list. -func (k *Key) ValidUints(delim string) []uint { - vals, _ := k.parseUints(k.Strings(delim), false, false) - return vals -} - -// ValidUint64s returns list of uint64 divided by given delimiter. If some value is not 64-bit unsigned -// integer, then it will not be included to result list. -func (k *Key) ValidUint64s(delim string) []uint64 { - vals, _ := k.parseUint64s(k.Strings(delim), false, false) - return vals -} - -// ValidBools returns list of bool divided by given delimiter. If some value is not 64-bit unsigned -// integer, then it will not be included to result list. -func (k *Key) ValidBools(delim string) []bool { - vals, _ := k.parseBools(k.Strings(delim), false, false) - return vals -} - -// ValidTimesFormat parses with given format and returns list of time.Time divided by given delimiter. -func (k *Key) ValidTimesFormat(format, delim string) []time.Time { - vals, _ := k.parseTimesFormat(format, k.Strings(delim), false, false) - return vals -} - -// ValidTimes parses with RFC3339 format and returns list of time.Time divided by given delimiter. -func (k *Key) ValidTimes(delim string) []time.Time { - return k.ValidTimesFormat(time.RFC3339, delim) -} - -// StrictFloat64s returns list of float64 divided by given delimiter or error on first invalid input. -func (k *Key) StrictFloat64s(delim string) ([]float64, error) { - return k.parseFloat64s(k.Strings(delim), false, true) -} - -// StrictInts returns list of int divided by given delimiter or error on first invalid input. -func (k *Key) StrictInts(delim string) ([]int, error) { - return k.parseInts(k.Strings(delim), false, true) -} - -// StrictInt64s returns list of int64 divided by given delimiter or error on first invalid input. -func (k *Key) StrictInt64s(delim string) ([]int64, error) { - return k.parseInt64s(k.Strings(delim), false, true) -} - -// StrictUints returns list of uint divided by given delimiter or error on first invalid input. -func (k *Key) StrictUints(delim string) ([]uint, error) { - return k.parseUints(k.Strings(delim), false, true) -} - -// StrictUint64s returns list of uint64 divided by given delimiter or error on first invalid input. -func (k *Key) StrictUint64s(delim string) ([]uint64, error) { - return k.parseUint64s(k.Strings(delim), false, true) -} - -// StrictBools returns list of bool divided by given delimiter or error on first invalid input. -func (k *Key) StrictBools(delim string) ([]bool, error) { - return k.parseBools(k.Strings(delim), false, true) -} - -// StrictTimesFormat parses with given format and returns list of time.Time divided by given delimiter -// or error on first invalid input. -func (k *Key) StrictTimesFormat(format, delim string) ([]time.Time, error) { - return k.parseTimesFormat(format, k.Strings(delim), false, true) -} - -// StrictTimes parses with RFC3339 format and returns list of time.Time divided by given delimiter -// or error on first invalid input. -func (k *Key) StrictTimes(delim string) ([]time.Time, error) { - return k.StrictTimesFormat(time.RFC3339, delim) -} - -// parseBools transforms strings to bools. -func (k *Key) parseBools(strs []string, addInvalid, returnOnInvalid bool) ([]bool, error) { - vals := make([]bool, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := parseBool(str) - return val, err - } - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, val.(bool)) - } - } - return vals, err -} - -// parseFloat64s transforms strings to float64s. -func (k *Key) parseFloat64s(strs []string, addInvalid, returnOnInvalid bool) ([]float64, error) { - vals := make([]float64, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := strconv.ParseFloat(str, 64) - return val, err - } - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, val.(float64)) - } - } - return vals, err -} - -// parseInts transforms strings to ints. -func (k *Key) parseInts(strs []string, addInvalid, returnOnInvalid bool) ([]int, error) { - vals := make([]int, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := strconv.ParseInt(str, 0, 64) - return val, err - } - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, int(val.(int64))) - } - } - return vals, err -} - -// parseInt64s transforms strings to int64s. -func (k *Key) parseInt64s(strs []string, addInvalid, returnOnInvalid bool) ([]int64, error) { - vals := make([]int64, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := strconv.ParseInt(str, 0, 64) - return val, err - } - - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, val.(int64)) - } - } - return vals, err -} - -// parseUints transforms strings to uints. -func (k *Key) parseUints(strs []string, addInvalid, returnOnInvalid bool) ([]uint, error) { - vals := make([]uint, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := strconv.ParseUint(str, 0, 64) - return val, err - } - - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, uint(val.(uint64))) - } - } - return vals, err -} - -// parseUint64s transforms strings to uint64s. -func (k *Key) parseUint64s(strs []string, addInvalid, returnOnInvalid bool) ([]uint64, error) { - vals := make([]uint64, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := strconv.ParseUint(str, 0, 64) - return val, err - } - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, val.(uint64)) - } - } - return vals, err -} - -type Parser func(str string) (interface{}, error) - -// parseTimesFormat transforms strings to times in given format. -func (k *Key) parseTimesFormat(format string, strs []string, addInvalid, returnOnInvalid bool) ([]time.Time, error) { - vals := make([]time.Time, 0, len(strs)) - parser := func(str string) (interface{}, error) { - val, err := time.Parse(format, str) - return val, err - } - rawVals, err := k.doParse(strs, addInvalid, returnOnInvalid, parser) - if err == nil { - for _, val := range rawVals { - vals = append(vals, val.(time.Time)) - } - } - return vals, err -} - -// doParse transforms strings to different types -func (k *Key) doParse(strs []string, addInvalid, returnOnInvalid bool, parser Parser) ([]interface{}, error) { - vals := make([]interface{}, 0, len(strs)) - for _, str := range strs { - val, err := parser(str) - if err != nil && returnOnInvalid { - return nil, err - } - if err == nil || addInvalid { - vals = append(vals, val) - } - } - return vals, nil -} - // SetValue changes key value. func (k *Key) SetValue(v string) { - if k.s.f.BlockMode { + if k.s.f.locking { k.s.f.lock.Lock() defer k.s.f.lock.Unlock() } diff --git a/key_test.go b/key_test.go index 30db7f4..d7bcac3 100644 --- a/key_test.go +++ b/key_test.go @@ -39,7 +39,7 @@ func TestKey_AddShadow(t *testing.T) { require.NotNil(t, k) assert.NoError(t, k.AddShadow("ini.v1")) - assert.Equal(t, []string{"ini", "ini.v1"}, k.ValueWithShadows()) + assert.Equal(t, []string{"ini", "ini.v1"}, k.Values()) t.Run("add shadow to boolean key", func(t *testing.T) { k, err := f.Section("").NewBooleanKey("published") @@ -55,14 +55,14 @@ func TestKey_AddShadow(t *testing.T) { t.Run("deduplicate an existing value", func(t *testing.T) { k := f.Section("").Key("NAME") assert.NoError(t, k.AddShadow("ini")) - assert.Equal(t, []string{"ini", "ini.v1"}, k.ValueWithShadows()) + assert.Equal(t, []string{"ini", "ini.v1"}, k.Values()) }) t.Run("ignore empty shadow values", func(t *testing.T) { k := f.Section("").Key("empty") assert.NoError(t, k.AddShadow("")) assert.NoError(t, k.AddShadow("ini")) - assert.Equal(t, []string{"ini"}, k.ValueWithShadows()) + assert.Equal(t, []string{"ini"}, k.Values()) }) }) @@ -77,7 +77,7 @@ func TestKey_AddShadow(t *testing.T) { assert.NoError(t, k.AddShadow("ini.v1")) assert.NoError(t, k.AddShadow("ini")) assert.NoError(t, k.AddShadow("ini")) - assert.Equal(t, []string{"ini", "ini.v1", "ini", "ini"}, k.ValueWithShadows()) + assert.Equal(t, []string{"ini", "ini.v1", "ini", "ini"}, k.Values()) }) t.Run("shadow is not allowed", func(t *testing.T) { @@ -92,70 +92,6 @@ func TestKey_AddShadow(t *testing.T) { }) } -// Helpers for slice tests. -func float64sEqual(t *testing.T, values []float64, expected ...float64) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v, values[i]) - } -} - -func intsEqual(t *testing.T, values []int, expected ...int) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v, values[i]) - } -} - -func int64sEqual(t *testing.T, values []int64, expected ...int64) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v, values[i]) - } -} - -func uintsEqual(t *testing.T, values []uint, expected ...uint) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v, values[i]) - } -} - -func uint64sEqual(t *testing.T, values []uint64, expected ...uint64) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v, values[i]) - } -} - -func boolsEqual(t *testing.T, values []bool, expected ...bool) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v, values[i]) - } -} - -func timesEqual(t *testing.T, values []time.Time, expected ...time.Time) { - t.Helper() - - assert.Len(t, values, len(expected)) - for i, v := range expected { - assert.Equal(t, v.String(), values[i].String()) - } -} - func TestKey_Helpers(t *testing.T) { t.Run("getting and setting values", func(t *testing.T) { f, err := Load(fullConf) @@ -323,51 +259,34 @@ func TestKey_Helpers(t *testing.T) { t.Run("get value with candidates", func(t *testing.T) { sec := f.Section("types") - assert.Equal(t, "str", sec.Key("STRING").In("", []string{"str", "arr", "types"})) - assert.Equal(t, float64(1.25), sec.Key("FLOAT64").InFloat64(0, []float64{1.25, 2.5, 3.75})) - assert.Equal(t, int(10), sec.Key("INT").InInt(0, []int{10, 20, 30})) - assert.Equal(t, int64(10), sec.Key("INT").InInt64(0, []int64{10, 20, 30})) - assert.Equal(t, uint(3), sec.Key("UINT").InUint(0, []uint{3, 6, 9})) - assert.Equal(t, uint64(3), sec.Key("UINT").InUint64(0, []uint64{3, 6, 9})) - - zt, err := time.Parse(time.RFC3339, "0001-01-01T01:00:00Z") - require.NoError(t, err) - ti, err := time.Parse(time.RFC3339, "2015-01-01T20:17:05Z") - require.NoError(t, err) - assert.Equal(t, ti.String(), sec.Key("TIME").InTime(zt, []time.Time{ti, time.Now(), time.Now().Add(1 * time.Second)}).String()) - - t.Run("get value with candidates and default value", func(t *testing.T) { - assert.Equal(t, "str", sec.Key("STRING_404_2").In("str", []string{"str", "arr", "types"})) - assert.Equal(t, float64(1.25), sec.Key("FLOAT64_404_2").InFloat64(1.25, []float64{1.25, 2.5, 3.75})) - assert.Equal(t, int(10), sec.Key("INT_404_2").InInt(10, []int{10, 20, 30})) - assert.Equal(t, int64(10), sec.Key("INT64_404_2").InInt64(10, []int64{10, 20, 30})) - assert.Equal(t, uint(3), sec.Key("UINT_404_2").InUint(3, []uint{3, 6, 9})) - assert.Equal(t, uint64(3), sec.Key("UINT_404_2").InUint64(3, []uint64{3, 6, 9})) - assert.Equal(t, ti.String(), sec.Key("TIME_404_2").InTime(ti, []time.Time{time.Now(), time.Now(), time.Now().Add(1 * time.Second)}).String()) + assert.Equal(t, "str", In(sec.Key("STRING"), "", []string{"str", "arr", "types"})) + assert.Equal(t, 1.25, In(sec.Key("FLOAT64"), 0, []float64{1.25, 2.5, 3.75})) + assert.Equal(t, 10, In(sec.Key("INT"), 0, []int{10, 20, 30})) + assert.Equal(t, uint8(3), In(sec.Key("UINT"), 0, []uint8{3, 6, 9})) + assert.Equal(t, 165*time.Minute, In(sec.Key("DURATION"), 0, []time.Duration{time.Hour, 165 * time.Minute})) + ti := time.Date(2015, 1, 1, 20, 17, 5, 0, time.UTC) + assert.Equal(t, ti, In(sec.Key("TIME"), time.Time{}, []time.Time{ti})) + + t.Run("default when not a candidate or not parsable", func(t *testing.T) { + assert.Equal(t, "x", In(sec.Key("STRING"), "x", []string{"arr"})) + assert.Equal(t, 7, In(sec.Key("INT"), 7, []int{20, 30})) + assert.Equal(t, 7, In(sec.Key("STRING"), 7, []int{7, 10})) + assert.Equal(t, int8(7), In(sec.Key("HEX_NUMBER"), 7, []int8{0}), "0x3000 overflows int8") }) }) t.Run("get values in range", func(t *testing.T) { sec := f.Section("types") - assert.Equal(t, float64(1.25), sec.Key("FLOAT64").RangeFloat64(0, 1, 2)) - assert.Equal(t, int(10), sec.Key("INT").RangeInt(0, 10, 20)) - assert.Equal(t, int64(10), sec.Key("INT").RangeInt64(0, 10, 20)) - - minT, err := time.Parse(time.RFC3339, "0001-01-01T01:00:00Z") - require.NoError(t, err) - midT, err := time.Parse(time.RFC3339, "2013-01-01T01:00:00Z") - require.NoError(t, err) - maxT, err := time.Parse(time.RFC3339, "9999-01-01T01:00:00Z") - require.NoError(t, err) - ti, err := time.Parse(time.RFC3339, "2015-01-01T20:17:05Z") - require.NoError(t, err) - assert.Equal(t, ti.String(), sec.Key("TIME").RangeTime(ti, minT, maxT).String()) - - t.Run("get value in range with default value", func(t *testing.T) { - assert.Equal(t, float64(5), sec.Key("FLOAT64").RangeFloat64(5, 0, 1)) - assert.Equal(t, 7, sec.Key("INT").RangeInt(7, 0, 5)) - assert.Equal(t, int64(7), sec.Key("INT").RangeInt64(7, 0, 5)) - assert.Equal(t, ti.String(), sec.Key("TIME").RangeTime(ti, minT, midT).String()) + assert.Equal(t, 1.25, Range(sec.Key("FLOAT64"), 0.0, 1, 2)) + assert.Equal(t, 10, Range(sec.Key("INT"), 0, 10, 20)) + assert.Equal(t, uint16(0x3000), Range(sec.Key("HEX_NUMBER"), uint16(0), 0, 0xffff)) + assert.Equal(t, 165*time.Minute, Range(sec.Key("DURATION"), 0, time.Hour, 3*time.Hour)) + + t.Run("default when out of range or not parsable", func(t *testing.T) { + assert.Equal(t, 5.0, Range(sec.Key("FLOAT64"), 5.0, 0, 1)) + assert.Equal(t, 7, Range(sec.Key("INT"), 7, 0, 5)) + assert.Equal(t, 7, Range(sec.Key("STRING"), 7, 0, 100)) + assert.Equal(t, uint8(1), Range(sec.Key("HEX_NUMBER"), uint8(1), 0, 255), "0x3000 overflows uint8") }) }) @@ -376,28 +295,44 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, "en,zh,de", strings.Join(sec.Key("STRINGS").Strings(","), ",")) assert.Equal(t, 0, len(sec.Key("STRINGS_404").Strings(","))) - vals1 := sec.Key("FLOAT64S").Float64s(",") - float64sEqual(t, vals1, 1.1, 2.2, 3.3) + floats, err := List[float64](sec.Key("FLOAT64S"), ",") + require.NoError(t, err) + assert.Equal(t, []float64{1.1, 2.2, 3.3}, floats) - vals2 := sec.Key("INTS").Ints(",") - intsEqual(t, vals2, 1, 2, 3) + ints, err := List[int](sec.Key("INTS"), ",") + require.NoError(t, err) + assert.Equal(t, []int{1, 2, 3}, ints) - vals3 := sec.Key("INTS").Int64s(",") - int64sEqual(t, vals3, 1, 2, 3) + uints, err := List[uint8](sec.Key("UINTS"), ",") + require.NoError(t, err) + assert.Equal(t, []uint8{1, 2, 3}, uints) - vals4 := sec.Key("UINTS").Uints(",") - uintsEqual(t, vals4, 1, 2, 3) + bools, err := List[bool](sec.Key("BOOLS"), ",") + require.NoError(t, err) + assert.Equal(t, []bool{true, false, false}, bools) - vals5 := sec.Key("UINTS").Uint64s(",") - uint64sEqual(t, vals5, 1, 2, 3) + ti := time.Date(2015, 1, 1, 20, 17, 5, 0, time.UTC) + times, err := List[time.Time](sec.Key("TIMES"), ",") + require.NoError(t, err) + assert.Equal(t, []time.Time{ti, ti, ti}, times) - vals6 := sec.Key("BOOLS").Bools(",") - boolsEqual(t, vals6, true, false, false) + type lang string + langs, err := List[lang](sec.Key("STRINGS"), ",") + require.NoError(t, err) + assert.Equal(t, []lang{"en", "zh", "de"}, langs) - ti, err := time.Parse(time.RFC3339, "2015-01-01T20:17:05Z") + empty, err := List[int](sec.Key("INTS_404"), ",") require.NoError(t, err) - vals7 := sec.Key("TIMES").Times(",") - timesEqual(t, vals7, ti, ti, ti) + assert.Empty(t, empty) + + t.Run("error on the first invalid element", func(t *testing.T) { + vals, err := List[int](sec.Key("STRINGS"), ",") + assert.Error(t, err) + assert.Nil(t, vals) + + _, err = List[time.Time](sec.Key("INTS"), ",") + assert.Error(t, err) + }) }) t.Run("test string slice escapes", func(t *testing.T) { @@ -410,123 +345,10 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, []string{"aaa", "bbb and space", "ccc"}, sec.Key("key6").Strings(" ")) }) - t.Run("get valid values into slice", func(t *testing.T) { - sec := f.Section("array") - vals1 := sec.Key("FLOAT64S").ValidFloat64s(",") - float64sEqual(t, vals1, 1.1, 2.2, 3.3) - - vals2 := sec.Key("INTS").ValidInts(",") - intsEqual(t, vals2, 1, 2, 3) - - vals3 := sec.Key("INTS").ValidInt64s(",") - int64sEqual(t, vals3, 1, 2, 3) - - vals4 := sec.Key("UINTS").ValidUints(",") - uintsEqual(t, vals4, 1, 2, 3) - - vals5 := sec.Key("UINTS").ValidUint64s(",") - uint64sEqual(t, vals5, 1, 2, 3) - - vals6 := sec.Key("BOOLS").ValidBools(",") - boolsEqual(t, vals6, true, false, false) - - ti, err := time.Parse(time.RFC3339, "2015-01-01T20:17:05Z") - require.NoError(t, err) - vals7 := sec.Key("TIMES").ValidTimes(",") - timesEqual(t, vals7, ti, ti, ti) - }) - - t.Run("get values one type into slice of another type", func(t *testing.T) { - sec := f.Section("array") - vals1 := sec.Key("STRINGS").ValidFloat64s(",") - assert.Empty(t, vals1) - - vals2 := sec.Key("STRINGS").ValidInts(",") - assert.Empty(t, vals2) - - vals3 := sec.Key("STRINGS").ValidInt64s(",") - assert.Empty(t, vals3) - - vals4 := sec.Key("STRINGS").ValidUints(",") - assert.Empty(t, vals4) - - vals5 := sec.Key("STRINGS").ValidUint64s(",") - assert.Empty(t, vals5) - - vals6 := sec.Key("STRINGS").ValidBools(",") - assert.Empty(t, vals6) - - vals7 := sec.Key("STRINGS").ValidTimes(",") - assert.Empty(t, vals7) - }) - - t.Run("get valid values into slice without errors", func(t *testing.T) { - sec := f.Section("array") - vals1, err := sec.Key("FLOAT64S").StrictFloat64s(",") - require.NoError(t, err) - float64sEqual(t, vals1, 1.1, 2.2, 3.3) - - vals2, err := sec.Key("INTS").StrictInts(",") - require.NoError(t, err) - intsEqual(t, vals2, 1, 2, 3) - - vals3, err := sec.Key("INTS").StrictInt64s(",") - require.NoError(t, err) - int64sEqual(t, vals3, 1, 2, 3) - - vals4, err := sec.Key("UINTS").StrictUints(",") - require.NoError(t, err) - uintsEqual(t, vals4, 1, 2, 3) - - vals5, err := sec.Key("UINTS").StrictUint64s(",") - require.NoError(t, err) - uint64sEqual(t, vals5, 1, 2, 3) - - vals6, err := sec.Key("BOOLS").StrictBools(",") - require.NoError(t, err) - boolsEqual(t, vals6, true, false, false) - - ti, err := time.Parse(time.RFC3339, "2015-01-01T20:17:05Z") - require.NoError(t, err) - vals7, err := sec.Key("TIMES").StrictTimes(",") - require.NoError(t, err) - timesEqual(t, vals7, ti, ti, ti) - }) - - t.Run("get invalid values into slice", func(t *testing.T) { - sec := f.Section("array") - vals1, err := sec.Key("STRINGS").StrictFloat64s(",") - assert.Empty(t, vals1) - assert.Error(t, err) - - vals2, err := sec.Key("STRINGS").StrictInts(",") - assert.Empty(t, vals2) - assert.Error(t, err) - - vals3, err := sec.Key("STRINGS").StrictInt64s(",") - assert.Empty(t, vals3) - assert.Error(t, err) - - vals4, err := sec.Key("STRINGS").StrictUints(",") - assert.Empty(t, vals4) - assert.Error(t, err) - - vals5, err := sec.Key("STRINGS").StrictUint64s(",") - assert.Empty(t, vals5) - assert.Error(t, err) - - vals6, err := sec.Key("STRINGS").StrictBools(",") - assert.Empty(t, vals6) - assert.Error(t, err) - - vals7, err := sec.Key("STRINGS").StrictTimes(",") - assert.Empty(t, vals7) - assert.Error(t, err) - }) }) } -func TestKey_ValueWithShadows(t *testing.T) { +func TestKey_Values(t *testing.T) { t.Run("", func(t *testing.T) { f, err := Load([]byte(` keyName = value1 @@ -537,11 +359,11 @@ keyName = value2 k := f.Section("").Key("FakeKey") require.NotNil(t, k) - assert.Equal(t, []string{}, k.ValueWithShadows()) + assert.Equal(t, []string{}, k.Values()) k = f.Section("").Key("keyName") require.NotNil(t, k) - assert.Equal(t, []string{"value1", "value2"}, k.ValueWithShadows()) + assert.Equal(t, []string{"value1", "value2"}, k.Values()) }) } diff --git a/lexer.go b/lexer.go index 1bbff87..ee8305e 100644 --- a/lexer.go +++ b/lexer.go @@ -15,6 +15,7 @@ package ini import ( + "errors" "fmt" "io" "strconv" @@ -154,7 +155,7 @@ func (l *lexer) rawBody() token { return t } -func (l *lexer) debug(format string, args ...interface{}) { +func (l *lexer) debug(format string, args ...any) { if l.opts.DebugFunc != nil { l.opts.DebugFunc(fmt.Sprintf(format, args...)) } @@ -195,7 +196,7 @@ func (l *lexer) key(line string) (_ token, ok bool, _ error) { l.lastValueEmpty = value == "" return token{kind: tokKey, name: name, value: value, comment: l.takeComment()}, true, nil - case IsErrDelimiterNotFound(err) && l.opts.AllowBooleanKeys: + case errors.Is(err, ErrDelimiterNotFound) && l.opts.AllowBooleanKeys: // The whole line is the key name, read like a value. name, err := l.readValue(line) if err != nil { @@ -203,7 +204,7 @@ func (l *lexer) key(line string) (_ token, ok bool, _ error) { } return token{kind: tokBooleanKey, name: name, comment: l.takeComment()}, true, nil - case (IsErrDelimiterNotFound(err) || IsErrEmptyKeyName(err)) && l.opts.SkipUnrecognizableLines: + case (errors.Is(err, ErrDelimiterNotFound) || errors.Is(err, ErrEmptyKeyName)) && l.opts.SkipUnrecognizableLines: return token{}, false, nil } return token{}, false, err @@ -469,7 +470,7 @@ func readKeyName(delimiters string, line string) (string, int, error) { // Find key-value delimiter i := strings.IndexAny(line[pos+startIdx:], delimiters) if i < 0 { - return "", -1, ErrDelimiterNotFound{line} + return "", -1, fmt.Errorf("%w: %s", ErrDelimiterNotFound, line) } endIdx = pos + i return strings.TrimSpace(line[startIdx:pos]), endIdx + startIdx + 1, nil @@ -477,10 +478,10 @@ func readKeyName(delimiters string, line string) (string, int, error) { endIdx = strings.IndexAny(line, delimiters) if endIdx < 0 { - return "", -1, ErrDelimiterNotFound{line} + return "", -1, fmt.Errorf("%w: %s", ErrDelimiterNotFound, line) } if endIdx == 0 { - return "", -1, ErrEmptyKeyName{line} + return "", -1, fmt.Errorf("%w: %s", ErrEmptyKeyName, line) } return strings.TrimSpace(line[0:endIdx]), endIdx + 1, nil diff --git a/options.go b/options.go index 3c1e8a6..b03f9d7 100644 --- a/options.go +++ b/options.go @@ -53,6 +53,8 @@ type loadOptions struct { DefaultSection string CommentSymbols string KeepEmptyShadowValues bool + NoLocking bool + NameMapper NameMapper } func newLoadOptions(opts []LoadOption) loadOptions { @@ -68,21 +70,34 @@ func newLoadOptions(opts []LoadOption) loadOptions { return o } +// NoLocking turns off the lock that lets a File be read from many goroutines +// while others change it. Use it for a File only one goroutine uses, to save +// the cost of locking. +func NoLocking() LoadOption { return func(o *loadOptions) { o.NoLocking = true } } + +// NameMapping sets the NameMapper that maps struct field names to section +// and key names, for MapTo, StrictMapTo and ReflectFrom. +func NameMapping(mapper NameMapper) LoadOption { + return func(o *loadOptions) { o.NameMapper = mapper } +} + // DebugFunc is the type of function called to log parse events. type DebugFunc func(message string) -// Loose ignores data sources that are files which don't exist, instead of +// IgnoreMissingFiles ignores data sources that are files which don't exist, instead of // returning an error. -func Loose() LoadOption { return func(o *loadOptions) { o.Loose = true } } +func IgnoreMissingFiles() LoadOption { return func(o *loadOptions) { o.Loose = true } } -// Insensitive lowercases all section and key names. -func Insensitive() LoadOption { return func(o *loadOptions) { o.Insensitive = true } } +// CaseInsensitive lowercases all section and key names. +func CaseInsensitive() LoadOption { return func(o *loadOptions) { o.Insensitive = true } } -// InsensitiveSections lowercases all section names. -func InsensitiveSections() LoadOption { return func(o *loadOptions) { o.InsensitiveSections = true } } +// CaseInsensitiveSections lowercases all section names. +func CaseInsensitiveSections() LoadOption { + return func(o *loadOptions) { o.InsensitiveSections = true } +} -// InsensitiveKeys lowercases all key names. -func InsensitiveKeys() LoadOption { return func(o *loadOptions) { o.InsensitiveKeys = true } } +// CaseInsensitiveKeys lowercases all key names. +func CaseInsensitiveKeys() LoadOption { return func(o *loadOptions) { o.InsensitiveKeys = true } } // IgnoreContinuation treats a backslash at the end of a line as part of the // value instead of joining the next line. @@ -98,8 +113,8 @@ func SkipUnrecognizableLines() LoadOption { return func(o *loadOptions) { o.SkipUnrecognizableLines = true } } -// ShortCircuit stops loading after the first data source that loads. -func ShortCircuit() LoadOption { return func(o *loadOptions) { o.ShortCircuit = true } } +// FirstSourceOnly stops loading after the first data source that loads. +func FirstSourceOnly() LoadOption { return func(o *loadOptions) { o.ShortCircuit = true } } // AllowBooleanKeys reads a line without a delimiter as a key whose value is // "true", as in my.cnf. @@ -139,9 +154,9 @@ func UnescapeValueCommentSymbols() LoadOption { return func(o *loadOptions) { o.UnescapeValueCommentSymbols = true } } -// UnparseableSections keeps the bodies of the named sections as raw text, +// RawSections keeps the bodies of the named sections as raw text, // available from Section.Body. -func UnparseableSections(names ...string) LoadOption { +func RawSections(names ...string) LoadOption { return func(o *loadOptions) { o.UnparseableSections = names } } @@ -186,7 +201,7 @@ func AllowDuplicateShadowValues() LoadOption { return func(o *loadOptions) { o.AllowDuplicateShadowValues = true } } -// KeepEmptyShadowValues keeps empty values in Key.ValueWithShadows and when +// KeepEmptyShadowValues keeps empty values in Key.Values and when // writing shadow keys. An empty value can be meaningful: in systemd units it // resets the values before it. func KeepEmptyShadowValues() LoadOption { @@ -257,9 +272,9 @@ func LineBreak(lineBreak string) WriteOption { return func(o *writeOptions) { o.lineBreak = lineBreak } } -// KeyValueDelimiterOnWrite sets the delimiter written between keys and +// WriteDelimiter sets the delimiter written between keys and // values. The default is "=". -func KeyValueDelimiterOnWrite(delimiter string) WriteOption { +func WriteDelimiter(delimiter string) WriteOption { return func(o *writeOptions) { o.keyValueDelimiter = delimiter } } diff --git a/options_test.go b/options_test.go index 7a80152..9728cd4 100644 --- a/options_test.go +++ b/options_test.go @@ -44,8 +44,8 @@ func TestDefaultSectionName(t *testing.T) { f, err := Load([]byte("a = 1\n[default]\nb = 2\n[profile x]\nc = 3\n"), DefaultSectionName("default")) require.NoError(t, err) - assert.Equal(t, []string{"default", "profile x"}, f.SectionStrings()) - assert.Equal(t, map[string]string{"a": "1", "b": "2"}, f.Section("").KeysHash()) + assert.Equal(t, []string{"default", "profile x"}, f.SectionNames()) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, f.Section("").KeyValues()) var b bytes.Buffer _, err = f.WriteTo(&b) @@ -80,7 +80,7 @@ func TestCommentSymbols(t *testing.T) { require.NoError(t, err) sec := f.Section("") - assert.Equal(t, []string{"→ arrow", "k", "j", "u"}, sec.KeyStrings()) + assert.Equal(t, []string{"→ arrow", "k", "j", "u"}, sec.KeyNames()) assert.Equal(t, "€ head", sec.Key("→ arrow").Comment) assert.Equal(t, "a", sec.Key("k").String()) assert.Equal(t, "€c", sec.Key("k").Comment) @@ -132,10 +132,10 @@ func TestParseErrorPosition(t *testing.T) { name string data string line, column int - is func(error) bool + is error }{ - {"missing delimiter", "a = 1\n[s]\n\u00a0\u00a0été x\n", 3, 3, IsErrDelimiterNotFound}, - {"empty key name", "a = 1\n\n= 2\n", 3, 1, IsErrEmptyKeyName}, + {"missing delimiter", "a = 1\n[s]\n\u00a0\u00a0été x\n", 3, 3, ErrDelimiterNotFound}, + {"empty key name", "a = 1\n\n= 2\n", 3, 1, ErrEmptyKeyName}, {"unclosed section", "a = 1\n\t[s\n", 2, 2, nil}, {"unclosed quote", "a = 1\nk = `x\ny\n", 2, 1, nil}, } @@ -147,7 +147,7 @@ func TestParseErrorPosition(t *testing.T) { assert.Equal(t, test.line, perr.Line) assert.Equal(t, test.column, perr.Column) if test.is != nil { - assert.True(t, test.is(err)) + assert.ErrorIs(t, err, test.is) } }) } @@ -158,11 +158,11 @@ func TestKeepEmptyShadowValues(t *testing.T) { f, err := Load(data, AllowShadows()) require.NoError(t, err) - assert.Equal(t, []string{"/bin/a", "/bin/b"}, f.Section("Service").Key("ExecStart").ValueWithShadows()) + assert.Equal(t, []string{"/bin/a", "/bin/b"}, f.Section("Service").Key("ExecStart").Values()) f, err = Load(data, AllowShadows(), KeepEmptyShadowValues()) require.NoError(t, err) - assert.Equal(t, []string{"/bin/a", "", "/bin/b"}, f.Section("Service").Key("ExecStart").ValueWithShadows()) + assert.Equal(t, []string{"/bin/a", "", "/bin/b"}, f.Section("Service").Key("ExecStart").Values()) var b bytes.Buffer _, err = f.WriteTo(&b) @@ -173,13 +173,13 @@ func TestKeepEmptyShadowValues(t *testing.T) { f, err := Load([]byte("[u]\nExecStart=\nExecStart=a\nExecStart=\nExecStart=b\nExecStart=a\n"), AllowShadows(), KeepEmptyShadowValues()) require.NoError(t, err) - assert.Equal(t, []string{"", "a", "", "b"}, f.Section("u").Key("ExecStart").ValueWithShadows()) + assert.Equal(t, []string{"", "a", "", "b"}, f.Section("u").Key("ExecStart").Values()) }) } func TestKey_IsBooleanType(t *testing.T) { f, err := Load([]byte("[mysqld]\nskip-name-resolve\nuser = mysql\n"), AllowBooleanKeys()) require.NoError(t, err) - assert.True(t, f.Section("mysqld").Key("skip-name-resolve").IsBooleanType()) - assert.False(t, f.Section("mysqld").Key("user").IsBooleanType()) + assert.True(t, f.Section("mysqld").Key("skip-name-resolve").IsBoolean()) + assert.False(t, f.Section("mysqld").Key("user").IsBoolean()) } diff --git a/parity_test.go b/parity_test.go index badabb8..9e47ecc 100644 --- a/parity_test.go +++ b/parity_test.go @@ -67,9 +67,9 @@ func v2Options(o v1.LoadOptions) []LoadOption { set bool opt func() LoadOption }{ - {o.Insensitive, Insensitive}, - {o.InsensitiveSections, InsensitiveSections}, - {o.InsensitiveKeys, InsensitiveKeys}, + {o.Insensitive, CaseInsensitive}, + {o.InsensitiveSections, CaseInsensitiveSections}, + {o.InsensitiveKeys, CaseInsensitiveKeys}, {o.IgnoreContinuation, IgnoreContinuation}, {o.IgnoreInlineComment, IgnoreInlineComment}, {o.SkipUnrecognizableLines, SkipUnrecognizableLines}, @@ -89,7 +89,7 @@ func v2Options(o v1.LoadOptions) []LoadOption { } } if o.UnparseableSections != nil { - opts = append(opts, UnparseableSections(o.UnparseableSections...)) + opts = append(opts, RawSections(o.UnparseableSections...)) } return append(opts, KeyValueDelimiters(o.KeyValueDelimiters)) } @@ -97,7 +97,7 @@ func v2Options(o v1.LoadOptions) []LoadOption { // dumpFile describes everything stored in a v1 or v2 *File. It reads the // unexported fields both versions share by reflection, so the comparison // covers state the public API doesn't expose, such as empty shadow values. -func dumpFile(f interface{}) string { +func dumpFile(f any) string { v := reflect.ValueOf(f).Elem() names, indexes, sections := v.FieldByName("sectionList"), v.FieldByName("sectionIndexes"), v.FieldByName("sections") @@ -131,7 +131,7 @@ func dumpKey(b *strings.Builder, indent string, k reflect.Value) { } // loadResult describes the outcome of loading: the stored File or the error. -func loadResult(load func() (interface{}, error)) (out string) { +func loadResult(load func() (any, error)) (out string) { defer func() { if r := recover(); r != nil { out = fmt.Sprintf("panic: %v", r) @@ -159,8 +159,8 @@ func checkParity(t *testing.T, data []byte, flags uint32) { return } - want := loadResult(func() (interface{}, error) { return v1.LoadSources(opts, data, data) }) - got := loadResult(func() (interface{}, error) { return LoadSources([]any{data, data}, v2Options(opts)...) }) + want := loadResult(func() (any, error) { return v1.LoadSources(opts, data, data) }) + got := loadResult(func() (any, error) { return LoadSources([]any{data, data}, v2Options(opts)...) }) if want != got { t.Fatalf("v1 and v2 differ\ninput: %q\noptions: %+v\nv1:\n%s\nv2:\n%s", data, opts, want, got) } diff --git a/roundtrip_test.go b/roundtrip_test.go index d0b9317..fee7abf 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -130,7 +130,7 @@ func TestWriteReadsBack(t *testing.T) { {"k = a\n; c\nk = b\n", []LoadOption{AllowShadows()}}, // comment of a shadowed key {"k =\n n\nk\n", []LoadOption{AllowNestedValues(), AllowBooleanKeys()}}, {"k = \"\"\n n\n", []LoadOption{AllowNestedValues(), AllowPythonMultilineValues()}}, - {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{Insensitive(), AllowNonUniqueSections()}}, + {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{CaseInsensitive(), AllowNonUniqueSections()}}, {"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section } { f, err := Load([]byte(tc.data), tc.opts...) @@ -144,7 +144,7 @@ func TestWriteReadsBack(t *testing.T) { } t.Run("raw body without line break", func(t *testing.T) { - f := Empty(UnparseableSections("raw")) + f := Empty(RawSections("raw")) _, _ = f.NewRawSection("raw", "body") _, _ = f.Section("s").NewKey("k", "v") var b bytes.Buffer diff --git a/section.go b/section.go index 4706ad0..243f07e 100644 --- a/section.go +++ b/section.go @@ -116,14 +116,14 @@ func (s *Section) addBooleanKey(name string) (*Key, error) { // GetKey returns key in section by given name. func (s *Section) GetKey(name string) (*Key, error) { - if s.f.BlockMode { + if s.f.locking { s.f.lock.RLock() } if s.f.options.Insensitive || s.f.options.InsensitiveKeys { name = strings.ToLower(name) } key := s.keys[name] - if s.f.BlockMode { + if s.f.locking { s.f.lock.RUnlock() } @@ -152,14 +152,9 @@ func (s *Section) HasKey(name string) bool { return key != nil } -// Deprecated: Use "HasKey" instead. -func (s *Section) Haskey(name string) bool { - return s.HasKey(name) -} - // HasValue returns true if section contains given raw value. func (s *Section) HasValue(value string) bool { - if s.f.BlockMode { + if s.f.locking { s.f.lock.RLock() defer s.f.lock.RUnlock() } @@ -216,8 +211,8 @@ func (s *Section) ParentKeys() []*Key { return parentKeys } -// KeyStrings returns list of key names of section. -func (s *Section) KeyStrings() []string { +// KeyNames returns list of key names of section. +func (s *Section) KeyNames() []string { s.f.rlock() defer s.f.runlock() list := make([]string, len(s.keyList)) @@ -225,9 +220,9 @@ func (s *Section) KeyStrings() []string { return list } -// KeysHash returns keys hash consisting of names and values. -func (s *Section) KeysHash() map[string]string { - if s.f.BlockMode { +// KeyValues returns keys hash consisting of names and values. +func (s *Section) KeyValues() map[string]string { + if s.f.locking { s.f.lock.RLock() defer s.f.lock.RUnlock() } @@ -244,7 +239,7 @@ func (s *Section) DeleteKey(name string) { if s.f.options.Insensitive || s.f.options.InsensitiveKeys { name = strings.ToLower(name) } - if s.f.BlockMode { + if s.f.locking { s.f.lock.Lock() defer s.f.lock.Unlock() } diff --git a/section_test.go b/section_test.go index ecfa658..d7515ab 100644 --- a/section_test.go +++ b/section_test.go @@ -86,7 +86,7 @@ func TestSection_NewKey(t *testing.T) { require.NoError(t, err) require.NotNil(t, k) - assert.Equal(t, []string{"ini", "ini.v1"}, k.ValueWithShadows()) + assert.Equal(t, []string{"ini", "ini.v1"}, k.Values()) }) } @@ -266,7 +266,7 @@ func TestSection_KeyStrings(t *testing.T) { require.NoError(t, err) require.NotNil(t, k) - assert.Equal(t, []string{"NAME", "VERSION", "IMPORT_PATH"}, f.Section("").KeyStrings()) + assert.Equal(t, []string{"NAME", "VERSION", "IMPORT_PATH"}, f.Section("").KeyNames()) }) } @@ -288,7 +288,7 @@ file = b.log assert.Equal(t, "two", f.Section("").Key("key").String()) - hash := f.Section("log").KeysHash() + hash := f.Section("log").KeyValues() relation := map[string]string{ "name": "app2", "file": "b.log", @@ -307,7 +307,7 @@ file = b.log sec.Key("set").SetValue("3") sec.DeleteKey("gone") - assert.Equal(t, map[string]string{"empty": "", "set": "3"}, sec.KeysHash()) + assert.Equal(t, map[string]string{"empty": "", "set": "3"}, sec.KeyValues()) }) } diff --git a/struct.go b/struct.go index 1789266..654d439 100644 --- a/struct.go +++ b/struct.go @@ -30,8 +30,8 @@ type NameMapper func(string) string // Built-in name getters. var ( - // SnackCase converts to format SNACK_CASE. - SnackCase NameMapper = func(raw string) string { + // SnakeCase converts to format SNAKE_CASE. + SnakeCase NameMapper = func(raw string) string { newstr := make([]rune, 0, len(raw)) for i, chr := range raw { if isUpper := 'A' <= chr && chr <= 'Z'; isUpper { @@ -215,8 +215,8 @@ func tooDeep(path []reflect.Type, t reflect.Type) bool { return n >= 2 } -// setSliceWithProperType sets proper values to slice based on its type. -func setSliceWithProperType(key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { +// decodeSlice sets proper values to slice based on its type. +func decodeSlice(key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { elemType := field.Type().Elem() if !isSliceElemType(elemType) { return fmt.Errorf("unsupported type '[]%s'", elemType) @@ -228,7 +228,7 @@ func setSliceWithProperType(key *Key, field reflect.Value, delim string, allowSh // elements read back as Marshal wrote them. An empty value, kept // with KeepEmptyShadowValues, is an empty element, unless it is the // only one: an empty slice is written as one empty value. - vals := key.ValueWithShadows() + vals := key.Values() if len(vals) == 1 && vals[0] == "" { vals = nil } @@ -237,7 +237,7 @@ func setSliceWithProperType(key *Key, field reflect.Value, delim string, allowSh strs = append(strs, "") continue } - strs = append(strs, splitList(key.transformValue(v), delim)...) + strs = append(strs, splitList(key.expandValue(v), delim)...) } } else { strs = key.Strings(delim) @@ -271,12 +271,12 @@ func wrapStrictError(err error, isStrict bool) error { return nil } -// setWithProperType sets proper value to field based on its type, +// decodeValue sets proper value to field based on its type, // but it does not return error for failing parsing, // because we want to use default value that is already assigned to struct. -func setWithProperType(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { +func decodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow, isStrict bool) error { if t.Kind() == reflect.Slice { - return setSliceWithProperType(key, field, delim, allowShadow, isStrict) + return decodeSlice(key, field, delim, allowShadow, isStrict) } vt := t @@ -318,10 +318,10 @@ func parseTagOptions(tag string) (rawName string, omitEmpty bool, allowShadow bo return rawName, omitEmpty, allowShadow, allowNonUnique, extends } -// mapToField maps the given value to the matching field of the given section. +// decodeStruct maps the given value to the matching field of the given section. // The sectionIndex is the index (if non unique sections are enabled) to which the value should be added. // The path holds the struct types being mapped, see tooDeep. -func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, sectionName string, path []reflect.Type) error { +func (s *Section) decodeStruct(val reflect.Value, isStrict bool, sectionIndex int, sectionName string, path []reflect.Type) error { if val.Kind() == reflect.Pointer { val = val.Elem() } @@ -365,7 +365,7 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, fieldSection = secs[sectionIndex] } } - if err := fieldSection.mapToField(field, isStrict, sectionIndex, sectionName, path); err != nil { + if err := fieldSection.decodeStruct(field, isStrict, sectionIndex, sectionName, path); err != nil { return fmt.Errorf("map to field %q: %v", fieldName, err) } continue @@ -382,7 +382,7 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, if isStructPtr && field.IsNil() { field.Set(reflect.New(structType)) } - if err = secs[sectionIndex].mapToField(field, isStrict, sectionIndex, fieldName, path); err != nil { + if err = secs[sectionIndex].decodeStruct(field, isStrict, sectionIndex, fieldName, path); err != nil { return fmt.Errorf("map to field %q: %v", fieldName, err) } continue @@ -391,7 +391,7 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, // Map non-unique sections if allowNonUnique && tpField.Type.Kind() == reflect.Slice { - newField, err := s.mapToSlice(fieldName, field, isStrict, path) + newField, err := s.decodeSections(fieldName, field, isStrict, path) if err != nil { return fmt.Errorf("map to slice %q: %v", fieldName, err) } @@ -402,7 +402,7 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, if key, err := s.GetKey(fieldName); err == nil { delim := parseDelim(tpField.Tag.Get("delim")) - if err = setWithProperType(tpField.Type, key, field, delim, allowShadow, isStrict); err != nil { + if err = decodeValue(tpField.Type, key, field, delim, allowShadow, isStrict); err != nil { return fmt.Errorf("set field %q: %v", fieldName, err) } } @@ -410,9 +410,9 @@ func (s *Section) mapToField(val reflect.Value, isStrict bool, sectionIndex int, return nil } -// mapToSlice maps all sections with the same name and returns the new value. +// decodeSections maps all sections with the same name and returns the new value. // The type of the Value must be a slice of structs or of pointers to structs. -func (s *Section) mapToSlice(secName string, val reflect.Value, isStrict bool, path []reflect.Type) (reflect.Value, error) { +func (s *Section) decodeSections(secName string, val reflect.Value, isStrict bool, path []reflect.Type) (reflect.Value, error) { elemType := val.Type().Elem() isPtr := elemType.Kind() == reflect.Pointer structType := elemType @@ -431,7 +431,7 @@ func (s *Section) mapToSlice(secName string, val reflect.Value, isStrict bool, p for i, sec := range secs { elem := reflect.New(structType) - if err = sec.mapToField(elem, isStrict, i, sec.name, path); err != nil { + if err = sec.decodeStruct(elem, isStrict, i, sec.name, path); err != nil { return reflect.Value{}, fmt.Errorf("map to field from section %q: %v", secName, err) } if !isPtr { @@ -443,7 +443,7 @@ func (s *Section) mapToSlice(secName string, val reflect.Value, isStrict bool, p } // mapTo maps a section to object v. -func (s *Section) mapTo(v interface{}, isStrict bool) error { +func (s *Section) mapTo(v any, isStrict bool) error { val := reflect.ValueOf(v) if val.Kind() != reflect.Pointer || val.IsNil() { return errors.New("not a pointer to a struct") @@ -466,7 +466,7 @@ func (s *Section) mapTo(v interface{}, isStrict bool) error { } if typ.Kind() == reflect.Slice { - newField, err := s.mapToSlice(s.name, val, isStrict, nil) + newField, err := s.decodeSections(s.name, val, isStrict, nil) if err != nil { return err } @@ -475,68 +475,54 @@ func (s *Section) mapTo(v interface{}, isStrict bool) error { return nil } - return s.mapToField(val, isStrict, 0, s.name, nil) + return s.decodeStruct(val, isStrict, 0, s.name, nil) } // MapTo maps section to given struct. -func (s *Section) MapTo(v interface{}) error { +func (s *Section) MapTo(v any) error { return s.mapTo(v, false) } // StrictMapTo maps section to given struct in strict mode, // which returns all possible error including value parsing error. -func (s *Section) StrictMapTo(v interface{}) error { +func (s *Section) StrictMapTo(v any) error { return s.mapTo(v, true) } // MapTo maps file to given struct. -func (f *File) MapTo(v interface{}) error { +func (f *File) MapTo(v any) error { return f.Section("").MapTo(v) } // StrictMapTo maps file to given struct in strict mode, // which returns all possible error including value parsing error. -func (f *File) StrictMapTo(v interface{}) error { +func (f *File) StrictMapTo(v any) error { return f.Section("").StrictMapTo(v) } -// MapToWithMapper loads a data source with opts and maps it to the given -// struct with the name mapper. -func MapToWithMapper(v any, mapper NameMapper, source any, opts ...LoadOption) error { +// MapTo loads a data source with opts and maps it to the given struct. +// NameMapping sets how field names map to section and key names. +func MapTo(v, source any, opts ...LoadOption) error { cfg, err := Load(source, opts...) if err != nil { return err } - cfg.NameMapper = mapper return cfg.MapTo(v) } -// StrictMapToWithMapper loads a data source with opts and maps it to the -// given struct with the name mapper in strict mode, which returns all -// possible error including value parsing error. -func StrictMapToWithMapper(v any, mapper NameMapper, source any, opts ...LoadOption) error { +// StrictMapTo loads a data source with opts and maps it to the given struct +// in strict mode, which returns all possible error including value parsing +// error. +func StrictMapTo(v, source any, opts ...LoadOption) error { cfg, err := Load(source, opts...) if err != nil { return err } - cfg.NameMapper = mapper return cfg.StrictMapTo(v) } -// MapTo loads a data source with opts and maps it to the given struct. -func MapTo(v, source any, opts ...LoadOption) error { - return MapToWithMapper(v, nil, source, opts...) -} - -// StrictMapTo loads a data source with opts and maps it to the given struct -// in strict mode, which returns all possible error including value parsing -// error. -func StrictMapTo(v, source any, opts ...LoadOption) error { - return StrictMapToWithMapper(v, nil, source, opts...) -} - -// reflectSliceWithProperType does the opposite thing as setSliceWithProperType. -func reflectSliceWithProperType(key *Key, field reflect.Value, delim string, allowShadow bool) error { +// encodeSlice does the opposite thing as decodeSlice. +func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) error { if field.Len() == 0 { return nil } @@ -593,14 +579,14 @@ func escapeSliceElem(s, delim string) string { return b.String() } -// reflectWithProperType does the opposite thing as setWithProperType. -func reflectWithProperType(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow bool) error { +// encodeValue does the opposite thing as decodeValue. +func encodeValue(t reflect.Type, key *Key, field reflect.Value, delim string, allowShadow bool) error { switch { case t.Kind() == reflect.Slice: - return reflectSliceWithProperType(key, field, delim, allowShadow) + return encodeSlice(key, field, delim, allowShadow) case t.Kind() == reflect.Pointer: if !field.IsNil() { - return reflectWithProperType(t.Elem(), key, field.Elem(), delim, allowShadow) + return encodeValue(t.Elem(), key, field.Elem(), delim, allowShadow) } case isValueType(t): key.SetValue(formatValue(field)) @@ -638,9 +624,9 @@ type StructReflector interface { ReflectINIStruct(*File) error } -// reflectFrom reflects the section from val. The path holds the struct types +// encodeStruct reflects the section from val. The path holds the struct types // being reflected from, see tooDeep. -func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { +func (s *Section) encodeStruct(val reflect.Value, path []reflect.Type) error { if val.Kind() == reflect.Pointer { val = val.Elem() } @@ -693,7 +679,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { if tooDeep(path, structType) { continue } - if err := s.reflectFrom(field, path); err != nil { + if err := s.encodeStruct(field, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) } continue @@ -713,7 +699,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { // Add comment from comment tag s.f.defaultComment(&sec.Comment, tpField.Tag.Get("comment")) - if err = sec.reflectFrom(field, path); err != nil { + if err = sec.encodeStruct(field, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) } continue @@ -748,7 +734,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { // Add comment from comment tag s.f.defaultComment(&sec.Comment, tpField.Tag.Get("comment")) - if err := sec.reflectFrom(elem, path); err != nil { + if err := sec.encodeStruct(elem, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) } } @@ -765,7 +751,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { s.f.defaultComment(&key.Comment, tpField.Tag.Get("comment")) delim := parseDelim(tpField.Tag.Get("delim")) - if err = reflectWithProperType(tpField.Type, key, field, delim, allowShadow); err != nil { + if err = encodeValue(tpField.Type, key, field, delim, allowShadow); err != nil { return fmt.Errorf("reflect field %q: %v", fieldName, err) } @@ -774,7 +760,7 @@ func (s *Section) reflectFrom(val reflect.Value, path []reflect.Type) error { } // ReflectFrom reflects section from given struct. It overwrites existing ones. -func (s *Section) ReflectFrom(v interface{}) error { +func (s *Section) ReflectFrom(v any) error { typ := reflect.TypeOf(v) val := reflect.ValueOf(v) if typ == nil || (typ.Kind() == reflect.Pointer && val.IsNil()) { @@ -798,7 +784,7 @@ func (s *Section) ReflectFrom(v interface{}) error { if err != nil { return err } - return sec.reflectFrom(val.Elem(), nil) + return sec.encodeStruct(val.Elem(), nil) } for i := 0; i < val.Len(); i++ { @@ -812,7 +798,7 @@ func (s *Section) ReflectFrom(v interface{}) error { return err } - err = sec.reflectFrom(elem, nil) + err = sec.encodeStruct(elem, nil) if err != nil { return fmt.Errorf("reflect from %dth field: %v", i, err) } @@ -824,25 +810,14 @@ func (s *Section) ReflectFrom(v interface{}) error { if typ.Kind() != reflect.Pointer || typ.Elem().Kind() != reflect.Struct { return errors.New("not a pointer to a struct") } - return s.reflectFrom(val.Elem(), nil) + return s.encodeStruct(val.Elem(), nil) } // ReflectFrom reflects file from given struct. -func (f *File) ReflectFrom(v interface{}) error { +func (f *File) ReflectFrom(v any) error { return f.Section("").ReflectFrom(v) } -// ReflectFromWithMapper reflects data sources from given struct with name mapper. -func ReflectFromWithMapper(cfg *File, v interface{}, mapper NameMapper) error { - cfg.NameMapper = mapper - return cfg.ReflectFrom(v) -} - -// ReflectFrom reflects data sources from given struct. -func ReflectFrom(cfg *File, v interface{}) error { - return ReflectFromWithMapper(cfg, v, nil) -} - // Unmarshal parses INI data with opts and maps it to the struct v points to, // as File.MapTo does. It reads what Marshal writes: when v has allowshadow // fields it loads with AllowShadows, AllowDuplicateShadowValues and diff --git a/struct_test.go b/struct_test.go index d39dc45..7eafad2 100644 --- a/struct_test.go +++ b/struct_test.go @@ -524,7 +524,7 @@ func Test_ReflectFromStruct(t *testing.T) { []int{}, }} cfg := Empty() - assert.NoError(t, ReflectFrom(cfg, a)) + assert.NoError(t, cfg.ReflectFrom(a)) var buf bytes.Buffer _, err = cfg.WriteTo(&buf) @@ -554,7 +554,7 @@ None = ) t.Run("reflect from non-point struct", func(t *testing.T) { - assert.Error(t, ReflectFrom(cfg, Author{})) + assert.Error(t, cfg.ReflectFrom(Author{})) }) t.Run("reflect from struct with omitempty", func(t *testing.T) { @@ -579,7 +579,7 @@ None = NotEmpty: 9, } - assert.NoError(t, ReflectFrom(cfg, special)) + assert.NoError(t, cfg.ReflectFrom(special)) var buf bytes.Buffer _, err = cfg.WriteTo(&buf) @@ -651,7 +651,7 @@ func Test_ReflectFromStructNonUniqueSections(t *testing.T) { cfg := Empty(AllowNonUniqueSections()) - assert.NoError(t, ReflectFrom(cfg, nonUnique)) + assert.NoError(t, cfg.ReflectFrom(nonUnique)) var buf bytes.Buffer _, err := cfg.WriteTo(&buf) @@ -785,7 +785,7 @@ path = /tmp/gpm-profiles/test1.profile None: []int{}, } - assert.NoError(t, ReflectFrom(cfg, shadow)) + assert.NoError(t, cfg.ReflectFrom(shadow)) var buf bytes.Buffer _, err := cfg.WriteTo(&buf) @@ -823,13 +823,13 @@ type testMapper struct { func Test_NameGetter(t *testing.T) { t.Run("test name mappers", func(t *testing.T) { - assert.NoError(t, MapToWithMapper(&testMapper{}, TitleUnderscore, []byte("packag_name=ini"))) + assert.NoError(t, MapTo(&testMapper{}, []byte("packag_name=ini"), NameMapping(TitleUnderscore))) cfg, err := Load([]byte("PACKAGE_NAME=ini")) require.NoError(t, err) require.NotNil(t, cfg) - cfg.NameMapper = SnackCase + cfg.NameMapper = SnakeCase tg := new(testMapper) assert.NoError(t, cfg.MapTo(tg)) assert.Equal(t, "ini", tg.PackageName) @@ -926,7 +926,7 @@ func TestMarshalUnmarshal(t *testing.T) { assert.Equal(t, in, out) err = Unmarshal([]byte("name"), &out) - assert.True(t, IsErrDelimiterNotFound(err)) + assert.ErrorIs(t, err, ErrDelimiterNotFound) } func TestMarshal_ShadowsAndNonUniqueSections(t *testing.T) { @@ -1261,14 +1261,14 @@ func TestReflectFrom_AllowShadow(t *testing.T) { // The comment of a key goes before its last line, as the lexer reads it. assert.Equal(t, "path = a\n; search paths\npath = b\n", string(data)) - assert.Error(t, ReflectFrom(Empty(), &cfg{Paths: []string{"a", "b"}}), "values must not be dropped silently") + assert.Error(t, Empty().ReflectFrom(&cfg{Paths: []string{"a", "b"}}), "values must not be dropped silently") f := Empty() - require.NoError(t, ReflectFrom(f, &cfg{Paths: []string{"a"}})) + require.NoError(t, f.ReflectFrom(&cfg{Paths: []string{"a"}})) assert.Equal(t, "a", f.Section("").Key("path").String()) } func TestReflectFrom_InsensitiveDefaultSection(t *testing.T) { - for _, opt := range []LoadOption{Insensitive(), InsensitiveSections()} { + for _, opt := range []LoadOption{CaseInsensitive(), CaseInsensitiveSections()} { f, err := Load([]byte("a=1\n[x]\nb=2\n"), opt, AllowNonUniqueSections()) require.NoError(t, err) require.NoError(t, f.ReflectFrom(&struct{ C int }{3})) From f00940baa2dde1d2300e03f1c095b70e351dee79 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 00:10:16 +0000 Subject: [PATCH 20/41] Keep v1's list and time-layout accessors convenient List returns the parsed elements with the zero value for one that doesn't parse, as v1's Ints did, so it needs no error handling. ValidList and StrictList bring back ValidInts and StrictInts, for any Scalar type. TimesFormat, ValidTimesFormat, StrictTimesFormat, InTimeFormat and RangeTimeFormat stay on Key, as a layout can't be a type parameter. Range also takes time.Time, replacing RangeTime, and In compares times as instants with Equal, as v1 did, instead of ==, which failed for the same instant in another zone. --- README.md | 5 ++- api_fuzz_test.go | 15 ++++---- generic.go | 94 ++++++++++++++++++++++++++++++++++++++---------- key.go | 43 ++++++++++++++++++++++ key_test.go | 72 +++++++++++++++++++------------------ 5 files changed, 168 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 6b5e553..088201c 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,9 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `IsErrDelimiterNotFound(err)`, `IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)`, `errors.Is(err, ini.ErrEmptyKeyName)` | | `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues` | | `Key.ValueWithShadows`, `Key.IsBooleanType`, `Section.Haskey` | `Values`, `IsBoolean`, `HasKey` | -| `k.Ints(",")`, `k.StrictInts(",")`, `k.ValidInts(",")` and the same for the other types | `ini.List[int](k, ",")`, which returns an error for an invalid element; any `Scalar` type works, such as `int8`, `time.Duration` or a named string type | +| `k.Ints(",")`, `k.ValidInts(",")`, `k.StrictInts(",")` and the same for the other types | `ini.List[int](k, ",")`, `ini.ValidList[int](k, ",")`, `ini.StrictList[int](k, ",")`, with the same behavior; any `Scalar` type works, such as `int8`, `time.Duration` or a named string type | | `k.InInt(def, cands)`, `k.In(def, cands)` and the others | `ini.In(k, def, cands)` | -| `k.RangeInt(def, lo, hi)` and the others | `ini.Range(k, def, lo, hi)`; type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)`. Times compare with `Key.Time` | -| `TimesFormat`, `InTimeFormat`, `RangeTimeFormat` | `Strings` with `time.Parse`; `TimeFormat` and `MustTimeFormat` remain | +| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)` and the others | `ini.Range(k, def, lo, hi)`; type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)` | | `Reload` merged sources into the current contents; `Append` re-read every source | `Reload` replaces the contents with the sources (kept `*Section`/`*Key` pointers see new values; on error nothing changes); `Append` reads only the new sources; readers are buffered so they can be reloaded | New in v2: diff --git a/api_fuzz_test.go b/api_fuzz_test.go index fc31b38..a344d9a 100644 --- a/api_fuzz_test.go +++ b/api_fuzz_test.go @@ -123,12 +123,14 @@ func FuzzStructMapping(f *testing.F) { _ = k.Strings(delim) } _ = k.StringsWithShadows(",") - _, _ = List[int](k, ",") - _, _ = List[uint64](k, ",") - _, _ = List[float32](k, ",") - _, _ = List[bool](k, ",") - _, _ = List[time.Time](k, ",") - _, _ = List[time.Duration](k, ",") + _ = List[int](k, ",") + _ = ValidList[uint64](k, ",") + _ = List[float32](k, ",") + _ = ValidList[bool](k, ",") + _, _ = StrictList[time.Time](k, ",") + _, _ = StrictList[time.Duration](k, ",") + _ = k.TimesFormat("2006-01-02", ",") + _ = k.RangeTimeFormat("2006-01-02", time.Time{}, time.Time{}, time.Now()) _, _ = k.Duration() _, _ = k.Time() _ = k.MustInt(1) @@ -136,6 +138,7 @@ func FuzzStructMapping(f *testing.F) { _ = In(k, int8(1), []int8{1, 2}) _ = Range(k, 1.5, -1e9, 1e9) _ = Range(k, uint16(1), 0, 100) + _ = Range(k, time.Time{}, time.Time{}, time.Now()) _ = k.Values() _ = k.NestedValues() } diff --git a/generic.go b/generic.go index 581306f..2fd592b 100644 --- a/generic.go +++ b/generic.go @@ -24,7 +24,7 @@ import ( // Scalar is a type a value can be parsed as, as struct mapping parses // fields: strings, booleans, integers and floats of any size, named types of // those, time.Duration (as "1s" or nanoseconds) and time.Time (RFC 3339). -// A number that doesn't fit the type is an error. +// A number that doesn't fit the type doesn't parse. type Scalar interface { ~string | ~bool | ~int | ~int8 | ~int16 | ~int32 | ~int64 | @@ -32,6 +32,12 @@ type Scalar interface { ~float32 | ~float64 | time.Time } +// Ordered is a Scalar that Range can compare: any but booleans. +type Ordered interface { + Scalar + cmp.Ordered | time.Time +} + // parse parses s as a T. func parse[T Scalar](s string) (T, error) { v, err := parseValue(reflect.TypeFor[T](), s) @@ -42,27 +48,55 @@ func parse[T Scalar](s string) (T, error) { return v.Interface().(T), nil } -// List splits the value of k at delim, as Key.Strings does, and parses each -// element as a T. It returns an error for the first element that doesn't -// parse. -func List[T Scalar](k *Key, delim string) ([]T, error) { - strs := k.Strings(delim) - vals := make([]T, len(strs)) - for i, s := range strs { - v, err := parse[T](s) +// parseList parses strs with parse. An element that doesn't parse is an +// error when strict, the zero value when keepInvalid, and left out +// otherwise. +func parseList[T any](strs []string, parse func(string) (T, error), keepInvalid, strict bool) ([]T, error) { + vals := make([]T, 0, len(strs)) + for _, s := range strs { + v, err := parse(s) if err != nil { - return nil, err + if strict { + return nil, err + } + if !keepInvalid { + continue + } + var zero T + v = zero } - vals[i] = v + vals = append(vals, v) } return vals, nil } +// List splits the value of k at delim, as Key.Strings does, and parses each +// element as a T. An element that doesn't parse is the zero value, as in +// List[int] of "1, x, 3", which is [1 0 3]. +func List[T Scalar](k *Key, delim string) []T { + vals, _ := parseList(k.Strings(delim), parse[T], true, false) + return vals +} + +// ValidList is List without the elements that don't parse: ValidList[int] +// of "1, x, 3" is [1 3]. +func ValidList[T Scalar](k *Key, delim string) []T { + vals, _ := parseList(k.Strings(delim), parse[T], false, false) + return vals +} + +// StrictList is List that returns an error for the first element that +// doesn't parse. +func StrictList[T Scalar](k *Key, delim string) ([]T, error) { + return parseList(k.Strings(delim), parse[T], false, true) +} + // In returns the value of k parsed as a T when it is one of candidates, and // defaultVal otherwise. T comes from candidates, as in -// In(k, "info", []string{"debug", "info"}). +// In(k, "info", []string{"debug", "info"}). Times match when they are the +// same instant. func In[T Scalar](k *Key, defaultVal T, candidates []T) T { - if v, err := parse[T](k.String()); err == nil && slices.Contains(candidates, v) { + if v, err := parse[T](k.String()); err == nil && slices.ContainsFunc(candidates, func(c T) bool { return equal(v, c) }) { return v } return defaultVal @@ -71,12 +105,36 @@ func In[T Scalar](k *Key, defaultVal T, candidates []T) T { // Range returns the value of k parsed as a T when it is between lo and hi // inclusive, and defaultVal otherwise. Untyped constants make T int, so give // another type explicitly: Range(k, 0.5, 0, 1) or Range(k, uint8(0), 0, 9). -func Range[T interface { - Scalar - cmp.Ordered -}](k *Key, defaultVal, lo, hi T) T { - if v, err := parse[T](k.String()); err == nil && v >= lo && v <= hi { +func Range[T Ordered](k *Key, defaultVal, lo, hi T) T { + if v, err := parse[T](k.String()); err == nil && !less(v, lo) && !less(hi, v) { return v } return defaultVal } + +// equal compares times as instants and other values with ==. +func equal[T Scalar](a, b T) bool { + if t, ok := any(a).(time.Time); ok { + return t.Equal(any(b).(time.Time)) + } + return a == b +} + +// less reports whether a < b. Times compare as instants; the rest by kind, +// as T can't use < while time.Time is in its type set. +func less[T Ordered](a, b T) bool { + if t, ok := any(a).(time.Time); ok { + return t.Before(any(b).(time.Time)) + } + va, vb := reflect.ValueOf(a), reflect.ValueOf(b) + switch va.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return va.Int() < vb.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return va.Uint() < vb.Uint() + case reflect.Float32, reflect.Float64: + return va.Float() < vb.Float() + default: + return va.String() < vb.String() + } +} diff --git a/key.go b/key.go index 8a5756e..c978b42 100644 --- a/key.go +++ b/key.go @@ -17,6 +17,7 @@ package ini import ( "errors" "fmt" + "slices" "strconv" "strings" "time" @@ -297,6 +298,48 @@ func (k *Key) TimeFormat(format string) (time.Time, error) { return time.Parse(format, k.String()) } +// TimesFormat parses the elements of the value, split at delim, with the +// given layout. An element that doesn't parse is the zero time. For RFC 3339 +// times, use List[time.Time]. +func (k *Key) TimesFormat(format, delim string) []time.Time { + vals, _ := parseList(k.Strings(delim), timeParser(format), true, false) + return vals +} + +// ValidTimesFormat is TimesFormat without the elements that don't parse. +func (k *Key) ValidTimesFormat(format, delim string) []time.Time { + vals, _ := parseList(k.Strings(delim), timeParser(format), false, false) + return vals +} + +// StrictTimesFormat is TimesFormat that returns an error for the first +// element that doesn't parse. +func (k *Key) StrictTimesFormat(format, delim string) ([]time.Time, error) { + return parseList(k.Strings(delim), timeParser(format), false, true) +} + +// InTimeFormat returns the value parsed with the given layout when it is the +// same instant as one of candidates, and defaultVal otherwise. +func (k *Key) InTimeFormat(format string, defaultVal time.Time, candidates []time.Time) time.Time { + if v, err := k.TimeFormat(format); err == nil && slices.ContainsFunc(candidates, v.Equal) { + return v + } + return defaultVal +} + +// RangeTimeFormat returns the value parsed with the given layout when it is +// between min and max inclusive, and defaultVal otherwise. +func (k *Key) RangeTimeFormat(format string, defaultVal, min, max time.Time) time.Time { + if v, err := k.TimeFormat(format); err == nil && !v.Before(min) && !v.After(max) { + return v + } + return defaultVal +} + +func timeParser(format string) func(string) (time.Time, error) { + return func(s string) (time.Time, error) { return time.Parse(format, s) } +} + // Time parses with RFC3339 format and returns time.Time type value. func (k *Key) Time() (time.Time, error) { return k.TimeFormat(time.RFC3339) diff --git a/key_test.go b/key_test.go index d7bcac3..957ef53 100644 --- a/key_test.go +++ b/key_test.go @@ -266,6 +266,7 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, 165*time.Minute, In(sec.Key("DURATION"), 0, []time.Duration{time.Hour, 165 * time.Minute})) ti := time.Date(2015, 1, 1, 20, 17, 5, 0, time.UTC) assert.Equal(t, ti, In(sec.Key("TIME"), time.Time{}, []time.Time{ti})) + assert.Equal(t, ti, In(sec.Key("TIME"), time.Time{}, []time.Time{ti.In(time.FixedZone("x", 3600))}), "same instant, other zone") t.Run("default when not a candidate or not parsable", func(t *testing.T) { assert.Equal(t, "x", In(sec.Key("STRING"), "x", []string{"arr"})) @@ -281,6 +282,9 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, 10, Range(sec.Key("INT"), 0, 10, 20)) assert.Equal(t, uint16(0x3000), Range(sec.Key("HEX_NUMBER"), uint16(0), 0, 0xffff)) assert.Equal(t, 165*time.Minute, Range(sec.Key("DURATION"), 0, time.Hour, 3*time.Hour)) + ti := time.Date(2015, 1, 1, 20, 17, 5, 0, time.UTC) + assert.Equal(t, ti, Range(sec.Key("TIME"), time.Time{}, ti, ti.Add(time.Hour))) + assert.Equal(t, time.Time{}, Range(sec.Key("TIME"), time.Time{}, ti.Add(time.Nanosecond), ti.Add(time.Hour))) t.Run("default when out of range or not parsable", func(t *testing.T) { assert.Equal(t, 5.0, Range(sec.Key("FLOAT64"), 5.0, 0, 1)) @@ -295,44 +299,44 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, "en,zh,de", strings.Join(sec.Key("STRINGS").Strings(","), ",")) assert.Equal(t, 0, len(sec.Key("STRINGS_404").Strings(","))) - floats, err := List[float64](sec.Key("FLOAT64S"), ",") - require.NoError(t, err) - assert.Equal(t, []float64{1.1, 2.2, 3.3}, floats) - - ints, err := List[int](sec.Key("INTS"), ",") - require.NoError(t, err) - assert.Equal(t, []int{1, 2, 3}, ints) - - uints, err := List[uint8](sec.Key("UINTS"), ",") - require.NoError(t, err) - assert.Equal(t, []uint8{1, 2, 3}, uints) - - bools, err := List[bool](sec.Key("BOOLS"), ",") - require.NoError(t, err) - assert.Equal(t, []bool{true, false, false}, bools) - + assert.Equal(t, []float64{1.1, 2.2, 3.3}, List[float64](sec.Key("FLOAT64S"), ",")) + assert.Equal(t, []int{1, 2, 3}, List[int](sec.Key("INTS"), ",")) + assert.Equal(t, []uint8{1, 2, 3}, List[uint8](sec.Key("UINTS"), ",")) + assert.Equal(t, []bool{true, false, false}, List[bool](sec.Key("BOOLS"), ",")) ti := time.Date(2015, 1, 1, 20, 17, 5, 0, time.UTC) - times, err := List[time.Time](sec.Key("TIMES"), ",") - require.NoError(t, err) - assert.Equal(t, []time.Time{ti, ti, ti}, times) - + assert.Equal(t, []time.Time{ti, ti, ti}, List[time.Time](sec.Key("TIMES"), ",")) type lang string - langs, err := List[lang](sec.Key("STRINGS"), ",") - require.NoError(t, err) - assert.Equal(t, []lang{"en", "zh", "de"}, langs) - - empty, err := List[int](sec.Key("INTS_404"), ",") + assert.Equal(t, []lang{"en", "zh", "de"}, List[lang](sec.Key("STRINGS"), ",")) + assert.Equal(t, []int{}, List[int](sec.Key("INTS_404"), ",")) + + // v1's Ints, ValidInts and StrictInts for any type. + k := f.Section("").Key("mixed_list") + k.SetValue("1, x, 300, 3") + assert.Equal(t, []int8{1, 0, 0, 3}, List[int8](k, ","), "invalid and out-of-range elements are zero") + assert.Equal(t, []int8{1, 3}, ValidList[int8](k, ",")) + vals, err := StrictList[int8](k, ",") + assert.Error(t, err) + assert.Nil(t, vals) + strict, err := StrictList[int](sec.Key("INTS"), ",") require.NoError(t, err) - assert.Empty(t, empty) - - t.Run("error on the first invalid element", func(t *testing.T) { - vals, err := List[int](sec.Key("STRINGS"), ",") - assert.Error(t, err) - assert.Nil(t, vals) + assert.Equal(t, []int{1, 2, 3}, strict) + }) - _, err = List[time.Time](sec.Key("INTS"), ",") - assert.Error(t, err) - }) + t.Run("get times with a layout", func(t *testing.T) { + k := f.Section("").Key("dates") + k.SetValue("2015-01-01, bad, 2016-02-03") + d1 := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC) + d2 := time.Date(2016, 2, 3, 0, 0, 0, 0, time.UTC) + assert.Equal(t, []time.Time{d1, {}, d2}, k.TimesFormat("2006-01-02", ",")) + assert.Equal(t, []time.Time{d1, d2}, k.ValidTimesFormat("2006-01-02", ",")) + _, err := k.StrictTimesFormat("2006-01-02", ",") + assert.Error(t, err) + + k.SetValue("2015-01-01") + assert.Equal(t, d1, k.InTimeFormat("2006-01-02", d2, []time.Time{d1.In(time.FixedZone("x", 3600))}), "same instant, other zone") + assert.Equal(t, d2, k.InTimeFormat("2006-01-02", d2, []time.Time{d2})) + assert.Equal(t, d1, k.RangeTimeFormat("2006-01-02", d2, d1, d2), "bounds are inclusive") + assert.Equal(t, d2, k.RangeTimeFormat("2006-01-02", d2, d1.Add(time.Nanosecond), d2)) }) t.Run("test string slice escapes", func(t *testing.T) { From 3d54e3f816f6b47dcac8fc4793ad450825a54393 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 00:48:04 +0000 Subject: [PATCH 21/41] Store sections and keys in order, and lock comments and mappers Section and Key comments become Comment() and SetComment(), which take the file's lock like every other accessor. NameMapper and ValueMapper move from embedded File fields to the NameMapping and ValueMapping load options, so they can't change while other goroutines read values. A File keeps its sections in one ordered slice plus a name index, in place of sectionList, sectionIndexes and the map. A Section keeps its keys the same way, in place of keyList. Shadow values are a []string on the key instead of a *Key each, which drops the isShadow flag. KeyValues now reports the value each key holds, including defaults stored by Must* methods; v1's KeysHash left those out. Loading 10,000 keys uses 7-15% less memory and is faster in every case. --- README.md | 8 +- file.go | 185 ++++++++++++++++++---------------------------- file_test.go | 12 ++- ini_test.go | 8 +- key.go | 93 +++++++++++------------ key_test.go | 20 +++-- lexer.go | 6 +- options.go | 7 ++ options_test.go | 14 ++-- parity_test.go | 67 ++++++++++++----- roundtrip_test.go | 9 +-- section.go | 85 ++++++++++++--------- section_test.go | 4 +- struct.go | 21 ++++-- struct_test.go | 13 ++-- 15 files changed, 276 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index 088201c..a2f76ef 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,11 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections` | `ini.IgnoreMissingFiles()`, `ini.FirstSourceOnly()`, `ini.RawSections(names...)` | | `File.BlockMode = false` | `ini.NoLocking()` | | `MapToWithMapper(v, m, src)`, `StrictMapToWithMapper` | `MapTo(v, src, ini.NameMapping(m))`, `StrictMapTo(v, src, ini.NameMapping(m))` | -| `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`; set `f.NameMapper = m` or load with `ini.NameMapping(m)` | +| `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with the file loaded with `ini.NameMapping(m)` | +| `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | +| `s.Comment`, `k.Comment` fields | `Comment()` and `SetComment(c)` methods | | `IsErrDelimiterNotFound(err)`, `IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)`, `errors.Is(err, ini.ErrEmptyKeyName)` | -| `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues` | +| `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues`; `KeyValues` includes the defaults `Must*` methods stored, which v1's `KeysHash` left out | | `Key.ValueWithShadows`, `Key.IsBooleanType`, `Section.Haskey` | `Values`, `IsBoolean`, `HasKey` | | `k.Ints(",")`, `k.ValidInts(",")`, `k.StrictInts(",")` and the same for the other types | `ini.List[int](k, ",")`, `ini.ValidList[int](k, ",")`, `ini.StrictList[int](k, ",")`, with the same behavior; any `Scalar` type works, such as `int8`, `time.Duration` or a named string type | | `k.InInt(def, cands)`, `k.In(def, cands)` and the others | `ini.In(k, def, cands)` | @@ -75,7 +77,7 @@ New in v2: - Nil pointer fields aren't written. - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - A `nonunique` field with no sections is left empty instead of failing. -- Unless loaded with `NoLocking()`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. `Comment` fields are plain fields: set them before sharing the `File`. +- Unless loaded with `NoLocking()`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. - `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated, empty or delimiter-containing elements and `nonunique` sections. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. diff --git a/file.go b/file.go index 23d542f..8a1d5b8 100644 --- a/file.go +++ b/file.go @@ -35,17 +35,10 @@ type File struct { locking bool lock sync.RWMutex - // To keep data in order. - sectionList []string - // To keep track of the index of a section with same name. - // This meta list is only used with non-unique section names are allowed. - sectionIndexes []int - - // Actual data is stored here. - sections map[string][]*Section - - NameMapper - ValueMapper + // sections are in the order they were added; byName indexes + // them, in the same order, by name. + sections []*Section + byName map[string][]*Section } // newFile initializes File object with given data sources. @@ -54,9 +47,8 @@ func newFile(dataSources []dataSource, opts []LoadOption) *File { return &File{ locking: !o.NoLocking, dataSources: dataSources, - sections: make(map[string][]*Section), + byName: make(map[string][]*Section), options: o, - NameMapper: o.NameMapper, } } @@ -126,19 +118,13 @@ func (f *File) addSection(name string) (*Section, error) { name = strings.ToLower(name) } - if _, ok := f.sections[name]; ok && !f.options.AllowNonUniqueSections { - return f.sections[name][0], nil + if secs := f.byName[name]; len(secs) > 0 && !f.options.AllowNonUniqueSections { + return secs[0], nil } - f.sectionList = append(f.sectionList, name) - - // NOTE: Append to indexes must happen before appending to sections, - // otherwise index will have off-by-one problem. - f.sectionIndexes = append(f.sectionIndexes, len(f.sections[name])) - sec := newSection(f, name) - f.sections[name] = append(f.sections[name], sec) - + f.sections = append(f.sections, sec) + f.byName[name] = append(f.byName[name], sec) return sec, nil } @@ -209,7 +195,7 @@ func (f *File) sectionsByName(name string) ([]*Section, error) { name = strings.ToLower(name) } - secs := f.sections[name] + secs := f.byName[name] if len(secs) == 0 { return nil, fmt.Errorf("section %q does not exist", name) } @@ -245,16 +231,9 @@ func (f *File) SectionWithIndex(name string, index int) *Section { // Sections returns a list of Section stored in the current instance. func (f *File) Sections() []*Section { - if f.locking { - f.lock.RLock() - defer f.lock.RUnlock() - } - - sections := make([]*Section, len(f.sectionList)) - for i, name := range f.sectionList { - sections[i] = f.sections[name][f.sectionIndexes[i]] - } - return sections + f.rlock() + defer f.runlock() + return slices.Clone(f.sections) } // ChildSections returns a list of child sections of given section name. @@ -266,9 +245,11 @@ func (f *File) ChildSections(name string) []*Section { func (f *File) SectionNames() []string { f.rlock() defer f.runlock() - list := make([]string, len(f.sectionList)) - copy(list, f.sectionList) - return list + names := make([]string, len(f.sections)) + for i, sec := range f.sections { + names[i] = sec.name + } + return names } // DeleteSection deletes a section or all sections with given name. @@ -302,41 +283,19 @@ func (f *File) DeleteSectionWithIndex(name string, index int) error { name = strings.ToLower(name) } - if f.locking { - f.lock.Lock() - defer f.lock.Unlock() + f.wlock() + defer f.wunlock() + secs := f.byName[name] + if index >= len(secs) { + return nil } - - // Count occurrences of the sections - occurrences := 0 - - sectionListCopy := make([]string, len(f.sectionList)) - copy(sectionListCopy, f.sectionList) - - for i, s := range sectionListCopy { - if s != name { - continue - } - - if occurrences == index { - if len(f.sections[name]) <= 1 { - delete(f.sections, name) // The last one in the map - } else { - f.sections[name] = append(f.sections[name][:index], f.sections[name][index+1:]...) - } - - // Fix section lists - f.sectionList = append(f.sectionList[:i], f.sectionList[i+1:]...) - f.sectionIndexes = append(f.sectionIndexes[:i], f.sectionIndexes[i+1:]...) - - } else if occurrences > index { - // Fix the indices of all following sections with this name. - f.sectionIndexes[i-1]-- - } - - occurrences++ + sec := secs[index] + if len(secs) == 1 { + delete(f.byName, name) + } else { + f.byName[name] = slices.Delete(secs, index, index+1) } - + f.sections = slices.DeleteFunc(f.sections, func(s *Section) bool { return s == sec }) return nil } @@ -360,44 +319,47 @@ func (f *File) Reload() error { f.rlock() sources := f.dataSources f.runlock() - next := &File{options: f.options, sections: make(map[string][]*Section)} + next := &File{options: f.options, byName: make(map[string][]*Section)} if err := next.load(sources); err != nil { return err } f.wlock() defer f.wunlock() - for name, secs := range next.sections { + for name, secs := range next.byName { + old := f.byName[name] for i, sec := range secs { - if old := f.sections[name]; i < len(old) { - // Keep the section and keys callers may hold. Their s and - // f stay, as readers use them to find the lock. - o := old[i] - for kname, k := range sec.keys { - if ok := o.keys[kname]; ok != nil { - ok.Comment, ok.value, ok.hashValue = k.Comment, k.value, k.hashValue - ok.isAutoIncrement, ok.isBooleanType = k.isAutoIncrement, k.isBooleanType - ok.shadows, ok.nestedValues = k.shadows, k.nestedValues - sec.keys[kname] = ok - } - } - o.Comment, o.keys, o.keyList = sec.Comment, sec.keys, sec.keyList - o.isRawSection, o.rawBody = sec.isRawSection, sec.rawBody - secs[i], sec = o, o - } else { + if i >= len(old) { sec.f = f + continue } - for _, k := range sec.keys { - if k.s != sec { - k.s = sec - } - for _, s := range k.shadows { - s.s = sec + // Keep the section and keys callers may hold, with the new + // contents. Their s and f stay, as readers use them to find + // the lock. + o := old[i] + for j, k := range sec.keys { + if ok := o.byName[k.name]; ok != nil { + ok.comment, ok.value, ok.shadows = k.comment, k.value, k.shadows + ok.isAutoIncrement, ok.isBooleanType = k.isAutoIncrement, k.isBooleanType + ok.nestedValues = k.nestedValues + sec.keys[j], sec.byName[k.name] = ok, ok + } else { + k.s = o } } + o.comment, o.keys, o.byName = sec.comment, sec.keys, sec.byName + o.isRawSection, o.rawBody = sec.isRawSection, sec.rawBody + secs[i] = o } } - f.sectionList, f.sectionIndexes, f.sections = next.sectionList, next.sectionIndexes, next.sections + // The n-th section of a name in next.sections is the n-th in + // next.byName, which now holds the sections kept. + seen := make(map[string]int) + for i, sec := range next.sections { + next.sections[i] = next.byName[sec.name][seen[sec.name]] + seen[sec.name]++ + } + f.sections, f.byName = next.sections, next.byName return nil } @@ -546,17 +508,15 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // Per-key buffers, reused for every key. var vals, lines []string var empty []bool - lastSectionIdx := len(f.sectionList) - 1 - for i, sname := range f.sectionList { - // Not SectionWithIndex: with CaseInsensitive, a "[DEFAULT]" header makes a - // section that lookups by name don't find. - sec := f.sections[sname][f.sectionIndexes[i]] + lastSectionIdx := len(f.sections) - 1 + for i, sec := range f.sections { + sname := sec.name if strings.Contains(sname, "\n") { return nil, fmt.Errorf("cannot write section %q: its name has a line break", sname) } - if len(sec.Comment) > 0 { + if len(sec.comment) > 0 { // Support multiline comments - lines := strings.Split(sec.Comment, lineBreak) + lines := strings.Split(sec.comment, lineBreak) for i := range lines { var err error if lines[i], err = formatComment(lines[i], false); err != nil { @@ -572,15 +532,15 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // v1 left out the header of the default section only when its name was // upper case, so a renamed default section keeps its header. It is // skipped like any default section when it has no keys. - emptyDefault := i == 0 && len(sec.keyList) == 0 && strings.EqualFold(sec.name, f.options.DefaultSection) + emptyDefault := i == 0 && len(sec.keys) == 0 && strings.EqualFold(sec.name, f.options.DefaultSection) // A comment needs the header to stay on the section. - if i > 0 || o.defaultHeader || len(sec.Comment) > 0 || (strings.ToUpper(sec.name) != f.options.DefaultSection && !emptyDefault) { + if i > 0 || o.defaultHeader || len(sec.comment) > 0 || (strings.ToUpper(sec.name) != f.options.DefaultSection && !emptyDefault) { if _, err := buf.WriteString("[" + sname + "]" + lineBreak); err != nil { return nil, err } } else { // Write nothing if default section is empty - if len(sec.keyList) == 0 { + if len(sec.keys) == 0 { continue } } @@ -609,7 +569,8 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // we need to take that into account in our calculation. alignLength := 0 if o.prettyFormat { - for _, kname := range sec.keyList { + for _, key := range sec.keys { + kname := key.name keyLength := len(kname) // First case will surround key by ` and second by """ if strings.Contains(kname, "\"") || strings.ContainsAny(kname, f.options.KeyValueDelimiters) { @@ -641,8 +602,8 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { return nil } - for _, kname := range sec.keyList { - key := sec.keys[kname] + for _, key := range sec.keys { + kname := key.name name, form := kname, kname switch { case key.isAutoIncrement: @@ -675,9 +636,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // the values before it in systemd units, and nested values // follow an empty value. vals = append(vals[:0], key.value) - for _, s := range key.shadows { - vals = append(vals, s.value) - } + vals = append(vals, key.shadows...) // regular are the values written on key lines. A boolean line // after a key of the same name makes it a boolean key with the @@ -744,12 +703,12 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { } swallows = f.options.AllowPythonMultilineValues || (f.options.AllowNestedValues && empty[i]) } - if len(key.Comment) > 0 { + if len(key.comment) > 0 { if err := writeIndented(""); err != nil { return nil, err } // Support multiline comments - comments := strings.Split(key.Comment, lineBreak) + comments := strings.Split(key.comment, lineBreak) for i := range comments { var err error if comments[i], err = formatComment(comments[i], true); err != nil { diff --git a/file_test.go b/file_test.go index 20f6a9e..9863a3f 100644 --- a/file_test.go +++ b/file_test.go @@ -409,9 +409,9 @@ func TestFile_WriteTo(t *testing.T) { require.NoError(t, err) require.NotNil(t, f) - f.Section("author").Comment = `Information about package author -# Bio can be written in multiple lines.` - f.Section("author").Key("NAME").Comment = "This is author name" + f.Section("author").SetComment(`Information about package author +# Bio can be written in multiple lines.`) + f.Section("author").Key("NAME").SetComment("This is author name") _, _ = f.Section("note").NewBooleanKey("boolean_key") _, _ = f.Section("note").NewKey("more", "notes") @@ -439,7 +439,7 @@ domain = mydomain.com `)) require.NoError(t, err) - f.Section("").Key("test").Comment = "Multiline\nComment" + f.Section("").Key("test").SetComment("Multiline\nComment") var buf bytes.Buffer _, err = f.WriteTo(&buf) @@ -689,12 +689,14 @@ func TestFile_Concurrency(t *testing.T) { run(func(int) { for _, sec := range f.Sections() { _ = sec.Body() + _ = sec.Comment() _ = sec.KeyNames() _ = sec.KeyValues() _ = sec.ChildSections() for _, k := range sec.Keys() { _ = k.String() _ = k.Value() + _ = k.Comment() _ = k.Values() _ = k.IsBoolean() _ = k.NestedValues() @@ -713,6 +715,8 @@ func TestFile_Concurrency(t *testing.T) { run(func(i int) { sec := f.Section("s") sec.Key("k").SetValue(strconv.Itoa(i)) + sec.SetComment("; c" + strconv.Itoa(i)) + sec.Key("k").SetComment("; c" + strconv.Itoa(i)) _ = sec.Key("k").AddShadow("z") _ = sec.Key("m").MustInt(3) _, _ = f.Section("new").NewKey("n", "v") diff --git a/ini_test.go b/ini_test.go index 6967669..1d1284d 100644 --- a/ini_test.go +++ b/ini_test.go @@ -410,9 +410,9 @@ key2=value2 #comment2`), AllowPythonMultilineValues()) require.NotNil(t, f) assert.Equal(t, `value`, f.Section("").Key("key1").String()) - assert.Equal(t, `;comment`, f.Section("").Key("key1").Comment) + assert.Equal(t, `;comment`, f.Section("").Key("key1").Comment()) assert.Equal(t, `value2`, f.Section("").Key("key2").String()) - assert.Equal(t, `#comment2`, f.Section("").Key("key2").Comment) + assert.Equal(t, `#comment2`, f.Section("").Key("key2").Comment()) }) }) @@ -963,9 +963,9 @@ key2=value2 #comment2`)) require.NotNil(t, f) assert.Equal(t, `value`, f.Section("").Key("key1").String()) - assert.Equal(t, `;comment`, f.Section("").Key("key1").Comment) + assert.Equal(t, `;comment`, f.Section("").Key("key1").Comment()) assert.Equal(t, `value2`, f.Section("").Key("key2").String()) - assert.Equal(t, `#comment2`, f.Section("").Key("key2").Comment) + assert.Equal(t, `#comment2`, f.Section("").Key("key2").Comment()) }) }) diff --git a/key.go b/key.go index c978b42..3d4e727 100644 --- a/key.go +++ b/key.go @@ -27,35 +27,29 @@ import ( // Key represents a key under a section. type Key struct { s *Section - Comment string name string + comment string value string - // hashValue is the value KeyValues reports. Must* defaults change value - // but not hashValue, as they never updated KeyValues in v1. - hashValue string + // shadows are the values after the first of a key that appears more + // than once, with AllowShadows. + shadows []string isAutoIncrement bool isBooleanType bool - isShadow bool - shadows []*Key - nestedValues []string } // newKey simply return a key object with given values. func newKey(s *Section, name, val string) *Key { return &Key{ - s: s, - name: name, - value: val, - hashValue: val, + s: s, + name: name, + value: val, } } func (k *Key) addShadow(val string) error { - if k.isShadow { - return errors.New("cannot add shadow to another shadow key") - } else if k.isAutoIncrement || k.isBooleanType { + if k.isAutoIncrement || k.isBooleanType { return errors.New("cannot add shadow to auto-increment or boolean key") } @@ -63,19 +57,12 @@ func (k *Key) addShadow(val string) error { // KeepEmptyShadowValues each reset the values before them, so they stay. keptEmpty := val == "" && k.s.f.options.KeepEmptyShadowValues if !k.s.f.options.AllowDuplicateShadowValues && !keptEmpty { - if k.value == val { + if k.value == val || slices.Contains(k.shadows, val) { return nil } - for i := range k.shadows { - if k.shadows[i].value == val { - return nil - } - } } - shadow := newKey(k.s, k.name, val) - shadow.isShadow = true - k.shadows = append(k.shadows, shadow) + k.shadows = append(k.shadows, val) return nil } @@ -116,6 +103,22 @@ func (k *Key) Name() string { return k.name } +// Comment returns the comment lines before the key, with their comment +// symbols. +func (k *Key) Comment() string { + k.s.f.rlock() + defer k.s.f.runlock() + return k.comment +} + +// SetComment sets the comment lines written before the key. Lines without a +// comment symbol get one. +func (k *Key) SetComment(comment string) { + k.s.f.wlock() + defer k.s.f.wunlock() + k.comment = comment +} + // Value returns raw value of key for performance purpose. func (k *Key) Value() string { f := k.s.f @@ -139,9 +142,9 @@ func (k *Key) Values() []string { if keepEmpty || k.value != "" { vals = append(vals, k.value) } - for _, s := range k.shadows { - if keepEmpty || s.value != "" { - vals = append(vals, s.value) + for _, v := range k.shadows { + if keepEmpty || v != "" { + vals = append(vals, v) } } return vals @@ -181,8 +184,8 @@ type expansion struct { // reference cycles and values that double at every level stop instead of // overflowing the stack or using up time and memory. func (k *Key) expand(val string, depth int, e *expansion) string { - if k.s.f.ValueMapper != nil { - val = k.s.f.ValueMapper(val) + if k.s.f.options.ValueMapper != nil { + val = k.s.f.options.ValueMapper(val) } // Fail-fast if no indicate char found for recursive value @@ -227,14 +230,6 @@ func (k *Key) String() string { return k.expandValue(k.Value()) } -// setDefault sets the value a Must method falls back to. Unlike SetValue, -// it leaves KeyValues as it was, as in v1. -func (k *Key) setDefault(v string) { - k.s.f.wlock() - defer k.s.f.wunlock() - k.value = v -} - // Validate accepts a validate function which can // return modifed result as key value. func (k *Key) Validate(fn func(string) string) string { @@ -349,7 +344,7 @@ func (k *Key) Time() (time.Time, error) { func (k *Key) MustString(defaultVal string) string { val := k.String() if len(val) == 0 { - k.setDefault(defaultVal) + k.SetValue(defaultVal) return defaultVal } return val @@ -360,7 +355,7 @@ func (k *Key) MustString(defaultVal string) string { func (k *Key) MustBool(defaultVal ...bool) bool { val, err := k.Bool() if len(defaultVal) > 0 && err != nil { - k.setDefault(strconv.FormatBool(defaultVal[0])) + k.SetValue(strconv.FormatBool(defaultVal[0])) return defaultVal[0] } return val @@ -371,7 +366,7 @@ func (k *Key) MustBool(defaultVal ...bool) bool { func (k *Key) MustFloat64(defaultVal ...float64) float64 { val, err := k.Float64() if len(defaultVal) > 0 && err != nil { - k.setDefault(strconv.FormatFloat(defaultVal[0], 'f', -1, 64)) + k.SetValue(strconv.FormatFloat(defaultVal[0], 'f', -1, 64)) return defaultVal[0] } return val @@ -382,7 +377,7 @@ func (k *Key) MustFloat64(defaultVal ...float64) float64 { func (k *Key) MustInt(defaultVal ...int) int { val, err := k.Int() if len(defaultVal) > 0 && err != nil { - k.setDefault(strconv.FormatInt(int64(defaultVal[0]), 10)) + k.SetValue(strconv.FormatInt(int64(defaultVal[0]), 10)) return defaultVal[0] } return val @@ -393,7 +388,7 @@ func (k *Key) MustInt(defaultVal ...int) int { func (k *Key) MustInt64(defaultVal ...int64) int64 { val, err := k.Int64() if len(defaultVal) > 0 && err != nil { - k.setDefault(strconv.FormatInt(defaultVal[0], 10)) + k.SetValue(strconv.FormatInt(defaultVal[0], 10)) return defaultVal[0] } return val @@ -404,7 +399,7 @@ func (k *Key) MustInt64(defaultVal ...int64) int64 { func (k *Key) MustUint(defaultVal ...uint) uint { val, err := k.Uint() if len(defaultVal) > 0 && err != nil { - k.setDefault(strconv.FormatUint(uint64(defaultVal[0]), 10)) + k.SetValue(strconv.FormatUint(uint64(defaultVal[0]), 10)) return defaultVal[0] } return val @@ -415,7 +410,7 @@ func (k *Key) MustUint(defaultVal ...uint) uint { func (k *Key) MustUint64(defaultVal ...uint64) uint64 { val, err := k.Uint64() if len(defaultVal) > 0 && err != nil { - k.setDefault(strconv.FormatUint(defaultVal[0], 10)) + k.SetValue(strconv.FormatUint(defaultVal[0], 10)) return defaultVal[0] } return val @@ -426,7 +421,7 @@ func (k *Key) MustUint64(defaultVal ...uint64) uint64 { func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { val, err := k.Duration() if len(defaultVal) > 0 && err != nil { - k.setDefault(defaultVal[0].String()) + k.SetValue(defaultVal[0].String()) return defaultVal[0] } return val @@ -437,7 +432,7 @@ func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { func (k *Key) MustTimeFormat(format string, defaultVal ...time.Time) time.Time { val, err := k.TimeFormat(format) if len(defaultVal) > 0 && err != nil { - k.setDefault(defaultVal[0].Format(format)) + k.SetValue(defaultVal[0].Format(format)) return defaultVal[0] } return val @@ -528,11 +523,7 @@ func (k *Key) StringsWithShadows(delim string) []string { // SetValue changes key value. func (k *Key) SetValue(v string) { - if k.s.f.locking { - k.s.f.lock.Lock() - defer k.s.f.lock.Unlock() - } - + k.s.f.wlock() + defer k.s.f.wunlock() k.value = v - k.hashValue = v } diff --git a/key_test.go b/key_test.go index 957ef53..c04742e 100644 --- a/key_test.go +++ b/key_test.go @@ -106,18 +106,18 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, "ini", sec.Key("NAME").Validate(func(in string) string { return in })) - assert.Equal(t, "; Package name", sec.Key("NAME").Comment) + assert.Equal(t, "; Package name", sec.Key("NAME").Comment()) assert.Equal(t, "gopkg.in/ini.v1", sec.Key("IMPORT_PATH").String()) t.Run("with ValueMapper", func(t *testing.T) { - f.ValueMapper = func(in string) string { + f, err := Load(fullConf, ValueMapping(func(in string) string { if in == "gopkg.in/%(NAME)s.%(VERSION)s" { return "github.com/go-ini/ini" } return in - } - t.Cleanup(func() { f.ValueMapper = nil }) - assert.Equal(t, "github.com/go-ini/ini", sec.Key("IMPORT_PATH").String()) + })) + require.NoError(t, err) + assert.Equal(t, "github.com/go-ini/ini", f.Section("").Key("IMPORT_PATH").String()) }) }) @@ -481,16 +481,14 @@ bar = %(missing)s f, err := Load([]byte(` ACCOUNT_ID = ${LFSD_R2_ACCOUNT_ID} ENDPOINT = https://%(ACCOUNT_ID)s.r2.cloudflarestorage.com -`)) - require.NoError(t, err) - require.NotNil(t, f) - - f.ValueMapper = func(in string) string { +`), ValueMapping(func(in string) string { if in == "${LFSD_R2_ACCOUNT_ID}" { return "abc123" } return in - } + })) + require.NoError(t, err) + require.NotNil(t, f) assert.Equal(t, "https://abc123.r2.cloudflarestorage.com", f.Section("").Key("ENDPOINT").String()) }) diff --git a/lexer.go b/lexer.go index ee8305e..ef09be4 100644 --- a/lexer.go +++ b/lexer.go @@ -404,7 +404,7 @@ func (f *File) parse(reader io.Reader) error { if section, err = f.addSection(tok.name); err != nil { return tok.errorf(err) } - section.Comment = tok.comment + section.comment = tok.comment count = 1 case tokRawBody: @@ -421,7 +421,7 @@ func (f *File) parse(reader io.Reader) error { if err != nil { return tok.errorf(err) } - key.Comment = tok.comment + key.comment = tok.comment case tokKey: name, isAutoIncr := tok.name, tok.name == "-" @@ -434,7 +434,7 @@ func (f *File) parse(reader io.Reader) error { return tok.errorf(err) } key.isAutoIncrement = isAutoIncr - key.Comment = tok.comment + key.comment = tok.comment lastRegularKey = key } } diff --git a/options.go b/options.go index b03f9d7..f39f872 100644 --- a/options.go +++ b/options.go @@ -55,6 +55,7 @@ type loadOptions struct { KeepEmptyShadowValues bool NoLocking bool NameMapper NameMapper + ValueMapper ValueMapper } func newLoadOptions(opts []LoadOption) loadOptions { @@ -81,6 +82,12 @@ func NameMapping(mapper NameMapper) LoadOption { return func(o *loadOptions) { o.NameMapper = mapper } } +// ValueMapping sets the ValueMapper applied to raw values, such as +// os.ExpandEnv, by Key.String and the accessors built on it. +func ValueMapping(mapper ValueMapper) LoadOption { + return func(o *loadOptions) { o.ValueMapper = mapper } +} + // DebugFunc is the type of function called to log parse events. type DebugFunc func(message string) diff --git a/options_test.go b/options_test.go index 9728cd4..17cdff0 100644 --- a/options_test.go +++ b/options_test.go @@ -69,9 +69,9 @@ func TestCommentSymbols(t *testing.T) { require.NoError(t, err) sec := f.Section("s") - assert.Equal(t, "// header\n// section", sec.Comment) + assert.Equal(t, "// header\n// section", sec.Comment()) assert.Equal(t, "http://x/#a;b", sec.Key("url").String()) - assert.Equal(t, "// note", sec.Key("url").Comment) + assert.Equal(t, "// note", sec.Key("url").Comment()) assert.Equal(t, "a//b", sec.Key("k").String()) t.Run("multi-byte symbols", func(t *testing.T) { @@ -81,9 +81,9 @@ func TestCommentSymbols(t *testing.T) { sec := f.Section("") assert.Equal(t, []string{"→ arrow", "k", "j", "u"}, sec.KeyNames()) - assert.Equal(t, "€ head", sec.Key("→ arrow").Comment) + assert.Equal(t, "€ head", sec.Key("→ arrow").Comment()) assert.Equal(t, "a", sec.Key("k").String()) - assert.Equal(t, "€c", sec.Key("k").Comment) + assert.Equal(t, "€c", sec.Key("k").Comment()) assert.Equal(t, "x€y", sec.Key("j").String()) assert.Equal(t, `\→`, sec.Key("u").String()) @@ -91,7 +91,7 @@ func TestCommentSymbols(t *testing.T) { require.NoError(t, err) _, err = f.Section("").NewKey("n", "1") require.NoError(t, err) - f.Section("").Key("n").Comment = "note" + f.Section("").Key("n").SetComment("note") var b bytes.Buffer _, err = f.WriteTo(&b) require.NoError(t, err) @@ -109,7 +109,7 @@ func TestCommentSymbols(t *testing.T) { assert.Equal(t, "k = v\n", b.String()) // A comment can't be written so that it reads back as a comment. - k.Comment = "set x = y" + k.SetComment("set x = y") _, err = f.WriteTo(&b) assert.Error(t, err) }) @@ -118,7 +118,7 @@ func TestCommentSymbols(t *testing.T) { f := Empty(CommentSymbols("#")) k, err := f.Section("").NewKey("k", "v;w") require.NoError(t, err) - k.Comment = "note" + k.SetComment("note") var b bytes.Buffer _, err = f.WriteTo(&b) diff --git a/parity_test.go b/parity_test.go index 9e47ecc..5ab55a1 100644 --- a/parity_test.go +++ b/parity_test.go @@ -94,10 +94,10 @@ func v2Options(o v1.LoadOptions) []LoadOption { return append(opts, KeyValueDelimiters(o.KeyValueDelimiters)) } -// dumpFile describes everything stored in a v1 or v2 *File. It reads the -// unexported fields both versions share by reflection, so the comparison -// covers state the public API doesn't expose, such as empty shadow values. -func dumpFile(f any) string { +// dumpV1 describes everything the parser stores in a v1 *File, by +// reflection, so the comparison covers state the public API doesn't +// expose, such as empty shadow values. dumpV2 gives the same for v2. +func dumpV1(f any) string { v := reflect.ValueOf(f).Elem() names, indexes, sections := v.FieldByName("sectionList"), v.FieldByName("sectionIndexes"), v.FieldByName("sections") @@ -105,33 +105,60 @@ func dumpFile(f any) string { for i := 0; i < names.Len(); i++ { name := names.Index(i) s := sections.MapIndex(name).Index(int(indexes.Index(i).Int())).Elem() - fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.FieldByName("name").String(), - s.FieldByName("Comment").String(), s.FieldByName("isRawSection").Bool(), s.FieldByName("rawBody").String()) + dumpSection(&b, s.FieldByName("name").String(), s.FieldByName("Comment").String(), + s.FieldByName("isRawSection").Bool(), s.FieldByName("rawBody").String()) keyList, keys := s.FieldByName("keyList"), s.FieldByName("keys") for j := 0; j < keyList.Len(); j++ { - dumpKey(&b, " ", keys.MapIndex(keyList.Index(j)).Elem()) + dumpV1Key(&b, " ", keys.MapIndex(keyList.Index(j)).Elem()) } } return b.String() } -func dumpKey(b *strings.Builder, indent string, k reflect.Value) { +func dumpV1Key(b *strings.Builder, indent string, k reflect.Value) { nested := k.FieldByName("nestedValues") - fmt.Fprintf(b, "%s%q=%q comment=%q auto=%v bool=%v shadow=%v nested=%d", - indent, k.FieldByName("name").String(), k.FieldByName("value").String(), k.FieldByName("Comment").String(), - k.FieldByName("isAutoIncrement").Bool(), k.FieldByName("isBooleanType").Bool(), k.FieldByName("isShadow").Bool(), nested.Len()) - for i := 0; i < nested.Len(); i++ { - fmt.Fprintf(b, " %q", nested.Index(i).String()) + vals := make([]string, nested.Len()) + for i := range vals { + vals[i] = nested.Index(i).String() } - b.WriteString("\n") + dumpKey(b, indent, k.FieldByName("name").String(), k.FieldByName("value").String(), k.FieldByName("Comment").String(), + k.FieldByName("isAutoIncrement").Bool(), k.FieldByName("isBooleanType").Bool(), k.FieldByName("isShadow").Bool(), vals) shadows := k.FieldByName("shadows") for i := 0; i < shadows.Len(); i++ { - dumpKey(b, indent+" ", shadows.Index(i).Elem()) + dumpV1Key(b, indent+" ", shadows.Index(i).Elem()) + } +} + +// dumpV2 describes f in the format of dumpV1. v1 stored each shadow as a +// key with only a name and value. +func dumpV2(f *File) string { + var b strings.Builder + for _, s := range f.sections { + dumpSection(&b, s.name, s.comment, s.isRawSection, s.rawBody) + for _, k := range s.keys { + dumpKey(&b, " ", k.name, k.value, k.comment, k.isAutoIncrement, k.isBooleanType, false, k.nestedValues) + for _, v := range k.shadows { + dumpKey(&b, " ", k.name, v, "", false, false, true, nil) + } + } } + return b.String() +} + +func dumpSection(b *strings.Builder, name, comment string, raw bool, body string) { + fmt.Fprintf(b, "[%q] comment=%q raw=%v body=%q\n", name, comment, raw, body) +} + +func dumpKey(b *strings.Builder, indent, name, value, comment string, auto, boolean, shadow bool, nested []string) { + fmt.Fprintf(b, "%s%q=%q comment=%q auto=%v bool=%v shadow=%v nested=%d", indent, name, value, comment, auto, boolean, shadow, len(nested)) + for _, v := range nested { + fmt.Fprintf(b, " %q", v) + } + b.WriteString("\n") } -// loadResult describes the outcome of loading: the stored File or the error. -func loadResult(load func() (any, error)) (out string) { +// loadResult describes the outcome of loading: the File dumped, or the error. +func loadResult[F any](load func() (F, error), dump func(F) string) (out string) { defer func() { if r := recover(); r != nil { out = fmt.Sprintf("panic: %v", r) @@ -146,7 +173,7 @@ func loadResult(load func() (any, error)) (out string) { if err != nil { return "error: " + err.Error() } - return dumpFile(f) + return dump(f) } // checkParity loads data twice, as two sources of one File, with v1 and v2. @@ -159,8 +186,8 @@ func checkParity(t *testing.T, data []byte, flags uint32) { return } - want := loadResult(func() (any, error) { return v1.LoadSources(opts, data, data) }) - got := loadResult(func() (any, error) { return LoadSources([]any{data, data}, v2Options(opts)...) }) + want := loadResult(func() (*v1.File, error) { return v1.LoadSources(opts, data, data) }, func(f *v1.File) string { return dumpV1(f) }) + got := loadResult(func() (*File, error) { return LoadSources([]any{data, data}, v2Options(opts)...) }, dumpV2) if want != got { t.Fatalf("v1 and v2 differ\ninput: %q\noptions: %+v\nv1:\n%s\nv2:\n%s", data, opts, want, got) } diff --git a/roundtrip_test.go b/roundtrip_test.go index fee7abf..f2e1005 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -36,14 +36,11 @@ func describeFile(f *File) string { for _, s := range f.Sections() { // A raw body gets a final line break when a section follows it; // Body trims it either way. - fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.Comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n")) + fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n")) for _, k := range s.Keys() { - vals := []string{k.value} - for _, s := range k.shadows { - vals = append(vals, s.value) - } + vals := append([]string{k.value}, k.shadows...) fmt.Fprintf(&b, " %q=%q comment=%q auto=%v bool=%v nested=%q\n", - k.name, vals, normalizeComment(k.Comment), k.isAutoIncrement, k.isBooleanType, k.nestedValues) + k.name, vals, normalizeComment(k.comment), k.isAutoIncrement, k.isBooleanType, k.nestedValues) } } return b.String() diff --git a/section.go b/section.go index 243f07e..aaab02b 100644 --- a/section.go +++ b/section.go @@ -17,16 +17,18 @@ package ini import ( "errors" "fmt" + "slices" "strings" ) // Section represents a config section. type Section struct { f *File - Comment string name string - keys map[string]*Key - keyList []string + comment string + // keys are in the order they were added; byName indexes them by name. + keys []*Key + byName map[string]*Key isRawSection bool rawBody string @@ -34,10 +36,10 @@ type Section struct { func newSection(f *File, name string) *Section { return &Section{ - f: f, - name: name, - keys: make(map[string]*Key), - keyList: make([]string, 0, 10), + f: f, + name: name, + keys: make([]*Key, 0, 10), + byName: make(map[string]*Key), } } @@ -46,6 +48,22 @@ func (s *Section) Name() string { return s.name } +// Comment returns the comment lines before the section header, with their +// comment symbols. +func (s *Section) Comment() string { + s.f.rlock() + defer s.f.runlock() + return s.comment +} + +// SetComment sets the comment lines written before the section header. +// Lines without a comment symbol get one. +func (s *Section) SetComment(comment string) { + s.f.wlock() + defer s.f.wunlock() + s.comment = comment +} + // Body returns rawBody of Section if the section was marked as unparseable. // It still follows the other rules of the INI format surrounding leading/trailing whitespace. func (s *Section) Body() string { @@ -79,21 +97,21 @@ func (s *Section) addKey(name, val string) (*Key, error) { name = strings.ToLower(name) } - if _, ok := s.keys[name]; ok { + if key := s.byName[name]; key != nil { if s.f.options.AllowShadows { - if err := s.keys[name].addShadow(val); err != nil { + if err := key.addShadow(val); err != nil { return nil, err } } else { - s.keys[name].value = val - s.keys[name].hashValue = val + key.value = val } - return s.keys[name], nil + return key, nil } - s.keyList = append(s.keyList, name) - s.keys[name] = newKey(s, name, val) - return s.keys[name], nil + key := newKey(s, name, val) + s.keys = append(s.keys, key) + s.byName[name] = key + return key, nil } // NewBooleanKey creates a new boolean type key to given section. @@ -122,7 +140,7 @@ func (s *Section) GetKey(name string) (*Key, error) { if s.f.options.Insensitive || s.f.options.InsensitiveKeys { name = strings.ToLower(name) } - key := s.keys[name] + key := s.byName[name] if s.f.locking { s.f.lock.RUnlock() } @@ -184,11 +202,7 @@ func (s *Section) Key(name string) *Key { func (s *Section) Keys() []*Key { s.f.rlock() defer s.f.runlock() - keys := make([]*Key, len(s.keyList)) - for i, name := range s.keyList { - keys[i] = s.keys[name] - } - return keys + return slices.Clone(s.keys) } // ParentKeys returns list of keys of parent section. @@ -215,12 +229,14 @@ func (s *Section) ParentKeys() []*Key { func (s *Section) KeyNames() []string { s.f.rlock() defer s.f.runlock() - list := make([]string, len(s.keyList)) - copy(list, s.keyList) - return list + names := make([]string, len(s.keys)) + for i, k := range s.keys { + names[i] = k.name + } + return names } -// KeyValues returns keys hash consisting of names and values. +// KeyValues returns the names and raw values of the section's keys. func (s *Section) KeyValues() map[string]string { if s.f.locking { s.f.lock.RLock() @@ -228,8 +244,8 @@ func (s *Section) KeyValues() map[string]string { } hash := make(map[string]string, len(s.keys)) - for name, k := range s.keys { - hash[name] = k.hashValue + for _, k := range s.keys { + hash[k.name] = k.value } return hash } @@ -244,12 +260,9 @@ func (s *Section) DeleteKey(name string) { defer s.f.lock.Unlock() } - for i, k := range s.keyList { - if k == name { - s.keyList = append(s.keyList[:i], s.keyList[i+1:]...) - delete(s.keys, name) - return - } + if key := s.byName[name]; key != nil { + delete(s.byName, name) + s.keys = slices.DeleteFunc(s.keys, func(k *Key) bool { return k == key }) } } @@ -261,9 +274,9 @@ func (s *Section) ChildSections() []*Section { defer s.f.runlock() prefix := s.name + s.f.options.ChildSectionDelimiter children := make([]*Section, 0, 3) - for i, name := range s.f.sectionList { - if strings.HasPrefix(name, prefix) { - children = append(children, s.f.sections[name][s.f.sectionIndexes[i]]) + for _, sec := range s.f.sections { + if strings.HasPrefix(sec.name, prefix) { + children = append(children, sec) } } return children diff --git a/section_test.go b/section_test.go index d7515ab..5c6de83 100644 --- a/section_test.go +++ b/section_test.go @@ -298,7 +298,7 @@ file = b.log } }) - t.Run("reports values as loaded or set, not Must* defaults", func(t *testing.T) { + t.Run("reports current values, Must* defaults included", func(t *testing.T) { f, err := Load([]byte("empty =\nset = 1\ngone = 2\n")) require.NoError(t, err) sec := f.Section("") @@ -307,7 +307,7 @@ file = b.log sec.Key("set").SetValue("3") sec.DeleteKey("gone") - assert.Equal(t, map[string]string{"empty": "", "set": "3"}, sec.KeyValues()) + assert.Equal(t, map[string]string{"empty": "fallback", "set": "3"}, sec.KeyValues()) }) } diff --git a/struct.go b/struct.go index 654d439..07660d3 100644 --- a/struct.go +++ b/struct.go @@ -63,8 +63,8 @@ func (s *Section) parseFieldName(raw, actual string) string { if len(actual) > 0 { return actual } - if s.f.NameMapper != nil { - return s.f.NameMapper(raw) + if s.f.options.NameMapper != nil { + return s.f.options.NameMapper(raw) } return raw } @@ -540,14 +540,19 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) vals[i] = escapeSliceElem(vals[i], delim) } if allowShadow { - keyWithShadows := newKey(key.s, key.name, vals[0]) + if len(vals) > 1 && !key.s.f.options.AllowShadows { + return errors.New("shadow key is not allowed") + } + // Shadows deduplicated as the file's options say, on a key that + // isn't boolean or auto-increment. + tmp := newKey(key.s, key.name, vals[0]) for _, val := range vals[1:] { - if err := keyWithShadows.AddShadow(val); err != nil { + if err := tmp.addShadow(val); err != nil { return err } } key.s.f.wlock() - key.value, key.hashValue, key.shadows = keyWithShadows.value, keyWithShadows.hashValue, keyWithShadows.shadows + key.value, key.shadows = tmp.value, tmp.shadows key.s.f.wunlock() return nil } @@ -697,7 +702,7 @@ func (s *Section) encodeStruct(val reflect.Value, path []reflect.Type) error { } // Add comment from comment tag - s.f.defaultComment(&sec.Comment, tpField.Tag.Get("comment")) + s.f.defaultComment(&sec.comment, tpField.Tag.Get("comment")) if err = sec.encodeStruct(field, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) @@ -732,7 +737,7 @@ func (s *Section) encodeStruct(val reflect.Value, path []reflect.Type) error { } // Add comment from comment tag - s.f.defaultComment(&sec.Comment, tpField.Tag.Get("comment")) + s.f.defaultComment(&sec.comment, tpField.Tag.Get("comment")) if err := sec.encodeStruct(elem, path); err != nil { return fmt.Errorf("reflect from field %q: %v", fieldName, err) @@ -748,7 +753,7 @@ func (s *Section) encodeStruct(val reflect.Value, path []reflect.Type) error { } // Add comment from comment tag - s.f.defaultComment(&key.Comment, tpField.Tag.Get("comment")) + s.f.defaultComment(&key.comment, tpField.Tag.Get("comment")) delim := parseDelim(tpField.Tag.Get("delim")) if err = encodeValue(tpField.Type, key, field, delim, allowShadow); err != nil { diff --git a/struct_test.go b/struct_test.go index 7eafad2..6f6f086 100644 --- a/struct_test.go +++ b/struct_test.go @@ -293,16 +293,14 @@ func Test_MapToStruct(t *testing.T) { }) t.Run("map to unsupported type", func(t *testing.T) { - f, err := Load([]byte(confDataStruct)) - require.NoError(t, err) - require.NotNil(t, f) - - f.NameMapper = func(raw string) string { + f, err := Load([]byte(confDataStruct), NameMapping(func(raw string) string { if raw == "Byte" { return "NAME" } return raw - } + })) + require.NoError(t, err) + require.NotNil(t, f) assert.Error(t, f.MapTo(&unsupport{})) assert.Error(t, f.MapTo(&unsupport2{})) assert.Error(t, f.MapTo(&unsupport4{})) @@ -825,11 +823,10 @@ func Test_NameGetter(t *testing.T) { t.Run("test name mappers", func(t *testing.T) { assert.NoError(t, MapTo(&testMapper{}, []byte("packag_name=ini"), NameMapping(TitleUnderscore))) - cfg, err := Load([]byte("PACKAGE_NAME=ini")) + cfg, err := Load([]byte("PACKAGE_NAME=ini"), NameMapping(SnakeCase)) require.NoError(t, err) require.NotNil(t, cfg) - cfg.NameMapper = SnakeCase tg := new(testMapper) assert.NoError(t, cfg.MapTo(tg)) assert.Equal(t, "ini", tg.PackageName) From cae5bf040c8948976eb71621134063e32dcd90a4 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 00:59:27 +0000 Subject: [PATCH 22/41] Accept a pointer to a struct pointer in ReflectFrom, as v1 did Gogs calls Section.ReflectFrom(&I18n) with I18n a package-level *i18nConf. v1 dereferenced the extra pointer; v2 had started returning "not a pointer to a struct". --- struct.go | 8 ++++++++ struct_test.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/struct.go b/struct.go index 07660d3..68d72e7 100644 --- a/struct.go +++ b/struct.go @@ -771,6 +771,14 @@ func (s *Section) ReflectFrom(v any) error { if typ == nil || (typ.Kind() == reflect.Pointer && val.IsNil()) { return errors.New("not a pointer to a struct") } + // v1 accepted a pointer to a pointer to a struct, as in ReflectFrom(&cfg) + // with cfg a *Config. + for typ.Kind() == reflect.Pointer && typ.Elem().Kind() == reflect.Pointer { + if val = val.Elem(); val.IsNil() { + return errors.New("not a pointer to a struct") + } + typ = typ.Elem() + } if !s.f.isDefaultSection(s.name) && s.f.options.AllowNonUniqueSections && (typ.Kind() == reflect.Slice || typ.Kind() == reflect.Pointer) { diff --git a/struct_test.go b/struct_test.go index 6f6f086..7c506c3 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1100,6 +1100,21 @@ func TestReflectFrom_StructSlice(t *testing.T) { assert.Error(t, err) } +// v1 accepted a pointer to a struct pointer, as Gogs passes &cfg with cfg a +// package-level *Config. +func TestReflectFrom_PointerToPointer(t *testing.T) { + type conf struct{ Lang string } + c := &conf{Lang: "en"} + for _, opts := range [][]LoadOption{nil, {AllowNonUniqueSections()}} { + f := Empty(opts...) + require.NoError(t, f.Section("i18n").ReflectFrom(&c)) + assert.Equal(t, "en", f.Section("i18n").Key("Lang").String()) + } + + var nilConf *conf + assert.Error(t, Empty().Section("i18n").ReflectFrom(&nilConf)) +} + func TestReflectFrom_NonUniqueNilAndNonStruct(t *testing.T) { type peer struct{ Key string } data, err := Marshal(struct { From e69b4b4ee364cdb737b56b2f0273db14881e4850 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 01:15:15 +0000 Subject: [PATCH 23/41] Name the formatting and quote options for what they do PrettyFormat(bool) becomes AlignDelimiters(bool) and PrettySection(bool) becomes BlankLineBetweenSections(bool), naming the output literally as encoding/json/jsontext does with SpaceAfterColon and Multiline. PrettyEqual() is removed: DelimiterSpaces(" ", " ") writes the same bytes. PreserveSurroundedQuote() becomes PreserveSurroundingQuotes(), fixing the grammar and keeping the verb jsontext uses in PreserveRawStrings. --- README.md | 5 +++-- file.go | 10 +++++----- ini_test.go | 4 ++-- lexer.go | 2 +- options.go | 43 +++++++++++++++++++++---------------------- options_test.go | 2 +- parity_test.go | 2 +- 7 files changed, 34 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index a2f76ef..7fc5026 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,14 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `LoadOptions.DebugFunc` | `ini.Debug(fn)` | | `LoadOptions.ReaderBufferSize` | removed; values are never cut at a buffer size | | `ini.DefaultSection = "default"` | `ini.DefaultSectionName("default")` | -| `ini.LineBreak`, `PrettyFormat`, `PrettySection`, `PrettyEqual`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo` | +| `ini.LineBreak`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo`: `ini.LineBreak(s)`, `ini.DefaultHeader()`, `ini.DelimiterSpaces(left, right)` | +| `ini.PrettyFormat = false`, `ini.PrettySection = false`, `ini.PrettyEqual = true` | `ini.AlignDelimiters(false)`, `ini.BlankLineBetweenSections(false)`, `ini.DelimiterSpaces(" ", " ")` | | `LoadOptions.KeyValueDelimiterOnWrite` | `ini.WriteDelimiter(d)` write option | | `WriteToIndent(w, i)`, `SaveToIndent(f, i)` | `Write(w, ini.Indent(i))`, `SaveTo(f, ini.Indent(i))` | | `MapTo(v, a, b)` | `MapTo(v, a, opts...)`; one source | | `DEFAULT_SECTION`, `AllCapsUnderscore`, `SnackCase` | `DefaultSection`, `SnakeCase` | | `LoadOptions.Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `ini.CaseInsensitive()`, `ini.CaseInsensitiveSections()`, `ini.CaseInsensitiveKeys()` | -| `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections` | `ini.IgnoreMissingFiles()`, `ini.FirstSourceOnly()`, `ini.RawSections(names...)` | +| `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections`, `PreserveSurroundedQuote` | `ini.IgnoreMissingFiles()`, `ini.FirstSourceOnly()`, `ini.RawSections(names...)`, `ini.PreserveSurroundingQuotes()` | | `File.BlockMode = false` | `ini.NoLocking()` | | `MapToWithMapper(v, m, src)`, `StrictMapToWithMapper` | `MapTo(v, src, ini.NameMapping(m))`, `StrictMapTo(v, src, ini.NameMapping(m))` | | `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with the file loaded with `ini.NameMapping(m)` | diff --git a/file.go b/file.go index 8a1d5b8..2137a08 100644 --- a/file.go +++ b/file.go @@ -410,7 +410,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { o := newWriteOptions(opts) indent, lineBreak := o.indent, o.lineBreak equalSign := o.formatLeft + o.keyValueDelimiter + o.formatRight - if o.prettyFormat || o.prettyEqual { + if o.alignDelimiters { equalSign = fmt.Sprintf(" %s ", o.keyValueDelimiter) } @@ -555,7 +555,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { buf.WriteString(lineBreak) } - if o.prettySection && !isLastSection { + if o.blankLines && !isLastSection { // Put a line between sections if _, err := buf.WriteString(lineBreak); err != nil { return nil, err @@ -568,7 +568,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // longest key. Keys may be modified if they contain certain characters so // we need to take that into account in our calculation. alignLength := 0 - if o.prettyFormat { + if o.alignDelimiters { for _, key := range sec.keys { kname := key.name keyLength := len(kname) @@ -615,7 +615,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { } pad := "" - if o.prettyFormat { + if o.alignDelimiters { pad = string(alignSpaces[:max(0, alignLength-len(form))]) } // lines are the lines of the key, each with the nested values @@ -723,7 +723,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { swallows = f.options.AllowPythonMultilineValues || (f.options.AllowNestedValues && empty[last]) } - if o.prettySection && !isLastSection { + if o.blankLines && !isLastSection { // Put a line between sections if _, err := buf.WriteString(lineBreak); err != nil { return nil, err diff --git a/ini_test.go b/ini_test.go index 1d1284d..a41db37 100644 --- a/ini_test.go +++ b/ini_test.go @@ -1415,13 +1415,13 @@ key2!value2 }) } -func Test_PreserveSurroundedQuote(t *testing.T) { +func Test_PreserveSurroundingQuotes(t *testing.T) { t.Run("preserve surrounded quote test", func(t *testing.T) { f, err := Load([]byte(` [section] key1 = "value1" key2 = value2 -`), PreserveSurroundedQuote()) +`), PreserveSurroundingQuotes()) require.NoError(t, err) require.NotNil(t, f) diff --git a/lexer.go b/lexer.go index ef09be4..5963a85 100644 --- a/lexer.go +++ b/lexer.go @@ -239,7 +239,7 @@ func (l *lexer) readValue(in string) (string, error) { } switch { - case !l.opts.PreserveSurroundedQuote && (hasSurroundedQuote(line, '\'') || hasSurroundedQuote(line, '"')): + case !l.opts.PreserveSurroundingQuotes && (hasSurroundedQuote(line, '\'') || hasSurroundedQuote(line, '"')): return line[1 : len(line)-1], nil case l.opts.UnescapeValueCommentSymbols: // Last symbol first, as v1 unescaped \; before \#. diff --git a/options.go b/options.go index f39f872..88bbbf7 100644 --- a/options.go +++ b/options.go @@ -46,7 +46,7 @@ type loadOptions struct { UnparseableSections []string KeyValueDelimiters string ChildSectionDelimiter string - PreserveSurroundedQuote bool + PreserveSurroundingQuotes bool DebugFunc DebugFunc AllowNonUniqueSections bool AllowDuplicateShadowValues bool @@ -187,9 +187,9 @@ func ChildSectionDelimiter(delimiter string) LoadOption { } } -// PreserveSurroundedQuote keeps single and double quotes around values. -func PreserveSurroundedQuote() LoadOption { - return func(o *loadOptions) { o.PreserveSurroundedQuote = true } +// PreserveSurroundingQuotes keeps single and double quotes around values. +func PreserveSurroundingQuotes() LoadOption { + return func(o *loadOptions) { o.PreserveSurroundingQuotes = true } } // Debug calls fn with debug information while parsing, currently about @@ -240,9 +240,8 @@ type writeOptions struct { lineBreak string keyValueDelimiter string defaultHeader bool - prettySection bool - prettyFormat bool - prettyEqual bool + blankLines bool + alignDelimiters bool formatLeft string formatRight string } @@ -254,8 +253,8 @@ func newWriteOptions(opts []WriteOption) writeOptions { o := writeOptions{ lineBreak: "\n", keyValueDelimiter: "=", - prettySection: true, - prettyFormat: true, + blankLines: true, + alignDelimiters: true, } if runtime.GOOS == "windows" && !inTest { o.lineBreak = "\r\n" @@ -288,22 +287,22 @@ func WriteDelimiter(delimiter string) WriteOption { // DefaultHeader writes the header of the default section. func DefaultHeader() WriteOption { return func(o *writeOptions) { o.defaultHeader = true } } -// PrettySection sets whether to put an empty line between sections. The -// default is true. -func PrettySection(pretty bool) WriteOption { - return func(o *writeOptions) { o.prettySection = pretty } +// BlankLineBetweenSections sets whether to put an empty line between +// sections. The default is true. +func BlankLineBetweenSections(blank bool) WriteOption { + return func(o *writeOptions) { o.blankLines = blank } } -// PrettyFormat sets whether to align the delimiters of a section with -// spaces. The default is true. -func PrettyFormat(pretty bool) WriteOption { return func(o *writeOptions) { o.prettyFormat = pretty } } - -// PrettyEqual puts spaces around the delimiter even when PrettyFormat is -// false. -func PrettyEqual() WriteOption { return func(o *writeOptions) { o.prettyEqual = true } } +// AlignDelimiters sets whether to pad the keys of a section with spaces so +// that their delimiters line up, with one space on each side. The default +// is true. +func AlignDelimiters(align bool) WriteOption { + return func(o *writeOptions) { o.alignDelimiters = align } +} -// DelimiterSpaces puts left and right around the delimiter when neither -// PrettyFormat nor PrettyEqual applies. +// DelimiterSpaces puts left and right around the delimiter when +// AlignDelimiters is false. DelimiterSpaces(" ", " ") writes "k = v" +// without aligning. func DelimiterSpaces(left, right string) WriteOption { return func(o *writeOptions) { o.formatLeft, o.formatRight = left, right } } diff --git a/options_test.go b/options_test.go index 17cdff0..d744f41 100644 --- a/options_test.go +++ b/options_test.go @@ -29,7 +29,7 @@ func TestWriteOptions(t *testing.T) { // Expected output is what v1 wrote with the same package globals set. var b bytes.Buffer - _, err = f.Write(&b, PrettyFormat(false), PrettySection(false), DelimiterSpaces("", " "), LineBreak("\r\n"), DefaultHeader()) + _, err = f.Write(&b, AlignDelimiters(false), BlankLineBetweenSections(false), DelimiterSpaces("", " "), LineBreak("\r\n"), DefaultHeader()) require.NoError(t, err) assert.Equal(t, "[DEFAULT]\r\na= 1\r\n[s]\r\nbb= 2\r\n", b.String()) diff --git a/parity_test.go b/parity_test.go index 5ab55a1..6b16511 100644 --- a/parity_test.go +++ b/parity_test.go @@ -80,7 +80,7 @@ func v2Options(o v1.LoadOptions) []LoadOption { {o.SpaceBeforeInlineComment, SpaceBeforeInlineComment}, {o.UnescapeValueDoubleQuotes, UnescapeValueDoubleQuotes}, {o.UnescapeValueCommentSymbols, UnescapeValueCommentSymbols}, - {o.PreserveSurroundedQuote, PreserveSurroundedQuote}, + {o.PreserveSurroundedQuote, PreserveSurroundingQuotes}, {o.AllowNonUniqueSections, AllowNonUniqueSections}, {o.AllowDuplicateShadowValues, AllowDuplicateShadowValues}, } { From d93a85d983b9ace0a0e1b38ab679b5122a0f5e99 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 01:21:49 +0000 Subject: [PATCH 24/41] Take a bool in every boolean option, as encoding/json/v2 does AllowShadows(), CaseInsensitive(), DefaultHeader() and the other 18 boolean options become AllowShadows(true) and so on, matching AlignDelimiters(bool) and BlankLineBetweenSections(bool). An option can now be set from a config flag, as in AllowShadows(cfg.Shadows), and a later option can turn off an earlier one. KeepEmptyShadowValues becomes PreserveEmptyShadowValues, matching PreserveSurroundingQuotes. --- README.md | 16 ++++---- bench_test.go | 2 +- file_test.go | 24 ++++++------ ini_test.go | 98 +++++++++++++++++++++++------------------------ key.go | 8 ++-- key_test.go | 10 ++--- options.go | 68 ++++++++++++++++---------------- options_test.go | 16 ++++---- parity_test.go | 44 +++++++++------------ roundtrip_test.go | 16 ++++---- section_test.go | 2 +- struct.go | 10 ++--- struct_test.go | 30 +++++++-------- 13 files changed, 169 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index 7fc5026..7dbf146 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,20 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t |---|---| | `import "gopkg.in/ini.v1"` | `import "unknwon.dev/ini/v2"` (package name is still `ini`) | | `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | -| `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows())` | -| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.IgnoreMissingFiles()`, `ini.CaseInsensitive()`, `ini.AllowShadows()` | +| `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows(true))` | +| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.IgnoreMissingFiles(true)`, `ini.CaseInsensitive(true)`, `ini.AllowShadows(true)` | | `LoadOptions.DebugFunc` | `ini.Debug(fn)` | | `LoadOptions.ReaderBufferSize` | removed; values are never cut at a buffer size | | `ini.DefaultSection = "default"` | `ini.DefaultSectionName("default")` | -| `ini.LineBreak`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo`: `ini.LineBreak(s)`, `ini.DefaultHeader()`, `ini.DelimiterSpaces(left, right)` | +| `ini.LineBreak`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo`: `ini.LineBreak(s)`, `ini.DefaultHeader(true)`, `ini.DelimiterSpaces(left, right)` | | `ini.PrettyFormat = false`, `ini.PrettySection = false`, `ini.PrettyEqual = true` | `ini.AlignDelimiters(false)`, `ini.BlankLineBetweenSections(false)`, `ini.DelimiterSpaces(" ", " ")` | | `LoadOptions.KeyValueDelimiterOnWrite` | `ini.WriteDelimiter(d)` write option | | `WriteToIndent(w, i)`, `SaveToIndent(f, i)` | `Write(w, ini.Indent(i))`, `SaveTo(f, ini.Indent(i))` | | `MapTo(v, a, b)` | `MapTo(v, a, opts...)`; one source | | `DEFAULT_SECTION`, `AllCapsUnderscore`, `SnackCase` | `DefaultSection`, `SnakeCase` | -| `LoadOptions.Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `ini.CaseInsensitive()`, `ini.CaseInsensitiveSections()`, `ini.CaseInsensitiveKeys()` | -| `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections`, `PreserveSurroundedQuote` | `ini.IgnoreMissingFiles()`, `ini.FirstSourceOnly()`, `ini.RawSections(names...)`, `ini.PreserveSurroundingQuotes()` | -| `File.BlockMode = false` | `ini.NoLocking()` | +| `LoadOptions.Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `ini.CaseInsensitive(true)`, `ini.CaseInsensitiveSections(true)`, `ini.CaseInsensitiveKeys(true)` | +| `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections`, `PreserveSurroundedQuote` | `ini.IgnoreMissingFiles(true)`, `ini.FirstSourceOnly(true)`, `ini.RawSections(names...)`, `ini.PreserveSurroundingQuotes(true)` | +| `File.BlockMode = false` | `ini.NoLocking(true)` | | `MapToWithMapper(v, m, src)`, `StrictMapToWithMapper` | `MapTo(v, src, ini.NameMapping(m))`, `StrictMapTo(v, src, ini.NameMapping(m))` | | `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with the file loaded with `ini.NameMapping(m)` | | `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | @@ -65,7 +65,7 @@ New in v2: - Parse errors are `*ini.ParseError` with 1-based `Line` and `Column`. - `ini.CommentSymbols(s)` sets the comment characters. -- `ini.KeepEmptyShadowValues()` keeps empty values of shadow keys. +- `ini.PreserveEmptyShadowValues(true)` keeps empty values of shadow keys. - `Key.IsBoolean()`, and `ini.Marshal`/`ini.Unmarshal`. - `File.Write` writes files that load back to the same sections, keys, values and comments: it picks a quoting the parser reads back, writes empty shadow values, and returns an error instead of writing a file that loads differently. - `MapTo`, `StrictMapTo`, `Unmarshal`, `ReflectFrom` and `Marshal` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Differences from v1 that valid input can show: @@ -78,7 +78,7 @@ New in v2: - Nil pointer fields aren't written. - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - A `nonunique` field with no sections is left empty instead of failing. -- Unless loaded with `NoLocking()`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. +- Unless loaded with `NoLocking(true)`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. - `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated, empty or delimiter-containing elements and `nonunique` sections. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. diff --git a/bench_test.go b/bench_test.go index c1b9dda..f0ee5f5 100644 --- a/bench_test.go +++ b/bench_test.go @@ -25,7 +25,7 @@ import ( func newTestFile(block bool) *File { var opts []LoadOption if !block { - opts = append(opts, NoLocking()) + opts = append(opts, NoLocking(true)) } c, _ := Load([]byte(confData), opts...) return c diff --git a/file_test.go b/file_test.go index 9863a3f..3385f97 100644 --- a/file_test.go +++ b/file_test.go @@ -81,7 +81,7 @@ AllowedIPs = 192.168.2.2/32 [Peer] PublicKey = -AllowedIPs = 192.168.2.3/32`), AllowNonUniqueSections()) +AllowedIPs = 192.168.2.3/32`), AllowNonUniqueSections(true)) require.NoError(t, err) require.NotNil(t, f) @@ -133,7 +133,7 @@ AllowedIPs = 192.168.2.3/32 PublicKey = AllowedIPs = 192.168.2.4/32 -`), AllowNonUniqueSections()) +`), AllowNonUniqueSections(true)) require.NoError(t, err) require.NotNil(t, f) @@ -160,7 +160,7 @@ AllowedIPs = 192.168.2.4/32 }) t.Run("delete all sections", func(t *testing.T) { - f := Empty(AllowNonUniqueSections()) + f := Empty(AllowNonUniqueSections(true)) require.NotNil(t, f) _ = f.NewSections("Interface", "Peer", "Peer") @@ -270,7 +270,7 @@ func TestFile_Section(t *testing.T) { f, err := Load([]byte(` [default] NAME = ini -VERSION = v1`), CaseInsensitive()) +VERSION = v1`), CaseInsensitive(true)) require.NoError(t, err) require.NotNil(t, f) @@ -375,7 +375,7 @@ key1 = value1 }) t.Run("delete a section with CaseInsensitiveSections", func(t *testing.T) { - f := Empty(CaseInsensitiveSections()) + f := Empty(CaseInsensitiveSections(true)) require.NotNil(t, f) _ = f.NewSections("author", "package", "features") @@ -524,7 +524,7 @@ func TestReloadAfterShadowLoad(t *testing.T) { v = 1 v = 2 v = 3 -`), AllowShadows()) +`), AllowShadows(true)) require.NoError(t, err) require.NotNil(t, f) @@ -558,7 +558,7 @@ func TestFile_Reload(t *testing.T) { // https://github.com/go-ini/ini/issues/273 t.Run("shadows don't pile up", func(t *testing.T) { write("[log]\nlevel = info\nlevel = warn\n") - f, err := Load(path, AllowShadows(), AllowDuplicateShadowValues()) + f, err := Load(path, AllowShadows(true), AllowDuplicateShadowValues(true)) require.NoError(t, err) write("[log]\nlevel = debug\n") @@ -610,7 +610,7 @@ func TestFile_EdgeCases(t *testing.T) { }) t.Run("negative and out-of-range section indexes", func(t *testing.T) { - f, err := Load([]byte("a = 1\n[x]\nb = 2\n"), AllowNonUniqueSections()) + f, err := Load([]byte("a = 1\n[x]\nb = 2\n"), AllowNonUniqueSections(true)) require.NoError(t, err) assert.NotNil(t, f.SectionWithIndex("x", -1)) assert.Error(t, f.DeleteSectionWithIndex("x", -1)) @@ -619,7 +619,7 @@ func TestFile_EdgeCases(t *testing.T) { }) t.Run("default section after deleting it, case-insensitive", func(t *testing.T) { - f, err := Load([]byte("a = 1\n"), CaseInsensitive(), AllowNonUniqueSections()) + f, err := Load([]byte("a = 1\n"), CaseInsensitive(true), AllowNonUniqueSections(true)) require.NoError(t, err) f.DeleteSection("") f.Section("").Key("k").SetValue("v") @@ -629,7 +629,7 @@ func TestFile_EdgeCases(t *testing.T) { // https://github.com/go-ini/ini/issues/340 t.Run("DeleteKey with CaseInsensitive", func(t *testing.T) { - f, err := Load([]byte("TestKey = 1\n"), CaseInsensitive()) + f, err := Load([]byte("TestKey = 1\n"), CaseInsensitive(true)) require.NoError(t, err) f.Section("").DeleteKey("TestKey") assert.False(t, f.Section("").HasKey("TestKey")) @@ -644,7 +644,7 @@ func TestFile_EdgeCases(t *testing.T) { }) t.Run("child sections with non-unique names", func(t *testing.T) { - f, err := Load([]byte("[a]\n[a.b]\nx = 1\n[a.b]\nx = 2\n"), AllowNonUniqueSections()) + f, err := Load([]byte("[a]\n[a.b]\nx = 1\n[a.b]\nx = 2\n"), AllowNonUniqueSections(true)) require.NoError(t, err) children := f.Section("a").ChildSections() require.Len(t, children, 2) @@ -658,7 +658,7 @@ func TestFile_EdgeCases(t *testing.T) { func TestFile_Concurrency(t *testing.T) { path := filepath.Join(t.TempDir(), "app.ini") require.NoError(t, os.WriteFile(path, []byte("name = %(base)s/x\nbase = /srv\n[s]\nk = a\nk = b\n[s.child]\nc = 1\n[raw]\nbody\n"), 0o600)) - f, err := Load(path, AllowShadows(), RawSections("raw")) + f, err := Load(path, AllowShadows(true), RawSections("raw")) require.NoError(t, err) type cfg struct { diff --git a/ini_test.go b/ini_test.go index a41db37..011fc6b 100644 --- a/ini_test.go +++ b/ini_test.go @@ -221,7 +221,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- } func TestLooseLoad(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, IgnoreMissingFiles()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, IgnoreMissingFiles(true)) require.NoError(t, err) require.NotNil(t, f) @@ -233,7 +233,7 @@ func TestLooseLoad(t *testing.T) { func TestInsensitiveLoad(t *testing.T) { t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := Load(minimalConf, CaseInsensitive()) + f, err := Load(minimalConf, CaseInsensitive(true)) require.NoError(t, err) require.NotNil(t, f) @@ -265,7 +265,7 @@ e-mail = u@gogs.io user = unknwon [profile] email = unknwon@local -`), CaseInsensitive()) +`), CaseInsensitive(true)) require.NoError(t, err) require.NotNil(t, f) @@ -276,18 +276,18 @@ email = unknwon@local func TestLoadSources(t *testing.T) { t.Run("with true `AllowPythonMultilineValues`", func(t *testing.T) { t.Run("ignore nonexistent files", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, AllowPythonMultilineValues(), IgnoreMissingFiles()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, AllowPythonMultilineValues(true), IgnoreMissingFiles(true)) require.NoError(t, err) require.NotNil(t, f) t.Run("inverse case", func(t *testing.T) { - _, err = Load(notFoundConf, AllowPythonMultilineValues()) + _, err = Load(notFoundConf, AllowPythonMultilineValues(true)) require.Error(t, err) }) }) t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := Load(minimalConf, AllowPythonMultilineValues(), CaseInsensitive()) + f, err := Load(minimalConf, AllowPythonMultilineValues(true), CaseInsensitive(true)) require.NoError(t, err) require.NotNil(t, f) @@ -305,7 +305,7 @@ e-mail = u@gogs.io }) t.Run("inverse case", func(t *testing.T) { - f, err := Load(minimalConf, AllowPythonMultilineValues()) + f, err := Load(minimalConf, AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -314,7 +314,7 @@ e-mail = u@gogs.io }) t.Run("insensitive to sections and sensitive to key names", func(t *testing.T) { - f, err := Load(minimalConf, CaseInsensitiveSections()) + f, err := Load(minimalConf, CaseInsensitiveSections(true)) require.NoError(t, err) require.NotNil(t, f) @@ -341,7 +341,7 @@ E-MAIL = u@gogs.io }) t.Run("sensitive to sections and insensitive to key names", func(t *testing.T) { - f, err := Load(minimalConf, CaseInsensitiveKeys()) + f, err := Load(minimalConf, CaseInsensitiveKeys(true)) require.NoError(t, err) require.NotNil(t, f) @@ -371,7 +371,7 @@ e-mail = u@gogs.io f, err := Load([]byte(` key1=a\b\ key2=c\d\ -key3=value`), AllowPythonMultilineValues(), IgnoreContinuation()) +key3=value`), AllowPythonMultilineValues(true), IgnoreContinuation(true)) require.NoError(t, err) require.NotNil(t, f) @@ -382,7 +382,7 @@ key3=value`), AllowPythonMultilineValues(), IgnoreContinuation()) t.Run("inverse case", func(t *testing.T) { f, err := Load([]byte(` key1=a\b\ -key2=c\d\`), AllowPythonMultilineValues()) +key2=c\d\`), AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -394,7 +394,7 @@ key2=c\d\`), AllowPythonMultilineValues()) f, err := Load([]byte(` key1=value ;comment key2=value2 #comment2 -key3=val#ue #comment3`), AllowPythonMultilineValues(), IgnoreInlineComment()) +key3=val#ue #comment3`), AllowPythonMultilineValues(true), IgnoreInlineComment(true)) require.NoError(t, err) require.NotNil(t, f) @@ -405,7 +405,7 @@ key3=val#ue #comment3`), AllowPythonMultilineValues(), IgnoreInlineComment()) t.Run("inverse case", func(t *testing.T) { f, err := Load([]byte(` key1=value ;comment -key2=value2 #comment2`), AllowPythonMultilineValues()) +key2=value2 #comment2`), AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -430,7 +430,7 @@ BiomeGroup(NormalBiomes, 3, 99, RoofedForestEnchanted, ForestSakura, FloatingJun BiomeGroup(IceBiomes, 4, 85, Ice Plains) = RainForest -`), SkipUnrecognizableLines()) +`), SkipUnrecognizableLines(true)) require.NoError(t, err) require.NotNil(t, f) @@ -443,7 +443,7 @@ BiomeGroup(IceBiomes, 4, 85, Ice Plains) f, err := Load([]byte(` key1=hello #key2 -key3`), AllowPythonMultilineValues(), AllowBooleanKeys()) +key3`), AllowPythonMultilineValues(true), AllowBooleanKeys(true)) require.NoError(t, err) require.NotNil(t, f) @@ -466,7 +466,7 @@ key3 _, err := Load([]byte(` key1=hello #key2 -key3`), AllowPythonMultilineValues()) +key3`), AllowPythonMultilineValues(true)) require.Error(t, err) }) }) @@ -476,7 +476,7 @@ key3`), AllowPythonMultilineValues()) [remote "origin"] url = https://github.com/Antergone/test1.git url = https://github.com/Antergone/test2.git -fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows(), AllowPythonMultilineValues()) +fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows(true), AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -508,7 +508,7 @@ fetch = +refs/heads/*:refs/remotes/origin/* f, err := Load([]byte(` [remote "origin"] url = https://github.com/Antergone/test1.git -url = https://github.com/Antergone/test2.git`), AllowPythonMultilineValues()) +url = https://github.com/Antergone/test2.git`), AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -518,7 +518,7 @@ url = https://github.com/Antergone/test2.git`), AllowPythonMultilineValues()) t.Run("unescape double quotes inside value", func(t *testing.T) { f, err := Load([]byte(` -create_repo="创建了仓库 %s"`), AllowPythonMultilineValues(), UnescapeValueDoubleQuotes()) +create_repo="创建了仓库 %s"`), AllowPythonMultilineValues(true), UnescapeValueDoubleQuotes(true)) require.NoError(t, err) require.NotNil(t, f) @@ -526,7 +526,7 @@ create_repo="创建了仓库 %s"`), AllowPythonMultilineValue t.Run("inverse case", func(t *testing.T) { f, err := Load([]byte(` -create_repo="创建了仓库 %s"`), AllowPythonMultilineValues()) +create_repo="创建了仓库 %s"`), AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -537,7 +537,7 @@ create_repo="创建了仓库 %s"`), AllowPythonMultilineValue t.Run("unescape comment symbols inside value", func(t *testing.T) { f, err := Load([]byte(` key = test value more text -`), AllowPythonMultilineValues(), IgnoreInlineComment(), UnescapeValueCommentSymbols()) +`), AllowPythonMultilineValues(true), IgnoreInlineComment(true), UnescapeValueCommentSymbols(true)) require.NoError(t, err) require.NotNil(t, f) @@ -547,7 +547,7 @@ key = test value more text t.Run("unescape double quotes with backslash continuation", func(t *testing.T) { f, err := Load([]byte(`hello = "!f() { \ echo "hello world"; \ -};f"`), UnescapeValueDoubleQuotes()) +};f"`), UnescapeValueDoubleQuotes(true)) require.NoError(t, err) require.NotNil(t, f) @@ -559,7 +559,7 @@ key = test value more text t.Run("inverse case", func(t *testing.T) { _, err := Load([]byte(`hello = "!f() { \ echo "hello world"; \ -};f"`), UnescapeValueDoubleQuotes(), IgnoreContinuation()) +};f"`), UnescapeValueDoubleQuotes(true), IgnoreContinuation(true)) require.Error(t, err) }) }) @@ -577,7 +577,7 @@ multiline_list = first second third -`), AllowPythonMultilineValues(), CaseInsensitive(), RawSections("core_lesson", "comments")) +`), AllowPythonMultilineValues(true), CaseInsensitive(true), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -686,7 +686,7 @@ long_rsa_private_key = -----BEGIN RSA PRIVATE KEY----- 95foobar 96barfoo -----END RSA PRIVATE KEY----- -`), AllowPythonMultilineValues(), CaseInsensitive(), RawSections("core_lesson", "comments")) +`), AllowPythonMultilineValues(true), CaseInsensitive(true), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -805,7 +805,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [COMMENTS] <1> This slide has the fuel listed in the wrong units -`), AllowPythonMultilineValues(), CaseInsensitive(), RawSections("core_lesson", "comments")) +`), AllowPythonMultilineValues(true), CaseInsensitive(true), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -841,7 +841,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 _, err := Load([]byte(` [CORE_LESSON] my lesson state data – 1111111111111111111000000000000000001110000 -111111111111111111100000000000111000000000 – end my lesson state data`), AllowPythonMultilineValues()) +111111111111111111100000000000111000000000 – end my lesson state data`), AllowPythonMultilineValues(true)) require.Error(t, err) }) }) @@ -869,7 +869,7 @@ GITHUB = U;n;k;n;w;o;n [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n -`), SpaceBeforeInlineComment()) +`), SpaceBeforeInlineComment(true)) require.NoError(t, err) require.NotNil(t, f) sec := f.Section("author") @@ -883,7 +883,7 @@ GITHUB = U;n;k;n;w;o;n t.Run("with false `AllowPythonMultilineValues`", func(t *testing.T) { t.Run("ignore nonexistent files", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, IgnoreMissingFiles()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, IgnoreMissingFiles(true)) require.NoError(t, err) require.NotNil(t, f) @@ -894,7 +894,7 @@ GITHUB = U;n;k;n;w;o;n }) t.Run("insensitive to section and key names", func(t *testing.T) { - f, err := Load(minimalConf, CaseInsensitive()) + f, err := Load(minimalConf, CaseInsensitive(true)) require.NoError(t, err) require.NotNil(t, f) @@ -924,7 +924,7 @@ e-mail = u@gogs.io f, err := Load([]byte(` key1=a\b\ key2=c\d\ -key3=value`), IgnoreContinuation()) +key3=value`), IgnoreContinuation(true)) require.NoError(t, err) require.NotNil(t, f) @@ -947,7 +947,7 @@ key2=c\d\`)) f, err := Load([]byte(` key1=value ;comment key2=value2 #comment2 -key3=val#ue #comment3`), IgnoreInlineComment()) +key3=val#ue #comment3`), IgnoreInlineComment(true)) require.NoError(t, err) require.NotNil(t, f) @@ -973,7 +973,7 @@ key2=value2 #comment2`)) f, err := Load([]byte(` key1=hello #key2 -key3`), AllowBooleanKeys()) +key3`), AllowBooleanKeys(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1006,7 +1006,7 @@ key3`)) [remote "origin"] url = https://github.com/Antergone/test1.git url = https://github.com/Antergone/test2.git -fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows()) +fetch = +refs/heads/*:refs/remotes/origin/*`), AllowShadows(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1048,7 +1048,7 @@ url = https://github.com/Antergone/test2.git`)) t.Run("unescape double quotes inside value", func(t *testing.T) { f, err := Load([]byte(` -create_repo="创建了仓库 %s"`), UnescapeValueDoubleQuotes()) +create_repo="创建了仓库 %s"`), UnescapeValueDoubleQuotes(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1067,7 +1067,7 @@ create_repo="创建了仓库 %s"`)) t.Run("unescape comment symbols inside value", func(t *testing.T) { f, err := Load([]byte(` key = test value more text -`), IgnoreInlineComment(), UnescapeValueCommentSymbols()) +`), IgnoreInlineComment(true), UnescapeValueCommentSymbols(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1209,7 +1209,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [COMMENTS] <1> This slide has the fuel listed in the wrong units -`), CaseInsensitive(), RawSections("core_lesson", "comments")) +`), CaseInsensitive(true), RawSections("core_lesson", "comments")) require.NoError(t, err) require.NotNil(t, f) @@ -1256,7 +1256,7 @@ my lesson state data – 1111111111111111111000000000000000001110000 [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n -`), AllowPythonMultilineValues()) +`), AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) sec := f.Section("author") @@ -1273,7 +1273,7 @@ GITHUB = U;n;k;n;w;o;n [author] NAME = U#n#k#n#w#o#n GITHUB = U;n;k;n;w;o;n -`), AllowPythonMultilineValues(), SpaceBeforeInlineComment()) +`), AllowPythonMultilineValues(true), SpaceBeforeInlineComment(true)) require.NoError(t, err) require.NotNil(t, f) sec := f.Section("author") @@ -1349,7 +1349,7 @@ GITHUB = U;n;k;n;w;o;n t.Run("FirstSourceOnly", func(t *testing.T) { t.Run("load the first available configuration, ignore other configuration", func(t *testing.T) { - f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}, FirstSourceOnly()) + f, err := LoadSources([]any{minimalConf, []byte(`key1 = value1`)}, FirstSourceOnly(true)) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1363,13 +1363,13 @@ E-MAIL = u@gogs.io }) t.Run("return an error when fail to load", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, FirstSourceOnly()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, FirstSourceOnly(true)) assert.Nil(t, f) require.Error(t, err) }) t.Run("used with IgnoreMissingFiles to ignore errors that the file does not exist", func(t *testing.T) { - f, err := LoadSources([]any{notFoundConf, minimalConf}, FirstSourceOnly(), IgnoreMissingFiles()) + f, err := LoadSources([]any{notFoundConf, minimalConf}, FirstSourceOnly(true), IgnoreMissingFiles(true)) require.NotNil(t, f) require.NoError(t, err) var buf bytes.Buffer @@ -1421,7 +1421,7 @@ func Test_PreserveSurroundingQuotes(t *testing.T) { [section] key1 = "value1" key2 = value2 -`), PreserveSurroundingQuotes()) +`), PreserveSurroundingQuotes(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1455,7 +1455,7 @@ func TestPythonMultiline(t *testing.T) { } path := filepath.Join("testdata", "multiline.ini") - f, err := Load(path, AllowPythonMultilineValues()) + f, err := Load(path, AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) assert.Len(t, f.Sections(), 1) @@ -1501,7 +1501,7 @@ func TestPythonMultiline_EOF(t *testing.T) { } path := filepath.Join("testdata", "multiline_eof.ini") - f, err := Load(path, AllowPythonMultilineValues()) + f, err := Load(path, AllowPythonMultilineValues(true)) require.NoError(t, err) require.NotNil(t, f) assert.Len(t, f.Sections(), 1) @@ -1522,7 +1522,7 @@ func Test_NestedValuesSpanningSections(t *testing.T) { key1 = value1 key2 = nested1 = nestedvalue1 -`), AllowNestedValues()) +`), AllowNestedValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1536,7 +1536,7 @@ key2 = [section] key1 = value1 key2 = -`), AllowNestedValues()) +`), AllowNestedValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1552,7 +1552,7 @@ key2 = [section2] key3 = value3 -`), AllowNestedValues()) +`), AllowNestedValues(true)) require.NoError(t, err) require.NotNil(t, f) @@ -1569,7 +1569,7 @@ key2 = [section2] key3 = value3 -`), AllowNestedValues()) +`), AllowNestedValues(true)) require.NoError(t, err) require.NotNil(t, f) diff --git a/key.go b/key.go index 3d4e727..cf2d71e 100644 --- a/key.go +++ b/key.go @@ -54,8 +54,8 @@ func (k *Key) addShadow(val string) error { } // Deduplicate shadows based on their values. Empty values kept with - // KeepEmptyShadowValues each reset the values before them, so they stay. - keptEmpty := val == "" && k.s.f.options.KeepEmptyShadowValues + // PreserveEmptyShadowValues each reset the values before them, so they stay. + keptEmpty := val == "" && k.s.f.options.PreserveEmptyShadowValues if !k.s.f.options.AllowDuplicateShadowValues && !keptEmpty { if k.value == val || slices.Contains(k.shadows, val) { return nil @@ -133,11 +133,11 @@ func (k *Key) Value() string { // Values returns raw values of key and its shadows if any. Empty // values are left out, unless the file was loaded with -// KeepEmptyShadowValues. +// PreserveEmptyShadowValues. func (k *Key) Values() []string { k.s.f.rlock() defer k.s.f.runlock() - keepEmpty := k.s.f.options.KeepEmptyShadowValues + keepEmpty := k.s.f.options.PreserveEmptyShadowValues vals := make([]string, 0, len(k.shadows)+1) if keepEmpty || k.value != "" { vals = append(vals, k.value) diff --git a/key_test.go b/key_test.go index c04742e..269e7f9 100644 --- a/key_test.go +++ b/key_test.go @@ -30,7 +30,7 @@ func TestKey_AddShadow(t *testing.T) { t.Run("add shadow to a key", func(t *testing.T) { f, err := Load([]byte(` [notes] --: note1`), AllowShadows()) +-: note1`), AllowShadows(true)) require.NoError(t, err) require.NotNil(t, f) @@ -67,7 +67,7 @@ func TestKey_AddShadow(t *testing.T) { }) t.Run("allow duplicate shadowed values", func(t *testing.T) { - f := Empty(AllowShadows(), AllowDuplicateShadowValues()) + f := Empty(AllowShadows(true), AllowDuplicateShadowValues(true)) require.NotNil(t, f) k, err := f.Section("").NewKey("NAME", "ini") @@ -357,7 +357,7 @@ func TestKey_Values(t *testing.T) { f, err := Load([]byte(` keyName = value1 keyName = value2 -`), AllowShadows()) +`), AllowShadows(true)) require.NoError(t, err) require.NotNil(t, f) @@ -373,7 +373,7 @@ keyName = value2 func TestKey_StringsWithShadows(t *testing.T) { t.Run("get strings of shadows of a key", func(t *testing.T) { - f, err := Load([]byte(""), AllowShadows()) + f, err := Load([]byte(""), AllowShadows(true)) require.NoError(t, err) require.NotNil(t, f) @@ -411,7 +411,7 @@ aws_secret_access_key = bar region = us-west-2 s3 = max_concurrent_requests=10 - max_queue_size=1000`), AllowNestedValues()) + max_queue_size=1000`), AllowNestedValues(true)) require.NoError(t, err) require.NotNil(t, f) diff --git a/options.go b/options.go index 88bbbf7..625db5b 100644 --- a/options.go +++ b/options.go @@ -52,7 +52,7 @@ type loadOptions struct { AllowDuplicateShadowValues bool DefaultSection string CommentSymbols string - KeepEmptyShadowValues bool + PreserveEmptyShadowValues bool NoLocking bool NameMapper NameMapper ValueMapper ValueMapper @@ -74,7 +74,7 @@ func newLoadOptions(opts []LoadOption) loadOptions { // NoLocking turns off the lock that lets a File be read from many goroutines // while others change it. Use it for a File only one goroutine uses, to save // the cost of locking. -func NoLocking() LoadOption { return func(o *loadOptions) { o.NoLocking = true } } +func NoLocking(v bool) LoadOption { return func(o *loadOptions) { o.NoLocking = v } } // NameMapping sets the NameMapper that maps struct field names to section // and key names, for MapTo, StrictMapTo and ReflectFrom. @@ -93,72 +93,74 @@ type DebugFunc func(message string) // IgnoreMissingFiles ignores data sources that are files which don't exist, instead of // returning an error. -func IgnoreMissingFiles() LoadOption { return func(o *loadOptions) { o.Loose = true } } +func IgnoreMissingFiles(v bool) LoadOption { return func(o *loadOptions) { o.Loose = v } } // CaseInsensitive lowercases all section and key names. -func CaseInsensitive() LoadOption { return func(o *loadOptions) { o.Insensitive = true } } +func CaseInsensitive(v bool) LoadOption { return func(o *loadOptions) { o.Insensitive = v } } // CaseInsensitiveSections lowercases all section names. -func CaseInsensitiveSections() LoadOption { - return func(o *loadOptions) { o.InsensitiveSections = true } +func CaseInsensitiveSections(v bool) LoadOption { + return func(o *loadOptions) { o.InsensitiveSections = v } } // CaseInsensitiveKeys lowercases all key names. -func CaseInsensitiveKeys() LoadOption { return func(o *loadOptions) { o.InsensitiveKeys = true } } +func CaseInsensitiveKeys(v bool) LoadOption { return func(o *loadOptions) { o.InsensitiveKeys = v } } // IgnoreContinuation treats a backslash at the end of a line as part of the // value instead of joining the next line. -func IgnoreContinuation() LoadOption { return func(o *loadOptions) { o.IgnoreContinuation = true } } +func IgnoreContinuation(v bool) LoadOption { return func(o *loadOptions) { o.IgnoreContinuation = v } } // IgnoreInlineComment keeps comment symbols after a value as part of it. // Comments on lines of their own still work. -func IgnoreInlineComment() LoadOption { return func(o *loadOptions) { o.IgnoreInlineComment = true } } +func IgnoreInlineComment(v bool) LoadOption { + return func(o *loadOptions) { o.IgnoreInlineComment = v } +} // SkipUnrecognizableLines skips lines that are not sections, keys or // comments instead of returning an error. -func SkipUnrecognizableLines() LoadOption { - return func(o *loadOptions) { o.SkipUnrecognizableLines = true } +func SkipUnrecognizableLines(v bool) LoadOption { + return func(o *loadOptions) { o.SkipUnrecognizableLines = v } } // FirstSourceOnly stops loading after the first data source that loads. -func FirstSourceOnly() LoadOption { return func(o *loadOptions) { o.ShortCircuit = true } } +func FirstSourceOnly(v bool) LoadOption { return func(o *loadOptions) { o.ShortCircuit = v } } // AllowBooleanKeys reads a line without a delimiter as a key whose value is // "true", as in my.cnf. -func AllowBooleanKeys() LoadOption { return func(o *loadOptions) { o.AllowBooleanKeys = true } } +func AllowBooleanKeys(v bool) LoadOption { return func(o *loadOptions) { o.AllowBooleanKeys = v } } // AllowShadows keeps every value of a key that repeats in a section, // instead of only the last one. -func AllowShadows() LoadOption { return func(o *loadOptions) { o.AllowShadows = true } } +func AllowShadows(v bool) LoadOption { return func(o *loadOptions) { o.AllowShadows = v } } // AllowNestedValues reads indented lines under a key with an empty value as // AWS-style nested values. // Docs: http://docs.aws.amazon.com/cli/latest/topic/config-vars.html#nested-values -func AllowNestedValues() LoadOption { return func(o *loadOptions) { o.AllowNestedValues = true } } +func AllowNestedValues(v bool) LoadOption { return func(o *loadOptions) { o.AllowNestedValues = v } } // AllowPythonMultilineValues continues a value on the following lines that // are indented. // Docs: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure -func AllowPythonMultilineValues() LoadOption { - return func(o *loadOptions) { o.AllowPythonMultilineValues = true } +func AllowPythonMultilineValues(v bool) LoadOption { + return func(o *loadOptions) { o.AllowPythonMultilineValues = v } } // SpaceBeforeInlineComment starts an inline comment only at a comment symbol // that follows a space, so values can contain comment symbols. -func SpaceBeforeInlineComment() LoadOption { - return func(o *loadOptions) { o.SpaceBeforeInlineComment = true } +func SpaceBeforeInlineComment(v bool) LoadOption { + return func(o *loadOptions) { o.SpaceBeforeInlineComment = v } } // UnescapeValueDoubleQuotes unescapes \" inside values surrounded by double // quotes, e.g. key="a \"value\"" is `a "value"`. -func UnescapeValueDoubleQuotes() LoadOption { - return func(o *loadOptions) { o.UnescapeValueDoubleQuotes = true } +func UnescapeValueDoubleQuotes(v bool) LoadOption { + return func(o *loadOptions) { o.UnescapeValueDoubleQuotes = v } } // UnescapeValueCommentSymbols unescapes \# and \; in values that are not // surrounded by quotes. -func UnescapeValueCommentSymbols() LoadOption { - return func(o *loadOptions) { o.UnescapeValueCommentSymbols = true } +func UnescapeValueCommentSymbols(v bool) LoadOption { + return func(o *loadOptions) { o.UnescapeValueCommentSymbols = v } } // RawSections keeps the bodies of the named sections as raw text, @@ -188,8 +190,8 @@ func ChildSectionDelimiter(delimiter string) LoadOption { } // PreserveSurroundingQuotes keeps single and double quotes around values. -func PreserveSurroundingQuotes() LoadOption { - return func(o *loadOptions) { o.PreserveSurroundingQuotes = true } +func PreserveSurroundingQuotes(v bool) LoadOption { + return func(o *loadOptions) { o.PreserveSurroundingQuotes = v } } // Debug calls fn with debug information while parsing, currently about @@ -198,21 +200,21 @@ func Debug(fn DebugFunc) LoadOption { return func(o *loadOptions) { o.DebugFunc // AllowNonUniqueSections keeps sections that repeat a name as separate // sections, instead of merging them. -func AllowNonUniqueSections() LoadOption { - return func(o *loadOptions) { o.AllowNonUniqueSections = true } +func AllowNonUniqueSections(v bool) LoadOption { + return func(o *loadOptions) { o.AllowNonUniqueSections = v } } // AllowDuplicateShadowValues keeps shadow values that repeat an earlier // value of the key. By default they are dropped. -func AllowDuplicateShadowValues() LoadOption { - return func(o *loadOptions) { o.AllowDuplicateShadowValues = true } +func AllowDuplicateShadowValues(v bool) LoadOption { + return func(o *loadOptions) { o.AllowDuplicateShadowValues = v } } -// KeepEmptyShadowValues keeps empty values in Key.Values and when +// PreserveEmptyShadowValues keeps empty values in Key.Values and when // writing shadow keys. An empty value can be meaningful: in systemd units it // resets the values before it. -func KeepEmptyShadowValues() LoadOption { - return func(o *loadOptions) { o.KeepEmptyShadowValues = true } +func PreserveEmptyShadowValues(v bool) LoadOption { + return func(o *loadOptions) { o.PreserveEmptyShadowValues = v } } // DefaultSectionName names the section that holds keys before the first @@ -285,7 +287,7 @@ func WriteDelimiter(delimiter string) WriteOption { } // DefaultHeader writes the header of the default section. -func DefaultHeader() WriteOption { return func(o *writeOptions) { o.defaultHeader = true } } +func DefaultHeader(v bool) WriteOption { return func(o *writeOptions) { o.defaultHeader = v } } // BlankLineBetweenSections sets whether to put an empty line between // sections. The default is true. diff --git a/options_test.go b/options_test.go index d744f41..bd4a7a9 100644 --- a/options_test.go +++ b/options_test.go @@ -29,7 +29,7 @@ func TestWriteOptions(t *testing.T) { // Expected output is what v1 wrote with the same package globals set. var b bytes.Buffer - _, err = f.Write(&b, AlignDelimiters(false), BlankLineBetweenSections(false), DelimiterSpaces("", " "), LineBreak("\r\n"), DefaultHeader()) + _, err = f.Write(&b, AlignDelimiters(false), BlankLineBetweenSections(false), DelimiterSpaces("", " "), LineBreak("\r\n"), DefaultHeader(true)) require.NoError(t, err) assert.Equal(t, "[DEFAULT]\r\na= 1\r\n[s]\r\nbb= 2\r\n", b.String()) @@ -65,7 +65,7 @@ func TestDefaultSectionName(t *testing.T) { func TestCommentSymbols(t *testing.T) { f, err := Load([]byte("// header\n[s] // section\nurl = http://x/#a;b // note\nk = a\\/\\/b\n"), - CommentSymbols("/"), UnescapeValueCommentSymbols(), SpaceBeforeInlineComment()) + CommentSymbols("/"), UnescapeValueCommentSymbols(true), SpaceBeforeInlineComment(true)) require.NoError(t, err) sec := f.Section("s") @@ -76,7 +76,7 @@ func TestCommentSymbols(t *testing.T) { t.Run("multi-byte symbols", func(t *testing.T) { f, err := Load([]byte("€ head\n→ arrow = 1\nk = a €c\nj = x\\€y\nu = \\→\n"), - CommentSymbols("€"), SpaceBeforeInlineComment(), UnescapeValueCommentSymbols()) + CommentSymbols("€"), SpaceBeforeInlineComment(true), UnescapeValueCommentSymbols(true)) require.NoError(t, err) sec := f.Section("") @@ -153,14 +153,14 @@ func TestParseErrorPosition(t *testing.T) { } } -func TestKeepEmptyShadowValues(t *testing.T) { +func TestPreserveEmptyShadowValues(t *testing.T) { data := []byte("[Service]\nExecStart=/bin/a\nExecStart=\nExecStart=/bin/b\n") - f, err := Load(data, AllowShadows()) + f, err := Load(data, AllowShadows(true)) require.NoError(t, err) assert.Equal(t, []string{"/bin/a", "/bin/b"}, f.Section("Service").Key("ExecStart").Values()) - f, err = Load(data, AllowShadows(), KeepEmptyShadowValues()) + f, err = Load(data, AllowShadows(true), PreserveEmptyShadowValues(true)) require.NoError(t, err) assert.Equal(t, []string{"/bin/a", "", "/bin/b"}, f.Section("Service").Key("ExecStart").Values()) @@ -171,14 +171,14 @@ func TestKeepEmptyShadowValues(t *testing.T) { t.Run("every empty value is kept", func(t *testing.T) { f, err := Load([]byte("[u]\nExecStart=\nExecStart=a\nExecStart=\nExecStart=b\nExecStart=a\n"), - AllowShadows(), KeepEmptyShadowValues()) + AllowShadows(true), PreserveEmptyShadowValues(true)) require.NoError(t, err) assert.Equal(t, []string{"", "a", "", "b"}, f.Section("u").Key("ExecStart").Values()) }) } func TestKey_IsBooleanType(t *testing.T) { - f, err := Load([]byte("[mysqld]\nskip-name-resolve\nuser = mysql\n"), AllowBooleanKeys()) + f, err := Load([]byte("[mysqld]\nskip-name-resolve\nuser = mysql\n"), AllowBooleanKeys(true)) require.NoError(t, err) assert.True(t, f.Section("mysqld").Key("skip-name-resolve").IsBoolean()) assert.False(t, f.Section("mysqld").Key("user").IsBoolean()) diff --git a/parity_test.go b/parity_test.go index 6b16511..db94ae2 100644 --- a/parity_test.go +++ b/parity_test.go @@ -62,31 +62,23 @@ func parityOptions(flags uint32) v1.LoadOptions { // v2Options returns the v2 equivalent of v1 load options. func v2Options(o v1.LoadOptions) []LoadOption { - var opts []LoadOption - for _, flag := range []struct { - set bool - opt func() LoadOption - }{ - {o.Insensitive, CaseInsensitive}, - {o.InsensitiveSections, CaseInsensitiveSections}, - {o.InsensitiveKeys, CaseInsensitiveKeys}, - {o.IgnoreContinuation, IgnoreContinuation}, - {o.IgnoreInlineComment, IgnoreInlineComment}, - {o.SkipUnrecognizableLines, SkipUnrecognizableLines}, - {o.AllowBooleanKeys, AllowBooleanKeys}, - {o.AllowShadows, AllowShadows}, - {o.AllowNestedValues, AllowNestedValues}, - {o.AllowPythonMultilineValues, AllowPythonMultilineValues}, - {o.SpaceBeforeInlineComment, SpaceBeforeInlineComment}, - {o.UnescapeValueDoubleQuotes, UnescapeValueDoubleQuotes}, - {o.UnescapeValueCommentSymbols, UnescapeValueCommentSymbols}, - {o.PreserveSurroundedQuote, PreserveSurroundingQuotes}, - {o.AllowNonUniqueSections, AllowNonUniqueSections}, - {o.AllowDuplicateShadowValues, AllowDuplicateShadowValues}, - } { - if flag.set { - opts = append(opts, flag.opt()) - } + opts := []LoadOption{ + CaseInsensitive(o.Insensitive), + CaseInsensitiveSections(o.InsensitiveSections), + CaseInsensitiveKeys(o.InsensitiveKeys), + IgnoreContinuation(o.IgnoreContinuation), + IgnoreInlineComment(o.IgnoreInlineComment), + SkipUnrecognizableLines(o.SkipUnrecognizableLines), + AllowBooleanKeys(o.AllowBooleanKeys), + AllowShadows(o.AllowShadows), + AllowNestedValues(o.AllowNestedValues), + AllowPythonMultilineValues(o.AllowPythonMultilineValues), + SpaceBeforeInlineComment(o.SpaceBeforeInlineComment), + UnescapeValueDoubleQuotes(o.UnescapeValueDoubleQuotes), + UnescapeValueCommentSymbols(o.UnescapeValueCommentSymbols), + PreserveSurroundingQuotes(o.PreserveSurroundedQuote), + AllowNonUniqueSections(o.AllowNonUniqueSections), + AllowDuplicateShadowValues(o.AllowDuplicateShadowValues), } if o.UnparseableSections != nil { opts = append(opts, RawSections(o.UnparseableSections...)) @@ -355,7 +347,7 @@ func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { } b.WriteString("next = ok\n") - f, err := Load([]byte(b.String()), AllowPythonMultilineValues()) + f, err := Load([]byte(b.String()), AllowPythonMultilineValues(true)) if err != nil { t.Fatal(err) } diff --git a/roundtrip_test.go b/roundtrip_test.go index f2e1005..3ed3670 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -120,14 +120,14 @@ func TestWriteReadsBack(t *testing.T) { data string opts []LoadOption }{ - {"a = 'x\\'\nb = 1\n", nil}, // value ending in a backslash - {"k = `a\"\"\"\nb`\n", nil}, // value containing """ - {"k=`= v`\n", []LoadOption{KeyValueDelimiters(" =")}}, // value starting with a delimiter - {"[u]\nExecStart=\nExecStart=a\n", []LoadOption{AllowShadows()}}, // empty value resets - {"k = a\n; c\nk = b\n", []LoadOption{AllowShadows()}}, // comment of a shadowed key - {"k =\n n\nk\n", []LoadOption{AllowNestedValues(), AllowBooleanKeys()}}, - {"k = \"\"\n n\n", []LoadOption{AllowNestedValues(), AllowPythonMultilineValues()}}, - {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{CaseInsensitive(), AllowNonUniqueSections()}}, + {"a = 'x\\'\nb = 1\n", nil}, // value ending in a backslash + {"k = `a\"\"\"\nb`\n", nil}, // value containing """ + {"k=`= v`\n", []LoadOption{KeyValueDelimiters(" =")}}, // value starting with a delimiter + {"[u]\nExecStart=\nExecStart=a\n", []LoadOption{AllowShadows(true)}}, // empty value resets + {"k = a\n; c\nk = b\n", []LoadOption{AllowShadows(true)}}, // comment of a shadowed key + {"k =\n n\nk\n", []LoadOption{AllowNestedValues(true), AllowBooleanKeys(true)}}, + {"k = \"\"\n n\n", []LoadOption{AllowNestedValues(true), AllowPythonMultilineValues(true)}}, + {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{CaseInsensitive(true), AllowNonUniqueSections(true)}}, {"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section } { f, err := Load([]byte(tc.data), tc.opts...) diff --git a/section_test.go b/section_test.go index 5c6de83..beaa9b3 100644 --- a/section_test.go +++ b/section_test.go @@ -75,7 +75,7 @@ func TestSection_NewKey(t *testing.T) { }) t.Run("create keys with same name and allow shadow", func(t *testing.T) { - f, err := Load([]byte(""), AllowShadows()) + f, err := Load([]byte(""), AllowShadows(true)) require.NoError(t, err) require.NotNil(t, f) diff --git a/struct.go b/struct.go index 68d72e7..c867630 100644 --- a/struct.go +++ b/struct.go @@ -226,7 +226,7 @@ func decodeSlice(key *Key, field reflect.Value, delim string, allowShadow, isStr if allowShadow { // Each value is split as Key.Strings splits one, so that escaped // elements read back as Marshal wrote them. An empty value, kept - // with KeepEmptyShadowValues, is an empty element, unless it is the + // with PreserveEmptyShadowValues, is an empty element, unless it is the // only one: an empty slice is written as one empty value. vals := key.Values() if len(vals) == 1 && vals[0] == "" { @@ -834,16 +834,16 @@ func (f *File) ReflectFrom(v any) error { // Unmarshal parses INI data with opts and maps it to the struct v points to, // as File.MapTo does. It reads what Marshal writes: when v has allowshadow // fields it loads with AllowShadows, AllowDuplicateShadowValues and -// KeepEmptyShadowValues, and when it has nonunique fields with +// PreserveEmptyShadowValues, and when it has nonunique fields with // AllowNonUniqueSections, as those tags need. func Unmarshal(data []byte, v any, opts ...LoadOption) error { if v != nil { shadows, nonUnique := tagOptions(reflect.TypeOf(v), map[reflect.Type]bool{}) if shadows { - opts = append([]LoadOption{AllowShadows(), AllowDuplicateShadowValues(), KeepEmptyShadowValues()}, opts...) + opts = append([]LoadOption{AllowShadows(true), AllowDuplicateShadowValues(true), PreserveEmptyShadowValues(true)}, opts...) } if nonUnique { - opts = append([]LoadOption{AllowNonUniqueSections()}, opts...) + opts = append([]LoadOption{AllowNonUniqueSections(true)}, opts...) } } return MapTo(v, data, opts...) @@ -878,7 +878,7 @@ func Marshal(v any, opts ...WriteOption) ([]byte, error) { } // Shadows and non-unique sections let allowshadow and nonunique fields // write all their values. - f := Empty(AllowShadows(), AllowDuplicateShadowValues(), AllowNonUniqueSections()) + f := Empty(AllowShadows(true), AllowDuplicateShadowValues(true), AllowNonUniqueSections(true)) if err := f.ReflectFrom(v); err != nil { return nil, err } diff --git a/struct_test.go b/struct_test.go index 7c506c3..8dddbe5 100644 --- a/struct_test.go +++ b/struct_test.go @@ -314,7 +314,7 @@ func Test_MapToStruct(t *testing.T) { }) t.Run("map with shadows", func(t *testing.T) { - f, err := Load([]byte(confDataStruct), AllowShadows()) + f, err := Load([]byte(confDataStruct), AllowShadows(true)) require.NoError(t, err) ts := new(testStruct) assert.NoError(t, f.MapTo(ts)) @@ -387,7 +387,7 @@ names=alice, bruce`)) func Test_MapToStructNonUniqueSections(t *testing.T) { t.Run("map to struct non unique", func(t *testing.T) { t.Run("map file to struct non unique", func(t *testing.T) { - f, err := Load([]byte(confNonUniqueSectionDataStruct), AllowNonUniqueSections()) + f, err := Load([]byte(confNonUniqueSectionDataStruct), AllowNonUniqueSections(true)) require.NoError(t, err) ts := new(testNonUniqueSectionsStruct) @@ -412,7 +412,7 @@ func Test_MapToStructNonUniqueSections(t *testing.T) { newPeer := new(testPeer) newPeerSlice := make([]testPeer, 0) - f, err := Load([]byte(confNonUniqueSectionDataStruct), AllowNonUniqueSections()) + f, err := Load([]byte(confNonUniqueSectionDataStruct), AllowNonUniqueSections(true)) require.NoError(t, err) // try only first one @@ -446,7 +446,7 @@ FieldInSection = 3 FieldInSubSection = 4 FieldInSubSection2 = 5 FieldInSection = 6 -`), AllowNonUniqueSections()) +`), AllowNonUniqueSections(true)) require.NoError(t, err) type SubSection struct { @@ -647,7 +647,7 @@ func Test_ReflectFromStructNonUniqueSections(t *testing.T) { }, } - cfg := Empty(AllowNonUniqueSections()) + cfg := Empty(AllowNonUniqueSections(true)) assert.NoError(t, cfg.ReflectFrom(nonUnique)) @@ -730,7 +730,7 @@ func TestMapToAndReflectFromStructWithShadows(t *testing.T) { cfg, err := Load([]byte(` [include] path = /tmp/gpm-profiles/test5.profile -path = /tmp/gpm-profiles/test1.profile`), AllowShadows()) +path = /tmp/gpm-profiles/test1.profile`), AllowShadows(true)) require.NoError(t, err) sec := cfg.Section("include") @@ -752,7 +752,7 @@ path = /tmp/gpm-profiles/test1.profile ) t.Run("reflect from struct with shadows", func(t *testing.T) { - cfg := Empty(AllowShadows()) + cfg := Empty(AllowShadows(true)) type ShadowStruct struct { StringArray []string `ini:"sa,allowshadow"` EmptyStringArrat []string `ini:"empty,omitempty,allowshadow"` @@ -941,7 +941,7 @@ func TestMarshal_ShadowsAndNonUniqueSections(t *testing.T) { assert.Equal(t, "path = a\npath = b\npath = c\n\n[Peer]\nkey = x\n\n[Peer]\nkey = y\n", string(data)) var out config - require.NoError(t, Unmarshal(data, &out, AllowShadows(), AllowNonUniqueSections())) + require.NoError(t, Unmarshal(data, &out, AllowShadows(true), AllowNonUniqueSections(true))) assert.Equal(t, in, out) } @@ -1056,7 +1056,7 @@ func TestMapTo_NonUniqueSlices(t *testing.T) { After string } var c cfg - require.NoError(t, MapTo(&c, []byte("After = a\n[Peer]\nKey = x\n[Peer]\nKey = y\n"), AllowNonUniqueSections())) + require.NoError(t, MapTo(&c, []byte("After = a\n[Peer]\nKey = x\n[Peer]\nKey = y\n"), AllowNonUniqueSections(true))) require.Len(t, c.Peers, 2) assert.Equal(t, "x", c.Peers[0].Key) assert.Equal(t, "y", c.Peers[1].Key) @@ -1066,7 +1066,7 @@ func TestMapTo_NonUniqueSlices(t *testing.T) { var s struct { Names []string `ini:"Name,nonunique"` } - assert.Error(t, MapTo(&s, []byte("[Name]\nk = v\n"), AllowNonUniqueSections())) + assert.Error(t, MapTo(&s, []byte("[Name]\nk = v\n"), AllowNonUniqueSections(true))) } type testNode struct { @@ -1077,7 +1077,7 @@ type testNode struct { func TestMapToReflectFrom_RecursiveType(t *testing.T) { var n testNode - require.NoError(t, MapTo(&n, []byte("X = 1\n[Child]\nX = 2\n[Kid]\nX = 3\n[Kid]\nX = 4\n"), AllowNonUniqueSections())) + require.NoError(t, MapTo(&n, []byte("X = 1\n[Child]\nX = 2\n[Kid]\nX = 3\n[Kid]\nX = 4\n"), AllowNonUniqueSections(true))) assert.Equal(t, 1, n.X) require.NotNil(t, n.Child) assert.Equal(t, 2, n.Child.X) @@ -1105,7 +1105,7 @@ func TestReflectFrom_StructSlice(t *testing.T) { func TestReflectFrom_PointerToPointer(t *testing.T) { type conf struct{ Lang string } c := &conf{Lang: "en"} - for _, opts := range [][]LoadOption{nil, {AllowNonUniqueSections()}} { + for _, opts := range [][]LoadOption{nil, {AllowNonUniqueSections(true)}} { f := Empty(opts...) require.NoError(t, f.Section("i18n").ReflectFrom(&c)) assert.Equal(t, "en", f.Section("i18n").Key("Lang").String()) @@ -1129,7 +1129,7 @@ func TestReflectFrom_NonUniqueNilAndNonStruct(t *testing.T) { }{Ints: []*int{&n}}) assert.Error(t, err) - f := Empty(AllowNonUniqueSections()) + f := Empty(AllowNonUniqueSections(true)) require.NoError(t, f.Section("Peer").ReflectFrom([]*peer{nil, {"y"}})) secs, err := f.SectionsByName("Peer") require.NoError(t, err) @@ -1280,8 +1280,8 @@ func TestReflectFrom_AllowShadow(t *testing.T) { } func TestReflectFrom_InsensitiveDefaultSection(t *testing.T) { - for _, opt := range []LoadOption{CaseInsensitive(), CaseInsensitiveSections()} { - f, err := Load([]byte("a=1\n[x]\nb=2\n"), opt, AllowNonUniqueSections()) + for _, opt := range []LoadOption{CaseInsensitive(true), CaseInsensitiveSections(true)} { + f, err := Load([]byte("a=1\n[x]\nb=2\n"), opt, AllowNonUniqueSections(true)) require.NoError(t, err) require.NoError(t, f.ReflectFrom(&struct{ C int }{3})) assert.Equal(t, "1", f.Section("").Key("a").String()) From 8d0d76448f7fc4b00a1d2db4bbc84bc3bea0b889 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 01:27:04 +0000 Subject: [PATCH 25/41] Replace NoLocking(bool) with Locking(bool), and let Unmarshal's tags win Locking(false) reads without a double negative and maps directly from v1's File.BlockMode = false. Unmarshal adds the options its allowshadow and nonunique tags need after the caller's, so AllowShadows(false) or a config flag that defaults to false can't silently drop slice elements and sections. --- README.md | 4 ++-- bench_test.go | 2 +- file.go | 4 ++-- file_test.go | 2 +- options.go | 11 ++++++----- struct.go | 9 ++++++--- struct_test.go | 6 ++++++ 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7dbf146..d4b0a9b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `DEFAULT_SECTION`, `AllCapsUnderscore`, `SnackCase` | `DefaultSection`, `SnakeCase` | | `LoadOptions.Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `ini.CaseInsensitive(true)`, `ini.CaseInsensitiveSections(true)`, `ini.CaseInsensitiveKeys(true)` | | `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections`, `PreserveSurroundedQuote` | `ini.IgnoreMissingFiles(true)`, `ini.FirstSourceOnly(true)`, `ini.RawSections(names...)`, `ini.PreserveSurroundingQuotes(true)` | -| `File.BlockMode = false` | `ini.NoLocking(true)` | +| `File.BlockMode = false` | `ini.Locking(false)` | | `MapToWithMapper(v, m, src)`, `StrictMapToWithMapper` | `MapTo(v, src, ini.NameMapping(m))`, `StrictMapTo(v, src, ini.NameMapping(m))` | | `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with the file loaded with `ini.NameMapping(m)` | | `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | @@ -78,7 +78,7 @@ New in v2: - Nil pointer fields aren't written. - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - A `nonunique` field with no sections is left empty instead of failing. -- Unless loaded with `NoLocking(true)`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. +- Unless loaded with `Locking(false)`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. - `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated, empty or delimiter-containing elements and `nonunique` sections. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. diff --git a/bench_test.go b/bench_test.go index f0ee5f5..ab075f2 100644 --- a/bench_test.go +++ b/bench_test.go @@ -25,7 +25,7 @@ import ( func newTestFile(block bool) *File { var opts []LoadOption if !block { - opts = append(opts, NoLocking(true)) + opts = append(opts, Locking(false)) } c, _ := Load([]byte(confData), opts...) return c diff --git a/file.go b/file.go index 2137a08..a819b2f 100644 --- a/file.go +++ b/file.go @@ -31,7 +31,7 @@ type File struct { options loadOptions dataSources []dataSource - // locking guards the contents with lock, unless NoLocking is set. + // locking guards the contents with lock, unless loaded with Locking(false). locking bool lock sync.RWMutex @@ -45,7 +45,7 @@ type File struct { func newFile(dataSources []dataSource, opts []LoadOption) *File { o := newLoadOptions(opts) return &File{ - locking: !o.NoLocking, + locking: o.Locking, dataSources: dataSources, byName: make(map[string][]*Section), options: o, diff --git a/file_test.go b/file_test.go index 3385f97..68ab6c2 100644 --- a/file_test.go +++ b/file_test.go @@ -653,7 +653,7 @@ func TestFile_EdgeCases(t *testing.T) { }) } -// Unless loaded with NoLocking, a File can be read while other goroutines +// Unless loaded with Locking(false), a File can be read while other goroutines // change it. Run with -race. func TestFile_Concurrency(t *testing.T) { path := filepath.Join(t.TempDir(), "app.ini") diff --git a/options.go b/options.go index 625db5b..d3fa7f4 100644 --- a/options.go +++ b/options.go @@ -53,13 +53,14 @@ type loadOptions struct { DefaultSection string CommentSymbols string PreserveEmptyShadowValues bool - NoLocking bool + Locking bool NameMapper NameMapper ValueMapper ValueMapper } func newLoadOptions(opts []LoadOption) loadOptions { o := loadOptions{ + Locking: true, KeyValueDelimiters: "=:", ChildSectionDelimiter: ".", DefaultSection: DefaultSection, @@ -71,10 +72,10 @@ func newLoadOptions(opts []LoadOption) loadOptions { return o } -// NoLocking turns off the lock that lets a File be read from many goroutines -// while others change it. Use it for a File only one goroutine uses, to save -// the cost of locking. -func NoLocking(v bool) LoadOption { return func(o *loadOptions) { o.NoLocking = v } } +// Locking sets whether a File takes a lock so it can be read from many +// goroutines while others change it. The default is true; turn it off for a +// File only one goroutine uses, to save the cost of locking. +func Locking(v bool) LoadOption { return func(o *loadOptions) { o.Locking = v } } // NameMapping sets the NameMapper that maps struct field names to section // and key names, for MapTo, StrictMapTo and ReflectFrom. diff --git a/struct.go b/struct.go index c867630..738be28 100644 --- a/struct.go +++ b/struct.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "reflect" + "slices" "strconv" "strings" "time" @@ -835,15 +836,17 @@ func (f *File) ReflectFrom(v any) error { // as File.MapTo does. It reads what Marshal writes: when v has allowshadow // fields it loads with AllowShadows, AllowDuplicateShadowValues and // PreserveEmptyShadowValues, and when it has nonunique fields with -// AllowNonUniqueSections, as those tags need. +// AllowNonUniqueSections, as those tags need. These come after opts, so +// passing one of them as false doesn't drop the values the tags read. func Unmarshal(data []byte, v any, opts ...LoadOption) error { if v != nil { shadows, nonUnique := tagOptions(reflect.TypeOf(v), map[reflect.Type]bool{}) + opts = slices.Clone(opts) if shadows { - opts = append([]LoadOption{AllowShadows(true), AllowDuplicateShadowValues(true), PreserveEmptyShadowValues(true)}, opts...) + opts = append(opts, AllowShadows(true), AllowDuplicateShadowValues(true), PreserveEmptyShadowValues(true)) } if nonUnique { - opts = append([]LoadOption{AllowNonUniqueSections(true)}, opts...) + opts = append(opts, AllowNonUniqueSections(true)) } } return MapTo(v, data, opts...) diff --git a/struct_test.go b/struct_test.go index 8dddbe5..229842f 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1333,4 +1333,10 @@ func TestMarshalUnmarshal_AllowShadowElements(t *testing.T) { var out cfg require.NoError(t, Unmarshal(data, &out)) assert.Equal(t, in, out, "written: %q", data) + + // The tags need these options; turning them off, as with a config + // flag that defaults to false, must not drop elements. + out = cfg{} + require.NoError(t, Unmarshal(data, &out, AllowShadows(false), AllowDuplicateShadowValues(false), PreserveEmptyShadowValues(false))) + assert.Equal(t, in, out, "written: %q", data) } From 5388ac299aa1abd51b08d032800fc7b380519014 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 01:57:52 +0000 Subject: [PATCH 26/41] Compare the public API with v1, and read allowshadow slices as v1 did api_parity_test.go calls v1 and v2 on the same files and values: every Key accessor, the lenient, Valid and Strict lists, In, Range and Must*, Write, MapTo and StrictMapTo into a struct with every field kind v1 maps, ReflectFrom of the mapped struct, and the struct API with targets of every shape (T, *T, **T, nil, slices). Where v1 doesn't panic, v2 must return the same, except for the deliberate changes in the README. Planting the old ReflectFrom(&ptr) bug back makes it fail. It found that allowshadow slices un-escaped backslashes, which v1 never did: a value such as C:\\dir mapped to a different string. They are read as in v1 again, each value split at the delimiter, and Marshal and ReflectFrom return an error for an element that contains the delimiter, which v1 wrote and read back as two elements. --- README.md | 2 +- api_parity_test.go | 679 +++++++++++++++++++++++++++++++++++++++++++++ struct.go | 27 +- struct_test.go | 7 +- 4 files changed, 702 insertions(+), 13 deletions(-) create mode 100644 api_parity_test.go diff --git a/README.md b/README.md index d4b0a9b..d956d08 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ New in v2: - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - A `nonunique` field with no sections is left empty instead of failing. - Unless loaded with `Locking(false)`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. -- `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated, empty or delimiter-containing elements and `nonunique` sections. +- `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated or empty elements and `nonunique` sections. An `allowshadow` element that contains the delimiter can't be read back, as each value is split at it, so `Marshal` and `ReflectFrom` return an error for it; v1 wrote it and read it back as two elements. - Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. ## Getting Help diff --git a/api_parity_test.go b/api_parity_test.go new file mode 100644 index 0000000..76494c6 --- /dev/null +++ b/api_parity_test.go @@ -0,0 +1,679 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "bytes" + "errors" + "fmt" + "math/rand" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + "time" + + v1 "gopkg.in/ini.v1" +) + +func init() { + // v1 writes "\r\n" on Windows; v2 keeps "\n" in this package's tests. + v1.LineBreak = "\n" +} + +// The API parity tests call the public API of v1 and v2 on the same files +// and values. Where v1 returns without panicking, v2 must return the same, +// unless the difference is one of the deliberate changes in the README. + +type apiSub struct { + Name string + Port int + Hosts []string +} + +// APIEmbed is exported so that MapTo can set the fields it promotes. +type APIEmbed struct{ Shared string } + +// apiCfg has a field of every kind v1 maps. +type apiCfg struct { + APIEmbed + Str string + Lower string `ini:"lower"` + Bool bool + Int int + Int64 int64 + Uint uint + Uint64 uint64 + Float float64 + Dur time.Duration + Time time.Time + PStr *string + PInt *int + PBool *bool + PFloat *float64 + Strs []string + Ints []int `delim:"|"` + Int64s []int64 + Uints []uint + Floats []float64 + Bools []bool + Times []time.Time + Shadow []string `ini:"shadow,,allowshadow"` + Omit string `ini:"omit,omitempty"` + Skip string `ini:"-"` + Sub apiSub `ini:"sub"` + PSub *apiSub `ini:"psub"` +} + +type apiPeers struct { + Peers []apiSub `ini:"peer,,,nonunique"` +} + +// newAPICfg returns an apiCfg with every field set, so the tests see which +// fields mapping changes and which it leaves. +func newAPICfg() *apiCfg { + s, i, b, f := "p", 4, true, 2.5 + return &apiCfg{ + APIEmbed: APIEmbed{"shared"}, Str: "def", Lower: "low", Bool: true, Int: 3, Int64: -3, Uint: 3, Uint64: 3, + Float: 1.5, Dur: 7 * time.Second, Time: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), + PStr: &s, PInt: &i, PBool: &b, PFloat: &f, + Strs: []string{"x"}, Ints: []int{9}, Int64s: []int64{9}, Uints: []uint{9}, Floats: []float64{9}, Bools: []bool{true}, + Times: []time.Time{time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}, Shadow: []string{"s"}, Omit: "o", Skip: "skip", + Sub: apiSub{"n", 1, []string{"h"}}, PSub: &apiSub{"pn", 2, []string{"ph"}}, + } +} + +// try calls fn and reports whether it panicked. +func try(fn func()) (panicked bool) { + defer func() { + if recover() != nil { + panicked = true + } + }() + fn() + return false +} + +func errStr(err error) string { + if err == nil { + return "" + } + return "error" +} + +type apiChecker struct { + t *testing.T + input []byte + flags uint32 +} + +func (c *apiChecker) same(what string, want, got any) { + c.t.Helper() + if !reflect.DeepEqual(want, got) { + c.t.Fatalf("v1 and v2 differ in %s\ninput: %q\nflags: %#x\nv1: %#v\nv2: %#v", what, c.input, c.flags, want, got) + } +} + +// checkAPIParity loads data with v1 and v2 and compares what the public API +// returns for it. +func checkAPIParity(t *testing.T, data []byte, flags uint32) { + t.Helper() + opts := parityOptions(flags) + if opts.AllowPythonMultilineValues && len(data) > 4096 { + return + } + c := &apiChecker{t: t, input: data, flags: flags} + + var f1 *v1.File + var err1 error + if try(func() { f1, err1 = v1.LoadSources(opts, data) }) || err1 != nil { + return // The parse parity tests cover errors. + } + f2, err := LoadSources([]any{data}, v2Options(opts)...) + c.same("load error", "", errStr(err)) + + expand := v1ExpandsSafely(f1) + + c.same("SectionNames", f1.SectionStrings(), f2.SectionNames()) + secs1, secs2 := f1.Sections(), f2.Sections() + for i, s1 := range secs1 { + s2, sname := secs2[i], s1.Name() + c.same(sname+" KeyNames", s1.KeyStrings(), s2.KeyNames()) + c.same(sname+" KeyValues", s1.KeysHash(), s2.KeyValues()) + c.same(sname+" Body", s1.Body(), s2.Body()) + c.same(sname+" Comment", s1.Comment, s2.Comment()) + c.same(sname+" ChildSections", len(s1.ChildSections()), len(s2.ChildSections())) + c.same(sname+" ParentKeys", len(s1.ParentKeys()), len(s2.ParentKeys())) + for _, kname := range s1.KeyStrings() { + c.checkKey(sname+"."+kname, s1.Key(kname), s2.Key(kname), expand) + } + } + c.checkWrite(opts, f1, f2) + if expand { + c.checkMapping(f1, f2) + } +} + +func (c *apiChecker) checkKey(name string, k1 *v1.Key, k2 *Key, expand bool) { + c.t.Helper() + c.same(name+" Value", k1.Value(), k2.Value()) + c.same(name+" Values", k1.ValueWithShadows(), k2.Values()) + c.same(name+" NestedValues", k1.NestedValues(), k2.NestedValues()) + c.same(name+" Comment", k1.Comment, k2.Comment()) + if !expand { + return + } + pair := func(what string, v1v any, e1 error, v2v any, e2 error) { + c.t.Helper() + c.same(name+" "+what+" error", errStr(e1), errStr(e2)) + if e1 == nil { + c.same(name+" "+what, v1v, v2v) + } + } + c.same(name+" String", k1.String(), k2.String()) + b1, e1 := k1.Bool() + b2, e2 := k2.Bool() + pair("Bool", b1, e1, b2, e2) + i1, e1 := k1.Int() + i2, e2 := k2.Int() + pair("Int", i1, e1, i2, e2) + j1, e1 := k1.Int64() + j2, e2 := k2.Int64() + pair("Int64", j1, e1, j2, e2) + u1, e1 := k1.Uint() + u2, e2 := k2.Uint() + pair("Uint", u1, e1, u2, e2) + w1, e1 := k1.Uint64() + w2, e2 := k2.Uint64() + pair("Uint64", w1, e1, w2, e2) + f1, e1 := k1.Float64() + f2, e2 := k2.Float64() + if e1 == nil && f1 != f1 { // NaN + f1, f2 = 0, 0 + } + pair("Float64", f1, e1, f2, e2) + d1, e1 := k1.Duration() + d2, e2 := k2.Duration() + pair("Duration", d1, e1, d2, e2) + t1, e1 := k1.Time() + t2, e2 := k2.Time() + pair("Time", t1, e1, t2, e2) + t1, e1 = k1.TimeFormat(time.DateOnly) + t2, e2 = k2.TimeFormat(time.DateOnly) + pair("TimeFormat", t1, e1, t2, e2) + + for _, delim := range []string{",", "|", " "} { + d := fmt.Sprintf("(%q)", delim) + c.same(name+" Strings"+d, k1.Strings(delim), k2.Strings(delim)) + c.same(name+" StringsWithShadows"+d, k1.StringsWithShadows(delim), k2.StringsWithShadows(delim)) + c.same(name+" Bools"+d, k1.Bools(delim), List[bool](k2, delim)) + c.same(name+" Ints"+d, outOfRangeToZero(k1, delim, k1.Ints(delim)), List[int](k2, delim)) + c.same(name+" Int64s"+d, outOfRangeToZero(k1, delim, k1.Int64s(delim)), List[int64](k2, delim)) + c.same(name+" Uints"+d, outOfRangeToZero(k1, delim, k1.Uints(delim)), List[uint](k2, delim)) + c.same(name+" Uint64s"+d, outOfRangeToZero(k1, delim, k1.Uint64s(delim)), List[uint64](k2, delim)) + c.same(name+" Float64s"+d, nanToZero(outOfRangeToZero(k1, delim, k1.Float64s(delim))), nanToZero(List[float64](k2, delim))) + c.same(name+" Times"+d, k1.Times(delim), List[time.Time](k2, delim)) + c.same(name+" TimesFormat"+d, k1.TimesFormat(time.DateOnly, delim), k2.TimesFormat(time.DateOnly, delim)) + c.same(name+" ValidBools"+d, k1.ValidBools(delim), ValidList[bool](k2, delim)) + c.same(name+" ValidInts"+d, k1.ValidInts(delim), ValidList[int](k2, delim)) + c.same(name+" ValidInt64s"+d, k1.ValidInt64s(delim), ValidList[int64](k2, delim)) + c.same(name+" ValidUints"+d, k1.ValidUints(delim), ValidList[uint](k2, delim)) + c.same(name+" ValidUint64s"+d, k1.ValidUint64s(delim), ValidList[uint64](k2, delim)) + c.same(name+" ValidFloat64s"+d, nanToZero(k1.ValidFloat64s(delim)), nanToZero(ValidList[float64](k2, delim))) + c.same(name+" ValidTimes"+d, k1.ValidTimes(delim), ValidList[time.Time](k2, delim)) + c.same(name+" ValidTimesFormat"+d, k1.ValidTimesFormat(time.DateOnly, delim), k2.ValidTimesFormat(time.DateOnly, delim)) + strict := func(what string, a any, e1 error, b any, e2 error) { + c.t.Helper() + c.same(name+" "+what+d+" error", errStr(e1), errStr(e2)) + if e1 == nil { + c.same(name+" "+what+d, a, b) + } + } + sb1, e1 := k1.StrictBools(delim) + sb2, e2 := StrictList[bool](k2, delim) + strict("StrictBools", sb1, e1, sb2, e2) + si1, e1 := k1.StrictInts(delim) + si2, e2 := StrictList[int](k2, delim) + strict("StrictInts", si1, e1, si2, e2) + sj1, e1 := k1.StrictInt64s(delim) + sj2, e2 := StrictList[int64](k2, delim) + strict("StrictInt64s", sj1, e1, sj2, e2) + su1, e1 := k1.StrictUints(delim) + su2, e2 := StrictList[uint](k2, delim) + strict("StrictUints", su1, e1, su2, e2) + sw1, e1 := k1.StrictUint64s(delim) + sw2, e2 := StrictList[uint64](k2, delim) + strict("StrictUint64s", sw1, e1, sw2, e2) + sf1, e1 := k1.StrictFloat64s(delim) + sf2, e2 := StrictList[float64](k2, delim) + strict("StrictFloat64s", nanToZero(sf1), e1, nanToZero(sf2), e2) + st1, e1 := k1.StrictTimes(delim) + st2, e2 := StrictList[time.Time](k2, delim) + strict("StrictTimes", st1, e1, st2, e2) + sf3, e1 := k1.StrictTimesFormat(time.DateOnly, delim) + sf4, e2 := k2.StrictTimesFormat(time.DateOnly, delim) + strict("StrictTimesFormat", sf3, e1, sf4, e2) + } + + day := func(d int) time.Time { return time.Date(2006, 1, d, 0, 0, 0, 0, time.UTC) } + c.same(name+" In", k1.In("d", []string{"a", "b", ""}), In(k2, "d", []string{"a", "b", ""})) + c.same(name+" InTimeFormat", k1.InTimeFormat(time.DateOnly, day(9), []time.Time{day(2)}), k2.InTimeFormat(time.DateOnly, day(9), []time.Time{day(2)})) + c.same(name+" RangeTimeFormat", k1.RangeTimeFormat(time.DateOnly, day(9), day(1), day(3)), k2.RangeTimeFormat(time.DateOnly, day(9), day(1), day(3))) + if _, err := k1.Int64(); err == nil { + c.same(name+" InInt", k1.InInt(7, []int{0, 1, 5}), In(k2, 7, []int{0, 1, 5})) + c.same(name+" InInt64", k1.InInt64(7, []int64{0, 1, 5}), In(k2, int64(7), []int64{0, 1, 5})) + c.same(name+" RangeInt", k1.RangeInt(7, -1, 3), Range(k2, 7, -1, 3)) + c.same(name+" RangeInt64", k1.RangeInt64(7, -1, 3), Range(k2, int64(7), -1, 3)) + } + if _, err := k1.Uint64(); err == nil { + c.same(name+" InUint", k1.InUint(7, []uint{0, 1, 5}), In(k2, uint(7), []uint{0, 1, 5})) + c.same(name+" InUint64", k1.InUint64(7, []uint64{0, 1, 5}), In(k2, uint64(7), []uint64{0, 1, 5})) + } + if v, err := k1.Float64(); err == nil && v == v { + c.same(name+" InFloat64", k1.InFloat64(7, []float64{0, 1.5}), In(k2, 7.0, []float64{0, 1.5})) + c.same(name+" RangeFloat64", k1.RangeFloat64(7, -1, 3), Range(k2, 7.0, -1, 3)) + } + if _, err := k1.Time(); err == nil { + c.same(name+" InTime", k1.InTime(day(9), []time.Time{day(2)}), In(k2, day(9), []time.Time{day(2)})) + } + + // Must* last: they store the default when the value doesn't parse. + c.same(name+" MustString", k1.MustString("d"), k2.MustString("d")) + c.same(name+" MustBool", k1.MustBool(true), k2.MustBool(true)) + c.same(name+" MustInt", k1.MustInt(7), k2.MustInt(7)) + c.same(name+" MustInt64", k1.MustInt64(7), k2.MustInt64(7)) + c.same(name+" MustUint", k1.MustUint(7), k2.MustUint(7)) + c.same(name+" MustUint64", k1.MustUint64(7), k2.MustUint64(7)) + if v, err := k1.Float64(); err != nil || v == v { + c.same(name+" MustFloat64", k1.MustFloat64(7), k2.MustFloat64(7)) + } + c.same(name+" MustDuration", k1.MustDuration(7), k2.MustDuration(7)) + c.same(name+" MustTime", k1.MustTime(day(9)), k2.MustTime(day(9))) + c.same(name+" String after Must", k1.String(), k2.String()) +} + +// outOfRangeToZero applies a deliberate change to v1's lenient list +// result: v1 kept the clamped value strconv returns for an out-of-range +// element (MaxInt64, +Inf), v2 gives the zero value as for any element that +// doesn't parse. +func outOfRangeToZero[T int | int64 | uint | uint64 | float64](k1 *v1.Key, delim string, vals []T) []T { + for i, s := range k1.Strings(delim) { + var err error + var zero T + switch any(zero).(type) { + case int, int64: + _, err = strconv.ParseInt(s, 0, 64) + case uint, uint64: + _, err = strconv.ParseUint(s, 0, 64) + case float64: + _, err = strconv.ParseFloat(s, 64) + } + if errors.Is(err, strconv.ErrRange) && i < len(vals) { + vals[i] = zero + } + } + return vals +} + +func nanToZero(vals []float64) []float64 { + for i, v := range vals { + if v != v { + vals[i] = 0 + } + } + return vals +} + +var refPattern = regexp.MustCompile(`%\(([^)]*)\)s`) + +// v1ExpandsSafely reports whether v1 can expand every %(name)s reference +// in f. v1 follows references without a limit, so a cycle overflows the +// stack and a chain of doubling values runs out of memory. A reference may +// resolve in the key's section, a parent section or the default section, +// so this treats a reference to a name as one to every key of that name. +func v1ExpandsSafely(f *v1.File) bool { + refs := map[string][]string{} + n := 0 + for _, sec := range f.Sections() { + for _, k := range sec.Keys() { + for _, v := range k.ValueWithShadows() { + for _, m := range refPattern.FindAllStringSubmatch(v, -1) { + from, to := strings.ToLower(k.Name()), strings.ToLower(m[1]) + refs[from] = append(refs[from], to) + n++ + } + } + } + } + if n > 16 { + return false + } + // No cycles: every name finishes its depth-first walk. + state := map[string]int{} // 1 visiting, 2 done + var visit func(string) bool + visit = func(name string) bool { + switch state[name] { + case 1: + return false + case 2: + return true + } + state[name] = 1 + for _, to := range refs[name] { + if !visit(to) { + return false + } + } + state[name] = 2 + return true + } + for name := range refs { + if !visit(name) { + return false + } + } + return true +} + +// v1WriteReadsBack reports whether v1's output for f1 loads with v1 to the +// same contents. Where it does, v2 must write the same bytes; where it +// doesn't, v2 deliberately writes something else that reads back. +func v1WriteReadsBack(opts v1.LoadOptions, f1 *v1.File, out []byte) bool { + g, err := v1.LoadSources(opts, out) + return err == nil && dumpV1(g) == dumpV1(f1) +} + +func (c *apiChecker) checkWrite(opts v1.LoadOptions, f1 *v1.File, f2 *File) { + c.t.Helper() + var b1, b2 bytes.Buffer + if try(func() { _, _ = f1.WriteTo(&b1) }) || !v1WriteReadsBack(opts, f1, b1.Bytes()) { + return + } + _, err := f2.Write(&b2) + c.same("Write error", "", errStr(err)) + c.same("Write", b1.String(), b2.String()) +} + +// checkMapping maps the default section and the peer sections with both +// versions, then writes the mapped struct back with ReflectFrom. +func (c *apiChecker) checkMapping(f1 *v1.File, f2 *File) { + c.t.Helper() + for _, strict := range []bool{false, true} { + m1, m2 := newAPICfg(), newAPICfg() + var e1 error + if try(func() { + if strict { + e1 = f1.StrictMapTo(m1) + } else { + e1 = f1.MapTo(m1) + } + }) { + continue + } + var e2 error + if strict { + e2 = f2.StrictMapTo(m2) + } else { + e2 = f2.MapTo(m2) + } + what := fmt.Sprintf("MapTo (strict %v)", strict) + if strict && e1 == nil && e2 != nil && hasInvalidSliceElem(f2) { + // Deliberate: StrictMapTo reports invalid slice elements, which + // v1 left as zero values. + continue + } + c.same(what+" error", errStr(e1), errStr(e2)) + if e1 == nil { + c.same(what, m1, m2) + } + if !strict && e1 == nil { + c.checkReflect(m1) + } + + p1, p2 := &apiPeers{}, &apiPeers{} + if try(func() { e1 = f1.MapTo(p1) }) || e1 != nil { + continue + } + c.same("MapTo peers error", "", errStr(f2.MapTo(p2))) + c.same("MapTo peers", p1, p2) + } +} + +// hasInvalidSliceElem reports whether a typed slice field of apiCfg has an +// element that doesn't parse. +func hasInvalidSliceElem(f *File) bool { + sec, err := f.GetSection("") + if err != nil { + return false + } + invalid := func(name string, list func(k *Key) error) bool { + k, err := sec.GetKey(name) + return err == nil && list(k) != nil + } + return invalid("Ints", func(k *Key) error { _, err := StrictList[int](k, "|"); return err }) || + invalid("Int64s", func(k *Key) error { _, err := StrictList[int64](k, ","); return err }) || + invalid("Uints", func(k *Key) error { _, err := StrictList[uint](k, ","); return err }) || + invalid("Floats", func(k *Key) error { _, err := StrictList[float64](k, ","); return err }) || + invalid("Bools", func(k *Key) error { _, err := StrictList[bool](k, ","); return err }) || + invalid("Times", func(k *Key) error { _, err := StrictList[time.Time](k, ","); return err }) +} + +// checkReflect writes v with ReflectFrom from both versions. Where v1's +// output maps back to v, v2 must write the same bytes. +func (c *apiChecker) checkReflect(v *apiCfg) { + c.t.Helper() + f1 := v1.Empty() + var b1 bytes.Buffer + var err error + if try(func() { err = f1.ReflectFrom(v) }) || err != nil { + return + } + if try(func() { _, err = f1.WriteTo(&b1) }) || err != nil { + return + } + back := newAPICfg() + g, err := v1.Load(b1.Bytes()) + if err != nil || try(func() { err = g.MapTo(back) }) || err != nil || !reflect.DeepEqual(back, v) { + return + } + f2 := Empty() + c.same("ReflectFrom error", "", errStr(f2.ReflectFrom(v))) + var b2 bytes.Buffer + _, err = f2.Write(&b2) + c.same("ReflectFrom Write error", "", errStr(err)) + c.same("ReflectFrom Write", b1.String(), b2.String()) +} + +// apiValues are values on the boundaries of the accessors and struct +// mapping. +var apiValues = []string{ + "", "0", "-0", "1", "-1", "+5", "007", "0x1F", "0o17", "0b101", "1_000", "1e3", "1.5", "-2.5", ".5", "5.", + "9223372036854775807", "9223372036854775808", "-9223372036854775808", "-9223372036854775809", + "18446744073709551615", "18446744073709551616", "4294967296", "1e400", "-1e400", "NaN", "Inf", "-Inf", "0x1p-2", + "true", "TRUE", "True", "yes", "on", "OFF", "y", "n", "t", "F", "maybe", " 1 ", + "1s", "-5s", "0s", "1h2m3.5s", "5", "1.5h", "300ms", "1us", "1µs", "-0s", + "2006-01-02T15:04:05Z", "2006-01-02T15:04:05.123456789+08:00", "2006-01-02", "2006-01-02T15:04:05", "2006-01-02 15:04:05Z", + "a,b", "a, b ,c", "a\\,b", "a\\\\,b", "a\\b", "a,", ",a", ",", ",,", "1|2|x", "1|99999999999999999999|-3", "1, 2, 3", + "1s,2s", "true,no,x", "2006-01-02,2006-01-03", "2006-01-02T15:04:05Z,bad", "1.5,NaN,2", "a b", "x y z", + "`quoted`", "\"q\"", "'s'", "\"\"\"tq\"\"\"", "v ; c", "v # c", "a\\", "\\", "é,ü", "\t", +} + +var apiKeys = []string{ + "Shared", "Str", "lower", "Bool", "Int", "Int64", "Uint", "Uint64", "Float", "Dur", "Time", + "PStr", "PInt", "PBool", "PFloat", "Strs", "Ints", "Int64s", "Uints", "Floats", "Bools", "Times", + "shadow", "shadow", "omit", "Skip", "Name", "Port", "Hosts", +} + +// apiDoc returns a document whose keys are apiCfg's, with random values. +func apiDoc(rng *rand.Rand) string { + var b strings.Builder + order := map[string]int{} + for i, k := range apiKeys { + order[k] = i + } + value := func(key string) string { + v := apiValues[rng.Intn(len(apiValues))] + // A reference only to a key earlier in apiKeys, so that there are + // no cycles and chains of references form. + if i := order[key]; i > 0 && rng.Intn(6) == 0 { + ref := apiKeys[rng.Intn(i)] + if rng.Intn(4) == 0 { + ref = "missing" + } + v = []string{"", v, "x,"}[rng.Intn(3)] + "%(" + ref + ")s" + []string{"", ",y", "|2"}[rng.Intn(3)] + } + return v + } + keys := func(names []string) { + for _, k := range names { + for n := rng.Intn(3); n > 0; n-- { + fmt.Fprintf(&b, "%s = %s\n", k, value(k)) + } + } + } + keys(apiKeys) + for _, sec := range []string{"sub", "psub", "peer", "peer", "sub.child"} { + if rng.Intn(2) == 0 { + fmt.Fprintf(&b, "[%s]\n", sec) + keys([]string{"Name", "Port", "Hosts", "Str"}) + } + } + return b.String() +} + +// apiSeeds are documents for the fuzz test and the random test. +func apiSeeds() []string { + rng := rand.New(rand.NewSource(2)) + seeds := []string{ + "Str = a\nlower = %(Str)s-x\nInt = [%(lower)s]\n[sub]\nName = %(Int)s,%(Str)s\nPort = %(Name)s\n", + "Str = ${HOME}\nInts = 1|2|3\nDur = 1m\nTime = 2006-01-02T15:04:05Z\n", + } + for i := 0; i < 50; i++ { + seeds = append(seeds, apiDoc(rng)) + } + return seeds +} + +// TestAPIParityShapes passes values of every shape to the struct API. +func TestAPIParityShapes(t *testing.T) { + data := []byte("Str = a\nInt = 2\nshadow = x\nshadow = y\n[sub]\nName = n\nPort = 3\n[peer]\nName = p1\n[peer]\nName = p2\n") + shapes := map[string]func() any{ + "*T": func() any { return newAPICfg() }, + "**T": func() any { p := newAPICfg(); return &p }, + "**T nil": func() any { var p *apiCfg; return &p }, + "***T": func() any { p := newAPICfg(); pp := &p; return &pp }, + "T": func() any { return *newAPICfg() }, + "nil": func() any { return nil }, + "*T nil": func() any { return (*apiCfg)(nil) }, + "*int": func() any { return new(int) }, + "*[]*sub": func() any { return &[]*apiSub{} }, + "[]*sub": func() any { return []*apiSub{{Name: "a"}, {Name: "b"}} }, + "[]sub": func() any { return []apiSub{{Name: "a"}} }, + "*sub": func() any { return &apiSub{Name: "a"} }, + "*peers": func() any { return &apiPeers{} }, + "*peers full": func() any { return &apiPeers{Peers: []apiSub{{Name: "a"}, {Name: "b"}}} }, + "*map": func() any { return &map[string]string{} }, + } + for _, flags := range []uint32{0, 1 << 7, 1 << 14, 1<<7 | 1<<14} { + opts := parityOptions(flags) + for name, shape := range shapes { + for _, call := range []string{"File.MapTo", "File.StrictMapTo", "Section.MapTo", "MapTo", "StrictMapTo", "File.ReflectFrom", "Section.ReflectFrom"} { + c := &apiChecker{t: t, input: data, flags: flags} + what := fmt.Sprintf("%s(%s)", call, name) + f1, err := v1.LoadSources(opts, data) + if err != nil { + t.Fatal(err) + } + f2, err := LoadSources([]any{data}, v2Options(opts)...) + if err != nil { + t.Fatal(err) + } + t1, t2 := shape(), shape() + var e1, e2 error + if try(func() { + switch call { + case "File.MapTo": + e1 = f1.MapTo(t1) + case "File.StrictMapTo": + e1 = f1.StrictMapTo(t1) + case "Section.MapTo": + e1 = f1.Section("peer").MapTo(t1) + case "MapTo": + e1 = v1.MapTo(t1, data) + case "StrictMapTo": + e1 = v1.StrictMapTo(t1, data) + case "File.ReflectFrom": + e1 = f1.ReflectFrom(t1) + case "Section.ReflectFrom": + e1 = f1.Section("peer").ReflectFrom(t1) + } + }) { + continue + } + switch call { + case "File.MapTo": + e2 = f2.MapTo(t2) + case "File.StrictMapTo": + e2 = f2.StrictMapTo(t2) + case "Section.MapTo": + e2 = f2.Section("peer").MapTo(t2) + case "MapTo": + e2 = MapTo(t2, data) + case "StrictMapTo": + e2 = StrictMapTo(t2, data) + case "File.ReflectFrom": + e2 = f2.ReflectFrom(t2) + case "Section.ReflectFrom": + e2 = f2.Section("peer").ReflectFrom(t2) + } + c.same(what+" error", errStr(e1), errStr(e2)) + c.same(what, t1, t2) + if e1 != nil { + // v1 could change the file before failing; v2 checks + // first. + continue + } + var b1, b2 bytes.Buffer + _, _ = f1.WriteTo(&b1) + _, _ = f2.Write(&b2) + c.same(what+" written", b1.String(), b2.String()) + } + } + } +} + +func TestAPIParity(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for _, in := range append(apiSeeds(), paritySeeds...) { + for i := 0; i < 3; i++ { + checkAPIParity(t, []byte(in), rng.Uint32()) + } + checkAPIParity(t, []byte(in), 0) + } + for i := 0; i < 300; i++ { + checkAPIParity(t, []byte(apiDoc(rng)), rng.Uint32()&(1<<16|1<<7|1<<14|1<<15|1<<1|1<<2|1<<6|1<<8)) + } +} + +func FuzzAPIParity(f *testing.F) { + for _, in := range append(apiSeeds(), paritySeeds...) { + f.Add([]byte(in), uint32(0)) + f.Add([]byte(in), uint32(1<<7)) + } + f.Fuzz(func(t *testing.T, data []byte, flags uint32) { + checkAPIParity(t, data, flags) + }) +} diff --git a/struct.go b/struct.go index 738be28..add989d 100644 --- a/struct.go +++ b/struct.go @@ -225,20 +225,18 @@ func decodeSlice(key *Key, field reflect.Value, delim string, allowShadow, isStr var strs []string if allowShadow { - // Each value is split as Key.Strings splits one, so that escaped - // elements read back as Marshal wrote them. An empty value, kept - // with PreserveEmptyShadowValues, is an empty element, unless it is the - // only one: an empty slice is written as one empty value. + // As in v1, each value is split at delim with no escapes, and each + // element trimmed and expanded. An empty value, kept with + // PreserveEmptyShadowValues, is an empty element, unless it is the only + // one: an empty slice is written as one empty value. vals := key.Values() if len(vals) == 1 && vals[0] == "" { vals = nil } for _, v := range vals { - if v == "" { - strs = append(strs, "") - continue + for _, s := range strings.Split(v, delim) { + strs = append(strs, key.expandValue(strings.TrimSpace(s))) } - strs = append(strs, splitList(key.expandValue(v), delim)...) } } else { strs = key.Strings(delim) @@ -537,10 +535,14 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) vals[i] = formatValue(field.Index(i)) } - for i := range vals { - vals[i] = escapeSliceElem(vals[i], delim) - } if allowShadow { + // Each element is a value of its own, and the reader splits values + // at delim without escapes, as v1 did. + for _, v := range vals { + if strings.Contains(v, delim) { + return fmt.Errorf("element %q contains the delimiter %q, which an allowshadow slice can't hold", v, delim) + } + } if len(vals) > 1 && !key.s.f.options.AllowShadows { return errors.New("shadow key is not allowed") } @@ -557,6 +559,9 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) key.s.f.wunlock() return nil } + for i := range vals { + vals[i] = escapeSliceElem(vals[i], delim) + } key.SetValue(strings.Join(vals, delim)) return nil } diff --git a/struct_test.go b/struct_test.go index 229842f..844f6ce 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1326,7 +1326,7 @@ func TestMarshalUnmarshal_AllowShadowElements(t *testing.T) { Sh []string `ini:"sh,,allowshadow"` N []int `ini:"n,,allowshadow"` } - in := cfg{Sh: []string{"a", "a", "b,c", "", `d\`}, N: []int{1, 1, 2}} + in := cfg{Sh: []string{"a", "a", `b\\c`, "", `d\`}, N: []int{1, 1, 2}} data, err := Marshal(in) require.NoError(t, err) @@ -1339,4 +1339,9 @@ func TestMarshalUnmarshal_AllowShadowElements(t *testing.T) { out = cfg{} require.NoError(t, Unmarshal(data, &out, AllowShadows(false), AllowDuplicateShadowValues(false), PreserveEmptyShadowValues(false))) assert.Equal(t, in, out, "written: %q", data) + + // v1 read each value split at the delimiter, so an element containing + // it can't be written. + _, err = Marshal(cfg{Sh: []string{"b,c"}}) + assert.Error(t, err) } From b9fc952345348ad51931279a4df08115fe469078 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 02:55:55 +0000 Subject: [PATCH 27/41] Must* return the default without storing it v1's Must* methods wrote the default into the key, so later reads, MapTo and Write saw it, and saving a config wrote out every default the program had asked for. They now only read; call SetValue to store a value. The API parity test resets v1's value after each Must* call. Without the write-back it also exposed two deliberate MapTo differences that the old chained defaults hid (zero durations, out-of-range list elements), now listed with the others. --- README.md | 3 ++- api_parity_test.go | 61 ++++++++++++++++++++++++++++++++++++++-------- key.go | 19 ++++++++------- key_test.go | 21 ++++++++-------- section_test.go | 4 +-- 5 files changed, 75 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index d956d08..293e425 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ Without options, v2 parses every input exactly as v1's `Load` does. The parity t | `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | | `s.Comment`, `k.Comment` fields | `Comment()` and `SetComment(c)` methods | | `IsErrDelimiterNotFound(err)`, `IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)`, `errors.Is(err, ini.ErrEmptyKeyName)` | -| `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues`; `KeyValues` includes the defaults `Must*` methods stored, which v1's `KeysHash` left out | +| `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues` | +| `k.MustInt(3)` and the other `Must*` stored the default in the key, so later reads, `MapTo` and `Write` saw it | `Must*` return the default without storing it; where code reads the default back later, call `k.SetValue` | | `Key.ValueWithShadows`, `Key.IsBooleanType`, `Section.Haskey` | `Values`, `IsBoolean`, `HasKey` | | `k.Ints(",")`, `k.ValidInts(",")`, `k.StrictInts(",")` and the same for the other types | `ini.List[int](k, ",")`, `ini.ValidList[int](k, ",")`, `ini.StrictList[int](k, ",")`, with the same behavior; any `Scalar` type works, such as `int8`, `time.Duration` or a named string type | | `k.InInt(def, cands)`, `k.In(def, cands)` and the others | `ini.In(k, def, cands)` | diff --git a/api_parity_test.go b/api_parity_test.go index 76494c6..4cd6811 100644 --- a/api_parity_test.go +++ b/api_parity_test.go @@ -290,18 +290,25 @@ func (c *apiChecker) checkKey(name string, k1 *v1.Key, k2 *Key, expand bool) { c.same(name+" InTime", k1.InTime(day(9), []time.Time{day(2)}), In(k2, day(9), []time.Time{day(2)})) } - // Must* last: they store the default when the value doesn't parse. - c.same(name+" MustString", k1.MustString("d"), k2.MustString("d")) - c.same(name+" MustBool", k1.MustBool(true), k2.MustBool(true)) - c.same(name+" MustInt", k1.MustInt(7), k2.MustInt(7)) - c.same(name+" MustInt64", k1.MustInt64(7), k2.MustInt64(7)) - c.same(name+" MustUint", k1.MustUint(7), k2.MustUint(7)) - c.same(name+" MustUint64", k1.MustUint64(7), k2.MustUint64(7)) + // v1's Must* stored the default in the key when the value didn't parse; + // v2's only read. Put v1's value back after each call so both see the + // same value, then check that v2 left the key as it was. + v1Value := k1.Value() + must := func(what string, v1, v2 any) { + c.same(name+" "+what, v1, v2) + k1.SetValue(v1Value) + } + must("MustString", k1.MustString("d"), k2.MustString("d")) + must("MustBool", k1.MustBool(true), k2.MustBool(true)) + must("MustInt", k1.MustInt(7), k2.MustInt(7)) + must("MustInt64", k1.MustInt64(7), k2.MustInt64(7)) + must("MustUint", k1.MustUint(7), k2.MustUint(7)) + must("MustUint64", k1.MustUint64(7), k2.MustUint64(7)) if v, err := k1.Float64(); err != nil || v == v { - c.same(name+" MustFloat64", k1.MustFloat64(7), k2.MustFloat64(7)) + must("MustFloat64", k1.MustFloat64(7), k2.MustFloat64(7)) } - c.same(name+" MustDuration", k1.MustDuration(7), k2.MustDuration(7)) - c.same(name+" MustTime", k1.MustTime(day(9)), k2.MustTime(day(9))) + must("MustDuration", k1.MustDuration(7), k2.MustDuration(7)) + must("MustTime", k1.MustTime(day(9)), k2.MustTime(day(9))) c.same(name+" String after Must", k1.String(), k2.String()) } @@ -436,6 +443,40 @@ func (c *apiChecker) checkMapping(f1 *v1.File, f2 *File) { continue } c.same(what+" error", errStr(e1), errStr(e2)) + if m2.Dur <= 0 && m1.Dur > 0 { + if d, err := f2.Section("").Key("Dur").Duration(); err == nil && d == m2.Dur { + // Deliberate: v2 applies zero and negative durations, which + // v1 skipped. + m1.Dur = m2.Dur + } + } + if !strict { + // Deliberate: an out-of-range list element maps to zero, as in + // ini.List. + sec := f1.Section("") + if k, err := sec.GetKey("Ints"); err == nil && len(m1.Ints) == len(m2.Ints) { + m1.Ints = outOfRangeToZero(k, "|", m1.Ints) + } + if k, err := sec.GetKey("Int64s"); err == nil && len(m1.Int64s) == len(m2.Int64s) { + m1.Int64s = outOfRangeToZero(k, ",", m1.Int64s) + } + if k, err := sec.GetKey("Uints"); err == nil && len(m1.Uints) == len(m2.Uints) { + m1.Uints = outOfRangeToZero(k, ",", m1.Uints) + } + if k, err := sec.GetKey("Floats"); err == nil && len(m1.Floats) == len(m2.Floats) { + m1.Floats = outOfRangeToZero(k, ",", m1.Floats) + } + } + // NaN never equals itself. + m1.Floats, m2.Floats = nanToZero(m1.Floats), nanToZero(m2.Floats) + for _, m := range []*apiCfg{m1, m2} { + if m.Float != m.Float { + m.Float = 0 + } + if m.PFloat != nil && *m.PFloat != *m.PFloat { + *m.PFloat = 0 + } + } if e1 == nil { c.same(what, m1, m2) } diff --git a/key.go b/key.go index cf2d71e..b4729a8 100644 --- a/key.go +++ b/key.go @@ -341,10 +341,10 @@ func (k *Key) Time() (time.Time, error) { } // MustString returns default value if key value is empty. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustString(defaultVal string) string { val := k.String() if len(val) == 0 { - k.SetValue(defaultVal) return defaultVal } return val @@ -352,10 +352,10 @@ func (k *Key) MustString(defaultVal string) string { // MustBool always returns value without error, // it returns false if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustBool(defaultVal ...bool) bool { val, err := k.Bool() if len(defaultVal) > 0 && err != nil { - k.SetValue(strconv.FormatBool(defaultVal[0])) return defaultVal[0] } return val @@ -363,10 +363,10 @@ func (k *Key) MustBool(defaultVal ...bool) bool { // MustFloat64 always returns value without error, // it returns 0.0 if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustFloat64(defaultVal ...float64) float64 { val, err := k.Float64() if len(defaultVal) > 0 && err != nil { - k.SetValue(strconv.FormatFloat(defaultVal[0], 'f', -1, 64)) return defaultVal[0] } return val @@ -374,10 +374,10 @@ func (k *Key) MustFloat64(defaultVal ...float64) float64 { // MustInt always returns value without error, // it returns 0 if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustInt(defaultVal ...int) int { val, err := k.Int() if len(defaultVal) > 0 && err != nil { - k.SetValue(strconv.FormatInt(int64(defaultVal[0]), 10)) return defaultVal[0] } return val @@ -385,10 +385,10 @@ func (k *Key) MustInt(defaultVal ...int) int { // MustInt64 always returns value without error, // it returns 0 if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustInt64(defaultVal ...int64) int64 { val, err := k.Int64() if len(defaultVal) > 0 && err != nil { - k.SetValue(strconv.FormatInt(defaultVal[0], 10)) return defaultVal[0] } return val @@ -396,10 +396,10 @@ func (k *Key) MustInt64(defaultVal ...int64) int64 { // MustUint always returns value without error, // it returns 0 if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustUint(defaultVal ...uint) uint { val, err := k.Uint() if len(defaultVal) > 0 && err != nil { - k.SetValue(strconv.FormatUint(uint64(defaultVal[0]), 10)) return defaultVal[0] } return val @@ -407,10 +407,10 @@ func (k *Key) MustUint(defaultVal ...uint) uint { // MustUint64 always returns value without error, // it returns 0 if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustUint64(defaultVal ...uint64) uint64 { val, err := k.Uint64() if len(defaultVal) > 0 && err != nil { - k.SetValue(strconv.FormatUint(defaultVal[0], 10)) return defaultVal[0] } return val @@ -418,10 +418,10 @@ func (k *Key) MustUint64(defaultVal ...uint64) uint64 { // MustDuration always returns value without error, // it returns zero value if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { val, err := k.Duration() if len(defaultVal) > 0 && err != nil { - k.SetValue(defaultVal[0].String()) return defaultVal[0] } return val @@ -429,10 +429,10 @@ func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { // MustTimeFormat always parses with given format and returns value without error, // it returns zero value if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustTimeFormat(format string, defaultVal ...time.Time) time.Time { val, err := k.TimeFormat(format) if len(defaultVal) > 0 && err != nil { - k.SetValue(defaultVal[0].Format(format)) return defaultVal[0] } return val @@ -440,6 +440,7 @@ func (k *Key) MustTimeFormat(format string, defaultVal ...time.Time) time.Time { // MustTime always parses with RFC3339 format and returns value without error, // it returns zero value if error occurs. +// It doesn't store the default in the key; call SetValue for that. func (k *Key) MustTime(defaultVal ...time.Time) time.Time { return k.MustTimeFormat(time.RFC3339, defaultVal...) } diff --git a/key_test.go b/key_test.go index 269e7f9..f2507af 100644 --- a/key_test.go +++ b/key_test.go @@ -241,17 +241,16 @@ func TestKey_Helpers(t *testing.T) { assert.Equal(t, dur.Seconds(), sec.Key("DURATION_404").MustDuration(dur).Seconds()) - t.Run("must should set default as key value", func(t *testing.T) { - assert.Equal(t, "404", sec.Key("STRING_404").String()) - assert.Equal(t, "true", sec.Key("BOOL_404").String()) - assert.Equal(t, "2.5", sec.Key("FLOAT64_404").String()) - assert.Equal(t, "15", sec.Key("INT_404").String()) - assert.Equal(t, "15", sec.Key("INT64_404").String()) - assert.Equal(t, "6", sec.Key("UINT_404").String()) - assert.Equal(t, "6", sec.Key("UINT64_404").String()) - assert.Equal(t, "2014-01-01T20:17:05Z", sec.Key("TIME_404").String()) - assert.Equal(t, "2h45m0s", sec.Key("DURATION_404").String()) - assert.Equal(t, "12289", sec.Key("HEX_NUMBER_404").String()) + t.Run("must doesn't store the default", func(t *testing.T) { + for _, name := range []string{"STRING_404", "BOOL_404", "FLOAT64_404", "INT_404", "INT64_404", + "UINT_404", "UINT64_404", "TIME_404", "DURATION_404", "HEX_NUMBER_404"} { + assert.Equal(t, "", sec.Key(name).String(), name) + } + // A value that doesn't parse stays as it is. + k, err := sec.NewKey("BAD_INT", "x") + require.NoError(t, err) + assert.Equal(t, 7, k.MustInt(7)) + assert.Equal(t, "x", k.Value()) }) }) }) diff --git a/section_test.go b/section_test.go index beaa9b3..8969b4b 100644 --- a/section_test.go +++ b/section_test.go @@ -298,7 +298,7 @@ file = b.log } }) - t.Run("reports current values, Must* defaults included", func(t *testing.T) { + t.Run("reports values as loaded or set", func(t *testing.T) { f, err := Load([]byte("empty =\nset = 1\ngone = 2\n")) require.NoError(t, err) sec := f.Section("") @@ -307,7 +307,7 @@ file = b.log sec.Key("set").SetValue("3") sec.DeleteKey("gone") - assert.Equal(t, map[string]string{"empty": "fallback", "set": "3"}, sec.KeyValues()) + assert.Equal(t, map[string]string{"empty": "", "set": "3"}, sec.KeyValues()) }) } From c4d8353da9c5c7dd6ea787ab23db054304cc8d2d Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 17:50:33 +0000 Subject: [PATCH 28/41] Remove the v1 comparison tests; add examples and a migration guide The tests that compared v2 with gopkg.in/ini.v1 made v1 a requirement of v2's module. They did their job during development and are removed; the parser inputs they started from move to testdata/inputs.txt, which the v2 fuzzers keep using. The package doc describes the whole API, runnable examples cover loading, options, lists, struct mapping, writing and parse errors, and docs/migrate-v1-to-v2.md replaces the README's migration table. The README gains a getting-started example and a v1 vs v2 performance table. CI also runs on pushes to the v2 branch. --- .github/workflows/go.yml | 4 +- README.md | 139 ++--- api_parity_test.go | 720 ------------------------ bench_test.go | 67 --- docs/migrate-v1-to-v2.md | 153 +++++ example_test.go | 179 ++++++ file.go | 14 +- go.mod | 5 +- go.sum | 11 - ini.go | 30 +- inputs_test.go | 92 +++ lexer.go | 9 +- parity_test.go | 360 ------------ parser_test.go | 24 + roundtrip_test.go | 19 +- struct.go | 4 +- api_fuzz_test.go => struct_fuzz_test.go | 15 +- testdata/inputs.txt | 99 ++++ 18 files changed, 669 insertions(+), 1275 deletions(-) delete mode 100644 api_parity_test.go create mode 100644 docs/migrate-v1-to-v2.md create mode 100644 example_test.go create mode 100644 inputs_test.go delete mode 100644 parity_test.go rename api_fuzz_test.go => struct_fuzz_test.go (93%) create mode 100644 testdata/inputs.txt diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fb682fe..745bf1c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,16 +1,18 @@ name: Go on: push: - branches: [main] + branches: [main, v2] paths: - '**.go' - 'go.mod' + - 'testdata/**' - '.golangci.yml' - '.github/workflows/go.yml' pull_request: paths: - '**.go' - 'go.mod' + - 'testdata/**' - '.golangci.yml' - '.github/workflows/go.yml' env: diff --git a/README.md b/README.md index 293e425..4209c31 100644 --- a/README.md +++ b/README.md @@ -4,88 +4,89 @@ ![](https://avatars0.githubusercontent.com/u/10216035?v=3&s=200) -Package ini provides INI file read and write functionality in Go. +Package ini reads and writes INI files in Go. ## Features -- Load from multiple data sources(file, `[]byte`, `io.Reader` and `io.ReadCloser`) with overwrites. -- Read with recursion values. -- Read with parent-child sections. -- Read with auto-increment key names. -- Read with multiple-line values. -- Read with tons of helper methods. -- Read and convert values to Go types. -- Read and **WRITE** comments of sections and keys. -- Manipulate sections, keys and comments with ease. -- Keep sections and keys in order as you parse and save. +- Load from files, `[]byte` and `io.Reader`, merging several sources in order. +- Sections and keys kept in order, parent-child sections, auto-increment keys, repeated (shadow) keys, nested values, multiline values, and `%(name)s` references. +- Typed access to values: numbers, booleans, durations, times, and lists, ranges and candidates of any of them. +- Map files to and from structs, and `Marshal` and `Unmarshal` like `encoding/json`. +- Write files back with their comments and order; what is written always loads back the same. +- Parse errors that give the line and column. +- Safe for concurrent use. ## Installation The minimum requirement of Go is **1.26**. ```sh -$ go get unknwon.dev/ini/v2@latest +go get unknwon.dev/ini/v2@latest ``` +## Getting started + +```go +package main + +import ( + "fmt" + "log" + "time" + + "unknwon.dev/ini/v2" +) + +func main() { + f, err := ini.Load("app.ini") + if err != nil { + log.Fatal(err) // e.g. "line 3, column 1: key-value delimiter not found: broken line" + } + + server := f.Section("server") + port := server.Key("port").MustInt(8080) + timeout := server.Key("timeout").MustDuration(30 * time.Second) + fmt.Println(port, timeout) + + server.Key("port").SetValue("9090") + if err := f.SaveTo("app.ini"); err != nil { + log.Fatal(err) + } +} +``` + +Options change the syntax that is accepted, such as `ini.Load("app.ini", ini.AllowShadows(true), ini.IgnoreInlineComment(true))`. The [package documentation](https://pkg.go.dev/unknwon.dev/ini/v2) has examples of struct mapping, typed lists and write options. + +## Performance + +v2 loads files up to 31 times faster than v1, with a third to a fifth of the allocations: + +| Operation | v1.67.3 | v2 | Allocations, v1 → v2 | +|---|---|---|---| +| Load 10,000 keys in one section | 137 ms | 4.4 ms (31× faster) | 50.2k → 10.1k | +| Load 10,000 keys in 1,000 sections | 8.2 ms | 3.2 ms (2.5× faster) | 58.1k → 18.1k | +| Load 10,000 keys, mixed syntax | 101 ms | 3.9 ms (26× faster) | 57.6k → 18.8k | +| Write 10,000 keys | 2.2–4.3 ms | 4.3–7.4 ms (1.7–2.0× slower) | 3.0k–20.0k → 13.0k–30.0k | +| `MapTo` a 13-field struct | 3.8 µs | 4.5 µs | 44 → 48 | +| `ReflectFrom` the same struct | 13.9 µs | 11.8 µs | 132 → 118 | +| Read one key | 60 ns | 71 ns, or 36 ns with `Locking(false)` | 0 → 0 | + +- **Load** is linear in the number of keys. v1 compared each new key with every key before it in its section, so a large section took quadratic time. +- **Write** is slower because it checks that every line it writes reads back as the same value. v1 could write files that loaded differently. +- **Reads** take the file's read lock so that they are safe while other goroutines change the file; v1 read without it. + +Measured with Go 1.26.10 on a 4-core Intel Xeon at 2.60 GHz, as the median of 8 runs. + ## Migrating from v1 -Without options, v2 parses every input exactly as v1's `Load` does. The parity tests check this against the released `gopkg.in/ini.v1`. The exception: a Python-style multiline value longer than 4 KiB is read in full instead of failing. The API changes are: - -| v1 | v2 | -|---|---| -| `import "gopkg.in/ini.v1"` | `import "unknwon.dev/ini/v2"` (package name is still `ini`) | -| `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | -| `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows(true))` | -| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `ini.IgnoreMissingFiles(true)`, `ini.CaseInsensitive(true)`, `ini.AllowShadows(true)` | -| `LoadOptions.DebugFunc` | `ini.Debug(fn)` | -| `LoadOptions.ReaderBufferSize` | removed; values are never cut at a buffer size | -| `ini.DefaultSection = "default"` | `ini.DefaultSectionName("default")` | -| `ini.LineBreak`, `DefaultHeader`, `DefaultFormatLeft/Right` | `WriteOption`s of `File.Write` and `File.SaveTo`: `ini.LineBreak(s)`, `ini.DefaultHeader(true)`, `ini.DelimiterSpaces(left, right)` | -| `ini.PrettyFormat = false`, `ini.PrettySection = false`, `ini.PrettyEqual = true` | `ini.AlignDelimiters(false)`, `ini.BlankLineBetweenSections(false)`, `ini.DelimiterSpaces(" ", " ")` | -| `LoadOptions.KeyValueDelimiterOnWrite` | `ini.WriteDelimiter(d)` write option | -| `WriteToIndent(w, i)`, `SaveToIndent(f, i)` | `Write(w, ini.Indent(i))`, `SaveTo(f, ini.Indent(i))` | -| `MapTo(v, a, b)` | `MapTo(v, a, opts...)`; one source | -| `DEFAULT_SECTION`, `AllCapsUnderscore`, `SnackCase` | `DefaultSection`, `SnakeCase` | -| `LoadOptions.Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `ini.CaseInsensitive(true)`, `ini.CaseInsensitiveSections(true)`, `ini.CaseInsensitiveKeys(true)` | -| `LoadOptions.Loose`, `ShortCircuit`, `UnparseableSections`, `PreserveSurroundedQuote` | `ini.IgnoreMissingFiles(true)`, `ini.FirstSourceOnly(true)`, `ini.RawSections(names...)`, `ini.PreserveSurroundingQuotes(true)` | -| `File.BlockMode = false` | `ini.Locking(false)` | -| `MapToWithMapper(v, m, src)`, `StrictMapToWithMapper` | `MapTo(v, src, ini.NameMapping(m))`, `StrictMapTo(v, src, ini.NameMapping(m))` | -| `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with the file loaded with `ini.NameMapping(m)` | -| `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | -| `s.Comment`, `k.Comment` fields | `Comment()` and `SetComment(c)` methods | -| `IsErrDelimiterNotFound(err)`, `IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)`, `errors.Is(err, ini.ErrEmptyKeyName)` | -| `File.SectionStrings`, `Section.KeyStrings`, `Section.KeysHash` | `SectionNames`, `KeyNames`, `KeyValues` | -| `k.MustInt(3)` and the other `Must*` stored the default in the key, so later reads, `MapTo` and `Write` saw it | `Must*` return the default without storing it; where code reads the default back later, call `k.SetValue` | -| `Key.ValueWithShadows`, `Key.IsBooleanType`, `Section.Haskey` | `Values`, `IsBoolean`, `HasKey` | -| `k.Ints(",")`, `k.ValidInts(",")`, `k.StrictInts(",")` and the same for the other types | `ini.List[int](k, ",")`, `ini.ValidList[int](k, ",")`, `ini.StrictList[int](k, ",")`, with the same behavior; any `Scalar` type works, such as `int8`, `time.Duration` or a named string type | -| `k.InInt(def, cands)`, `k.In(def, cands)` and the others | `ini.In(k, def, cands)` | -| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)` and the others | `ini.Range(k, def, lo, hi)`; type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)` | -| `Reload` merged sources into the current contents; `Append` re-read every source | `Reload` replaces the contents with the sources (kept `*Section`/`*Key` pointers see new values; on error nothing changes); `Append` reads only the new sources; readers are buffered so they can be reloaded | - -New in v2: - -- Parse errors are `*ini.ParseError` with 1-based `Line` and `Column`. -- `ini.CommentSymbols(s)` sets the comment characters. -- `ini.PreserveEmptyShadowValues(true)` keeps empty values of shadow keys. -- `Key.IsBoolean()`, and `ini.Marshal`/`ini.Unmarshal`. -- `File.Write` writes files that load back to the same sections, keys, values and comments: it picks a quoting the parser reads back, writes empty shadow values, and returns an error instead of writing a file that loads differently. -- `MapTo`, `StrictMapTo`, `Unmarshal`, `ReflectFrom` and `Marshal` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Differences from v1 that valid input can show: - - Out-of-range numbers (int8 `300`) are an error in `StrictMapTo` and leave the field unchanged in `MapTo`; v1 wrapped them. - - `StrictMapTo` reports invalid slice elements. - - Durations of `0` and negative durations are applied. - - uint8 fields map. - - Times keep sub-second precision when written. - - Slice elements containing the delimiter, or ending in a backslash, are escaped so they read back as one element. - - Nil pointer fields aren't written. - - Fields after an empty `nonunique` slice or a `StructReflector` field are written. - - A `nonunique` field with no sections is left empty instead of failing. -- Unless loaded with `Locking(false)`, a `File` can be read from many goroutines while others call `Reload`, `Append`, `SetValue`, `NewKey`, `NewSection`, the delete methods, `ReflectFrom` or `Write`; v1 locked only some writes. -- `Unmarshal` reads what `Marshal` writes, including `allowshadow` slices with repeated or empty elements and `nonunique` sections. An `allowshadow` element that contains the delimiter can't be read back, as each value is split at it, so `Marshal` and `ReflectFrom` return an error for it; v1 wrote it and read it back as two elements. -- Loading is O(n) in the number of keys; a 10,000-key section loads in about 6 ms instead of about 150 ms with v1.67.3. - -## Getting Help - -- [Getting Started](https://ini.unknwon.io/docs/intro/getting_started) +Without options, v2 parses every input exactly as v1 does. The API changed: the import path is `unknwon.dev/ini/v2`, options are functions, package-level settings became options, and typed list methods became generic functions. A few behaviors changed in ways the compiler can't catch, such as `Must*` methods no longer storing their default in the file. + +The [migration guide](docs/migrate-v1-to-v2.md) lists every change. + +## Getting help + +- [Package documentation and examples](https://pkg.go.dev/unknwon.dev/ini/v2) +- [Documentation site](https://ini.unknwon.io/docs/intro/getting_started), which still describes v1 ## License diff --git a/api_parity_test.go b/api_parity_test.go deleted file mode 100644 index 4cd6811..0000000 --- a/api_parity_test.go +++ /dev/null @@ -1,720 +0,0 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package ini - -import ( - "bytes" - "errors" - "fmt" - "math/rand" - "reflect" - "regexp" - "strconv" - "strings" - "testing" - "time" - - v1 "gopkg.in/ini.v1" -) - -func init() { - // v1 writes "\r\n" on Windows; v2 keeps "\n" in this package's tests. - v1.LineBreak = "\n" -} - -// The API parity tests call the public API of v1 and v2 on the same files -// and values. Where v1 returns without panicking, v2 must return the same, -// unless the difference is one of the deliberate changes in the README. - -type apiSub struct { - Name string - Port int - Hosts []string -} - -// APIEmbed is exported so that MapTo can set the fields it promotes. -type APIEmbed struct{ Shared string } - -// apiCfg has a field of every kind v1 maps. -type apiCfg struct { - APIEmbed - Str string - Lower string `ini:"lower"` - Bool bool - Int int - Int64 int64 - Uint uint - Uint64 uint64 - Float float64 - Dur time.Duration - Time time.Time - PStr *string - PInt *int - PBool *bool - PFloat *float64 - Strs []string - Ints []int `delim:"|"` - Int64s []int64 - Uints []uint - Floats []float64 - Bools []bool - Times []time.Time - Shadow []string `ini:"shadow,,allowshadow"` - Omit string `ini:"omit,omitempty"` - Skip string `ini:"-"` - Sub apiSub `ini:"sub"` - PSub *apiSub `ini:"psub"` -} - -type apiPeers struct { - Peers []apiSub `ini:"peer,,,nonunique"` -} - -// newAPICfg returns an apiCfg with every field set, so the tests see which -// fields mapping changes and which it leaves. -func newAPICfg() *apiCfg { - s, i, b, f := "p", 4, true, 2.5 - return &apiCfg{ - APIEmbed: APIEmbed{"shared"}, Str: "def", Lower: "low", Bool: true, Int: 3, Int64: -3, Uint: 3, Uint64: 3, - Float: 1.5, Dur: 7 * time.Second, Time: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), - PStr: &s, PInt: &i, PBool: &b, PFloat: &f, - Strs: []string{"x"}, Ints: []int{9}, Int64s: []int64{9}, Uints: []uint{9}, Floats: []float64{9}, Bools: []bool{true}, - Times: []time.Time{time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}, Shadow: []string{"s"}, Omit: "o", Skip: "skip", - Sub: apiSub{"n", 1, []string{"h"}}, PSub: &apiSub{"pn", 2, []string{"ph"}}, - } -} - -// try calls fn and reports whether it panicked. -func try(fn func()) (panicked bool) { - defer func() { - if recover() != nil { - panicked = true - } - }() - fn() - return false -} - -func errStr(err error) string { - if err == nil { - return "" - } - return "error" -} - -type apiChecker struct { - t *testing.T - input []byte - flags uint32 -} - -func (c *apiChecker) same(what string, want, got any) { - c.t.Helper() - if !reflect.DeepEqual(want, got) { - c.t.Fatalf("v1 and v2 differ in %s\ninput: %q\nflags: %#x\nv1: %#v\nv2: %#v", what, c.input, c.flags, want, got) - } -} - -// checkAPIParity loads data with v1 and v2 and compares what the public API -// returns for it. -func checkAPIParity(t *testing.T, data []byte, flags uint32) { - t.Helper() - opts := parityOptions(flags) - if opts.AllowPythonMultilineValues && len(data) > 4096 { - return - } - c := &apiChecker{t: t, input: data, flags: flags} - - var f1 *v1.File - var err1 error - if try(func() { f1, err1 = v1.LoadSources(opts, data) }) || err1 != nil { - return // The parse parity tests cover errors. - } - f2, err := LoadSources([]any{data}, v2Options(opts)...) - c.same("load error", "", errStr(err)) - - expand := v1ExpandsSafely(f1) - - c.same("SectionNames", f1.SectionStrings(), f2.SectionNames()) - secs1, secs2 := f1.Sections(), f2.Sections() - for i, s1 := range secs1 { - s2, sname := secs2[i], s1.Name() - c.same(sname+" KeyNames", s1.KeyStrings(), s2.KeyNames()) - c.same(sname+" KeyValues", s1.KeysHash(), s2.KeyValues()) - c.same(sname+" Body", s1.Body(), s2.Body()) - c.same(sname+" Comment", s1.Comment, s2.Comment()) - c.same(sname+" ChildSections", len(s1.ChildSections()), len(s2.ChildSections())) - c.same(sname+" ParentKeys", len(s1.ParentKeys()), len(s2.ParentKeys())) - for _, kname := range s1.KeyStrings() { - c.checkKey(sname+"."+kname, s1.Key(kname), s2.Key(kname), expand) - } - } - c.checkWrite(opts, f1, f2) - if expand { - c.checkMapping(f1, f2) - } -} - -func (c *apiChecker) checkKey(name string, k1 *v1.Key, k2 *Key, expand bool) { - c.t.Helper() - c.same(name+" Value", k1.Value(), k2.Value()) - c.same(name+" Values", k1.ValueWithShadows(), k2.Values()) - c.same(name+" NestedValues", k1.NestedValues(), k2.NestedValues()) - c.same(name+" Comment", k1.Comment, k2.Comment()) - if !expand { - return - } - pair := func(what string, v1v any, e1 error, v2v any, e2 error) { - c.t.Helper() - c.same(name+" "+what+" error", errStr(e1), errStr(e2)) - if e1 == nil { - c.same(name+" "+what, v1v, v2v) - } - } - c.same(name+" String", k1.String(), k2.String()) - b1, e1 := k1.Bool() - b2, e2 := k2.Bool() - pair("Bool", b1, e1, b2, e2) - i1, e1 := k1.Int() - i2, e2 := k2.Int() - pair("Int", i1, e1, i2, e2) - j1, e1 := k1.Int64() - j2, e2 := k2.Int64() - pair("Int64", j1, e1, j2, e2) - u1, e1 := k1.Uint() - u2, e2 := k2.Uint() - pair("Uint", u1, e1, u2, e2) - w1, e1 := k1.Uint64() - w2, e2 := k2.Uint64() - pair("Uint64", w1, e1, w2, e2) - f1, e1 := k1.Float64() - f2, e2 := k2.Float64() - if e1 == nil && f1 != f1 { // NaN - f1, f2 = 0, 0 - } - pair("Float64", f1, e1, f2, e2) - d1, e1 := k1.Duration() - d2, e2 := k2.Duration() - pair("Duration", d1, e1, d2, e2) - t1, e1 := k1.Time() - t2, e2 := k2.Time() - pair("Time", t1, e1, t2, e2) - t1, e1 = k1.TimeFormat(time.DateOnly) - t2, e2 = k2.TimeFormat(time.DateOnly) - pair("TimeFormat", t1, e1, t2, e2) - - for _, delim := range []string{",", "|", " "} { - d := fmt.Sprintf("(%q)", delim) - c.same(name+" Strings"+d, k1.Strings(delim), k2.Strings(delim)) - c.same(name+" StringsWithShadows"+d, k1.StringsWithShadows(delim), k2.StringsWithShadows(delim)) - c.same(name+" Bools"+d, k1.Bools(delim), List[bool](k2, delim)) - c.same(name+" Ints"+d, outOfRangeToZero(k1, delim, k1.Ints(delim)), List[int](k2, delim)) - c.same(name+" Int64s"+d, outOfRangeToZero(k1, delim, k1.Int64s(delim)), List[int64](k2, delim)) - c.same(name+" Uints"+d, outOfRangeToZero(k1, delim, k1.Uints(delim)), List[uint](k2, delim)) - c.same(name+" Uint64s"+d, outOfRangeToZero(k1, delim, k1.Uint64s(delim)), List[uint64](k2, delim)) - c.same(name+" Float64s"+d, nanToZero(outOfRangeToZero(k1, delim, k1.Float64s(delim))), nanToZero(List[float64](k2, delim))) - c.same(name+" Times"+d, k1.Times(delim), List[time.Time](k2, delim)) - c.same(name+" TimesFormat"+d, k1.TimesFormat(time.DateOnly, delim), k2.TimesFormat(time.DateOnly, delim)) - c.same(name+" ValidBools"+d, k1.ValidBools(delim), ValidList[bool](k2, delim)) - c.same(name+" ValidInts"+d, k1.ValidInts(delim), ValidList[int](k2, delim)) - c.same(name+" ValidInt64s"+d, k1.ValidInt64s(delim), ValidList[int64](k2, delim)) - c.same(name+" ValidUints"+d, k1.ValidUints(delim), ValidList[uint](k2, delim)) - c.same(name+" ValidUint64s"+d, k1.ValidUint64s(delim), ValidList[uint64](k2, delim)) - c.same(name+" ValidFloat64s"+d, nanToZero(k1.ValidFloat64s(delim)), nanToZero(ValidList[float64](k2, delim))) - c.same(name+" ValidTimes"+d, k1.ValidTimes(delim), ValidList[time.Time](k2, delim)) - c.same(name+" ValidTimesFormat"+d, k1.ValidTimesFormat(time.DateOnly, delim), k2.ValidTimesFormat(time.DateOnly, delim)) - strict := func(what string, a any, e1 error, b any, e2 error) { - c.t.Helper() - c.same(name+" "+what+d+" error", errStr(e1), errStr(e2)) - if e1 == nil { - c.same(name+" "+what+d, a, b) - } - } - sb1, e1 := k1.StrictBools(delim) - sb2, e2 := StrictList[bool](k2, delim) - strict("StrictBools", sb1, e1, sb2, e2) - si1, e1 := k1.StrictInts(delim) - si2, e2 := StrictList[int](k2, delim) - strict("StrictInts", si1, e1, si2, e2) - sj1, e1 := k1.StrictInt64s(delim) - sj2, e2 := StrictList[int64](k2, delim) - strict("StrictInt64s", sj1, e1, sj2, e2) - su1, e1 := k1.StrictUints(delim) - su2, e2 := StrictList[uint](k2, delim) - strict("StrictUints", su1, e1, su2, e2) - sw1, e1 := k1.StrictUint64s(delim) - sw2, e2 := StrictList[uint64](k2, delim) - strict("StrictUint64s", sw1, e1, sw2, e2) - sf1, e1 := k1.StrictFloat64s(delim) - sf2, e2 := StrictList[float64](k2, delim) - strict("StrictFloat64s", nanToZero(sf1), e1, nanToZero(sf2), e2) - st1, e1 := k1.StrictTimes(delim) - st2, e2 := StrictList[time.Time](k2, delim) - strict("StrictTimes", st1, e1, st2, e2) - sf3, e1 := k1.StrictTimesFormat(time.DateOnly, delim) - sf4, e2 := k2.StrictTimesFormat(time.DateOnly, delim) - strict("StrictTimesFormat", sf3, e1, sf4, e2) - } - - day := func(d int) time.Time { return time.Date(2006, 1, d, 0, 0, 0, 0, time.UTC) } - c.same(name+" In", k1.In("d", []string{"a", "b", ""}), In(k2, "d", []string{"a", "b", ""})) - c.same(name+" InTimeFormat", k1.InTimeFormat(time.DateOnly, day(9), []time.Time{day(2)}), k2.InTimeFormat(time.DateOnly, day(9), []time.Time{day(2)})) - c.same(name+" RangeTimeFormat", k1.RangeTimeFormat(time.DateOnly, day(9), day(1), day(3)), k2.RangeTimeFormat(time.DateOnly, day(9), day(1), day(3))) - if _, err := k1.Int64(); err == nil { - c.same(name+" InInt", k1.InInt(7, []int{0, 1, 5}), In(k2, 7, []int{0, 1, 5})) - c.same(name+" InInt64", k1.InInt64(7, []int64{0, 1, 5}), In(k2, int64(7), []int64{0, 1, 5})) - c.same(name+" RangeInt", k1.RangeInt(7, -1, 3), Range(k2, 7, -1, 3)) - c.same(name+" RangeInt64", k1.RangeInt64(7, -1, 3), Range(k2, int64(7), -1, 3)) - } - if _, err := k1.Uint64(); err == nil { - c.same(name+" InUint", k1.InUint(7, []uint{0, 1, 5}), In(k2, uint(7), []uint{0, 1, 5})) - c.same(name+" InUint64", k1.InUint64(7, []uint64{0, 1, 5}), In(k2, uint64(7), []uint64{0, 1, 5})) - } - if v, err := k1.Float64(); err == nil && v == v { - c.same(name+" InFloat64", k1.InFloat64(7, []float64{0, 1.5}), In(k2, 7.0, []float64{0, 1.5})) - c.same(name+" RangeFloat64", k1.RangeFloat64(7, -1, 3), Range(k2, 7.0, -1, 3)) - } - if _, err := k1.Time(); err == nil { - c.same(name+" InTime", k1.InTime(day(9), []time.Time{day(2)}), In(k2, day(9), []time.Time{day(2)})) - } - - // v1's Must* stored the default in the key when the value didn't parse; - // v2's only read. Put v1's value back after each call so both see the - // same value, then check that v2 left the key as it was. - v1Value := k1.Value() - must := func(what string, v1, v2 any) { - c.same(name+" "+what, v1, v2) - k1.SetValue(v1Value) - } - must("MustString", k1.MustString("d"), k2.MustString("d")) - must("MustBool", k1.MustBool(true), k2.MustBool(true)) - must("MustInt", k1.MustInt(7), k2.MustInt(7)) - must("MustInt64", k1.MustInt64(7), k2.MustInt64(7)) - must("MustUint", k1.MustUint(7), k2.MustUint(7)) - must("MustUint64", k1.MustUint64(7), k2.MustUint64(7)) - if v, err := k1.Float64(); err != nil || v == v { - must("MustFloat64", k1.MustFloat64(7), k2.MustFloat64(7)) - } - must("MustDuration", k1.MustDuration(7), k2.MustDuration(7)) - must("MustTime", k1.MustTime(day(9)), k2.MustTime(day(9))) - c.same(name+" String after Must", k1.String(), k2.String()) -} - -// outOfRangeToZero applies a deliberate change to v1's lenient list -// result: v1 kept the clamped value strconv returns for an out-of-range -// element (MaxInt64, +Inf), v2 gives the zero value as for any element that -// doesn't parse. -func outOfRangeToZero[T int | int64 | uint | uint64 | float64](k1 *v1.Key, delim string, vals []T) []T { - for i, s := range k1.Strings(delim) { - var err error - var zero T - switch any(zero).(type) { - case int, int64: - _, err = strconv.ParseInt(s, 0, 64) - case uint, uint64: - _, err = strconv.ParseUint(s, 0, 64) - case float64: - _, err = strconv.ParseFloat(s, 64) - } - if errors.Is(err, strconv.ErrRange) && i < len(vals) { - vals[i] = zero - } - } - return vals -} - -func nanToZero(vals []float64) []float64 { - for i, v := range vals { - if v != v { - vals[i] = 0 - } - } - return vals -} - -var refPattern = regexp.MustCompile(`%\(([^)]*)\)s`) - -// v1ExpandsSafely reports whether v1 can expand every %(name)s reference -// in f. v1 follows references without a limit, so a cycle overflows the -// stack and a chain of doubling values runs out of memory. A reference may -// resolve in the key's section, a parent section or the default section, -// so this treats a reference to a name as one to every key of that name. -func v1ExpandsSafely(f *v1.File) bool { - refs := map[string][]string{} - n := 0 - for _, sec := range f.Sections() { - for _, k := range sec.Keys() { - for _, v := range k.ValueWithShadows() { - for _, m := range refPattern.FindAllStringSubmatch(v, -1) { - from, to := strings.ToLower(k.Name()), strings.ToLower(m[1]) - refs[from] = append(refs[from], to) - n++ - } - } - } - } - if n > 16 { - return false - } - // No cycles: every name finishes its depth-first walk. - state := map[string]int{} // 1 visiting, 2 done - var visit func(string) bool - visit = func(name string) bool { - switch state[name] { - case 1: - return false - case 2: - return true - } - state[name] = 1 - for _, to := range refs[name] { - if !visit(to) { - return false - } - } - state[name] = 2 - return true - } - for name := range refs { - if !visit(name) { - return false - } - } - return true -} - -// v1WriteReadsBack reports whether v1's output for f1 loads with v1 to the -// same contents. Where it does, v2 must write the same bytes; where it -// doesn't, v2 deliberately writes something else that reads back. -func v1WriteReadsBack(opts v1.LoadOptions, f1 *v1.File, out []byte) bool { - g, err := v1.LoadSources(opts, out) - return err == nil && dumpV1(g) == dumpV1(f1) -} - -func (c *apiChecker) checkWrite(opts v1.LoadOptions, f1 *v1.File, f2 *File) { - c.t.Helper() - var b1, b2 bytes.Buffer - if try(func() { _, _ = f1.WriteTo(&b1) }) || !v1WriteReadsBack(opts, f1, b1.Bytes()) { - return - } - _, err := f2.Write(&b2) - c.same("Write error", "", errStr(err)) - c.same("Write", b1.String(), b2.String()) -} - -// checkMapping maps the default section and the peer sections with both -// versions, then writes the mapped struct back with ReflectFrom. -func (c *apiChecker) checkMapping(f1 *v1.File, f2 *File) { - c.t.Helper() - for _, strict := range []bool{false, true} { - m1, m2 := newAPICfg(), newAPICfg() - var e1 error - if try(func() { - if strict { - e1 = f1.StrictMapTo(m1) - } else { - e1 = f1.MapTo(m1) - } - }) { - continue - } - var e2 error - if strict { - e2 = f2.StrictMapTo(m2) - } else { - e2 = f2.MapTo(m2) - } - what := fmt.Sprintf("MapTo (strict %v)", strict) - if strict && e1 == nil && e2 != nil && hasInvalidSliceElem(f2) { - // Deliberate: StrictMapTo reports invalid slice elements, which - // v1 left as zero values. - continue - } - c.same(what+" error", errStr(e1), errStr(e2)) - if m2.Dur <= 0 && m1.Dur > 0 { - if d, err := f2.Section("").Key("Dur").Duration(); err == nil && d == m2.Dur { - // Deliberate: v2 applies zero and negative durations, which - // v1 skipped. - m1.Dur = m2.Dur - } - } - if !strict { - // Deliberate: an out-of-range list element maps to zero, as in - // ini.List. - sec := f1.Section("") - if k, err := sec.GetKey("Ints"); err == nil && len(m1.Ints) == len(m2.Ints) { - m1.Ints = outOfRangeToZero(k, "|", m1.Ints) - } - if k, err := sec.GetKey("Int64s"); err == nil && len(m1.Int64s) == len(m2.Int64s) { - m1.Int64s = outOfRangeToZero(k, ",", m1.Int64s) - } - if k, err := sec.GetKey("Uints"); err == nil && len(m1.Uints) == len(m2.Uints) { - m1.Uints = outOfRangeToZero(k, ",", m1.Uints) - } - if k, err := sec.GetKey("Floats"); err == nil && len(m1.Floats) == len(m2.Floats) { - m1.Floats = outOfRangeToZero(k, ",", m1.Floats) - } - } - // NaN never equals itself. - m1.Floats, m2.Floats = nanToZero(m1.Floats), nanToZero(m2.Floats) - for _, m := range []*apiCfg{m1, m2} { - if m.Float != m.Float { - m.Float = 0 - } - if m.PFloat != nil && *m.PFloat != *m.PFloat { - *m.PFloat = 0 - } - } - if e1 == nil { - c.same(what, m1, m2) - } - if !strict && e1 == nil { - c.checkReflect(m1) - } - - p1, p2 := &apiPeers{}, &apiPeers{} - if try(func() { e1 = f1.MapTo(p1) }) || e1 != nil { - continue - } - c.same("MapTo peers error", "", errStr(f2.MapTo(p2))) - c.same("MapTo peers", p1, p2) - } -} - -// hasInvalidSliceElem reports whether a typed slice field of apiCfg has an -// element that doesn't parse. -func hasInvalidSliceElem(f *File) bool { - sec, err := f.GetSection("") - if err != nil { - return false - } - invalid := func(name string, list func(k *Key) error) bool { - k, err := sec.GetKey(name) - return err == nil && list(k) != nil - } - return invalid("Ints", func(k *Key) error { _, err := StrictList[int](k, "|"); return err }) || - invalid("Int64s", func(k *Key) error { _, err := StrictList[int64](k, ","); return err }) || - invalid("Uints", func(k *Key) error { _, err := StrictList[uint](k, ","); return err }) || - invalid("Floats", func(k *Key) error { _, err := StrictList[float64](k, ","); return err }) || - invalid("Bools", func(k *Key) error { _, err := StrictList[bool](k, ","); return err }) || - invalid("Times", func(k *Key) error { _, err := StrictList[time.Time](k, ","); return err }) -} - -// checkReflect writes v with ReflectFrom from both versions. Where v1's -// output maps back to v, v2 must write the same bytes. -func (c *apiChecker) checkReflect(v *apiCfg) { - c.t.Helper() - f1 := v1.Empty() - var b1 bytes.Buffer - var err error - if try(func() { err = f1.ReflectFrom(v) }) || err != nil { - return - } - if try(func() { _, err = f1.WriteTo(&b1) }) || err != nil { - return - } - back := newAPICfg() - g, err := v1.Load(b1.Bytes()) - if err != nil || try(func() { err = g.MapTo(back) }) || err != nil || !reflect.DeepEqual(back, v) { - return - } - f2 := Empty() - c.same("ReflectFrom error", "", errStr(f2.ReflectFrom(v))) - var b2 bytes.Buffer - _, err = f2.Write(&b2) - c.same("ReflectFrom Write error", "", errStr(err)) - c.same("ReflectFrom Write", b1.String(), b2.String()) -} - -// apiValues are values on the boundaries of the accessors and struct -// mapping. -var apiValues = []string{ - "", "0", "-0", "1", "-1", "+5", "007", "0x1F", "0o17", "0b101", "1_000", "1e3", "1.5", "-2.5", ".5", "5.", - "9223372036854775807", "9223372036854775808", "-9223372036854775808", "-9223372036854775809", - "18446744073709551615", "18446744073709551616", "4294967296", "1e400", "-1e400", "NaN", "Inf", "-Inf", "0x1p-2", - "true", "TRUE", "True", "yes", "on", "OFF", "y", "n", "t", "F", "maybe", " 1 ", - "1s", "-5s", "0s", "1h2m3.5s", "5", "1.5h", "300ms", "1us", "1µs", "-0s", - "2006-01-02T15:04:05Z", "2006-01-02T15:04:05.123456789+08:00", "2006-01-02", "2006-01-02T15:04:05", "2006-01-02 15:04:05Z", - "a,b", "a, b ,c", "a\\,b", "a\\\\,b", "a\\b", "a,", ",a", ",", ",,", "1|2|x", "1|99999999999999999999|-3", "1, 2, 3", - "1s,2s", "true,no,x", "2006-01-02,2006-01-03", "2006-01-02T15:04:05Z,bad", "1.5,NaN,2", "a b", "x y z", - "`quoted`", "\"q\"", "'s'", "\"\"\"tq\"\"\"", "v ; c", "v # c", "a\\", "\\", "é,ü", "\t", -} - -var apiKeys = []string{ - "Shared", "Str", "lower", "Bool", "Int", "Int64", "Uint", "Uint64", "Float", "Dur", "Time", - "PStr", "PInt", "PBool", "PFloat", "Strs", "Ints", "Int64s", "Uints", "Floats", "Bools", "Times", - "shadow", "shadow", "omit", "Skip", "Name", "Port", "Hosts", -} - -// apiDoc returns a document whose keys are apiCfg's, with random values. -func apiDoc(rng *rand.Rand) string { - var b strings.Builder - order := map[string]int{} - for i, k := range apiKeys { - order[k] = i - } - value := func(key string) string { - v := apiValues[rng.Intn(len(apiValues))] - // A reference only to a key earlier in apiKeys, so that there are - // no cycles and chains of references form. - if i := order[key]; i > 0 && rng.Intn(6) == 0 { - ref := apiKeys[rng.Intn(i)] - if rng.Intn(4) == 0 { - ref = "missing" - } - v = []string{"", v, "x,"}[rng.Intn(3)] + "%(" + ref + ")s" + []string{"", ",y", "|2"}[rng.Intn(3)] - } - return v - } - keys := func(names []string) { - for _, k := range names { - for n := rng.Intn(3); n > 0; n-- { - fmt.Fprintf(&b, "%s = %s\n", k, value(k)) - } - } - } - keys(apiKeys) - for _, sec := range []string{"sub", "psub", "peer", "peer", "sub.child"} { - if rng.Intn(2) == 0 { - fmt.Fprintf(&b, "[%s]\n", sec) - keys([]string{"Name", "Port", "Hosts", "Str"}) - } - } - return b.String() -} - -// apiSeeds are documents for the fuzz test and the random test. -func apiSeeds() []string { - rng := rand.New(rand.NewSource(2)) - seeds := []string{ - "Str = a\nlower = %(Str)s-x\nInt = [%(lower)s]\n[sub]\nName = %(Int)s,%(Str)s\nPort = %(Name)s\n", - "Str = ${HOME}\nInts = 1|2|3\nDur = 1m\nTime = 2006-01-02T15:04:05Z\n", - } - for i := 0; i < 50; i++ { - seeds = append(seeds, apiDoc(rng)) - } - return seeds -} - -// TestAPIParityShapes passes values of every shape to the struct API. -func TestAPIParityShapes(t *testing.T) { - data := []byte("Str = a\nInt = 2\nshadow = x\nshadow = y\n[sub]\nName = n\nPort = 3\n[peer]\nName = p1\n[peer]\nName = p2\n") - shapes := map[string]func() any{ - "*T": func() any { return newAPICfg() }, - "**T": func() any { p := newAPICfg(); return &p }, - "**T nil": func() any { var p *apiCfg; return &p }, - "***T": func() any { p := newAPICfg(); pp := &p; return &pp }, - "T": func() any { return *newAPICfg() }, - "nil": func() any { return nil }, - "*T nil": func() any { return (*apiCfg)(nil) }, - "*int": func() any { return new(int) }, - "*[]*sub": func() any { return &[]*apiSub{} }, - "[]*sub": func() any { return []*apiSub{{Name: "a"}, {Name: "b"}} }, - "[]sub": func() any { return []apiSub{{Name: "a"}} }, - "*sub": func() any { return &apiSub{Name: "a"} }, - "*peers": func() any { return &apiPeers{} }, - "*peers full": func() any { return &apiPeers{Peers: []apiSub{{Name: "a"}, {Name: "b"}}} }, - "*map": func() any { return &map[string]string{} }, - } - for _, flags := range []uint32{0, 1 << 7, 1 << 14, 1<<7 | 1<<14} { - opts := parityOptions(flags) - for name, shape := range shapes { - for _, call := range []string{"File.MapTo", "File.StrictMapTo", "Section.MapTo", "MapTo", "StrictMapTo", "File.ReflectFrom", "Section.ReflectFrom"} { - c := &apiChecker{t: t, input: data, flags: flags} - what := fmt.Sprintf("%s(%s)", call, name) - f1, err := v1.LoadSources(opts, data) - if err != nil { - t.Fatal(err) - } - f2, err := LoadSources([]any{data}, v2Options(opts)...) - if err != nil { - t.Fatal(err) - } - t1, t2 := shape(), shape() - var e1, e2 error - if try(func() { - switch call { - case "File.MapTo": - e1 = f1.MapTo(t1) - case "File.StrictMapTo": - e1 = f1.StrictMapTo(t1) - case "Section.MapTo": - e1 = f1.Section("peer").MapTo(t1) - case "MapTo": - e1 = v1.MapTo(t1, data) - case "StrictMapTo": - e1 = v1.StrictMapTo(t1, data) - case "File.ReflectFrom": - e1 = f1.ReflectFrom(t1) - case "Section.ReflectFrom": - e1 = f1.Section("peer").ReflectFrom(t1) - } - }) { - continue - } - switch call { - case "File.MapTo": - e2 = f2.MapTo(t2) - case "File.StrictMapTo": - e2 = f2.StrictMapTo(t2) - case "Section.MapTo": - e2 = f2.Section("peer").MapTo(t2) - case "MapTo": - e2 = MapTo(t2, data) - case "StrictMapTo": - e2 = StrictMapTo(t2, data) - case "File.ReflectFrom": - e2 = f2.ReflectFrom(t2) - case "Section.ReflectFrom": - e2 = f2.Section("peer").ReflectFrom(t2) - } - c.same(what+" error", errStr(e1), errStr(e2)) - c.same(what, t1, t2) - if e1 != nil { - // v1 could change the file before failing; v2 checks - // first. - continue - } - var b1, b2 bytes.Buffer - _, _ = f1.WriteTo(&b1) - _, _ = f2.Write(&b2) - c.same(what+" written", b1.String(), b2.String()) - } - } - } -} - -func TestAPIParity(t *testing.T) { - rng := rand.New(rand.NewSource(1)) - for _, in := range append(apiSeeds(), paritySeeds...) { - for i := 0; i < 3; i++ { - checkAPIParity(t, []byte(in), rng.Uint32()) - } - checkAPIParity(t, []byte(in), 0) - } - for i := 0; i < 300; i++ { - checkAPIParity(t, []byte(apiDoc(rng)), rng.Uint32()&(1<<16|1<<7|1<<14|1<<15|1<<1|1<<2|1<<6|1<<8)) - } -} - -func FuzzAPIParity(f *testing.F) { - for _, in := range append(apiSeeds(), paritySeeds...) { - f.Add([]byte(in), uint32(0)) - f.Add([]byte(in), uint32(1<<7)) - } - f.Fuzz(func(t *testing.T, data []byte, flags uint32) { - checkAPIParity(t, data, flags) - }) -} diff --git a/bench_test.go b/bench_test.go index ab075f2..ab254dc 100644 --- a/bench_test.go +++ b/bench_test.go @@ -15,11 +15,7 @@ package ini import ( - "bytes" - "fmt" "testing" - - v1 "gopkg.in/ini.v1" ) func newTestFile(block bool) *File { @@ -134,66 +130,3 @@ func Benchmark_Key_Strings_LargeArray(b *testing.B) { _ = key.Strings(",") } } - -// genKeys10k returns INI input with 10,000 keys: in one section, in 1,000 -// sections of 10 keys, and in one section using every value syntax. -func genKeys10k(layout string) []byte { - var b bytes.Buffer - b.WriteString("; generated\n[s]\n") - for i := 0; i < 10000; i++ { - switch layout { - case "flat": - fmt.Fprintf(&b, "key_%d = some value number %d ; trailing comment\n", i, i) - case "sections": - if i%10 == 0 { - fmt.Fprintf(&b, "\n# section %d\n[section_%d]\n", i/10, i/10) - } - fmt.Fprintf(&b, "key_%d = value %d\n", i%10, i) - case "mixed": - switch i % 8 { - case 0: - fmt.Fprintf(&b, "# comment for key %d\nkey_%d = plain value %d\n", i, i, i) - case 1: - fmt.Fprintf(&b, "key_%d = \"double quoted %d\"\n", i, i) - case 2: - fmt.Fprintf(&b, "key_%d = 'single quoted %d' # comment\n", i, i) - case 3: - fmt.Fprintf(&b, "key_%d = `raw #%d; value`\n", i, i) - case 4: - fmt.Fprintf(&b, "key_%d = \"\"\"multi\nline %d\nvalue\"\"\"\n", i, i) - case 5: - fmt.Fprintf(&b, "key_%d = first part \\\n\tsecond part %d\n", i, i) - case 6: - fmt.Fprintf(&b, "- = auto increment %d\n", i) - case 7: - fmt.Fprintf(&b, "\"quoted key %d\" : value\r\n", i) - } - } - } - return b.Bytes() -} - -// BenchmarkLoad10kKeys compares loading with v2 and with the released v1. -func BenchmarkLoad10kKeys(b *testing.B) { - loaders := []struct { - name string - load func([]byte) error - }{ - {"v2", func(data []byte) error { _, err := Load(data); return err }}, - {"v1", func(data []byte) error { _, err := v1.Load(data); return err }}, - } - for _, layout := range []string{"flat", "sections", "mixed"} { - data := genKeys10k(layout) - for _, l := range loaders { - b.Run(layout+"/"+l.name, func(b *testing.B) { - b.SetBytes(int64(len(data))) - b.ReportAllocs() - for i := 0; i < b.N; i++ { - if err := l.load(data); err != nil { - b.Fatal(err) - } - } - }) - } - } -} diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md new file mode 100644 index 0000000..4e304cb --- /dev/null +++ b/docs/migrate-v1-to-v2.md @@ -0,0 +1,153 @@ +# Migrating from v1 to v2 + +v2 lives at a new import path, and the package name is still `ini`: + +```go +import "unknwon.dev/ini/v2" // was "gopkg.in/ini.v1" +``` + +It needs Go 1.26 or later. Both versions can be used in one program, so a large codebase can migrate one package at a time. + +**Parsing doesn't change.** Without options, v2 parses every input exactly as v1's `Load` does: the same sections, keys, values, comments and errors. The one exception is a Python-style multiline value longer than 4 KiB, which v1 cut at its read buffer and then failed on; v2 reads it in full. + +Most API changes are renames and stop the build, so the compiler finds them for you; the [API changes](#api-changes) tables list the replacements. Start with the next section, though: these changes compile fine and only show up at run time. + +## Changes the compiler won't catch + +### `Must*` methods no longer store the default + +In v1, `k.MustInt(3)` returned 3 for a missing or invalid value **and wrote "3" into the key**. Later reads, `MapTo` and `Write` then saw the default, so saving a config wrote out every default the program had asked for. In v2, `Must*` methods only read. If your code reads a default back later, or relies on it being saved, store it yourself: + +```go +// v1 +port := sec.Key("PORT").MustInt(3000) // also set PORT = 3000 + +// v2 +port := sec.Key("PORT").MustInt(3000) +sec.Key("PORT").SetValue(strconv.Itoa(port)) // only if you need it stored +``` + +### `Reload` and `Append` + +- `Reload` replaces the contents with what the sources hold now. In v1 it merged them into the current contents, so keys removed from a file stayed and shadow values piled up. `*Section` and `*Key` values you hold see the new values, and if loading fails, nothing changes. +- `Append` reads only the sources it is given, so changes you made in memory survive it. In v1 it read every source again. +- `io.Reader` sources are read once and kept, so `Reload` works with them; in v1 it failed on the exhausted reader. + +### Lists + +`ini.List` (v1's `Ints`, `Float64s` and friends) gives 0 for an element that is out of range for the type, as it does for any element that doesn't parse. v1 kept the clamped value that `strconv` returns, such as `math.MaxInt64` or `+Inf`. + +### Struct mapping + +`MapTo`, `StrictMapTo` and `ReflectFrom` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Valid input can see these differences: + +- An out-of-range number, such as `300` for an `int8` field, is an error in `StrictMapTo` and leaves the field unchanged in `MapTo`. v1 wrapped it to 44. +- `StrictMapTo` reports invalid slice elements; v1 set them to zero. +- Durations of `0` and negative durations are applied; v1 skipped them. +- `uint8` fields map; v1 returned an error. +- An embedded struct pointer that is already set keeps its values; v1 replaced it. +- A `nonunique` field with no matching sections is left empty; v1 failed. +- When writing: + - times keep sub-second precision; + - slice elements that contain the delimiter or end in a backslash are escaped, so they read back as one element; + - nil pointer fields aren't written; + - fields after an empty `nonunique` slice or a `StructReflector` field are written; + - an `allowshadow` element that contains the delimiter is an error, because it would read back as two elements. + +### Writing + +`Write` output is byte-identical to v1's for ordinary files. Where v1 wrote a file that loaded back differently, v2 writes one that loads back the same, or returns an error: + +- values ending in `\`, containing `"""`, or starting with a delimiter are quoted; +- empty shadow values are written; +- comments of keys written on several lines are kept; +- with `CaseInsensitive`, an explicit `[DEFAULT]` section is written with its own keys; +- a raw section body without a final newline no longer runs into the next header. + +### Smaller fixes you might notice + +- A `%(name)s` reference cycle is left unexpanded. In v1 it overflowed the stack and crashed the program. +- With `CaseInsensitive`, `DeleteKey("Name")` deletes the key; v1 ignored the case-folded name. +- `ChildSections` lists each child once with `AllowNonUniqueSections`. +- `Key.Strings("")`, a negative section index and `Section.Key("")` no longer hang or panic. +- Reading a key takes the file's read lock, so reads are safe while other goroutines change the file. That costs about 10 ns per read; load with `ini.Locking(false)` if the file never changes after loading. + +## API changes + +### Loading + +| v1 | v2 | +|---|---| +| `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | +| `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows(true))` | +| `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `Load` with `ini.IgnoreMissingFiles(true)`, `ini.CaseInsensitive(true)`, `ini.AllowShadows(true)` | +| `MapTo(v, a, b)` | `MapTo(v, a, opts...)`, with one source | +| `MapToWithMapper(v, m, a)`, `StrictMapToWithMapper` | `MapTo(v, a, ini.NameMapping(m))`, `StrictMapTo(v, a, ini.NameMapping(m))` | +| `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | +| `f.BlockMode = false` | load with `ini.Locking(false)` | +| `ini.DefaultSection = "default"` | load with `ini.DefaultSectionName("default")`; `ini.DefaultSection` is now a constant | + +Every `LoadOptions` field is now an option function of the same name that takes its value, such as `ini.SkipUnrecognizableLines(true)` or `ini.KeyValueDelimiters("=")`. A later option overrides an earlier one. These were renamed: + +| `LoadOptions` field | v2 option | +|---|---| +| `Loose` | `IgnoreMissingFiles` | +| `Insensitive`, `InsensitiveSections`, `InsensitiveKeys` | `CaseInsensitive`, `CaseInsensitiveSections`, `CaseInsensitiveKeys` | +| `ShortCircuit` | `FirstSourceOnly` | +| `UnparseableSections` | `RawSections(names...)` | +| `PreserveSurroundedQuote` | `PreserveSurroundingQuotes` | +| `DebugFunc` | `Debug(fn)` | +| `KeyValueDelimiterOnWrite` | `WriteDelimiter(d)`, an option of `Write` | +| `ReaderBufferSize` | removed; nothing is cut at a buffer size | + +New options: `CommentSymbols`, `PreserveEmptyShadowValues` and `DefaultSectionName`. + +### Writing + +The package variables that changed every file's output are options of `File.Write` and `File.SaveTo`, so one caller can't change another's output. The defaults write what v1 wrote. + +| v1 | v2 | +|---|---| +| `ini.LineBreak = "\r\n"` | `ini.LineBreak("\r\n")` | +| `ini.DefaultHeader = true` | `ini.DefaultHeader(true)` | +| `ini.DefaultFormatLeft`, `DefaultFormatRight` | `ini.DelimiterSpaces(left, right)` | +| `ini.PrettyFormat = false` | `ini.AlignDelimiters(false)` | +| `ini.PrettySection = false` | `ini.BlankLineBetweenSections(false)` | +| `ini.PrettyEqual = true` | `ini.DelimiterSpaces(" ", " ")` | +| `f.WriteToIndent(w, "\t")`, `f.SaveToIndent(name, "\t")` | `f.Write(w, ini.Indent("\t"))`, `f.SaveTo(name, ini.Indent("\t"))` | +| `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with `f` loaded with `ini.NameMapping(m)` | + +`WriteTo` stays, so a `*File` is still an `io.WriterTo`. + +### Sections and keys + +| v1 | v2 | +|---|---| +| `s.Comment`, `k.Comment` fields | `Comment()` and `SetComment(c)` methods | +| `f.SectionStrings()`, `s.KeyStrings()`, `s.KeysHash()` | `SectionNames()`, `KeyNames()`, `KeyValues()` | +| `k.ValueWithShadows()` | `k.Values()` | +| `s.Haskey(name)` | `s.HasKey(name)` | +| `DEFAULT_SECTION`, `AllCapsUnderscore`, `SnackCase` | `DefaultSection`, `SnakeCase` | + +### Typed values + +The typed list, candidate and range methods are generic functions. They behave as before, and work for any `Scalar` type, such as `int8`, `time.Duration` or a named string type. + +| v1 | v2 | +|---|---| +| `k.Ints(",")`, `k.Float64s(",")`, `k.Bools(",")`, `k.Times(",")`… | `ini.List[int](k, ",")`, `ini.List[float64](k, ",")`… | +| `k.ValidInts(",")`… | `ini.ValidList[int](k, ",")`… | +| `k.StrictInts(",")`… | `ini.StrictList[int](k, ",")`… | +| `k.In(def, cands)`, `k.InInt(def, cands)`, `k.InTime(def, cands)`… | `ini.In(k, def, cands)` | +| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)`… | `ini.Range(k, def, lo, hi)`; type the default for other types, as in `ini.Range(k, 0.5, 0, 1)` | + +`String`, `Int`, `Bool`, `Duration`, `Strings`, the `Must*` methods, and the custom-layout time methods (`TimesFormat`, `InTimeFormat`, `RangeTimeFormat` and their `Valid` and `Strict` forms) stay on `Key`. + +### Errors + +| v1 | v2 | +|---|---| +| `ini.IsErrDelimiterNotFound(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)` | +| `ini.IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrEmptyKeyName)` | + +Parse errors are `*ini.ParseError` values with the 1-based `Line` and `Column` of the problem; the message under the position is the same as v1's. diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..eae34db --- /dev/null +++ b/example_test.go @@ -0,0 +1,179 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini_test + +import ( + "errors" + "fmt" + "os" + "time" + + "unknwon.dev/ini/v2" +) + +const exampleConfig = `; Application settings +app_name = demo + +[server] +port = 8080 +timeout = 30s +hosts = a.example.com, b.example.com +root = /srv/demo +logs = %(root)s/logs +` + +func Example() { + f, err := ini.Load([]byte(exampleConfig)) + if err != nil { + panic(err) + } + + fmt.Println(f.Section("").Key("app_name").String()) + + server := f.Section("server") + port, _ := server.Key("port").Int() + timeout := server.Key("timeout").MustDuration(10 * time.Second) + fmt.Println(port, timeout) + fmt.Println(server.Key("hosts").Strings(",")) + fmt.Println(server.Key("logs").String()) + fmt.Println(server.Key("missing").MustString("fallback")) + // Output: + // demo + // 8080 30s + // [a.example.com b.example.com] + // /srv/demo/logs + // fallback +} + +func ExampleLoad_options() { + data := []byte(` +[unit] +ExecStart = /bin/a +ExecStart = /bin/b +password = abc#123 +`) + f, err := ini.Load(data, ini.AllowShadows(true), ini.IgnoreInlineComment(true)) + if err != nil { + panic(err) + } + sec := f.Section("unit") + fmt.Println(sec.Key("ExecStart").Values()) + fmt.Println(sec.Key("password").String()) + // Output: + // [/bin/a /bin/b] + // abc#123 +} + +func ExampleList() { + f, err := ini.Load([]byte("ports = 80, 443, x\nratio = 1.5\n")) + if err != nil { + panic(err) + } + k := f.Section("").Key("ports") + + // List gives the zero value for an element that doesn't parse, + // ValidList leaves it out, and StrictList returns an error. + fmt.Println(ini.List[int](k, ",")) + fmt.Println(ini.ValidList[uint16](k, ",")) + _, err = ini.StrictList[int](k, ",") + fmt.Println(err != nil) + + ratio := f.Section("").Key("ratio") + fmt.Println(ini.Range(ratio, 1.0, 0, 1)) + // Output: + // [80 443 0] + // [80 443] + // true + // 1 +} + +type Config struct { + AppName string `ini:"app_name"` + Server struct { + Port int + Timeout time.Duration + Hosts []string + } `ini:"server"` +} + +func ExampleUnmarshal() { + data := []byte(`app_name = demo + +[server] +Port = 8080 +Timeout = 30s +Hosts = a.example.com, b.example.com +`) + var cfg Config + if err := ini.Unmarshal(data, &cfg); err != nil { + panic(err) + } + fmt.Printf("%s %d %s %v\n", cfg.AppName, cfg.Server.Port, cfg.Server.Timeout, cfg.Server.Hosts) + // Output: + // demo 8080 30s [a.example.com b.example.com] +} + +func ExampleMarshal() { + var cfg Config + cfg.AppName = "demo" + cfg.Server.Port = 8080 + cfg.Server.Hosts = []string{"a.example.com", "b.example.com"} + + data, err := ini.Marshal(cfg) + if err != nil { + panic(err) + } + fmt.Print(string(data)) + // Output: + // app_name = demo + // + // [server] + // Port = 8080 + // Timeout = 0 + // Hosts = a.example.com,b.example.com +} + +func ExampleFile_Write() { + f := ini.Empty() + f.Section("").Key("name").SetValue("demo") + sec, _ := f.NewSection("server") + _, _ = sec.NewKey("port", "8080") + _, _ = sec.NewKey("bind_address", "0.0.0.0") + sec.SetComment("Network settings") + + _, _ = f.Write(os.Stdout, ini.AlignDelimiters(false), ini.DelimiterSpaces("", "")) + // Output: + // name=demo + // + // ; Network settings + // [server] + // port=8080 + // bind_address=0.0.0.0 +} + +func ExampleParseError() { + _, err := ini.Load([]byte("[server]\nport = 8080\nbroken line\n")) + + var perr *ini.ParseError + if errors.As(err, &perr) { + fmt.Println(perr.Line, perr.Column) + } + fmt.Println(errors.Is(err, ini.ErrDelimiterNotFound)) + fmt.Println(err) + // Output: + // 3 1 + // true + // line 3, column 1: key-value delimiter not found: broken line +} diff --git a/file.go b/file.go index a819b2f..a417efd 100644 --- a/file.go +++ b/file.go @@ -414,8 +414,8 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { equalSign = fmt.Sprintf(" %s ", o.keyValueDelimiter) } - // Comment lines that don't start with a comment symbol get one: ";" as in - // v1 when it is a comment symbol, otherwise the first one. Without + // Comment lines that don't start with a comment symbol get one: ";" when + // it is a comment symbol, otherwise the first one. Without // comment symbols, a comment can't be written so that it reads back. symbols := f.options.CommentSymbols commentPrefix := ";" @@ -441,11 +441,11 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // keyLine returns the line that writes val as a value of key, written as // form, or key itself for a boolean key, followed by nested values. - // v1's quoting comes first; when the lexer reads that back differently, - // other quotings and a bare delimiter are tried, and when none reads - // back, it's an error rather than a file that loads differently. A file - // read with delimiters that don't include the written one is meant for - // other readers, and is written as v1 did. + // The usual quoting comes first; when the lexer reads that back + // differently, other quotings and a bare delimiter are tried, and when + // none reads back, it's an error rather than a file that loads + // differently. A file read with delimiters that don't include the + // written one is meant for other readers, and is written unchecked. verify := strings.ContainsAny(o.keyValueDelimiter, f.options.KeyValueDelimiters) keyLine := func(key *Key, boolean bool, name, form, eq, val string, nested []string) (string, error) { var after string diff --git a/go.mod b/go.mod index b2026d2..d351099 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,7 @@ module unknwon.dev/ini/v2 go 1.26 -require ( - github.com/stretchr/testify v1.11.1 - gopkg.in/ini.v1 v1.67.3 -) +require github.com/stretchr/testify v1.11.1 require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index eeeaea6..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,10 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= -gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ini.go b/ini.go index 806d141..57bdc3a 100644 --- a/ini.go +++ b/ini.go @@ -12,7 +12,35 @@ // License for the specific language governing permissions and limitations // under the License. -// Package ini provides INI file read and write functionality in Go. +// Package ini reads and writes INI files. +// +// Load parses one source, a file name, []byte or io.Reader, and LoadSources +// merges several, later ones overriding earlier ones. Without options, every +// input parses exactly as it did with gopkg.in/ini.v1. LoadOption values +// such as AllowShadows, CaseInsensitive or IgnoreInlineComment change the +// syntax that is accepted. +// +// A File holds Sections in order, and each Section holds Keys in order. The +// default section, named DefaultSection, holds the keys before the first +// section header; pass "" to refer to it. Key returns values as strings or +// parses them, as in Key.Int or Key.Duration, and the generic List, In and +// Range parse lists, candidates and ranges of any Scalar type. A value can +// refer to another key of its section or of the default section as +// %(name)s. +// +// MapTo and StrictMapTo fill a struct from a File, and File.ReflectFrom +// fills a File from a struct; Unmarshal and Marshal do the same for INI +// text, like encoding/json. Field names map to key and section names +// unchanged unless NameMapping sets a mapper, and the ini, delim and +// comment struct tags adjust each field. +// +// File.Write and File.SaveTo write a File back with its comments and in its +// order, and WriteOption values set the formatting. What Write writes loads +// back to the same sections, keys, values and comments, or Write returns an +// error. +// +// A File is safe for concurrent use unless loaded with Locking(false). +// Parse errors are *ParseError values that give the line and column. package ini import ( diff --git a/inputs_test.go b/inputs_test.go new file mode 100644 index 0000000..682a1c9 --- /dev/null +++ b/inputs_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Unknwon +// +// Licensed under the Apache License, Version 2.0 (the "License"): you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package ini + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// parserInputs returns the inputs in testdata/inputs.txt. +func parserInputs(tb testing.TB) []string { + tb.Helper() + data, err := os.ReadFile("testdata/inputs.txt") + if err != nil { + tb.Fatal(err) + } + var inputs []string + for i, line := range strings.Split(string(data), "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + quoted, err := strconv.QuotedPrefix(line) + if err == nil { + var in string + in, err = strconv.Unquote(quoted) + inputs = append(inputs, in) + } + if err != nil { + tb.Fatalf("testdata/inputs.txt:%d: %v", i+1, err) + } + } + return inputs +} + +// fuzzInputs returns the seed inputs of the fuzzers: confData, the parser +// inputs and the testdata files. +func fuzzInputs(tb testing.TB) []string { + tb.Helper() + inputs := append([]string{confData}, parserInputs(tb)...) + files, _ := filepath.Glob("testdata/*.ini") + for _, name := range files { + data, err := os.ReadFile(name) + if err != nil { + tb.Fatal(err) + } + inputs = append(inputs, string(data)) + } + return inputs +} + +// optionsFromBits maps the bits of flags to load options, so that fuzzers +// explore option combinations along with inputs. +func optionsFromBits(flags uint32) []LoadOption { + bit := func(i uint) bool { return flags&(1<>17)&7])) +} diff --git a/lexer.go b/lexer.go index 5963a85..e9f8325 100644 --- a/lexer.go +++ b/lexer.go @@ -48,9 +48,8 @@ func (t token) errorf(err error) error { return &ParseError{Line: t.line, Column: t.column, Err: err} } -// lexer turns INI input into tokens. It follows the v1 parser rule for rule, -// including the order in which value rules apply; FuzzParseParity checks that -// v1 (gopkg.in/ini.v1) and v2 produce the same File. +// lexer turns INI input into tokens. Its rules, and the order in which they +// apply, are those of gopkg.in/ini.v1, so every input loads as it did there. type lexer struct { src string pos int // offset of the next unread byte @@ -211,7 +210,7 @@ func (l *lexer) key(line string) (_ token, ok bool, _ error) { } // readValue reads a value that starts in "in", the rest of the current line, -// and any following lines it spans. The rules apply in v1's order, which +// and any following lines it spans. The rules apply in a fixed order, which // decides the result when they overlap: a value is either quoted, continued // with a backslash, or plain; for a plain value the inline comment is cut // before surrounding quotes are stripped, so `"a;b"` is `"a` with comment @@ -242,7 +241,7 @@ func (l *lexer) readValue(in string) (string, error) { case !l.opts.PreserveSurroundingQuotes && (hasSurroundedQuote(line, '\'') || hasSurroundedQuote(line, '"')): return line[1 : len(line)-1], nil case l.opts.UnescapeValueCommentSymbols: - // Last symbol first, as v1 unescaped \; before \#. + // Last symbol first, so \; is unescaped before \#. symbols := []rune(l.opts.CommentSymbols) for i := len(symbols) - 1; i >= 0; i-- { c := string(symbols[i]) diff --git a/parity_test.go b/parity_test.go deleted file mode 100644 index db94ae2..0000000 --- a/parity_test.go +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package ini - -import ( - "errors" - "fmt" - "math/rand" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - v1 "gopkg.in/ini.v1" -) - -// The parity tests load the same input with the released v1 module and with -// this package and compare everything both stored. v2 changes no parsing -// result unless an option that v1 doesn't have asks for it. - -// parityOptions maps bits of flags to v1 load options, so the fuzzer explores -// option combinations along with inputs. -func parityOptions(flags uint32) v1.LoadOptions { - bit := func(i uint) bool { return flags&(1<>17)&7] - return opts -} - -// v2Options returns the v2 equivalent of v1 load options. -func v2Options(o v1.LoadOptions) []LoadOption { - opts := []LoadOption{ - CaseInsensitive(o.Insensitive), - CaseInsensitiveSections(o.InsensitiveSections), - CaseInsensitiveKeys(o.InsensitiveKeys), - IgnoreContinuation(o.IgnoreContinuation), - IgnoreInlineComment(o.IgnoreInlineComment), - SkipUnrecognizableLines(o.SkipUnrecognizableLines), - AllowBooleanKeys(o.AllowBooleanKeys), - AllowShadows(o.AllowShadows), - AllowNestedValues(o.AllowNestedValues), - AllowPythonMultilineValues(o.AllowPythonMultilineValues), - SpaceBeforeInlineComment(o.SpaceBeforeInlineComment), - UnescapeValueDoubleQuotes(o.UnescapeValueDoubleQuotes), - UnescapeValueCommentSymbols(o.UnescapeValueCommentSymbols), - PreserveSurroundingQuotes(o.PreserveSurroundedQuote), - AllowNonUniqueSections(o.AllowNonUniqueSections), - AllowDuplicateShadowValues(o.AllowDuplicateShadowValues), - } - if o.UnparseableSections != nil { - opts = append(opts, RawSections(o.UnparseableSections...)) - } - return append(opts, KeyValueDelimiters(o.KeyValueDelimiters)) -} - -// dumpV1 describes everything the parser stores in a v1 *File, by -// reflection, so the comparison covers state the public API doesn't -// expose, such as empty shadow values. dumpV2 gives the same for v2. -func dumpV1(f any) string { - v := reflect.ValueOf(f).Elem() - names, indexes, sections := v.FieldByName("sectionList"), v.FieldByName("sectionIndexes"), v.FieldByName("sections") - - var b strings.Builder - for i := 0; i < names.Len(); i++ { - name := names.Index(i) - s := sections.MapIndex(name).Index(int(indexes.Index(i).Int())).Elem() - dumpSection(&b, s.FieldByName("name").String(), s.FieldByName("Comment").String(), - s.FieldByName("isRawSection").Bool(), s.FieldByName("rawBody").String()) - keyList, keys := s.FieldByName("keyList"), s.FieldByName("keys") - for j := 0; j < keyList.Len(); j++ { - dumpV1Key(&b, " ", keys.MapIndex(keyList.Index(j)).Elem()) - } - } - return b.String() -} - -func dumpV1Key(b *strings.Builder, indent string, k reflect.Value) { - nested := k.FieldByName("nestedValues") - vals := make([]string, nested.Len()) - for i := range vals { - vals[i] = nested.Index(i).String() - } - dumpKey(b, indent, k.FieldByName("name").String(), k.FieldByName("value").String(), k.FieldByName("Comment").String(), - k.FieldByName("isAutoIncrement").Bool(), k.FieldByName("isBooleanType").Bool(), k.FieldByName("isShadow").Bool(), vals) - shadows := k.FieldByName("shadows") - for i := 0; i < shadows.Len(); i++ { - dumpV1Key(b, indent+" ", shadows.Index(i).Elem()) - } -} - -// dumpV2 describes f in the format of dumpV1. v1 stored each shadow as a -// key with only a name and value. -func dumpV2(f *File) string { - var b strings.Builder - for _, s := range f.sections { - dumpSection(&b, s.name, s.comment, s.isRawSection, s.rawBody) - for _, k := range s.keys { - dumpKey(&b, " ", k.name, k.value, k.comment, k.isAutoIncrement, k.isBooleanType, false, k.nestedValues) - for _, v := range k.shadows { - dumpKey(&b, " ", k.name, v, "", false, false, true, nil) - } - } - } - return b.String() -} - -func dumpSection(b *strings.Builder, name, comment string, raw bool, body string) { - fmt.Fprintf(b, "[%q] comment=%q raw=%v body=%q\n", name, comment, raw, body) -} - -func dumpKey(b *strings.Builder, indent, name, value, comment string, auto, boolean, shadow bool, nested []string) { - fmt.Fprintf(b, "%s%q=%q comment=%q auto=%v bool=%v shadow=%v nested=%d", indent, name, value, comment, auto, boolean, shadow, len(nested)) - for _, v := range nested { - fmt.Fprintf(b, " %q", v) - } - b.WriteString("\n") -} - -// loadResult describes the outcome of loading: the File dumped, or the error. -func loadResult[F any](load func() (F, error), dump func(F) string) (out string) { - defer func() { - if r := recover(); r != nil { - out = fmt.Sprintf("panic: %v", r) - } - }() - f, err := load() - // v2 adds the position to parse errors; compare the error under it. - var perr *ParseError - if errors.As(err, &perr) { - err = perr.Err - } - if err != nil { - return "error: " + err.Error() - } - return dump(f) -} - -// checkParity loads data twice, as two sources of one File, with v1 and v2. -func checkParity(t *testing.T, data []byte, flags uint32) { - t.Helper() - opts := parityOptions(flags) - // Known difference: v1 looks ahead at most one reader buffer (>= 4 KiB) - // for Python-style multiline values; v2 has no such limit. - if opts.AllowPythonMultilineValues && len(data) > 4096 { - return - } - - want := loadResult(func() (*v1.File, error) { return v1.LoadSources(opts, data, data) }, func(f *v1.File) string { return dumpV1(f) }) - got := loadResult(func() (*File, error) { return LoadSources([]any{data, data}, v2Options(opts)...) }, dumpV2) - if want != got { - t.Fatalf("v1 and v2 differ\ninput: %q\noptions: %+v\nv1:\n%s\nv2:\n%s", data, opts, want, got) - } -} - -// paritySeeds are inputs that exercise every parser feature, including the -// cases from the documentation and from GitHub issues and pull requests. -var paritySeeds = []string{ - "", - "\xEF\xBB\xBFk = v\n", - "\xFF\xFEk = v\n", - "\xFE\xFFk = v\n", - "\xEF\xBB", - "k = v", - "k = v\r\n[s]\r\nx = y\r\n", - "# c1\n; c2\nkey = v\n# sec comment\n[sec]\n", - "a = value # c\nb = value;c\nc = x#y\n", - "[sec1] some words\nk = v\n[sec2] # hash words\n[sec3] ; semi\n[sec] words # c\n", - "a = `v#1;2`\nb = \"\"\"v#1;2\"\"\"\nc = \"v#1\"\n", - "a = a#b ;c\nb = x;y # z\nc = p#q\n", - "a = x\\#y\\;z\n", - "foo = \"some value\"\nbar = 'some value'\nbaz = \"a\" \"b\"\nq = 'it''s'\n", - "create_repo=\"created repository %s\"\n", - "[advance]\nADDRESS = \"\"\"404 road,\nNotFound, State, 5000\nEarth\"\"\"\n", - "[advance]\ntwo_lines = how about \\\n\tcontinuation lines?\nlots_of_lines = 1 \\\n\t2 \\\n\t3 \\\n\t4\n", - "key1=a\\b\\\nkey2=c\\d\\\nkey3=value", - "k = `line1\nline2` \\\nmore` ; c\n", - "k = \"\"\"line1\nline2\"\"\" ; c\n", - "k = `unterminated\n", - "k = `unterminated", - "name=\"\"\"Unknwon", - "[long]\nlong_rsa_private_key = -----BEGIN RSA PRIVATE KEY-----\n foo\n bar\n\tbaz\r\n\f-----END RSA PRIVATE KEY-----\nnext = v\n", - "[s]\nk =\n line2\n line3\n\nnext = v\n", - "[profile development]\ns3 =\n max_concurrent_requests = 20\n \n\tmax_queue_size = 10000\nregion = us-west-2\n", - "- = a\n-: b\n[other]\n- = x\n-\n nested\n", - "[mysqld]\n...\nskip-host-cache\nskip-name-resolve # c\n\"quoted bool\"\n", - "[remote \"origin\"]\nurl = a\nurl = b\nurl = a\nurl =\nfetch = +refs/heads/*:refs/remotes/origin/*\n", - "[system]\nfoo = bar\n\n[system]\nbaz = gazonk\n[SYSTEM]\nfoo = 2\n", - "[COMMENTS]\n<1> fuel ; units\n indented\n# not body\n\n[raw]\nx\n[next]\nk = v\n", - "[raw]\nbody", - "GenerationDepth: 13\n\n################\nBiomeGroup(NormalBiomes, 3, 99\n= RainForest\n", - "\"\"\"\"\"\"name\"\"\"=value\n\"\"\"key\"\"\" = v\n`bk` = v\n\"dq k\" junk = v\n\"\"=1\n=\nname\n\"\"\"name\n\"\"\"name\"\"\"\n", - "a: 1\nb = 2\nc:d = 3\na ? 1\nk→v\n", - "[a] ; note ]\nk=v\n[\n", - "[]\n", - "\u00a0k\u2003=\u00a0v\u00a0\n\v[s]\v\n", - "k = %(x)s\nx = ${HOME}\n", - "k = v\\\n\n", - "k = \"\"\"\n\"\"\"", - "k = \"\"\"", - "k = \"x\"\"\"\n", - "k = `x` y `z`\n", - "k = \"quoted ; not comment\"\n", - "k=\xff\xfe = \xc3\n\t\xff\n", - - // From GitHub issues and pull requests. - "[background]\nprimary-color = '#e18477'\nsecondary-color = \"#000000\"\nhash-in-backticks = `#e18477`\nsemi-single = 'foo;bar'\nquoted-then-comment = '#e18477' ; trailing\n", // #393, #394 - "[background]\nurl = 'https://example.test/#fragment' ; trailing comment\nplain = value # actual comment\nk = don't # comment\n", // #411 - "#===== Start of the file =======\ntest_value=\"qwe#123\" \n#===== End of the file ======== \n", // #71 - "HOST = network=tcp,addr=:6379,password=`redis#$%12322`,db=15\nH2 = a=\"\"\"redis#1\"\"\",b\n", // #141 - "\n[author]\nNAME = U#n#k#n#w#o#n\nGITHUB = U;n;k;n;w;o;n\nkey=value_with_#_in_it\nkey2=value with # in it\nk3 = a\t#c\nk4 = #abc\nj = ;\n", // #146, #277 - "\nkey = test value more text\nk = \"a\\;b\"\n", // #129 - "; Comment before the section\n[comments] ; This is a comment for the section too\n; Comment before key\nkey = \"value\"\nkey2 = \"value2\" ; c\n", // #24 - "key1 = \"value\" ; comment\nkey2 = \"one\", \"two\", \"three\"\n", // #25 - "# BEGIN\n[profile test]\nsso_region = us-east-1\noutput = json\n# END\n", // #362 - "Name=\"bruce\"\nAddress=\"\"\nSingle=''\na=\"b=c\"\ntime=2025-01-04T16:25:00+08:00\"\nc = _\"x\"_\nd = \"a\"b\"\ne = '\"x\"'\n", // #56, #112, #363, #389 - "hello = \"!f() { \\\n echo \"hello world\"; \\\n};f\"", // #373 - "key = \"a \\\nb\"\n[access_log]\nreg_gsid = ` (\\d{39,41}) `\n", // #172, #229 - "KEY = \"\"\"VALUE\"\"\"\n[ANOTHER SECTION]\nKEYS=VALUES\n; \"\"\"\"\"\"\n`A pathological `c\"as\"`e` = v\n`KEY`=FAKE_VALUE ;\"`` = ACTUAL_VALUE\n", // #187 - "[Directories]\nSourceDir=C:\\App\\Source\\\nTargetDir=C:\\App\\Target\\\n[dirs]\ncert = Y:\\\\_1\\\\\nserial = 12345678\n", // #44, #347 - "[control]\naudio_path = \\\nvideo_path = \\\n\n\t\tA = C:\\\n\t\tB = D:\\\n\t\t", // #243 - "[git]\nsubmodules_disabled = \n .*secret.*\n fix.*\n", // #270 - "value1 = some text here\n\tsome more text here\n\t\n\tthere is an empty line above and below\n\t\n\nvalue2 = there is an empty line above\n that is not indented\n", // #210, #299 - "[long]\nnotes = first\n second\n\n third\nnext = x\n", // #205 - "value1 = some text here\n\tsome more text here 2", // #309 - "k = a\r\n b\r\nn = 1\r\nk = \"a\"\n b\n", // Python CRLF - "[profile testing]\nregion = us-west-2\ns3 =\n max_concurrent_requests=10\n max_queue_size=1000\n[section2]\nkey2 =\n[section3]\n key3 = value3\n", // #131, #306 - "[FILTER]\n name record_modifier\n whitelist_key _CMDLINE\n whitelist_key _EXE\n record filename systemd\n", // #367 - "keee:y=value\n\"keee:y\"=value\n\n[section]\nkey1?value1\nkey2!value2\n", // #166 - "[global]\nvfs objects = catia fruit streams_xattr\nfruit:metadata = stream\n`fruit:metadata` = stream\n", // #355 - "-key=a\n_key=b\n.key=c\n:key=a\n", // #183 - "[third_section]\nphpversion[] = \"5.0\"\nphpversion[] = \"5.3\"\n", // #315 - "[我]\n姓名 = 张三\n[1234]\n我=abc\n有支付宝语句 = a\\n支付宝:{支付宝}\\n[红包]\n", // #165, #244 - "[server]\ntimeout = {\n \"timeout\":1000\n}\n", // #344 - "key1=hello\nkey2\n#key3\nkey4\nkey5", // #53, #78 - "localhost ansible_connection=local\n\n[webservers]\nother1\nother2\nflag\nflag\n", // #159 - "[test]\n Key1=Key1Value1\n Key1=Key1Value2\n Key1=Key1Value1\n\n[Network]\nDNS=\nDNS=\"1.2.3.4\"\n", // #283, #284, #294 - "[features]\n\t-: Support read/write comments\n\t-: Support auto-increment of key names\n[Agents]\n- = a\n- = b", // #329, #240 - "NAME = Unknwon\n[DEFAULT]\nk1 = v1\n[default]\nk2 = v2\n", // #8, #37, #125, #198 - "[profile foo]\nMfa_Serial=xxxxxxx\n\n[profile FOO]\nmfa_serial=yyyyyyy\n", // #238 - "[foo]bar]\nk = v\n[sec] ; see [1]\n[my section]\n[ padded ]\n", // #46, #212 - "[Interface]\nPrivateKey = a\n# Peer 1\n[Peer]\nPublicKey = b\nAllowedIPs = c\n# Peer 2\n[Peer]\nPublicKey = d\n", // #190, #289 - "[Core]\n Lesson_Location = 87\n\n[CORE_LESSON]\nmy lesson state data – 1111\n[COMMENTS]\n<1> units \n", // #72 - "[raw]", // #291 - "[RAW]\n ; not a comment\n\n indented line\n[next]\n", - "[Version]\nSignature=\"$Windows NT$\"\n[Capture.2860.AddReg]\nHKR,,CLSID,,%ProxyVCap.CLSID%\nHKLM,\"%UNINSTKEY%\\%DRVNAME%\",\"caption\",0x0\n", // #173, #237 - "\xef\xbb\xbf\xef\xbb\xbf[author]\nE-MAIL = example@email.com", // #390 - "\xff\xfe[\x00c\x00o\x00n\x00f\x00]\x00\n\x00k\x00=\x00v\x00\n\x00", // #66, #212 - "; c1\r\n; c2\r\n[sec] ; sc\r\nkey = value\r\nq = \"quoted\"\r\nbt = `a\r\nb`\r\ncont = a \\\r\n b\r\nflag\r\na = 1\rb = 2\r", // #216 - "k =\u00a0v\u00a0\n\u00a0[s]\n", -} - -func FuzzParseParity(f *testing.F) { - inputs := append([]string{confData}, paritySeeds...) - files, _ := filepath.Glob("testdata/*.ini") - for _, name := range files { - data, err := os.ReadFile(name) - if err != nil { - f.Fatal(err) - } - inputs = append(inputs, string(data)) - } - for _, in := range inputs { - for _, flags := range []uint32{0, 1 << 9, 1<<8 | 1<<6, 1<<11 | 1<<10 | 1<<12, 1<<16 | 1<<7 | 1<<5, 1<<20 - 1} { - f.Add([]byte(in), flags) - } - } - f.Fuzz(func(t *testing.T, data []byte, flags uint32) { - checkParity(t, data, flags) - }) -} - -// TestParseParityRandom compares the parsers on documents assembled from -// fragments that sit on the boundaries between parser features. -func TestParseParityRandom(t *testing.T) { - indents := []string{"", "", "", " ", "\t", "\f", "\v", "\u00a0"} - fragments := []string{ - "[s]", "[raw]", "[COMMENTS]", "[S]", "[a] ; c ]", "[a] x", "[", "[]", - "k = v", "K = v2", "k=", "k :", "-", "- = x", "k : v", "\"q k\" = v", "`b k` = v", "\"\"\"tk\"\"\" = v", - "k = \"\"\"multi", "end\"\"\" ; c", "end\"\"\" \\", "k = `multi", "end` # c", "end`", - "k = a \\", "k = \\", "cont \\", "a long continued line that is well over fifty bytes in total \\", "k = 'x'", "k = \"x\"", "k = \"a\\\"b\"", "k = \"a\" ; c", - "k = x\\;y\\#z", "k = a #c", "k = a ;c", "k = a#b ;c", "k = a ;b #c", - "# comment", "; comment", "#", "bool", "\"bool q\"", "bool ; c", "nested = 1", - "k = `a` b `c`", "\"\"\"\"\"\"name\"\"\"=v", "k→v", "k = %(x)s", "k ? v", "= v", "k = '", - } - endings := []string{"\n", "\n", "\n", "\r\n", ""} - - rng := rand.New(rand.NewSource(1)) - for i := 0; i < 50000; i++ { - var b strings.Builder - for n := rng.Intn(10) + 1; n > 0; n-- { - b.WriteString(indents[rng.Intn(len(indents))]) - b.WriteString(fragments[rng.Intn(len(fragments))]) - if rng.Intn(4) == 0 { - b.WriteString(fragments[rng.Intn(len(fragments))]) - } - b.WriteString(endings[rng.Intn(len(endings))]) - } - checkParity(t, []byte(b.String()), rng.Uint32()) - } - for _, in := range paritySeeds { - for i := 0; i < 500; i++ { - checkParity(t, []byte(in), rng.Uint32()) - } - } -} - -// v1 reads a Python-style multiline value only as far as one reader buffer -// looks ahead (#210); the lexer reads it to the end however long it is. -func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { - var b strings.Builder - b.WriteString("[s]\nkey = first\n") - for i := 0; i < 200; i++ { - fmt.Fprintf(&b, " line %03d xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n", i) - } - b.WriteString("next = ok\n") - - f, err := Load([]byte(b.String()), AllowPythonMultilineValues(true)) - if err != nil { - t.Fatal(err) - } - if got := strings.Count(f.Section("s").Key("key").Value(), "\n"); got != 200 { - t.Fatalf("value has %d continuation lines, want 200", got) - } - if got := f.Section("s").Key("next").Value(); got != "ok" { - t.Fatalf("next = %q, want ok", got) - } -} diff --git a/parser_test.go b/parser_test.go index 7016d67..1274608 100644 --- a/parser_test.go +++ b/parser_test.go @@ -15,6 +15,8 @@ package ini import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -75,3 +77,25 @@ func TestBadLoad(t *testing.T) { }) }) } + +// A Python-style multiline value is read to its end however long it is. v1 +// read only as far as one 4 KiB reader buffer looked ahead. +func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { + var b strings.Builder + b.WriteString("[s]\nkey = first\n") + for i := 0; i < 200; i++ { + fmt.Fprintf(&b, " line %03d xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n", i) + } + b.WriteString("next = ok\n") + + f, err := Load([]byte(b.String()), AllowPythonMultilineValues(true)) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(f.Section("s").Key("key").Value(), "\n"); got != 200 { + t.Fatalf("value has %d continuation lines, want 200", got) + } + if got := f.Section("s").Key("next").Value(); got != "ok" { + t.Fatalf("next = %q, want ok", got) + } +} diff --git a/roundtrip_test.go b/roundtrip_test.go index 3ed3670..8a82fa3 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -19,8 +19,6 @@ import ( "fmt" "io" "math/rand" - "os" - "path/filepath" "strings" "testing" ) @@ -86,30 +84,21 @@ func checkRoundTrip(t *testing.T, data []byte, opts []LoadOption, wopts ...Write } func FuzzWriteRoundTrip(f *testing.F) { - inputs := append([]string{confData}, paritySeeds...) - files, _ := filepath.Glob("testdata/*.ini") - for _, name := range files { - data, err := os.ReadFile(name) - if err != nil { - f.Fatal(err) - } - inputs = append(inputs, string(data)) - } - for _, in := range inputs { + for _, in := range fuzzInputs(f) { for _, flags := range []uint32{0, 1 << 9, 1<<8 | 1<<6, 1<<11 | 1<<10 | 1<<12, 1<<16 | 1<<7 | 1<<5, 1<<20 - 1} { f.Add([]byte(in), flags) } } f.Fuzz(func(t *testing.T, data []byte, flags uint32) { - checkRoundTrip(t, data, v2Options(parityOptions(flags))) + checkRoundTrip(t, data, optionsFromBits(flags)) }) } func TestWriteRoundTripRandom(t *testing.T) { rng := rand.New(rand.NewSource(1)) - for _, in := range paritySeeds { + for _, in := range parserInputs(t) { for i := 0; i < 200; i++ { - checkRoundTrip(t, []byte(in), v2Options(parityOptions(rng.Uint32()))) + checkRoundTrip(t, []byte(in), optionsFromBits(rng.Uint32())) } } } diff --git a/struct.go b/struct.go index add989d..93152ff 100644 --- a/struct.go +++ b/struct.go @@ -777,8 +777,8 @@ func (s *Section) ReflectFrom(v any) error { if typ == nil || (typ.Kind() == reflect.Pointer && val.IsNil()) { return errors.New("not a pointer to a struct") } - // v1 accepted a pointer to a pointer to a struct, as in ReflectFrom(&cfg) - // with cfg a *Config. + // A pointer to a pointer to a struct is accepted too, as in + // ReflectFrom(&cfg) with cfg a *Config. for typ.Kind() == reflect.Pointer && typ.Elem().Kind() == reflect.Pointer { if val = val.Elem(); val.IsNil() { return errors.New("not a pointer to a struct") diff --git a/api_fuzz_test.go b/struct_fuzz_test.go similarity index 93% rename from api_fuzz_test.go rename to struct_fuzz_test.go index a344d9a..713db9a 100644 --- a/api_fuzz_test.go +++ b/struct_fuzz_test.go @@ -16,8 +16,6 @@ package ini import ( "bytes" - "os" - "path/filepath" "reflect" "testing" "time" @@ -83,16 +81,7 @@ type fzCfg struct { // with every Key accessor and maps it into fzCfg. Nothing may panic or hang, // and a value written by Marshal must read back the same. func FuzzStructMapping(f *testing.F) { - inputs := append([]string{confData}, paritySeeds...) - files, _ := filepath.Glob("testdata/*.ini") - for _, name := range files { - data, err := os.ReadFile(name) - if err != nil { - f.Fatal(err) - } - inputs = append(inputs, string(data)) - } - inputs = append(inputs, + inputs := append(fuzzInputs(f), "S = s\nI = -3\nI8 = 127\nU = 4\nU8 = 255\nF32 = 1.5\nF = 2.5\nB = true\nD = 1s\nT = 2020-01-02T03:04:05.5Z\n"+ "PS = p\nPI = 7\nPT = 2020-01-02T03:04:05Z\nPD = 3s\nSS = a,b\\, c,d\nSI = 1,2\nSU = 3,4\nSF = 1.5,2\nSB = true,false\n"+ "ST = 2020-01-02T03:04:05Z\nSP = x|y\nNamed = n\nPNamed = m\nshadow = a\nshadow = b\nE = e\n"+ @@ -107,7 +96,7 @@ func FuzzStructMapping(f *testing.F) { } f.Fuzz(func(t *testing.T, data []byte, flags uint32) { - opts := v2Options(parityOptions(flags)) + opts := optionsFromBits(flags) file, err := Load(data, opts...) if err != nil { return diff --git a/testdata/inputs.txt b/testdata/inputs.txt new file mode 100644 index 0000000..f7c20ee --- /dev/null +++ b/testdata/inputs.txt @@ -0,0 +1,99 @@ +# Inputs that exercise the parser: every feature, the examples from the +# documentation, and inputs from GitHub issues and pull requests. The +# fuzzers start from them. +# +# One Go-quoted string per line. Lines starting with # are comments, and +# text after the closing quote says where an input came from. + +"" +"\ufeffk = v\n" +"\xff\xfek = v\n" +"\xfe\xffk = v\n" +"\xef\xbb" +"k = v" +"k = v\r\n[s]\r\nx = y\r\n" +"# c1\n; c2\nkey = v\n# sec comment\n[sec]\n" +"a = value # c\nb = value;c\nc = x#y\n" +"[sec1] some words\nk = v\n[sec2] # hash words\n[sec3] ; semi\n[sec] words # c\n" +"a = `v#1;2`\nb = \"\"\"v#1;2\"\"\"\nc = \"v#1\"\n" +"a = a#b ;c\nb = x;y # z\nc = p#q\n" +"a = x\\#y\\;z\n" +"foo = \"some value\"\nbar = 'some value'\nbaz = \"a\" \"b\"\nq = 'it''s'\n" +"create_repo=\"created repository %s\"\n" +"[advance]\nADDRESS = \"\"\"404 road,\nNotFound, State, 5000\nEarth\"\"\"\n" +"[advance]\ntwo_lines = how about \\\n\tcontinuation lines?\nlots_of_lines = 1 \\\n\t2 \\\n\t3 \\\n\t4\n" +"key1=a\\b\\\nkey2=c\\d\\\nkey3=value" +"k = `line1\nline2` \\\nmore` ; c\n" +"k = \"\"\"line1\nline2\"\"\" ; c\n" +"k = `unterminated\n" +"k = `unterminated" +"name=\"\"\"Unknwon" +"[long]\nlong_rsa_private_key = -----BEGIN RSA PRIVATE KEY-----\n foo\n bar\n\tbaz\r\n\f-----END RSA PRIVATE KEY-----\nnext = v\n" +"[s]\nk =\n line2\n line3\n\nnext = v\n" +"[profile development]\ns3 =\n max_concurrent_requests = 20\n \n\tmax_queue_size = 10000\nregion = us-west-2\n" +"- = a\n-: b\n[other]\n- = x\n-\n nested\n" +"[mysqld]\n...\nskip-host-cache\nskip-name-resolve # c\n\"quoted bool\"\n" +"[remote \"origin\"]\nurl = a\nurl = b\nurl = a\nurl =\nfetch = +refs/heads/*:refs/remotes/origin/*\n" +"[system]\nfoo = bar\n\n[system]\nbaz = gazonk\n[SYSTEM]\nfoo = 2\n" +"[COMMENTS]\n<1> fuel ; units\n indented\n# not body\n\n[raw]\nx\n[next]\nk = v\n" +"[raw]\nbody" +"GenerationDepth: 13\n\n################\nBiomeGroup(NormalBiomes, 3, 99\n= RainForest\n" +"\"\"\"\"\"\"name\"\"\"=value\n\"\"\"key\"\"\" = v\n`bk` = v\n\"dq k\" junk = v\n\"\"=1\n=\nname\n\"\"\"name\n\"\"\"name\"\"\"\n" +"a: 1\nb = 2\nc:d = 3\na ? 1\nk→v\n" +"[a] ; note ]\nk=v\n[\n" +"[]\n" +"\u00a0k\u2003=\u00a0v\u00a0\n\v[s]\v\n" +"k = %(x)s\nx = ${HOME}\n" +"k = v\\\n\n" +"k = \"\"\"\n\"\"\"" +"k = \"\"\"" +"k = \"x\"\"\"\n" +"k = `x` y `z`\n" +"k = \"quoted ; not comment\"\n" +"k=\xff\xfe = \xc3\n\t\xff\n" + +# From GitHub issues and pull requests. +"[background]\nprimary-color = '#e18477'\nsecondary-color = \"#000000\"\nhash-in-backticks = `#e18477`\nsemi-single = 'foo;bar'\nquoted-then-comment = '#e18477' ; trailing\n" issue 393, issue 394 +"[background]\nurl = 'https://example.test/#fragment' ; trailing comment\nplain = value # actual comment\nk = don't # comment\n" issue 411 +"#===== Start of the file =======\ntest_value=\"qwe#123\" \n#===== End of the file ======== \n" issue 71 +"HOST = network=tcp,addr=:6379,password=`redis#$%12322`,db=15\nH2 = a=\"\"\"redis#1\"\"\",b\n" issue 141 +"\n[author]\nNAME = U#n#k#n#w#o#n\nGITHUB = U;n;k;n;w;o;n\nkey=value_with_#_in_it\nkey2=value with # in it\nk3 = a\t#c\nk4 = #abc\nj = ;\n" issue 146, issue 277 +"\nkey = test value more text\nk = \"a\\;b\"\n" issue 129 +"; Comment before the section\n[comments] ; This is a comment for the section too\n; Comment before key\nkey = \"value\"\nkey2 = \"value2\" ; c\n" issue 24 +"key1 = \"value\" ; comment\nkey2 = \"one\", \"two\", \"three\"\n" issue 25 +"# BEGIN\n[profile test]\nsso_region = us-east-1\noutput = json\n# END\n" issue 362 +"Name=\"bruce\"\nAddress=\"\"\nSingle=''\na=\"b=c\"\ntime=2025-01-04T16:25:00+08:00\"\nc = _\"x\"_\nd = \"a\"b\"\ne = '\"x\"'\n" issue 56, issue 112, issue 363, issue 389 +"hello = \"!f() { \\\n echo \"hello world\"; \\\n};f\"" issue 373 +"key = \"a \\\nb\"\n[access_log]\nreg_gsid = ` (\\d{39,41}) `\n" issue 172, issue 229 +"KEY = \"\"\"VALUE\"\"\"\n[ANOTHER SECTION]\nKEYS=VALUES\n; \"\"\"\"\"\"\n`A pathological `c\"as\"`e` = v\n`KEY`=FAKE_VALUE ;\"`` = ACTUAL_VALUE\n" issue 187 +"[Directories]\nSourceDir=C:\\App\\Source\\\nTargetDir=C:\\App\\Target\\\n[dirs]\ncert = Y:\\\\_1\\\\\nserial = 12345678\n" issue 44, issue 347 +"[control]\naudio_path = \\\nvideo_path = \\\n\n\t\tA = C:\\\n\t\tB = D:\\\n\t\t" issue 243 +"[git]\nsubmodules_disabled = \n .*secret.*\n fix.*\n" issue 270 +"value1 = some text here\n\tsome more text here\n\t\n\tthere is an empty line above and below\n\t\n\nvalue2 = there is an empty line above\n that is not indented\n" issue 210, issue 299 +"[long]\nnotes = first\n second\n\n third\nnext = x\n" issue 205 +"value1 = some text here\n\tsome more text here 2" issue 309 +"k = a\r\n b\r\nn = 1\r\nk = \"a\"\n b\n" Python CRLF +"[profile testing]\nregion = us-west-2\ns3 =\n max_concurrent_requests=10\n max_queue_size=1000\n[section2]\nkey2 =\n[section3]\n key3 = value3\n" issue 131, issue 306 +"[FILTER]\n name record_modifier\n whitelist_key _CMDLINE\n whitelist_key _EXE\n record filename systemd\n" issue 367 +"keee:y=value\n\"keee:y\"=value\n\n[section]\nkey1?value1\nkey2!value2\n" issue 166 +"[global]\nvfs objects = catia fruit streams_xattr\nfruit:metadata = stream\n`fruit:metadata` = stream\n" issue 355 +"-key=a\n_key=b\n.key=c\n:key=a\n" issue 183 +"[third_section]\nphpversion[] = \"5.0\"\nphpversion[] = \"5.3\"\n" issue 315 +"[我]\n姓名 = 张三\n[1234]\n我=abc\n有支付宝语句 = a\\n支付宝:{支付宝}\\n[红包]\n" issue 165, issue 244 +"[server]\ntimeout = {\n \"timeout\":1000\n}\n" issue 344 +"key1=hello\nkey2\n#key3\nkey4\nkey5" issue 53, issue 78 +"localhost ansible_connection=local\n\n[webservers]\nother1\nother2\nflag\nflag\n" issue 159 +"[test]\n Key1=Key1Value1\n Key1=Key1Value2\n Key1=Key1Value1\n\n[Network]\nDNS=\nDNS=\"1.2.3.4\"\n" issue 283, issue 284, issue 294 +"[features]\n\t-: Support read/write comments\n\t-: Support auto-increment of key names\n[Agents]\n- = a\n- = b" issue 329, issue 240 +"NAME = Unknwon\n[DEFAULT]\nk1 = v1\n[default]\nk2 = v2\n" issue 8, issue 37, issue 125, issue 198 +"[profile foo]\nMfa_Serial=xxxxxxx\n\n[profile FOO]\nmfa_serial=yyyyyyy\n" issue 238 +"[foo]bar]\nk = v\n[sec] ; see [1]\n[my section]\n[ padded ]\n" issue 46, issue 212 +"[Interface]\nPrivateKey = a\n# Peer 1\n[Peer]\nPublicKey = b\nAllowedIPs = c\n# Peer 2\n[Peer]\nPublicKey = d\n" issue 190, issue 289 +"[Core]\n Lesson_Location = 87\n\n[CORE_LESSON]\nmy lesson state data – 1111\n[COMMENTS]\n<1> units \n" issue 72 +"[raw]" issue 291 +"[RAW]\n ; not a comment\n\n indented line\n[next]\n" +"[Version]\nSignature=\"$Windows NT$\"\n[Capture.2860.AddReg]\nHKR,,CLSID,,%ProxyVCap.CLSID%\nHKLM,\"%UNINSTKEY%\\%DRVNAME%\",\"caption\",0x0\n" issue 173, issue 237 +"\ufeff\ufeff[author]\nE-MAIL = example@email.com" issue 390 +"\xff\xfe[\x00c\x00o\x00n\x00f\x00]\x00\n\x00k\x00=\x00v\x00\n\x00" issue 66, issue 212 +"; c1\r\n; c2\r\n[sec] ; sc\r\nkey = value\r\nq = \"quoted\"\r\nbt = `a\r\nb`\r\ncont = a \\\r\n b\r\nflag\r\na = 1\rb = 2\r" issue 216 +"k =\u00a0v\u00a0\n\u00a0[s]\n" From 257f3e5e81bf5b2cdc014e0f4b1775e6bd7cbd54 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 17:54:57 +0000 Subject: [PATCH 29/41] Read testdata/inputs.txt with CRLF line endings Git on Windows checks the file out with CRLF, which left a carriage return after each quoted input. --- inputs_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/inputs_test.go b/inputs_test.go index 682a1c9..1ff91f3 100644 --- a/inputs_test.go +++ b/inputs_test.go @@ -31,6 +31,8 @@ func parserInputs(tb testing.TB) []string { } var inputs []string for i, line := range strings.Split(string(data), "\n") { + // Git on Windows may check the file out with CRLF line endings. + line = strings.TrimSuffix(line, "\r") if line == "" || strings.HasPrefix(line, "#") { continue } From 0453a46851afc659d1db7c36109ce245786cdc52 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 19:40:13 +0000 Subject: [PATCH 30/41] Tighten the migration guide --- docs/migrate-v1-to-v2.md | 83 ++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 4e304cb..0307f53 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -1,76 +1,69 @@ # Migrating from v1 to v2 -v2 lives at a new import path, and the package name is still `ini`: - ```go -import "unknwon.dev/ini/v2" // was "gopkg.in/ini.v1" +import "unknwon.dev/ini/v2" // was "gopkg.in/ini.v1"; the package is still ini ``` -It needs Go 1.26 or later. Both versions can be used in one program, so a large codebase can migrate one package at a time. +v2 needs Go 1.26 or later. v1 and v2 can be used in the same program, so you can migrate one package at a time. -**Parsing doesn't change.** Without options, v2 parses every input exactly as v1's `Load` does: the same sections, keys, values, comments and errors. The one exception is a Python-style multiline value longer than 4 KiB, which v1 cut at its read buffer and then failed on; v2 reads it in full. +**Parsing is unchanged.** Without options, v2 parses every input exactly as v1 does: same sections, keys, values, comments and errors. The one exception is a Python-style multiline value longer than 4 KiB. v1 cut it at its read buffer and failed; v2 reads it in full. -Most API changes are renames and stop the build, so the compiler finds them for you; the [API changes](#api-changes) tables list the replacements. Start with the next section, though: these changes compile fine and only show up at run time. +Most API changes are renames, and the compiler finds them; the tables under [API changes](#api-changes) list the replacements. The changes in the next section still compile, so read it first. ## Changes the compiler won't catch -### `Must*` methods no longer store the default +### `Must*` no longer stores the default -In v1, `k.MustInt(3)` returned 3 for a missing or invalid value **and wrote "3" into the key**. Later reads, `MapTo` and `Write` then saw the default, so saving a config wrote out every default the program had asked for. In v2, `Must*` methods only read. If your code reads a default back later, or relies on it being saved, store it yourself: +In v1, `k.MustInt(3)` returned 3 for a missing or invalid value and also wrote `3` into the key, so later reads, `MapTo` and `Write` saw it. In v2, `Must*` only reads. If you need the default stored, set it: ```go -// v1 -port := sec.Key("PORT").MustInt(3000) // also set PORT = 3000 - -// v2 port := sec.Key("PORT").MustInt(3000) -sec.Key("PORT").SetValue(strconv.Itoa(port)) // only if you need it stored +sec.Key("PORT").SetValue(strconv.Itoa(port)) ``` ### `Reload` and `Append` -- `Reload` replaces the contents with what the sources hold now. In v1 it merged them into the current contents, so keys removed from a file stayed and shadow values piled up. `*Section` and `*Key` values you hold see the new values, and if loading fails, nothing changes. -- `Append` reads only the sources it is given, so changes you made in memory survive it. In v1 it read every source again. -- `io.Reader` sources are read once and kept, so `Reload` works with them; in v1 it failed on the exhausted reader. +- `Reload` replaces the contents with what the sources hold now. v1 merged them in, so removed keys stayed and shadow values piled up. `*Section` and `*Key` values you hold see the new values; on error, nothing changes. +- `Append` reads only the new sources, so changes made in memory survive. v1 read every source again. +- `io.Reader` sources are read once and kept, so `Reload` works with them. In v1 it failed. ### Lists -`ini.List` (v1's `Ints`, `Float64s` and friends) gives 0 for an element that is out of range for the type, as it does for any element that doesn't parse. v1 kept the clamped value that `strconv` returns, such as `math.MaxInt64` or `+Inf`. +`ini.List` gives 0 for an out-of-range element, as for any element that doesn't parse. v1's `Ints` and friends kept the clamped value, such as `math.MaxInt64` or `+Inf`. ### Struct mapping -`MapTo`, `StrictMapTo` and `ReflectFrom` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Valid input can see these differences: +`MapTo`, `StrictMapTo` and `ReflectFrom` return errors instead of panicking on unsupported, named, pointer, nil and recursive types. Valid input can also behave differently: -- An out-of-range number, such as `300` for an `int8` field, is an error in `StrictMapTo` and leaves the field unchanged in `MapTo`. v1 wrapped it to 44. -- `StrictMapTo` reports invalid slice elements; v1 set them to zero. -- Durations of `0` and negative durations are applied; v1 skipped them. -- `uint8` fields map; v1 returned an error. -- An embedded struct pointer that is already set keeps its values; v1 replaced it. -- A `nonunique` field with no matching sections is left empty; v1 failed. +- An out-of-range number, such as `300` for an `int8`, leaves the field unchanged in `MapTo` and is an error in `StrictMapTo`. v1 wrapped it to 44. +- `StrictMapTo` reports invalid slice elements. v1 set them to zero. +- Zero and negative durations are applied. v1 skipped them. +- `uint8` fields map. v1 returned an error. +- An embedded struct pointer that is already set keeps its values. v1 replaced it. +- A `nonunique` field with no matching sections is left empty. v1 failed. - When writing: - times keep sub-second precision; - - slice elements that contain the delimiter or end in a backslash are escaped, so they read back as one element; - - nil pointer fields aren't written; + - slice elements containing the delimiter or ending in a backslash are escaped; + - nil pointer fields are skipped; - fields after an empty `nonunique` slice or a `StructReflector` field are written; - - an `allowshadow` element that contains the delimiter is an error, because it would read back as two elements. + - an `allowshadow` element containing the delimiter is an error, since it would read back as two elements. ### Writing -`Write` output is byte-identical to v1's for ordinary files. Where v1 wrote a file that loaded back differently, v2 writes one that loads back the same, or returns an error: +For ordinary files, `Write` output is byte-identical to v1's. Where v1 wrote a file that loaded back differently, v2 writes one that loads back the same, or returns an error. For example: - values ending in `\`, containing `"""`, or starting with a delimiter are quoted; -- empty shadow values are written; -- comments of keys written on several lines are kept; +- empty shadow values and the comments of multi-line keys are kept; - with `CaseInsensitive`, an explicit `[DEFAULT]` section is written with its own keys; - a raw section body without a final newline no longer runs into the next header. -### Smaller fixes you might notice +### Other fixes -- A `%(name)s` reference cycle is left unexpanded. In v1 it overflowed the stack and crashed the program. -- With `CaseInsensitive`, `DeleteKey("Name")` deletes the key; v1 ignored the case-folded name. -- `ChildSections` lists each child once with `AllowNonUniqueSections`. +- A `%(name)s` reference cycle is left unexpanded. v1 overflowed the stack and crashed. +- With `CaseInsensitive`, `DeleteKey` matches names in any case. +- With `AllowNonUniqueSections`, `ChildSections` lists each child once. - `Key.Strings("")`, a negative section index and `Section.Key("")` no longer hang or panic. -- Reading a key takes the file's read lock, so reads are safe while other goroutines change the file. That costs about 10 ns per read; load with `ini.Locking(false)` if the file never changes after loading. +- Reads take the file's read lock, so they are safe while other goroutines change the file. This adds about 10 ns per read. If the file never changes after loading, load it with `ini.Locking(false)`. ## API changes @@ -85,9 +78,9 @@ sec.Key("PORT").SetValue(strconv.Itoa(port)) // only if you need it stored | `MapToWithMapper(v, m, a)`, `StrictMapToWithMapper` | `MapTo(v, a, ini.NameMapping(m))`, `StrictMapTo(v, a, ini.NameMapping(m))` | | `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | | `f.BlockMode = false` | load with `ini.Locking(false)` | -| `ini.DefaultSection = "default"` | load with `ini.DefaultSectionName("default")`; `ini.DefaultSection` is now a constant | +| `ini.DefaultSection = "default"` | load with `ini.DefaultSectionName("default")`; `DefaultSection` is now a constant | -Every `LoadOptions` field is now an option function of the same name that takes its value, such as `ini.SkipUnrecognizableLines(true)` or `ini.KeyValueDelimiters("=")`. A later option overrides an earlier one. These were renamed: +Each `LoadOptions` field becomes an option function of the same name, such as `ini.SkipUnrecognizableLines(true)` or `ini.KeyValueDelimiters("=")`. Later options override earlier ones. These were renamed: | `LoadOptions` field | v2 option | |---|---| @@ -97,14 +90,14 @@ Every `LoadOptions` field is now an option function of the same name that takes | `UnparseableSections` | `RawSections(names...)` | | `PreserveSurroundedQuote` | `PreserveSurroundingQuotes` | | `DebugFunc` | `Debug(fn)` | -| `KeyValueDelimiterOnWrite` | `WriteDelimiter(d)`, an option of `Write` | +| `KeyValueDelimiterOnWrite` | `WriteDelimiter(d)`, a write option | | `ReaderBufferSize` | removed; nothing is cut at a buffer size | New options: `CommentSymbols`, `PreserveEmptyShadowValues` and `DefaultSectionName`. ### Writing -The package variables that changed every file's output are options of `File.Write` and `File.SaveTo`, so one caller can't change another's output. The defaults write what v1 wrote. +The package variables that changed every file's output are now options of `File.Write` and `File.SaveTo`. Their defaults write what v1 wrote. | v1 | v2 | |---|---| @@ -117,13 +110,13 @@ The package variables that changed every file's output are options of `File.Writ | `f.WriteToIndent(w, "\t")`, `f.SaveToIndent(name, "\t")` | `f.Write(w, ini.Indent("\t"))`, `f.SaveTo(name, ini.Indent("\t"))` | | `ReflectFrom(f, v)`, `ReflectFromWithMapper(f, v, m)` | `f.ReflectFrom(v)`, with `f` loaded with `ini.NameMapping(m)` | -`WriteTo` stays, so a `*File` is still an `io.WriterTo`. +`WriteTo` stays, so `*File` is still an `io.WriterTo`. ### Sections and keys | v1 | v2 | |---|---| -| `s.Comment`, `k.Comment` fields | `Comment()` and `SetComment(c)` methods | +| `s.Comment`, `k.Comment` fields | `Comment()` and `SetComment(c)` | | `f.SectionStrings()`, `s.KeyStrings()`, `s.KeysHash()` | `SectionNames()`, `KeyNames()`, `KeyValues()` | | `k.ValueWithShadows()` | `k.Values()` | | `s.Haskey(name)` | `s.HasKey(name)` | @@ -131,7 +124,7 @@ The package variables that changed every file's output are options of `File.Writ ### Typed values -The typed list, candidate and range methods are generic functions. They behave as before, and work for any `Scalar` type, such as `int8`, `time.Duration` or a named string type. +The typed list, candidate and range methods are now generic functions. They behave the same and accept any `Scalar` type, such as `int8`, `time.Duration` or a named string type. | v1 | v2 | |---|---| @@ -139,9 +132,9 @@ The typed list, candidate and range methods are generic functions. They behave a | `k.ValidInts(",")`… | `ini.ValidList[int](k, ",")`… | | `k.StrictInts(",")`… | `ini.StrictList[int](k, ",")`… | | `k.In(def, cands)`, `k.InInt(def, cands)`, `k.InTime(def, cands)`… | `ini.In(k, def, cands)` | -| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)`… | `ini.Range(k, def, lo, hi)`; type the default for other types, as in `ini.Range(k, 0.5, 0, 1)` | +| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)`… | `ini.Range(k, def, lo, hi)`; type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)` | -`String`, `Int`, `Bool`, `Duration`, `Strings`, the `Must*` methods, and the custom-layout time methods (`TimesFormat`, `InTimeFormat`, `RangeTimeFormat` and their `Valid` and `Strict` forms) stay on `Key`. +`String`, `Int`, `Bool`, `Duration`, `Strings`, the `Must*` methods and the custom-layout time methods (`TimesFormat`, `InTimeFormat`, `RangeTimeFormat` and their `Valid` and `Strict` forms) stay on `Key`. ### Errors @@ -150,4 +143,4 @@ The typed list, candidate and range methods are generic functions. They behave a | `ini.IsErrDelimiterNotFound(err)` | `errors.Is(err, ini.ErrDelimiterNotFound)` | | `ini.IsErrEmptyKeyName(err)` | `errors.Is(err, ini.ErrEmptyKeyName)` | -Parse errors are `*ini.ParseError` values with the 1-based `Line` and `Column` of the problem; the message under the position is the same as v1's. +Parse errors are `*ini.ParseError` values with a 1-based `Line` and `Column`. The message after the position is the same as v1's. From d8dab3b5bf285f958cf0d15f1b920ef63e162259 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 19:41:32 +0000 Subject: [PATCH 31/41] Remove the license headers from source files The LICENSE file covers the repository. --- bench_test.go | 14 -------------- data_source.go | 14 -------------- error.go | 14 -------------- example_test.go | 14 -------------- file.go | 14 -------------- file_test.go | 14 -------------- generic.go | 14 -------------- ini.go | 14 -------------- ini_test.go | 14 -------------- inputs_test.go | 14 -------------- key.go | 14 -------------- key_test.go | 14 -------------- lexer.go | 14 -------------- options.go | 14 -------------- options_test.go | 14 -------------- parser_test.go | 14 -------------- roundtrip_test.go | 14 -------------- section.go | 14 -------------- section_test.go | 14 -------------- struct.go | 14 -------------- struct_fuzz_test.go | 14 -------------- struct_test.go | 14 -------------- 22 files changed, 308 deletions(-) diff --git a/bench_test.go b/bench_test.go index ab254dc..a13027d 100644 --- a/bench_test.go +++ b/bench_test.go @@ -1,17 +1,3 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/data_source.go b/data_source.go index 9737892..9b1f467 100644 --- a/data_source.go +++ b/data_source.go @@ -1,17 +1,3 @@ -// Copyright 2019 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/error.go b/error.go index fb2e04e..3f61595 100644 --- a/error.go +++ b/error.go @@ -1,17 +1,3 @@ -// Copyright 2016 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/example_test.go b/example_test.go index eae34db..decacd8 100644 --- a/example_test.go +++ b/example_test.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini_test import ( diff --git a/file.go b/file.go index a417efd..4293817 100644 --- a/file.go +++ b/file.go @@ -1,17 +1,3 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/file_test.go b/file_test.go index 68ab6c2..ac23dfc 100644 --- a/file_test.go +++ b/file_test.go @@ -1,17 +1,3 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/generic.go b/generic.go index 2fd592b..f909cb3 100644 --- a/generic.go +++ b/generic.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/ini.go b/ini.go index 57bdc3a..c1844ca 100644 --- a/ini.go +++ b/ini.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - // Package ini reads and writes INI files. // // Load parses one source, a file name, []byte or io.Reader, and LoadSources diff --git a/ini_test.go b/ini_test.go index 011fc6b..eddb61f 100644 --- a/ini_test.go +++ b/ini_test.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/inputs_test.go b/inputs_test.go index 1ff91f3..606068a 100644 --- a/inputs_test.go +++ b/inputs_test.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/key.go b/key.go index b4729a8..a6194ba 100644 --- a/key.go +++ b/key.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/key_test.go b/key_test.go index f2507af..dd6a0be 100644 --- a/key_test.go +++ b/key_test.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/lexer.go b/lexer.go index e9f8325..c453159 100644 --- a/lexer.go +++ b/lexer.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/options.go b/options.go index d3fa7f4..2dfa169 100644 --- a/options.go +++ b/options.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/options_test.go b/options_test.go index bd4a7a9..45edcca 100644 --- a/options_test.go +++ b/options_test.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/parser_test.go b/parser_test.go index 1274608..ccec370 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,17 +1,3 @@ -// Copyright 2016 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/roundtrip_test.go b/roundtrip_test.go index 8a82fa3..31a30bd 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/section.go b/section.go index aaab02b..c773f1d 100644 --- a/section.go +++ b/section.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/section_test.go b/section_test.go index 8969b4b..89b2f53 100644 --- a/section_test.go +++ b/section_test.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/struct.go b/struct.go index 93152ff..9000688 100644 --- a/struct.go +++ b/struct.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/struct_fuzz_test.go b/struct_fuzz_test.go index 713db9a..f65ca85 100644 --- a/struct_fuzz_test.go +++ b/struct_fuzz_test.go @@ -1,17 +1,3 @@ -// Copyright 2026 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( diff --git a/struct_test.go b/struct_test.go index 844f6ce..fa1b57d 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1,17 +1,3 @@ -// Copyright 2014 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package ini import ( From 67e5f7887a486e77c0452cb6fd0e01ec5f1a3972 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 19:45:03 +0000 Subject: [PATCH 32/41] Use no semicolons in prose --- README.md | 4 ++-- docs/migrate-v1-to-v2.md | 32 ++++++++++++++++---------------- file.go | 10 +++++----- generic.go | 2 +- ini.go | 8 ++++---- key.go | 22 +++++++++++----------- lexer.go | 2 +- options.go | 10 +++++----- roundtrip_test.go | 6 +++--- section.go | 4 ++-- struct_test.go | 4 ++-- 11 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 4209c31..6acf0cd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Package ini reads and writes INI files in Go. - Sections and keys kept in order, parent-child sections, auto-increment keys, repeated (shadow) keys, nested values, multiline values, and `%(name)s` references. - Typed access to values: numbers, booleans, durations, times, and lists, ranges and candidates of any of them. - Map files to and from structs, and `Marshal` and `Unmarshal` like `encoding/json`. -- Write files back with their comments and order; what is written always loads back the same. +- Write files back with their comments and order. What is written always loads back the same. - Parse errors that give the line and column. - Safe for concurrent use. @@ -73,7 +73,7 @@ v2 loads files up to 31 times faster than v1, with a third to a fifth of the all - **Load** is linear in the number of keys. v1 compared each new key with every key before it in its section, so a large section took quadratic time. - **Write** is slower because it checks that every line it writes reads back as the same value. v1 could write files that loaded differently. -- **Reads** take the file's read lock so that they are safe while other goroutines change the file; v1 read without it. +- **Reads** take the file's read lock so that they are safe while other goroutines change the file. v1 read without it. Measured with Go 1.26.10 on a 4-core Intel Xeon at 2.60 GHz, as the median of 8 runs. diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 0307f53..e53a879 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -6,9 +6,9 @@ import "unknwon.dev/ini/v2" // was "gopkg.in/ini.v1"; the package is still ini v2 needs Go 1.26 or later. v1 and v2 can be used in the same program, so you can migrate one package at a time. -**Parsing is unchanged.** Without options, v2 parses every input exactly as v1 does: same sections, keys, values, comments and errors. The one exception is a Python-style multiline value longer than 4 KiB. v1 cut it at its read buffer and failed; v2 reads it in full. +**Parsing is unchanged.** Without options, v2 parses every input exactly as v1 does: same sections, keys, values, comments and errors. The one exception is a Python-style multiline value longer than 4 KiB. v1 cut it at its read buffer and failed, and v2 reads it in full. -Most API changes are renames, and the compiler finds them; the tables under [API changes](#api-changes) list the replacements. The changes in the next section still compile, so read it first. +Most API changes are renames that the compiler finds, and the tables under [API changes](#api-changes) list the replacements. The changes in the next section still compile, so read it first. ## Changes the compiler won't catch @@ -23,7 +23,7 @@ sec.Key("PORT").SetValue(strconv.Itoa(port)) ### `Reload` and `Append` -- `Reload` replaces the contents with what the sources hold now. v1 merged them in, so removed keys stayed and shadow values piled up. `*Section` and `*Key` values you hold see the new values; on error, nothing changes. +- `Reload` replaces the contents with what the sources hold now. v1 merged them in, so removed keys stayed and shadow values piled up. `*Section` and `*Key` values you hold see the new values. On error, nothing changes. - `Append` reads only the new sources, so changes made in memory survive. v1 read every source again. - `io.Reader` sources are read once and kept, so `Reload` works with them. In v1 it failed. @@ -42,20 +42,20 @@ sec.Key("PORT").SetValue(strconv.Itoa(port)) - An embedded struct pointer that is already set keeps its values. v1 replaced it. - A `nonunique` field with no matching sections is left empty. v1 failed. - When writing: - - times keep sub-second precision; - - slice elements containing the delimiter or ending in a backslash are escaped; - - nil pointer fields are skipped; - - fields after an empty `nonunique` slice or a `StructReflector` field are written; - - an `allowshadow` element containing the delimiter is an error, since it would read back as two elements. + - Times keep sub-second precision. + - Slice elements containing the delimiter or ending in a backslash are escaped. + - Nil pointer fields are skipped. + - Fields after an empty `nonunique` slice or a `StructReflector` field are written. + - An `allowshadow` element containing the delimiter is an error, since it would read back as two elements. ### Writing For ordinary files, `Write` output is byte-identical to v1's. Where v1 wrote a file that loaded back differently, v2 writes one that loads back the same, or returns an error. For example: -- values ending in `\`, containing `"""`, or starting with a delimiter are quoted; -- empty shadow values and the comments of multi-line keys are kept; -- with `CaseInsensitive`, an explicit `[DEFAULT]` section is written with its own keys; -- a raw section body without a final newline no longer runs into the next header. +- Values ending in `\`, containing `"""`, or starting with a delimiter are quoted. +- Empty shadow values and the comments of multi-line keys are kept. +- With `CaseInsensitive`, an explicit `[DEFAULT]` section is written with its own keys. +- A raw section body without a final newline no longer runs into the next header. ### Other fixes @@ -71,14 +71,14 @@ For ordinary files, `Write` output is byte-identical to v1's. Where v1 wrote a f | v1 | v2 | |---|---| -| `Load(a, b)` | `LoadSources([]any{a, b})`; `Load(a)` is unchanged | +| `Load(a, b)` | `LoadSources([]any{a, b})`, while `Load(a)` is unchanged | | `LoadSources(LoadOptions{AllowShadows: true}, a)` | `Load(a, ini.AllowShadows(true))` | | `LooseLoad`, `InsensitiveLoad`, `ShadowLoad` | `Load` with `ini.IgnoreMissingFiles(true)`, `ini.CaseInsensitive(true)`, `ini.AllowShadows(true)` | | `MapTo(v, a, b)` | `MapTo(v, a, opts...)`, with one source | | `MapToWithMapper(v, m, a)`, `StrictMapToWithMapper` | `MapTo(v, a, ini.NameMapping(m))`, `StrictMapTo(v, a, ini.NameMapping(m))` | | `f.NameMapper = m`, `f.ValueMapper = fn` | load with `ini.NameMapping(m)`, `ini.ValueMapping(fn)` | | `f.BlockMode = false` | load with `ini.Locking(false)` | -| `ini.DefaultSection = "default"` | load with `ini.DefaultSectionName("default")`; `DefaultSection` is now a constant | +| `ini.DefaultSection = "default"` | load with `ini.DefaultSectionName("default")`. `DefaultSection` is now a constant. | Each `LoadOptions` field becomes an option function of the same name, such as `ini.SkipUnrecognizableLines(true)` or `ini.KeyValueDelimiters("=")`. Later options override earlier ones. These were renamed: @@ -91,7 +91,7 @@ Each `LoadOptions` field becomes an option function of the same name, such as `i | `PreserveSurroundedQuote` | `PreserveSurroundingQuotes` | | `DebugFunc` | `Debug(fn)` | | `KeyValueDelimiterOnWrite` | `WriteDelimiter(d)`, a write option | -| `ReaderBufferSize` | removed; nothing is cut at a buffer size | +| `ReaderBufferSize` | removed, since nothing is cut at a buffer size | New options: `CommentSymbols`, `PreserveEmptyShadowValues` and `DefaultSectionName`. @@ -132,7 +132,7 @@ The typed list, candidate and range methods are now generic functions. They beha | `k.ValidInts(",")`… | `ini.ValidList[int](k, ",")`… | | `k.StrictInts(",")`… | `ini.StrictList[int](k, ",")`… | | `k.In(def, cands)`, `k.InInt(def, cands)`, `k.InTime(def, cands)`… | `ini.In(k, def, cands)` | -| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)`… | `ini.Range(k, def, lo, hi)`; type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)` | +| `k.RangeInt(def, lo, hi)`, `k.RangeTime(def, lo, hi)`… | `ini.Range(k, def, lo, hi)`. Type the default for non-int types, as in `ini.Range(k, 0.5, 0, 1)`. | `String`, `Int`, `Bool`, `Duration`, `Strings`, the `Must*` methods and the custom-layout time methods (`TimesFormat`, `InTimeFormat`, `RangeTimeFormat` and their `Valid` and `Strict` forms) stay on `Key`. diff --git a/file.go b/file.go index 4293817..c9c2de4 100644 --- a/file.go +++ b/file.go @@ -21,7 +21,7 @@ type File struct { locking bool lock sync.RWMutex - // sections are in the order they were added; byName indexes + // sections are in the order they were added, and byName indexes // them, in the same order, by name. sections []*Section byName map[string][]*Section @@ -368,8 +368,8 @@ func (f *File) load(sources []dataSource) error { } // Append adds one or more data sources and reads them into the file. Their -// sections and keys add to the current ones and overwrite keys they repeat; -// changes made to the file in memory stay. +// sections and keys add to the current ones and overwrite keys they repeat. +// Changes made to the file in memory stay. func (f *File) Append(source any, others ...any) error { sources := make([]dataSource, 0, 1+len(others)) for _, s := range append([]any{source}, others...) { @@ -427,7 +427,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // keyLine returns the line that writes val as a value of key, written as // form, or key itself for a boolean key, followed by nested values. - // The usual quoting comes first; when the lexer reads that back + // The usual quoting comes first. When the lexer reads that back // differently, other quotings and a bare delimiter are tried, and when // none reads back, it's an error rather than a file that loads // differently. A file read with delimiters that don't include the @@ -626,7 +626,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { // regular are the values written on key lines. A boolean line // after a key of the same name makes it a boolean key with the - // value "true" added; a value set after the boolean line + // value "true" added, and a value set after the boolean line // replaces "true". regular, boolFirst := vals, false if key.isBooleanType { diff --git a/generic.go b/generic.go index f909cb3..6a85ddb 100644 --- a/generic.go +++ b/generic.go @@ -106,7 +106,7 @@ func equal[T Scalar](a, b T) bool { return a == b } -// less reports whether a < b. Times compare as instants; the rest by kind, +// less reports whether a < b. Times compare as instants and the rest by kind, // as T can't use < while time.Time is in its type set. func less[T Ordered](a, b T) bool { if t, ok := any(a).(time.Time); ok { diff --git a/ini.go b/ini.go index c1844ca..ca57e21 100644 --- a/ini.go +++ b/ini.go @@ -8,14 +8,14 @@ // // A File holds Sections in order, and each Section holds Keys in order. The // default section, named DefaultSection, holds the keys before the first -// section header; pass "" to refer to it. Key returns values as strings or +// section header. Pass "" to refer to it. Key returns values as strings or // parses them, as in Key.Int or Key.Duration, and the generic List, In and // Range parse lists, candidates and ranges of any Scalar type. A value can // refer to another key of its section or of the default section as // %(name)s. // // MapTo and StrictMapTo fill a struct from a File, and File.ReflectFrom -// fills a File from a struct; Unmarshal and Marshal do the same for INI +// fills a File from a struct. Unmarshal and Marshal do the same for INI // text, like encoding/json. Field names map to key and section names // unchanged unless NameMapping sets a mapper, and the ini, delim and // comment struct tags adjust each field. @@ -42,7 +42,7 @@ const ( // Maximum allowed depth when recursively substituing variable names. depthValues = 99 - // Limits of one %(name)s expansion; see Key.expand. + // Limits of one %(name)s expansion, described at Key.expand. maxSubstitutions = 10000 maxExpandedLen = 1 << 20 ) @@ -51,7 +51,7 @@ const ( var varPattern = regexp.MustCompile(`%\(([^)]+)\)s`) // Load loads and parses an INI data source. A source is a file name -// (string), raw data ([]byte), or an io.Reader; an io.ReadCloser is closed +// (string), raw data ([]byte), or an io.Reader. An io.ReadCloser is closed // after reading. func Load(source any, opts ...LoadOption) (*File, error) { return LoadSources([]any{source}, opts...) diff --git a/key.go b/key.go index a6194ba..da7f32a 100644 --- a/key.go +++ b/key.go @@ -327,7 +327,7 @@ func (k *Key) Time() (time.Time, error) { } // MustString returns default value if key value is empty. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustString(defaultVal string) string { val := k.String() if len(val) == 0 { @@ -338,7 +338,7 @@ func (k *Key) MustString(defaultVal string) string { // MustBool always returns value without error, // it returns false if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustBool(defaultVal ...bool) bool { val, err := k.Bool() if len(defaultVal) > 0 && err != nil { @@ -349,7 +349,7 @@ func (k *Key) MustBool(defaultVal ...bool) bool { // MustFloat64 always returns value without error, // it returns 0.0 if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustFloat64(defaultVal ...float64) float64 { val, err := k.Float64() if len(defaultVal) > 0 && err != nil { @@ -360,7 +360,7 @@ func (k *Key) MustFloat64(defaultVal ...float64) float64 { // MustInt always returns value without error, // it returns 0 if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustInt(defaultVal ...int) int { val, err := k.Int() if len(defaultVal) > 0 && err != nil { @@ -371,7 +371,7 @@ func (k *Key) MustInt(defaultVal ...int) int { // MustInt64 always returns value without error, // it returns 0 if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustInt64(defaultVal ...int64) int64 { val, err := k.Int64() if len(defaultVal) > 0 && err != nil { @@ -382,7 +382,7 @@ func (k *Key) MustInt64(defaultVal ...int64) int64 { // MustUint always returns value without error, // it returns 0 if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustUint(defaultVal ...uint) uint { val, err := k.Uint() if len(defaultVal) > 0 && err != nil { @@ -393,7 +393,7 @@ func (k *Key) MustUint(defaultVal ...uint) uint { // MustUint64 always returns value without error, // it returns 0 if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustUint64(defaultVal ...uint64) uint64 { val, err := k.Uint64() if len(defaultVal) > 0 && err != nil { @@ -404,7 +404,7 @@ func (k *Key) MustUint64(defaultVal ...uint64) uint64 { // MustDuration always returns value without error, // it returns zero value if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { val, err := k.Duration() if len(defaultVal) > 0 && err != nil { @@ -415,7 +415,7 @@ func (k *Key) MustDuration(defaultVal ...time.Duration) time.Duration { // MustTimeFormat always parses with given format and returns value without error, // it returns zero value if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustTimeFormat(format string, defaultVal ...time.Time) time.Time { val, err := k.TimeFormat(format) if len(defaultVal) > 0 && err != nil { @@ -426,7 +426,7 @@ func (k *Key) MustTimeFormat(format string, defaultVal ...time.Time) time.Time { // MustTime always parses with RFC3339 format and returns value without error, // it returns zero value if error occurs. -// It doesn't store the default in the key; call SetValue for that. +// It doesn't store the default in the key. Call SetValue for that. func (k *Key) MustTime(defaultVal ...time.Time) time.Time { return k.MustTimeFormat(time.RFC3339, defaultVal...) } @@ -437,7 +437,7 @@ func (k *Key) Strings(delim string) []string { } // splitList splits str at delim into trimmed elements. A backslash escapes -// delim and another backslash; before anything else it is kept. +// delim and another backslash. Before anything else it is kept. func splitList(str, delim string) []string { if len(str) == 0 { return []string{} diff --git a/lexer.go b/lexer.go index c453159..37b1811 100644 --- a/lexer.go +++ b/lexer.go @@ -198,7 +198,7 @@ func (l *lexer) key(line string) (_ token, ok bool, _ error) { // readValue reads a value that starts in "in", the rest of the current line, // and any following lines it spans. The rules apply in a fixed order, which // decides the result when they overlap: a value is either quoted, continued -// with a backslash, or plain; for a plain value the inline comment is cut +// with a backslash, or plain. For a plain value the inline comment is cut // before surrounding quotes are stripped, so `"a;b"` is `"a` with comment // `;b"`. func (l *lexer) readValue(in string) (string, error) { diff --git a/options.go b/options.go index 2dfa169..a40f626 100644 --- a/options.go +++ b/options.go @@ -59,7 +59,7 @@ func newLoadOptions(opts []LoadOption) loadOptions { } // Locking sets whether a File takes a lock so it can be read from many -// goroutines while others change it. The default is true; turn it off for a +// goroutines while others change it. The default is true. Turn it off for a // File only one goroutine uses, to save the cost of locking. func Locking(v bool) LoadOption { return func(o *loadOptions) { o.Locking = v } } @@ -157,7 +157,7 @@ func RawSections(names ...string) LoadOption { } // KeyValueDelimiters sets the characters that separate a key from its value. -// The default is "=:"; an empty string keeps the default. +// The default is "=:", and an empty string keeps the default. func KeyValueDelimiters(delimiters string) LoadOption { return func(o *loadOptions) { if delimiters != "" { @@ -167,7 +167,7 @@ func KeyValueDelimiters(delimiters string) LoadOption { } // ChildSectionDelimiter sets the separator between parent and child section -// names. The default is "."; an empty string keeps the default. +// names. The default is ".", and an empty string keeps the default. func ChildSectionDelimiter(delimiter string) LoadOption { return func(o *loadOptions) { if delimiter != "" { @@ -206,7 +206,7 @@ func PreserveEmptyShadowValues(v bool) LoadOption { // DefaultSectionName names the section that holds keys before the first // section header, such as "default" for AWS config files. The default is -// DefaultSection; an empty string keeps the default. +// DefaultSection, and an empty string keeps the default. func DefaultSectionName(name string) LoadOption { return func(o *loadOptions) { if name != "" { @@ -216,7 +216,7 @@ func DefaultSectionName(name string) LoadOption { } // CommentSymbols sets the characters that start a comment. The default is -// "#;"; an empty string means no line is a comment. +// "#;", and an empty string means no line is a comment. func CommentSymbols(symbols string) LoadOption { return func(o *loadOptions) { o.CommentSymbols = symbols } } diff --git a/roundtrip_test.go b/roundtrip_test.go index 31a30bd..24bb2b9 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -18,8 +18,8 @@ import ( func describeFile(f *File) string { var b strings.Builder for _, s := range f.Sections() { - // A raw body gets a final line break when a section follows it; - // Body trims it either way. + // A raw body gets a final line break when a section follows it, + // and Body trims it either way. fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n")) for _, k := range s.Keys() { vals := append([]string{k.value}, k.shadows...) @@ -56,7 +56,7 @@ func checkRoundTrip(t *testing.T, data []byte, opts []LoadOption, wopts ...Write var out bytes.Buffer if _, err = f.Write(&out, wopts...); err != nil { - // Some files can't be written; that must be reported, not + // Some files can't be written. That must be reported, not // written wrongly. return } diff --git a/section.go b/section.go index c773f1d..5152d70 100644 --- a/section.go +++ b/section.go @@ -12,7 +12,7 @@ type Section struct { f *File name string comment string - // keys are in the order they were added; byName indexes them by name. + // keys are in the order they were added, and byName indexes them by name. keys []*Key byName map[string]*Key @@ -176,7 +176,7 @@ func (s *Section) Key(name string) *Key { key, err := s.GetKey(name) if err != nil { if key, err = s.NewKey(name, ""); err != nil { - // An empty name can't be added; return a key that isn't. + // An empty name can't be added, so return a key that isn't. return newKey(s, name, "") } return key diff --git a/struct_test.go b/struct_test.go index fa1b57d..82b3a82 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1208,7 +1208,7 @@ func TestMarshalUnmarshal_EscapedSliceElements(t *testing.T) { assert.Equal(t, in, out) // Key.Strings drops an empty last element, so a slice ending in an empty - // string cannot round trip; empty elements before the last one do. + // string cannot round trip. Empty elements before the last one do. data, err = Marshal(cfg{S: []string{"", "a"}, P: []string{"", ""}}) require.NoError(t, err) assert.Equal(t, "S = ,a\nP = |\n", string(data)) @@ -1320,7 +1320,7 @@ func TestMarshalUnmarshal_AllowShadowElements(t *testing.T) { require.NoError(t, Unmarshal(data, &out)) assert.Equal(t, in, out, "written: %q", data) - // The tags need these options; turning them off, as with a config + // The tags need these options. Turning them off, as with a config // flag that defaults to false, must not drop elements. out = cfg{} require.NoError(t, Unmarshal(data, &out, AllowShadows(false), AllowDuplicateShadowValues(false), PreserveEmptyShadowValues(false))) From 4fbf386614bdb83df4b59795c30ad7c01ec8459d Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 19:47:36 +0000 Subject: [PATCH 33/41] Write "e.g.," with a comma --- README.md | 2 +- key.go | 2 +- options.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6acf0cd..190ccca 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ import ( func main() { f, err := ini.Load("app.ini") if err != nil { - log.Fatal(err) // e.g. "line 3, column 1: key-value delimiter not found: broken line" + log.Fatal(err) // e.g., "line 3, column 1: key-value delimiter not found: broken line" } server := f.Section("server") diff --git a/key.go b/key.go index da7f32a..b789043 100644 --- a/key.go +++ b/key.go @@ -81,7 +81,7 @@ func (k *Key) AddNestedValue(val string) error { return k.addNestedValue(val) } -// ValueMapper represents a mapping function for values, e.g. os.ExpandEnv +// ValueMapper represents a mapping function for values, e.g., os.ExpandEnv type ValueMapper func(string) string // Name returns name of key. diff --git a/options.go b/options.go index a40f626..eb6d8a2 100644 --- a/options.go +++ b/options.go @@ -139,7 +139,7 @@ func SpaceBeforeInlineComment(v bool) LoadOption { } // UnescapeValueDoubleQuotes unescapes \" inside values surrounded by double -// quotes, e.g. key="a \"value\"" is `a "value"`. +// quotes, e.g., key="a \"value\"" is `a "value"`. func UnescapeValueDoubleQuotes(v bool) LoadOption { return func(o *loadOptions) { o.UnescapeValueDoubleQuotes = v } } From 561c85a965f5749835d95f46ff1aebcfc040c04c Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 20:19:08 +0000 Subject: [PATCH 34/41] Name the lock helpers after sync.RWMutex, and correct the error docs The File's mutex field is now mu, and wlock/wunlock are lock/unlock, so the helpers read as the RWMutex methods they call. ParseError can wrap errors that have no sentinel, such as an unclosed section, and its Err wraps a sentinel rather than equaling it. --- error.go | 9 ++++++--- file.go | 45 +++++++++++++++++++++++---------------------- key.go | 20 ++++++++++---------- section.go | 32 ++++++++++++++++---------------- struct.go | 8 ++++---- 5 files changed, 59 insertions(+), 55 deletions(-) diff --git a/error.go b/error.go index 3f61595..1cb1020 100644 --- a/error.go +++ b/error.go @@ -5,7 +5,9 @@ import ( "fmt" ) -// Errors a ParseError wraps, for errors.Is. +// ErrDelimiterNotFound and ErrEmptyKeyName are the parse errors that +// errors.Is can identify. Other parse errors, such as an unclosed section, +// have no sentinel. var ( // ErrDelimiterNotFound: a line that should be a key has no key-value // delimiter. @@ -14,8 +16,9 @@ var ( ErrEmptyKeyName = errors.New("empty key name") ) -// ParseError reports where in a data source parsing failed. Err is the -// error at that position, such as ErrDelimiterNotFound. +// ParseError reports where in a data source parsing failed. Err describes +// the problem and wraps ErrDelimiterNotFound or ErrEmptyKeyName when one +// applies. type ParseError struct { Line int // 1-based line number Column int // 1-based column, in characters, where the line's content starts diff --git a/file.go b/file.go index c9c2de4..ac569ba 100644 --- a/file.go +++ b/file.go @@ -17,9 +17,9 @@ type File struct { options loadOptions dataSources []dataSource - // locking guards the contents with lock, unless loaded with Locking(false). + // locking guards the contents with mu, unless loaded with Locking(false). locking bool - lock sync.RWMutex + mu sync.RWMutex // sections are in the order they were added, and byName indexes // them, in the same order, by name. @@ -60,37 +60,38 @@ func (f *File) defaultSectionName() string { return f.options.DefaultSection } -// The lock helpers guard the file's sections, keys and values when -// locking is set. Unexported methods that change them expect the caller to -// hold the lock, or the file not to be shared yet, as while parsing. +// lock, unlock, rlock and runlock call the same methods of mu when locking +// is set. They guard the file's sections, keys and values. Unexported +// methods that change them expect the caller to hold the lock, or the file +// not to be shared yet, as while parsing. func (f *File) rlock() { if f.locking { - f.lock.RLock() + f.mu.RLock() } } func (f *File) runlock() { if f.locking { - f.lock.RUnlock() + f.mu.RUnlock() } } -func (f *File) wlock() { +func (f *File) lock() { if f.locking { - f.lock.Lock() + f.mu.Lock() } } -func (f *File) wunlock() { +func (f *File) unlock() { if f.locking { - f.lock.Unlock() + f.mu.Unlock() } } // NewSection creates a new section. func (f *File) NewSection(name string) (*Section, error) { - f.wlock() - defer f.wunlock() + f.lock() + defer f.unlock() return f.addSection(name) } @@ -116,8 +117,8 @@ func (f *File) addSection(name string) (*Section, error) { // NewRawSection creates a new section with an unparseable body. func (f *File) NewRawSection(name, body string) (*Section, error) { - f.wlock() - defer f.wunlock() + f.lock() + defer f.unlock() section, err := f.addSection(name) if err != nil { return nil, err @@ -269,8 +270,8 @@ func (f *File) DeleteSectionWithIndex(name string, index int) error { name = strings.ToLower(name) } - f.wlock() - defer f.wunlock() + f.lock() + defer f.unlock() secs := f.byName[name] if index >= len(secs) { return nil @@ -310,8 +311,8 @@ func (f *File) Reload() error { return err } - f.wlock() - defer f.wunlock() + f.lock() + defer f.unlock() for name, secs := range next.byName { old := f.byName[name] for i, sec := range secs { @@ -379,14 +380,14 @@ func (f *File) Append(source any, others ...any) error { } sources = append(sources, ds) } - f.wlock() + f.lock() f.dataSources = append(f.dataSources, sources...) if f.options.ShortCircuit { - f.wunlock() + f.unlock() // Only the first source that loads counts. return f.Reload() } - defer f.wunlock() + defer f.unlock() return f.load(sources) } diff --git a/key.go b/key.go index b789043..4253e8b 100644 --- a/key.go +++ b/key.go @@ -57,8 +57,8 @@ func (k *Key) AddShadow(val string) error { if !k.s.f.options.AllowShadows { return errors.New("shadow key is not allowed") } - k.s.f.wlock() - defer k.s.f.wunlock() + k.s.f.lock() + defer k.s.f.unlock() return k.addShadow(val) } @@ -76,8 +76,8 @@ func (k *Key) AddNestedValue(val string) error { if !k.s.f.options.AllowNestedValues { return errors.New("nested value is not allowed") } - k.s.f.wlock() - defer k.s.f.wunlock() + k.s.f.lock() + defer k.s.f.unlock() return k.addNestedValue(val) } @@ -100,8 +100,8 @@ func (k *Key) Comment() string { // SetComment sets the comment lines written before the key. Lines without a // comment symbol get one. func (k *Key) SetComment(comment string) { - k.s.f.wlock() - defer k.s.f.wunlock() + k.s.f.lock() + defer k.s.f.unlock() k.comment = comment } @@ -111,9 +111,9 @@ func (k *Key) Value() string { if !f.locking { return k.value } - f.lock.RLock() + f.mu.RLock() v := k.value - f.lock.RUnlock() + f.mu.RUnlock() return v } @@ -510,7 +510,7 @@ func (k *Key) StringsWithShadows(delim string) []string { // SetValue changes key value. func (k *Key) SetValue(v string) { - k.s.f.wlock() - defer k.s.f.wunlock() + k.s.f.lock() + defer k.s.f.unlock() k.value = v } diff --git a/section.go b/section.go index 5152d70..7cbdc89 100644 --- a/section.go +++ b/section.go @@ -45,8 +45,8 @@ func (s *Section) Comment() string { // SetComment sets the comment lines written before the section header. // Lines without a comment symbol get one. func (s *Section) SetComment(comment string) { - s.f.wlock() - defer s.f.wunlock() + s.f.lock() + defer s.f.unlock() s.comment = comment } @@ -60,8 +60,8 @@ func (s *Section) Body() string { // SetBody updates body content only if section is raw. func (s *Section) SetBody(body string) { - s.f.wlock() - defer s.f.wunlock() + s.f.lock() + defer s.f.unlock() if !s.isRawSection { return } @@ -70,8 +70,8 @@ func (s *Section) SetBody(body string) { // NewKey creates a new key to given section. func (s *Section) NewKey(name, val string) (*Key, error) { - s.f.wlock() - defer s.f.wunlock() + s.f.lock() + defer s.f.unlock() return s.addKey(name, val) } @@ -102,8 +102,8 @@ func (s *Section) addKey(name, val string) (*Key, error) { // NewBooleanKey creates a new boolean type key to given section. func (s *Section) NewBooleanKey(name string) (*Key, error) { - s.f.wlock() - defer s.f.wunlock() + s.f.lock() + defer s.f.unlock() return s.addBooleanKey(name) } @@ -121,14 +121,14 @@ func (s *Section) addBooleanKey(name string) (*Key, error) { // GetKey returns key in section by given name. func (s *Section) GetKey(name string) (*Key, error) { if s.f.locking { - s.f.lock.RLock() + s.f.mu.RLock() } if s.f.options.Insensitive || s.f.options.InsensitiveKeys { name = strings.ToLower(name) } key := s.byName[name] if s.f.locking { - s.f.lock.RUnlock() + s.f.mu.RUnlock() } if key == nil { @@ -159,8 +159,8 @@ func (s *Section) HasKey(name string) bool { // HasValue returns true if section contains given raw value. func (s *Section) HasValue(value string) bool { if s.f.locking { - s.f.lock.RLock() - defer s.f.lock.RUnlock() + s.f.mu.RLock() + defer s.f.mu.RUnlock() } for _, k := range s.keys { @@ -225,8 +225,8 @@ func (s *Section) KeyNames() []string { // KeyValues returns the names and raw values of the section's keys. func (s *Section) KeyValues() map[string]string { if s.f.locking { - s.f.lock.RLock() - defer s.f.lock.RUnlock() + s.f.mu.RLock() + defer s.f.mu.RUnlock() } hash := make(map[string]string, len(s.keys)) @@ -242,8 +242,8 @@ func (s *Section) DeleteKey(name string) { name = strings.ToLower(name) } if s.f.locking { - s.f.lock.Lock() - defer s.f.lock.Unlock() + s.f.mu.Lock() + defer s.f.mu.Unlock() } if key := s.byName[name]; key != nil { diff --git a/struct.go b/struct.go index 9000688..c7b3e57 100644 --- a/struct.go +++ b/struct.go @@ -181,8 +181,8 @@ func formatValue(v reflect.Value) string { // defaultComment sets *c to comment if it is empty, under the file's lock. func (f *File) defaultComment(c *string, comment string) { - f.wlock() - defer f.wunlock() + f.lock() + defer f.unlock() if *c == "" { *c = comment } @@ -540,9 +540,9 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) return err } } - key.s.f.wlock() + key.s.f.lock() key.value, key.shadows = tmp.value, tmp.shadows - key.s.f.wunlock() + key.s.f.unlock() return nil } for i := range vals { From cb7534a617459612a4db3e9dde2ca173936d7811 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 20:24:01 +0000 Subject: [PATCH 35/41] Give every file a counterpart parser_test.go tests the lexer, so it is lexer_test.go. The write round-trip tests move to file_test.go, FuzzStructMapping to struct_test.go, the shared fuzz inputs and options to ini_test.go next to confData, and the generic accessors to key.go next to the Key methods they replace. No code changes. --- file_test.go | 130 ++++++++++++++++++++++ generic.go | 126 --------------------- ini_test.go | 74 +++++++++++++ inputs_test.go | 80 -------------- key.go | 120 ++++++++++++++++++++ parser_test.go => lexer_test.go | 0 roundtrip_test.go | 138 ----------------------- struct_fuzz_test.go | 188 -------------------------------- struct_test.go | 181 ++++++++++++++++++++++++++++++ 9 files changed, 505 insertions(+), 532 deletions(-) delete mode 100644 generic.go delete mode 100644 inputs_test.go rename parser_test.go => lexer_test.go (100%) delete mode 100644 roundtrip_test.go delete mode 100644 struct_fuzz_test.go diff --git a/file_test.go b/file_test.go index ac23dfc..d2043a9 100644 --- a/file_test.go +++ b/file_test.go @@ -2,7 +2,9 @@ package ini import ( "bytes" + "fmt" "io" + "math/rand" "os" "path/filepath" "runtime" @@ -721,3 +723,131 @@ func TestFile_Concurrency(t *testing.T) { close(stop) wg.Wait() } + +// The round-trip tests write a loaded File and load the output again with +// the same options. Whatever was loaded must come back: section and key +// names, values, shadows, nested values, raw bodies, flags and comments. + +// describeFile lists everything a File holds that writing must preserve. +// Comments are compared after the normalization the writer applies. +func describeFile(f *File) string { + var b strings.Builder + for _, s := range f.Sections() { + // A raw body gets a final line break when a section follows it, + // and Body trims it either way. + fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n")) + for _, k := range s.Keys() { + vals := append([]string{k.value}, k.shadows...) + fmt.Fprintf(&b, " %q=%q comment=%q auto=%v bool=%v nested=%q\n", + k.name, vals, normalizeComment(k.comment), k.isAutoIncrement, k.isBooleanType, k.nestedValues) + } + } + return b.String() +} + +// normalizeComment trims every line and puts one space after its comment +// symbol, as the writer does. +func normalizeComment(c string) string { + lines := strings.Split(c, "\n") + for i, line := range lines { + if line = strings.TrimSpace(line); line != "" { + lines[i] = line[:1] + " " + strings.TrimSpace(line[1:]) + } + } + return strings.Join(lines, "\n") +} + +func checkRoundTrip(t *testing.T, data []byte, opts []LoadOption, wopts ...WriteOption) { + t.Helper() + f, err := Load(data, opts...) + if err != nil { + return + } + if !strings.Contains(f.options.KeyValueDelimiters, "=") { + // Written with "=" for other readers, as v1 did. + return + } + want := describeFile(f) + + var out bytes.Buffer + if _, err = f.Write(&out, wopts...); err != nil { + // Some files can't be written. That must be reported, not + // written wrongly. + return + } + g, err := Load(out.Bytes(), opts...) + if err != nil { + t.Fatalf("written file doesn't load: %v\ninput: %q\nwritten: %q", err, data, out.String()) + } + if got := describeFile(g); got != want { + t.Fatalf("written file loads differently\ninput: %q\nwritten: %q\nloaded:\n%s\nreloaded:\n%s", data, out.String(), want, got) + } +} + +func FuzzWriteRoundTrip(f *testing.F) { + for _, in := range fuzzInputs(f) { + for _, flags := range []uint32{0, 1 << 9, 1<<8 | 1<<6, 1<<11 | 1<<10 | 1<<12, 1<<16 | 1<<7 | 1<<5, 1<<20 - 1} { + f.Add([]byte(in), flags) + } + } + f.Fuzz(func(t *testing.T, data []byte, flags uint32) { + checkRoundTrip(t, data, optionsFromBits(flags)) + }) +} + +func TestWriteRoundTripRandom(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for _, in := range parserInputs(t) { + for i := 0; i < 200; i++ { + checkRoundTrip(t, []byte(in), optionsFromBits(rng.Uint32())) + } + } +} + +// v1 wrote each of these files so that they loaded differently. +func TestWriteReadsBack(t *testing.T) { + for _, tc := range []struct { + data string + opts []LoadOption + }{ + {"a = 'x\\'\nb = 1\n", nil}, // value ending in a backslash + {"k = `a\"\"\"\nb`\n", nil}, // value containing """ + {"k=`= v`\n", []LoadOption{KeyValueDelimiters(" =")}}, // value starting with a delimiter + {"[u]\nExecStart=\nExecStart=a\n", []LoadOption{AllowShadows(true)}}, // empty value resets + {"k = a\n; c\nk = b\n", []LoadOption{AllowShadows(true)}}, // comment of a shadowed key + {"k =\n n\nk\n", []LoadOption{AllowNestedValues(true), AllowBooleanKeys(true)}}, + {"k = \"\"\n n\n", []LoadOption{AllowNestedValues(true), AllowPythonMultilineValues(true)}}, + {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{CaseInsensitive(true), AllowNonUniqueSections(true)}}, + {"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section + } { + f, err := Load([]byte(tc.data), tc.opts...) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(io.Discard); err != nil { + t.Fatalf("%q: %v", tc.data, err) + } + checkRoundTrip(t, []byte(tc.data), tc.opts) + } + + t.Run("raw body without line break", func(t *testing.T) { + f := Empty(RawSections("raw")) + _, _ = f.NewRawSection("raw", "body") + _, _ = f.Section("s").NewKey("k", "v") + var b bytes.Buffer + if _, err := f.Write(&b); err != nil { + t.Fatal(err) + } + if want := "[raw]\nbody\n\n[s]\nk = v\n"; b.String() != want { + t.Fatalf("got %q, want %q", b.String(), want) + } + }) + + t.Run("unwritable key", func(t *testing.T) { + f := Empty() + _, _ = f.Section("").NewKey("a\nb", "v") + if _, err := f.Write(io.Discard); err == nil { + t.Fatal("want an error for a key name with a line break") + } + }) +} diff --git a/generic.go b/generic.go deleted file mode 100644 index 6a85ddb..0000000 --- a/generic.go +++ /dev/null @@ -1,126 +0,0 @@ -package ini - -import ( - "cmp" - "reflect" - "slices" - "time" -) - -// Scalar is a type a value can be parsed as, as struct mapping parses -// fields: strings, booleans, integers and floats of any size, named types of -// those, time.Duration (as "1s" or nanoseconds) and time.Time (RFC 3339). -// A number that doesn't fit the type doesn't parse. -type Scalar interface { - ~string | ~bool | - ~int | ~int8 | ~int16 | ~int32 | ~int64 | - ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | - ~float32 | ~float64 | time.Time -} - -// Ordered is a Scalar that Range can compare: any but booleans. -type Ordered interface { - Scalar - cmp.Ordered | time.Time -} - -// parse parses s as a T. -func parse[T Scalar](s string) (T, error) { - v, err := parseValue(reflect.TypeFor[T](), s) - if err != nil { - var zero T - return zero, err - } - return v.Interface().(T), nil -} - -// parseList parses strs with parse. An element that doesn't parse is an -// error when strict, the zero value when keepInvalid, and left out -// otherwise. -func parseList[T any](strs []string, parse func(string) (T, error), keepInvalid, strict bool) ([]T, error) { - vals := make([]T, 0, len(strs)) - for _, s := range strs { - v, err := parse(s) - if err != nil { - if strict { - return nil, err - } - if !keepInvalid { - continue - } - var zero T - v = zero - } - vals = append(vals, v) - } - return vals, nil -} - -// List splits the value of k at delim, as Key.Strings does, and parses each -// element as a T. An element that doesn't parse is the zero value, as in -// List[int] of "1, x, 3", which is [1 0 3]. -func List[T Scalar](k *Key, delim string) []T { - vals, _ := parseList(k.Strings(delim), parse[T], true, false) - return vals -} - -// ValidList is List without the elements that don't parse: ValidList[int] -// of "1, x, 3" is [1 3]. -func ValidList[T Scalar](k *Key, delim string) []T { - vals, _ := parseList(k.Strings(delim), parse[T], false, false) - return vals -} - -// StrictList is List that returns an error for the first element that -// doesn't parse. -func StrictList[T Scalar](k *Key, delim string) ([]T, error) { - return parseList(k.Strings(delim), parse[T], false, true) -} - -// In returns the value of k parsed as a T when it is one of candidates, and -// defaultVal otherwise. T comes from candidates, as in -// In(k, "info", []string{"debug", "info"}). Times match when they are the -// same instant. -func In[T Scalar](k *Key, defaultVal T, candidates []T) T { - if v, err := parse[T](k.String()); err == nil && slices.ContainsFunc(candidates, func(c T) bool { return equal(v, c) }) { - return v - } - return defaultVal -} - -// Range returns the value of k parsed as a T when it is between lo and hi -// inclusive, and defaultVal otherwise. Untyped constants make T int, so give -// another type explicitly: Range(k, 0.5, 0, 1) or Range(k, uint8(0), 0, 9). -func Range[T Ordered](k *Key, defaultVal, lo, hi T) T { - if v, err := parse[T](k.String()); err == nil && !less(v, lo) && !less(hi, v) { - return v - } - return defaultVal -} - -// equal compares times as instants and other values with ==. -func equal[T Scalar](a, b T) bool { - if t, ok := any(a).(time.Time); ok { - return t.Equal(any(b).(time.Time)) - } - return a == b -} - -// less reports whether a < b. Times compare as instants and the rest by kind, -// as T can't use < while time.Time is in its type set. -func less[T Ordered](a, b T) bool { - if t, ok := any(a).(time.Time); ok { - return t.Before(any(b).(time.Time)) - } - va, vb := reflect.ValueOf(a), reflect.ValueOf(b) - switch va.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return va.Int() < vb.Int() - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return va.Uint() < vb.Uint() - case reflect.Float32, reflect.Float64: - return va.Float() < vb.Float() - default: - return va.String() < vb.String() - } -} diff --git a/ini_test.go b/ini_test.go index eddb61f..5bac970 100644 --- a/ini_test.go +++ b/ini_test.go @@ -4,8 +4,11 @@ import ( "bytes" "flag" "io" + "os" "path/filepath" "runtime" + "strconv" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -1564,3 +1567,74 @@ key2 = assert.Equal(t, "value3", f.Section("section2").Key("key3").String()) }) } + +// parserInputs returns the inputs in testdata/inputs.txt. +func parserInputs(tb testing.TB) []string { + tb.Helper() + data, err := os.ReadFile("testdata/inputs.txt") + if err != nil { + tb.Fatal(err) + } + var inputs []string + for i, line := range strings.Split(string(data), "\n") { + // Git on Windows may check the file out with CRLF line endings. + line = strings.TrimSuffix(line, "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + quoted, err := strconv.QuotedPrefix(line) + if err == nil { + var in string + in, err = strconv.Unquote(quoted) + inputs = append(inputs, in) + } + if err != nil { + tb.Fatalf("testdata/inputs.txt:%d: %v", i+1, err) + } + } + return inputs +} + +// fuzzInputs returns the seed inputs of the fuzzers: confData, the parser +// inputs and the testdata files. +func fuzzInputs(tb testing.TB) []string { + tb.Helper() + inputs := append([]string{confData}, parserInputs(tb)...) + files, _ := filepath.Glob("testdata/*.ini") + for _, name := range files { + data, err := os.ReadFile(name) + if err != nil { + tb.Fatal(err) + } + inputs = append(inputs, string(data)) + } + return inputs +} + +// optionsFromBits maps the bits of flags to load options, so that fuzzers +// explore option combinations along with inputs. +func optionsFromBits(flags uint32) []LoadOption { + bit := func(i uint) bool { return flags&(1<>17)&7])) +} diff --git a/inputs_test.go b/inputs_test.go deleted file mode 100644 index 606068a..0000000 --- a/inputs_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package ini - -import ( - "os" - "path/filepath" - "strconv" - "strings" - "testing" -) - -// parserInputs returns the inputs in testdata/inputs.txt. -func parserInputs(tb testing.TB) []string { - tb.Helper() - data, err := os.ReadFile("testdata/inputs.txt") - if err != nil { - tb.Fatal(err) - } - var inputs []string - for i, line := range strings.Split(string(data), "\n") { - // Git on Windows may check the file out with CRLF line endings. - line = strings.TrimSuffix(line, "\r") - if line == "" || strings.HasPrefix(line, "#") { - continue - } - quoted, err := strconv.QuotedPrefix(line) - if err == nil { - var in string - in, err = strconv.Unquote(quoted) - inputs = append(inputs, in) - } - if err != nil { - tb.Fatalf("testdata/inputs.txt:%d: %v", i+1, err) - } - } - return inputs -} - -// fuzzInputs returns the seed inputs of the fuzzers: confData, the parser -// inputs and the testdata files. -func fuzzInputs(tb testing.TB) []string { - tb.Helper() - inputs := append([]string{confData}, parserInputs(tb)...) - files, _ := filepath.Glob("testdata/*.ini") - for _, name := range files { - data, err := os.ReadFile(name) - if err != nil { - tb.Fatal(err) - } - inputs = append(inputs, string(data)) - } - return inputs -} - -// optionsFromBits maps the bits of flags to load options, so that fuzzers -// explore option combinations along with inputs. -func optionsFromBits(flags uint32) []LoadOption { - bit := func(i uint) bool { return flags&(1<>17)&7])) -} diff --git a/key.go b/key.go index 4253e8b..02ee39f 100644 --- a/key.go +++ b/key.go @@ -1,8 +1,10 @@ package ini import ( + "cmp" "errors" "fmt" + "reflect" "slices" "strconv" "strings" @@ -514,3 +516,121 @@ func (k *Key) SetValue(v string) { defer k.s.f.unlock() k.value = v } + +// Scalar is a type a value can be parsed as, as struct mapping parses +// fields: strings, booleans, integers and floats of any size, named types of +// those, time.Duration (as "1s" or nanoseconds) and time.Time (RFC 3339). +// A number that doesn't fit the type doesn't parse. +type Scalar interface { + ~string | ~bool | + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | time.Time +} + +// Ordered is a Scalar that Range can compare: any but booleans. +type Ordered interface { + Scalar + cmp.Ordered | time.Time +} + +// parse parses s as a T. +func parse[T Scalar](s string) (T, error) { + v, err := parseValue(reflect.TypeFor[T](), s) + if err != nil { + var zero T + return zero, err + } + return v.Interface().(T), nil +} + +// parseList parses strs with parse. An element that doesn't parse is an +// error when strict, the zero value when keepInvalid, and left out +// otherwise. +func parseList[T any](strs []string, parse func(string) (T, error), keepInvalid, strict bool) ([]T, error) { + vals := make([]T, 0, len(strs)) + for _, s := range strs { + v, err := parse(s) + if err != nil { + if strict { + return nil, err + } + if !keepInvalid { + continue + } + var zero T + v = zero + } + vals = append(vals, v) + } + return vals, nil +} + +// List splits the value of k at delim, as Key.Strings does, and parses each +// element as a T. An element that doesn't parse is the zero value, as in +// List[int] of "1, x, 3", which is [1 0 3]. +func List[T Scalar](k *Key, delim string) []T { + vals, _ := parseList(k.Strings(delim), parse[T], true, false) + return vals +} + +// ValidList is List without the elements that don't parse: ValidList[int] +// of "1, x, 3" is [1 3]. +func ValidList[T Scalar](k *Key, delim string) []T { + vals, _ := parseList(k.Strings(delim), parse[T], false, false) + return vals +} + +// StrictList is List that returns an error for the first element that +// doesn't parse. +func StrictList[T Scalar](k *Key, delim string) ([]T, error) { + return parseList(k.Strings(delim), parse[T], false, true) +} + +// In returns the value of k parsed as a T when it is one of candidates, and +// defaultVal otherwise. T comes from candidates, as in +// In(k, "info", []string{"debug", "info"}). Times match when they are the +// same instant. +func In[T Scalar](k *Key, defaultVal T, candidates []T) T { + if v, err := parse[T](k.String()); err == nil && slices.ContainsFunc(candidates, func(c T) bool { return equal(v, c) }) { + return v + } + return defaultVal +} + +// Range returns the value of k parsed as a T when it is between lo and hi +// inclusive, and defaultVal otherwise. Untyped constants make T int, so give +// another type explicitly: Range(k, 0.5, 0, 1) or Range(k, uint8(0), 0, 9). +func Range[T Ordered](k *Key, defaultVal, lo, hi T) T { + if v, err := parse[T](k.String()); err == nil && !less(v, lo) && !less(hi, v) { + return v + } + return defaultVal +} + +// equal compares times as instants and other values with ==. +func equal[T Scalar](a, b T) bool { + if t, ok := any(a).(time.Time); ok { + return t.Equal(any(b).(time.Time)) + } + return a == b +} + +// less reports whether a < b. Times compare as instants and the rest by kind, +// as T can't use < while time.Time is in its type set. +func less[T Ordered](a, b T) bool { + if t, ok := any(a).(time.Time); ok { + return t.Before(any(b).(time.Time)) + } + va, vb := reflect.ValueOf(a), reflect.ValueOf(b) + switch va.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return va.Int() < vb.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return va.Uint() < vb.Uint() + case reflect.Float32, reflect.Float64: + return va.Float() < vb.Float() + default: + return va.String() < vb.String() + } +} diff --git a/parser_test.go b/lexer_test.go similarity index 100% rename from parser_test.go rename to lexer_test.go diff --git a/roundtrip_test.go b/roundtrip_test.go deleted file mode 100644 index 24bb2b9..0000000 --- a/roundtrip_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package ini - -import ( - "bytes" - "fmt" - "io" - "math/rand" - "strings" - "testing" -) - -// The round-trip tests write a loaded File and load the output again with -// the same options. Whatever was loaded must come back: section and key -// names, values, shadows, nested values, raw bodies, flags and comments. - -// describeFile lists everything a File holds that writing must preserve. -// Comments are compared after the normalization the writer applies. -func describeFile(f *File) string { - var b strings.Builder - for _, s := range f.Sections() { - // A raw body gets a final line break when a section follows it, - // and Body trims it either way. - fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q\n", s.name, normalizeComment(s.comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n")) - for _, k := range s.Keys() { - vals := append([]string{k.value}, k.shadows...) - fmt.Fprintf(&b, " %q=%q comment=%q auto=%v bool=%v nested=%q\n", - k.name, vals, normalizeComment(k.comment), k.isAutoIncrement, k.isBooleanType, k.nestedValues) - } - } - return b.String() -} - -// normalizeComment trims every line and puts one space after its comment -// symbol, as the writer does. -func normalizeComment(c string) string { - lines := strings.Split(c, "\n") - for i, line := range lines { - if line = strings.TrimSpace(line); line != "" { - lines[i] = line[:1] + " " + strings.TrimSpace(line[1:]) - } - } - return strings.Join(lines, "\n") -} - -func checkRoundTrip(t *testing.T, data []byte, opts []LoadOption, wopts ...WriteOption) { - t.Helper() - f, err := Load(data, opts...) - if err != nil { - return - } - if !strings.Contains(f.options.KeyValueDelimiters, "=") { - // Written with "=" for other readers, as v1 did. - return - } - want := describeFile(f) - - var out bytes.Buffer - if _, err = f.Write(&out, wopts...); err != nil { - // Some files can't be written. That must be reported, not - // written wrongly. - return - } - g, err := Load(out.Bytes(), opts...) - if err != nil { - t.Fatalf("written file doesn't load: %v\ninput: %q\nwritten: %q", err, data, out.String()) - } - if got := describeFile(g); got != want { - t.Fatalf("written file loads differently\ninput: %q\nwritten: %q\nloaded:\n%s\nreloaded:\n%s", data, out.String(), want, got) - } -} - -func FuzzWriteRoundTrip(f *testing.F) { - for _, in := range fuzzInputs(f) { - for _, flags := range []uint32{0, 1 << 9, 1<<8 | 1<<6, 1<<11 | 1<<10 | 1<<12, 1<<16 | 1<<7 | 1<<5, 1<<20 - 1} { - f.Add([]byte(in), flags) - } - } - f.Fuzz(func(t *testing.T, data []byte, flags uint32) { - checkRoundTrip(t, data, optionsFromBits(flags)) - }) -} - -func TestWriteRoundTripRandom(t *testing.T) { - rng := rand.New(rand.NewSource(1)) - for _, in := range parserInputs(t) { - for i := 0; i < 200; i++ { - checkRoundTrip(t, []byte(in), optionsFromBits(rng.Uint32())) - } - } -} - -// v1 wrote each of these files so that they loaded differently. -func TestWriteReadsBack(t *testing.T) { - for _, tc := range []struct { - data string - opts []LoadOption - }{ - {"a = 'x\\'\nb = 1\n", nil}, // value ending in a backslash - {"k = `a\"\"\"\nb`\n", nil}, // value containing """ - {"k=`= v`\n", []LoadOption{KeyValueDelimiters(" =")}}, // value starting with a delimiter - {"[u]\nExecStart=\nExecStart=a\n", []LoadOption{AllowShadows(true)}}, // empty value resets - {"k = a\n; c\nk = b\n", []LoadOption{AllowShadows(true)}}, // comment of a shadowed key - {"k =\n n\nk\n", []LoadOption{AllowNestedValues(true), AllowBooleanKeys(true)}}, - {"k = \"\"\n n\n", []LoadOption{AllowNestedValues(true), AllowPythonMultilineValues(true)}}, - {"NAME = x\n[DEFAULT]\nk1 = v1\n", []LoadOption{CaseInsensitive(true), AllowNonUniqueSections(true)}}, - {"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section - } { - f, err := Load([]byte(tc.data), tc.opts...) - if err != nil { - t.Fatal(err) - } - if _, err := f.Write(io.Discard); err != nil { - t.Fatalf("%q: %v", tc.data, err) - } - checkRoundTrip(t, []byte(tc.data), tc.opts) - } - - t.Run("raw body without line break", func(t *testing.T) { - f := Empty(RawSections("raw")) - _, _ = f.NewRawSection("raw", "body") - _, _ = f.Section("s").NewKey("k", "v") - var b bytes.Buffer - if _, err := f.Write(&b); err != nil { - t.Fatal(err) - } - if want := "[raw]\nbody\n\n[s]\nk = v\n"; b.String() != want { - t.Fatalf("got %q, want %q", b.String(), want) - } - }) - - t.Run("unwritable key", func(t *testing.T) { - f := Empty() - _, _ = f.Section("").NewKey("a\nb", "v") - if _, err := f.Write(io.Discard); err == nil { - t.Fatal("want an error for a key name with a line break") - } - }) -} diff --git a/struct_fuzz_test.go b/struct_fuzz_test.go deleted file mode 100644 index f65ca85..0000000 --- a/struct_fuzz_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package ini - -import ( - "bytes" - "reflect" - "testing" - "time" -) - -type fzName string - -type fzSub struct { - A int - B string - T time.Time -} - -type fzNode struct { - X int - Child *fzNode -} - -type fzEmbed struct { - E string -} - -// fzCfg has a field of every kind struct mapping supports. -type fzCfg struct { - S string - I int - I8 int8 - U uint - U8 uint8 - F32 float32 - F float64 - B bool - D time.Duration - T time.Time - - PS *string - PI *int - PT *time.Time - PD *time.Duration - - SS []string - SI []int - SU []uint64 - SF []float64 - SB []bool - ST []time.Time - SP []string `delim:"|"` - - Named fzName - PNamed *fzName - - Sub fzSub - PSub *fzSub - Shadow []string `ini:"shadow,,allowshadow"` - Peers []fzSub `ini:"Peer,,,nonunique"` - PPeers []*fzSub `ini:"PPeer,,,nonunique"` - Node *fzNode - fzEmbed `ini:",extends"` - Skip string `ini:"-"` -} - -// FuzzStructMapping loads arbitrary input under random options, reads it -// with every Key accessor and maps it into fzCfg. Nothing may panic or hang, -// and a value written by Marshal must read back the same. -func FuzzStructMapping(f *testing.F) { - inputs := append(fuzzInputs(f), - "S = s\nI = -3\nI8 = 127\nU = 4\nU8 = 255\nF32 = 1.5\nF = 2.5\nB = true\nD = 1s\nT = 2020-01-02T03:04:05.5Z\n"+ - "PS = p\nPI = 7\nPT = 2020-01-02T03:04:05Z\nPD = 3s\nSS = a,b\\, c,d\nSI = 1,2\nSU = 3,4\nSF = 1.5,2\nSB = true,false\n"+ - "ST = 2020-01-02T03:04:05Z\nSP = x|y\nNamed = n\nPNamed = m\nshadow = a\nshadow = b\nE = e\n"+ - "[Sub]\nA = 1\nB = b\n[PSub]\nA = 2\n[Peer]\nA = 3\n[Peer]\nA = 4\n[PPeer]\nB = q\n[Node]\nX = 1\n[Child]\nX = 2\n", - "I8 = 300\nU8 = -1\nF32 = 1e300\nD = -5s\nSI = 1,x,3\nT = 2006-01-02T15:04:05.123456789+07:00\n", - "a = %(b)s\nb = %(a)s\nS = %(S)s%(S)s\n", - ) - for _, in := range inputs { - for _, flags := range []uint32{0, 1 << 7, 1<<14 | 1<<7, 1<<0 | 1<<14, 1<<20 - 1} { - f.Add([]byte(in), flags) - } - } - - f.Fuzz(func(t *testing.T, data []byte, flags uint32) { - opts := optionsFromBits(flags) - file, err := Load(data, opts...) - if err != nil { - return - } - - for _, sec := range file.Sections() { - _ = sec.ChildSections() - _ = sec.ParentKeys() - _ = sec.KeyValues() - for _, k := range sec.Keys() { - _ = k.String() - for _, delim := range []string{",", "", "|", "\\", "::"} { - _ = k.Strings(delim) - } - _ = k.StringsWithShadows(",") - _ = List[int](k, ",") - _ = ValidList[uint64](k, ",") - _ = List[float32](k, ",") - _ = ValidList[bool](k, ",") - _, _ = StrictList[time.Time](k, ",") - _, _ = StrictList[time.Duration](k, ",") - _ = k.TimesFormat("2006-01-02", ",") - _ = k.RangeTimeFormat("2006-01-02", time.Time{}, time.Time{}, time.Now()) - _, _ = k.Duration() - _, _ = k.Time() - _ = k.MustInt(1) - _ = In(k, "a", []string{"a", "b"}) - _ = In(k, int8(1), []int8{1, 2}) - _ = Range(k, 1.5, -1e9, 1e9) - _ = Range(k, uint16(1), 0, 100) - _ = Range(k, time.Time{}, time.Time{}, time.Now()) - _ = k.Values() - _ = k.NestedValues() - } - } - _ = file.SectionWithIndex("", 1) - _ = file.SectionWithIndex("Peer", -1) - - var cfg fzCfg - _ = file.MapTo(&cfg) - var strict fzCfg - _ = file.StrictMapTo(&strict) - - // What Marshal writes, Unmarshal reads back the same, except - // for the limits of the format that are checked below. - out, err := Marshal(cfg) - if err != nil { - return - } - var back fzCfg - if err := Unmarshal(out, &back); err != nil { - t.Fatalf("Unmarshal of Marshal output failed: %v\nmarshaled: %q", err, out) - } - normalizeFz(&cfg) - normalizeFz(&back) - if !reflect.DeepEqual(cfg, back) { - t.Fatalf("Marshal/Unmarshal changed the value\nbefore: %+v\nafter: %+v\nmarshaled: %q", cfg, back, out) - } - - // ReflectFrom into the loaded file must not panic and must write. - if err := file.ReflectFrom(&cfg); err == nil { - var buf bytes.Buffer - _, _ = file.Write(&buf) - } - }) -} - -// normalizeFz clears what the format can't carry: empty slices and nil -// versus empty, monotonic clock readings and locations of times. -func normalizeFz(c *fzCfg) { - v := reflect.ValueOf(c).Elem() - normalizeFzValue(v) -} - -func normalizeFzValue(v reflect.Value) { - switch v.Kind() { - case reflect.Pointer: - if !v.IsNil() { - normalizeFzValue(v.Elem()) - } - case reflect.Struct: - if v.Type() == timeType { - if v.CanSet() { - t := v.Interface().(time.Time) - v.Set(reflect.ValueOf(time.Unix(t.Unix(), int64(t.Nanosecond())).UTC())) - } - return - } - for i := 0; i < v.NumField(); i++ { - if v.Field(i).CanSet() { - normalizeFzValue(v.Field(i)) - } - } - case reflect.Slice: - if v.Len() == 0 { - v.Set(reflect.Zero(v.Type())) - return - } - for i := 0; i < v.Len(); i++ { - normalizeFzValue(v.Index(i)) - } - } -} diff --git a/struct_test.go b/struct_test.go index 82b3a82..ce80599 100644 --- a/struct_test.go +++ b/struct_test.go @@ -3,6 +3,7 @@ package ini import ( "bytes" "fmt" + "reflect" "strings" "testing" "time" @@ -1331,3 +1332,183 @@ func TestMarshalUnmarshal_AllowShadowElements(t *testing.T) { _, err = Marshal(cfg{Sh: []string{"b,c"}}) assert.Error(t, err) } + +type fzName string + +type fzSub struct { + A int + B string + T time.Time +} + +type fzNode struct { + X int + Child *fzNode +} + +type fzEmbed struct { + E string +} + +// fzCfg has a field of every kind struct mapping supports. +type fzCfg struct { + S string + I int + I8 int8 + U uint + U8 uint8 + F32 float32 + F float64 + B bool + D time.Duration + T time.Time + + PS *string + PI *int + PT *time.Time + PD *time.Duration + + SS []string + SI []int + SU []uint64 + SF []float64 + SB []bool + ST []time.Time + SP []string `delim:"|"` + + Named fzName + PNamed *fzName + + Sub fzSub + PSub *fzSub + Shadow []string `ini:"shadow,,allowshadow"` + Peers []fzSub `ini:"Peer,,,nonunique"` + PPeers []*fzSub `ini:"PPeer,,,nonunique"` + Node *fzNode + fzEmbed `ini:",extends"` + Skip string `ini:"-"` +} + +// FuzzStructMapping loads arbitrary input under random options, reads it +// with every Key accessor and maps it into fzCfg. Nothing may panic or hang, +// and a value written by Marshal must read back the same. +func FuzzStructMapping(f *testing.F) { + inputs := append(fuzzInputs(f), + "S = s\nI = -3\nI8 = 127\nU = 4\nU8 = 255\nF32 = 1.5\nF = 2.5\nB = true\nD = 1s\nT = 2020-01-02T03:04:05.5Z\n"+ + "PS = p\nPI = 7\nPT = 2020-01-02T03:04:05Z\nPD = 3s\nSS = a,b\\, c,d\nSI = 1,2\nSU = 3,4\nSF = 1.5,2\nSB = true,false\n"+ + "ST = 2020-01-02T03:04:05Z\nSP = x|y\nNamed = n\nPNamed = m\nshadow = a\nshadow = b\nE = e\n"+ + "[Sub]\nA = 1\nB = b\n[PSub]\nA = 2\n[Peer]\nA = 3\n[Peer]\nA = 4\n[PPeer]\nB = q\n[Node]\nX = 1\n[Child]\nX = 2\n", + "I8 = 300\nU8 = -1\nF32 = 1e300\nD = -5s\nSI = 1,x,3\nT = 2006-01-02T15:04:05.123456789+07:00\n", + "a = %(b)s\nb = %(a)s\nS = %(S)s%(S)s\n", + ) + for _, in := range inputs { + for _, flags := range []uint32{0, 1 << 7, 1<<14 | 1<<7, 1<<0 | 1<<14, 1<<20 - 1} { + f.Add([]byte(in), flags) + } + } + + f.Fuzz(func(t *testing.T, data []byte, flags uint32) { + opts := optionsFromBits(flags) + file, err := Load(data, opts...) + if err != nil { + return + } + + for _, sec := range file.Sections() { + _ = sec.ChildSections() + _ = sec.ParentKeys() + _ = sec.KeyValues() + for _, k := range sec.Keys() { + _ = k.String() + for _, delim := range []string{",", "", "|", "\\", "::"} { + _ = k.Strings(delim) + } + _ = k.StringsWithShadows(",") + _ = List[int](k, ",") + _ = ValidList[uint64](k, ",") + _ = List[float32](k, ",") + _ = ValidList[bool](k, ",") + _, _ = StrictList[time.Time](k, ",") + _, _ = StrictList[time.Duration](k, ",") + _ = k.TimesFormat("2006-01-02", ",") + _ = k.RangeTimeFormat("2006-01-02", time.Time{}, time.Time{}, time.Now()) + _, _ = k.Duration() + _, _ = k.Time() + _ = k.MustInt(1) + _ = In(k, "a", []string{"a", "b"}) + _ = In(k, int8(1), []int8{1, 2}) + _ = Range(k, 1.5, -1e9, 1e9) + _ = Range(k, uint16(1), 0, 100) + _ = Range(k, time.Time{}, time.Time{}, time.Now()) + _ = k.Values() + _ = k.NestedValues() + } + } + _ = file.SectionWithIndex("", 1) + _ = file.SectionWithIndex("Peer", -1) + + var cfg fzCfg + _ = file.MapTo(&cfg) + var strict fzCfg + _ = file.StrictMapTo(&strict) + + // What Marshal writes, Unmarshal reads back the same, except + // for the limits of the format that are checked below. + out, err := Marshal(cfg) + if err != nil { + return + } + var back fzCfg + if err := Unmarshal(out, &back); err != nil { + t.Fatalf("Unmarshal of Marshal output failed: %v\nmarshaled: %q", err, out) + } + normalizeFz(&cfg) + normalizeFz(&back) + if !reflect.DeepEqual(cfg, back) { + t.Fatalf("Marshal/Unmarshal changed the value\nbefore: %+v\nafter: %+v\nmarshaled: %q", cfg, back, out) + } + + // ReflectFrom into the loaded file must not panic and must write. + if err := file.ReflectFrom(&cfg); err == nil { + var buf bytes.Buffer + _, _ = file.Write(&buf) + } + }) +} + +// normalizeFz clears what the format can't carry: empty slices and nil +// versus empty, monotonic clock readings and locations of times. +func normalizeFz(c *fzCfg) { + v := reflect.ValueOf(c).Elem() + normalizeFzValue(v) +} + +func normalizeFzValue(v reflect.Value) { + switch v.Kind() { + case reflect.Pointer: + if !v.IsNil() { + normalizeFzValue(v.Elem()) + } + case reflect.Struct: + if v.Type() == timeType { + if v.CanSet() { + t := v.Interface().(time.Time) + v.Set(reflect.ValueOf(time.Unix(t.Unix(), int64(t.Nanosecond())).UTC())) + } + return + } + for i := 0; i < v.NumField(); i++ { + if v.Field(i).CanSet() { + normalizeFzValue(v.Field(i)) + } + } + case reflect.Slice: + if v.Len() == 0 { + v.Set(reflect.Zero(v.Type())) + return + } + for i := 0; i < v.Len(); i++ { + normalizeFzValue(v.Index(i)) + } + } +} From 43f4a48c9d377f674cbd54a2b548231c2f6f2c5a Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 20:26:48 +0000 Subject: [PATCH 36/41] Put the shared fuzz helpers below confData --- ini_test.go | 142 ++++++++++++++++++++++++++-------------------------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/ini_test.go b/ini_test.go index 5bac970..ceee8b0 100644 --- a/ini_test.go +++ b/ini_test.go @@ -41,6 +41,77 @@ const ( var update = flag.Bool("update", false, "Update .golden files") +// parserInputs returns the inputs in testdata/inputs.txt. +func parserInputs(tb testing.TB) []string { + tb.Helper() + data, err := os.ReadFile("testdata/inputs.txt") + if err != nil { + tb.Fatal(err) + } + var inputs []string + for i, line := range strings.Split(string(data), "\n") { + // Git on Windows may check the file out with CRLF line endings. + line = strings.TrimSuffix(line, "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + quoted, err := strconv.QuotedPrefix(line) + if err == nil { + var in string + in, err = strconv.Unquote(quoted) + inputs = append(inputs, in) + } + if err != nil { + tb.Fatalf("testdata/inputs.txt:%d: %v", i+1, err) + } + } + return inputs +} + +// fuzzInputs returns the seed inputs of the fuzzers: confData, the parser +// inputs and the testdata files. +func fuzzInputs(tb testing.TB) []string { + tb.Helper() + inputs := append([]string{confData}, parserInputs(tb)...) + files, _ := filepath.Glob("testdata/*.ini") + for _, name := range files { + data, err := os.ReadFile(name) + if err != nil { + tb.Fatal(err) + } + inputs = append(inputs, string(data)) + } + return inputs +} + +// optionsFromBits maps the bits of flags to load options, so that fuzzers +// explore option combinations along with inputs. +func optionsFromBits(flags uint32) []LoadOption { + bit := func(i uint) bool { return flags&(1<>17)&7])) +} + func TestLoad(t *testing.T) { t.Run("load from good data sources", func(t *testing.T) { f, err := LoadSources([]any{"testdata/minimal.ini", []byte("NAME = ini\nIMPORT_PATH = gopkg.in/%(NAME)s.%(VERSION)s"), bytes.NewReader([]byte(`VERSION = v1`)), io.NopCloser(bytes.NewReader([]byte("[author]\nNAME = Unknwon")))}) @@ -1567,74 +1638,3 @@ key2 = assert.Equal(t, "value3", f.Section("section2").Key("key3").String()) }) } - -// parserInputs returns the inputs in testdata/inputs.txt. -func parserInputs(tb testing.TB) []string { - tb.Helper() - data, err := os.ReadFile("testdata/inputs.txt") - if err != nil { - tb.Fatal(err) - } - var inputs []string - for i, line := range strings.Split(string(data), "\n") { - // Git on Windows may check the file out with CRLF line endings. - line = strings.TrimSuffix(line, "\r") - if line == "" || strings.HasPrefix(line, "#") { - continue - } - quoted, err := strconv.QuotedPrefix(line) - if err == nil { - var in string - in, err = strconv.Unquote(quoted) - inputs = append(inputs, in) - } - if err != nil { - tb.Fatalf("testdata/inputs.txt:%d: %v", i+1, err) - } - } - return inputs -} - -// fuzzInputs returns the seed inputs of the fuzzers: confData, the parser -// inputs and the testdata files. -func fuzzInputs(tb testing.TB) []string { - tb.Helper() - inputs := append([]string{confData}, parserInputs(tb)...) - files, _ := filepath.Glob("testdata/*.ini") - for _, name := range files { - data, err := os.ReadFile(name) - if err != nil { - tb.Fatal(err) - } - inputs = append(inputs, string(data)) - } - return inputs -} - -// optionsFromBits maps the bits of flags to load options, so that fuzzers -// explore option combinations along with inputs. -func optionsFromBits(flags uint32) []LoadOption { - bit := func(i uint) bool { return flags&(1<>17)&7])) -} From a16bd0462dd027d05438954b18434d6ddfde5903 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 20:30:30 +0000 Subject: [PATCH 37/41] Use testify for every assertion in the tests --- bench_test.go | 6 +++--- file_test.go | 31 ++++++++++--------------------- ini_test.go | 12 +++--------- key_test.go | 2 +- lexer_test.go | 12 +++--------- struct_test.go | 8 ++------ 6 files changed, 22 insertions(+), 49 deletions(-) diff --git a/bench_test.go b/bench_test.go index a13027d..57af0b6 100644 --- a/bench_test.go +++ b/bench_test.go @@ -2,6 +2,8 @@ package ini import ( "testing" + + "github.com/stretchr/testify/require" ) func newTestFile(block bool) *File { @@ -106,9 +108,7 @@ func Benchmark_Key_SetValue_VisSection(b *testing.B) { func Benchmark_Key_Strings_LargeArray(b *testing.B) { c, err := Load("testdata/large_array.ini") - if err != nil { - b.Fatal(err) - } + require.NoError(b, err) key := c.Section("").Key("ARRAY") b.ReportAllocs() b.ResetTimer() diff --git a/file_test.go b/file_test.go index d2043a9..44f8583 100644 --- a/file_test.go +++ b/file_test.go @@ -776,12 +776,8 @@ func checkRoundTrip(t *testing.T, data []byte, opts []LoadOption, wopts ...Write return } g, err := Load(out.Bytes(), opts...) - if err != nil { - t.Fatalf("written file doesn't load: %v\ninput: %q\nwritten: %q", err, data, out.String()) - } - if got := describeFile(g); got != want { - t.Fatalf("written file loads differently\ninput: %q\nwritten: %q\nloaded:\n%s\nreloaded:\n%s", data, out.String(), want, got) - } + require.NoError(t, err, "written file doesn't load\ninput: %q\nwritten: %q", data, out.String()) + require.Equal(t, want, describeFile(g), "written file loads differently\ninput: %q\nwritten: %q", data, out.String()) } func FuzzWriteRoundTrip(f *testing.F) { @@ -821,12 +817,9 @@ func TestWriteReadsBack(t *testing.T) { {"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section } { f, err := Load([]byte(tc.data), tc.opts...) - if err != nil { - t.Fatal(err) - } - if _, err := f.Write(io.Discard); err != nil { - t.Fatalf("%q: %v", tc.data, err) - } + require.NoError(t, err) + _, err = f.Write(io.Discard) + require.NoError(t, err, "%q", tc.data) checkRoundTrip(t, []byte(tc.data), tc.opts) } @@ -835,19 +828,15 @@ func TestWriteReadsBack(t *testing.T) { _, _ = f.NewRawSection("raw", "body") _, _ = f.Section("s").NewKey("k", "v") var b bytes.Buffer - if _, err := f.Write(&b); err != nil { - t.Fatal(err) - } - if want := "[raw]\nbody\n\n[s]\nk = v\n"; b.String() != want { - t.Fatalf("got %q, want %q", b.String(), want) - } + _, err := f.Write(&b) + require.NoError(t, err) + assert.Equal(t, "[raw]\nbody\n\n[s]\nk = v\n", b.String()) }) t.Run("unwritable key", func(t *testing.T) { f := Empty() _, _ = f.Section("").NewKey("a\nb", "v") - if _, err := f.Write(io.Discard); err == nil { - t.Fatal("want an error for a key name with a line break") - } + _, err := f.Write(io.Discard) + assert.Error(t, err, "a key name with a line break") }) } diff --git a/ini_test.go b/ini_test.go index ceee8b0..84d19df 100644 --- a/ini_test.go +++ b/ini_test.go @@ -45,9 +45,7 @@ var update = flag.Bool("update", false, "Update .golden files") func parserInputs(tb testing.TB) []string { tb.Helper() data, err := os.ReadFile("testdata/inputs.txt") - if err != nil { - tb.Fatal(err) - } + require.NoError(tb, err) var inputs []string for i, line := range strings.Split(string(data), "\n") { // Git on Windows may check the file out with CRLF line endings. @@ -61,9 +59,7 @@ func parserInputs(tb testing.TB) []string { in, err = strconv.Unquote(quoted) inputs = append(inputs, in) } - if err != nil { - tb.Fatalf("testdata/inputs.txt:%d: %v", i+1, err) - } + require.NoError(tb, err, "testdata/inputs.txt:%d", i+1) } return inputs } @@ -76,9 +72,7 @@ func fuzzInputs(tb testing.TB) []string { files, _ := filepath.Glob("testdata/*.ini") for _, name := range files { data, err := os.ReadFile(name) - if err != nil { - tb.Fatal(err) - } + require.NoError(tb, err) inputs = append(inputs, string(data)) } return inputs diff --git a/key_test.go b/key_test.go index dd6a0be..a0cf3ab 100644 --- a/key_test.go +++ b/key_test.go @@ -446,7 +446,7 @@ expires = %(expires)s`)) case v := <-done: assert.LessOrEqual(t, len(v), maxExpandedLen) case <-time.After(5 * time.Second): - t.Fatalf("%q: String didn't return", data) + require.FailNowf(t, "String didn't return", "%q", data) } } }) diff --git a/lexer_test.go b/lexer_test.go index ccec370..21a54a0 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -75,13 +75,7 @@ func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { b.WriteString("next = ok\n") f, err := Load([]byte(b.String()), AllowPythonMultilineValues(true)) - if err != nil { - t.Fatal(err) - } - if got := strings.Count(f.Section("s").Key("key").Value(), "\n"); got != 200 { - t.Fatalf("value has %d continuation lines, want 200", got) - } - if got := f.Section("s").Key("next").Value(); got != "ok" { - t.Fatalf("next = %q, want ok", got) - } + require.NoError(t, err) + assert.Equal(t, 200, strings.Count(f.Section("s").Key("key").Value(), "\n"), "continuation lines") + assert.Equal(t, "ok", f.Section("s").Key("next").Value()) } diff --git a/struct_test.go b/struct_test.go index ce80599..dde1595 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1459,14 +1459,10 @@ func FuzzStructMapping(f *testing.F) { return } var back fzCfg - if err := Unmarshal(out, &back); err != nil { - t.Fatalf("Unmarshal of Marshal output failed: %v\nmarshaled: %q", err, out) - } + require.NoError(t, Unmarshal(out, &back), "marshaled: %q", out) normalizeFz(&cfg) normalizeFz(&back) - if !reflect.DeepEqual(cfg, back) { - t.Fatalf("Marshal/Unmarshal changed the value\nbefore: %+v\nafter: %+v\nmarshaled: %q", cfg, back, out) - } + require.Equal(t, cfg, back, "Marshal/Unmarshal changed the value\nmarshaled: %q", out) // ReflectFrom into the loaded file must not panic and must write. if err := file.ReflectFrom(&cfg); err == nil { From 57ce7aa8520c80749a2e93c9a6bf225daf20b6f9 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 21:01:05 +0000 Subject: [PATCH 38/41] Let the struct fuzzer allow trailing empty strings to drop Key.Strings drops an empty last element, so a []string ending in "" can't round trip through Marshal and Unmarshal, as struct_test.go already documents. The fuzzer found it with SS=, and the input is kept. --- struct_test.go | 12 +++++++++++- testdata/fuzz/FuzzStructMapping/ee0b270c66a8a6cd | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 testdata/fuzz/FuzzStructMapping/ee0b270c66a8a6cd diff --git a/struct_test.go b/struct_test.go index dde1595..7e04deb 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1472,7 +1472,8 @@ func FuzzStructMapping(f *testing.F) { }) } -// normalizeFz clears what the format can't carry: empty slices and nil +// normalizeFz clears what the format can't carry: trailing empty strings in +// slices, empty slices and nil // versus empty, monotonic clock readings and locations of times. func normalizeFz(c *fzCfg) { v := reflect.ValueOf(c).Elem() @@ -1499,6 +1500,15 @@ func normalizeFzValue(v reflect.Value) { } } case reflect.Slice: + // Key.Strings drops an empty last element, so trailing empty + // strings can't round trip. + if v.Type().Elem().Kind() == reflect.String { + n := v.Len() + for n > 0 && v.Index(n-1).String() == "" { + n-- + } + v.Set(v.Slice(0, n)) + } if v.Len() == 0 { v.Set(reflect.Zero(v.Type())) return diff --git a/testdata/fuzz/FuzzStructMapping/ee0b270c66a8a6cd b/testdata/fuzz/FuzzStructMapping/ee0b270c66a8a6cd new file mode 100644 index 0000000..a0e5792 --- /dev/null +++ b/testdata/fuzz/FuzzStructMapping/ee0b270c66a8a6cd @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("SS=,") +uint32(29) From b4cfdb3801ec2f3b61968820282abafab3aa29fb Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 21:01:18 +0000 Subject: [PATCH 39/41] Reflow the normalizeFz comment --- struct_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/struct_test.go b/struct_test.go index 7e04deb..1229932 100644 --- a/struct_test.go +++ b/struct_test.go @@ -1473,8 +1473,8 @@ func FuzzStructMapping(f *testing.F) { } // normalizeFz clears what the format can't carry: trailing empty strings in -// slices, empty slices and nil -// versus empty, monotonic clock readings and locations of times. +// slices, empty slices and nil versus empty, monotonic clock readings and +// locations of times. func normalizeFz(c *fzCfg) { v := reflect.ValueOf(c).Elem() normalizeFzValue(v) From fe7480f2556cdd32c39fdd0c139920df738f9021 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 21:07:13 +0000 Subject: [PATCH 40/41] Name the lock helpers maybeLock, maybeUnlock, maybeRLock and maybeRUnlock They lock only when the file was loaded with locking on. --- file.go | 63 +++++++++++++++++++++++++++--------------------------- key.go | 32 +++++++++++++-------------- section.go | 36 +++++++++++++++---------------- struct.go | 8 +++---- 4 files changed, 70 insertions(+), 69 deletions(-) diff --git a/file.go b/file.go index ac569ba..0c6944d 100644 --- a/file.go +++ b/file.go @@ -60,29 +60,30 @@ func (f *File) defaultSectionName() string { return f.options.DefaultSection } -// lock, unlock, rlock and runlock call the same methods of mu when locking -// is set. They guard the file's sections, keys and values. Unexported -// methods that change them expect the caller to hold the lock, or the file -// not to be shared yet, as while parsing. -func (f *File) rlock() { +// maybeLock, maybeUnlock, maybeRLock and maybeRUnlock call Lock, Unlock, +// RLock and RUnlock of mu when locking is set, and do nothing otherwise. +// They guard the file's sections, keys and values. Unexported methods that +// change them expect the caller to hold the lock, or the file not to be +// shared yet, as while parsing. +func (f *File) maybeRLock() { if f.locking { f.mu.RLock() } } -func (f *File) runlock() { +func (f *File) maybeRUnlock() { if f.locking { f.mu.RUnlock() } } -func (f *File) lock() { +func (f *File) maybeLock() { if f.locking { f.mu.Lock() } } -func (f *File) unlock() { +func (f *File) maybeUnlock() { if f.locking { f.mu.Unlock() } @@ -90,8 +91,8 @@ func (f *File) unlock() { // NewSection creates a new section. func (f *File) NewSection(name string) (*Section, error) { - f.lock() - defer f.unlock() + f.maybeLock() + defer f.maybeUnlock() return f.addSection(name) } @@ -117,8 +118,8 @@ func (f *File) addSection(name string) (*Section, error) { // NewRawSection creates a new section with an unparseable body. func (f *File) NewRawSection(name, body string) (*Section, error) { - f.lock() - defer f.unlock() + f.maybeLock() + defer f.maybeUnlock() section, err := f.addSection(name) if err != nil { return nil, err @@ -146,8 +147,8 @@ func (f *File) GetSection(name string) (*Section, error) { // sectionWithIndex returns the index-th section with the given name. func (f *File) sectionWithIndex(name string, index int) (*Section, error) { - f.rlock() - defer f.runlock() + f.maybeRLock() + defer f.maybeRUnlock() secs, err := f.sectionsByName(name) if err != nil { return nil, err @@ -166,8 +167,8 @@ func (f *File) HasSection(name string) bool { // SectionsByName returns all sections with given name. func (f *File) SectionsByName(name string) ([]*Section, error) { - f.rlock() - defer f.runlock() + f.maybeRLock() + defer f.maybeRUnlock() secs, err := f.sectionsByName(name) // A copy, as deleting a section shifts the ones after it in place. return slices.Clone(secs), err @@ -218,8 +219,8 @@ func (f *File) SectionWithIndex(name string, index int) *Section { // Sections returns a list of Section stored in the current instance. func (f *File) Sections() []*Section { - f.rlock() - defer f.runlock() + f.maybeRLock() + defer f.maybeRUnlock() return slices.Clone(f.sections) } @@ -230,8 +231,8 @@ func (f *File) ChildSections(name string) []*Section { // SectionNames returns list of section names. func (f *File) SectionNames() []string { - f.rlock() - defer f.runlock() + f.maybeRLock() + defer f.maybeRUnlock() names := make([]string, len(f.sections)) for i, sec := range f.sections { names[i] = sec.name @@ -270,8 +271,8 @@ func (f *File) DeleteSectionWithIndex(name string, index int) error { name = strings.ToLower(name) } - f.lock() - defer f.unlock() + f.maybeLock() + defer f.maybeUnlock() secs := f.byName[name] if index >= len(secs) { return nil @@ -303,16 +304,16 @@ func (f *File) loadSource(s dataSource) error { // same values, so pointers to them see the new contents. On error the file // is unchanged. func (f *File) Reload() error { - f.rlock() + f.maybeRLock() sources := f.dataSources - f.runlock() + f.maybeRUnlock() next := &File{options: f.options, byName: make(map[string][]*Section)} if err := next.load(sources); err != nil { return err } - f.lock() - defer f.unlock() + f.maybeLock() + defer f.maybeUnlock() for name, secs := range next.byName { old := f.byName[name] for i, sec := range secs { @@ -380,20 +381,20 @@ func (f *File) Append(source any, others ...any) error { } sources = append(sources, ds) } - f.lock() + f.maybeLock() f.dataSources = append(f.dataSources, sources...) if f.options.ShortCircuit { - f.unlock() + f.maybeUnlock() // Only the first source that loads counts. return f.Reload() } - defer f.unlock() + defer f.maybeUnlock() return f.load(sources) } func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { - f.rlock() - defer f.runlock() + f.maybeRLock() + defer f.maybeRUnlock() o := newWriteOptions(opts) indent, lineBreak := o.indent, o.lineBreak equalSign := o.formatLeft + o.keyValueDelimiter + o.formatRight diff --git a/key.go b/key.go index 02ee39f..c9d3e74 100644 --- a/key.go +++ b/key.go @@ -59,8 +59,8 @@ func (k *Key) AddShadow(val string) error { if !k.s.f.options.AllowShadows { return errors.New("shadow key is not allowed") } - k.s.f.lock() - defer k.s.f.unlock() + k.s.f.maybeLock() + defer k.s.f.maybeUnlock() return k.addShadow(val) } @@ -78,8 +78,8 @@ func (k *Key) AddNestedValue(val string) error { if !k.s.f.options.AllowNestedValues { return errors.New("nested value is not allowed") } - k.s.f.lock() - defer k.s.f.unlock() + k.s.f.maybeLock() + defer k.s.f.maybeUnlock() return k.addNestedValue(val) } @@ -94,16 +94,16 @@ func (k *Key) Name() string { // Comment returns the comment lines before the key, with their comment // symbols. func (k *Key) Comment() string { - k.s.f.rlock() - defer k.s.f.runlock() + k.s.f.maybeRLock() + defer k.s.f.maybeRUnlock() return k.comment } // SetComment sets the comment lines written before the key. Lines without a // comment symbol get one. func (k *Key) SetComment(comment string) { - k.s.f.lock() - defer k.s.f.unlock() + k.s.f.maybeLock() + defer k.s.f.maybeUnlock() k.comment = comment } @@ -123,8 +123,8 @@ func (k *Key) Value() string { // values are left out, unless the file was loaded with // PreserveEmptyShadowValues. func (k *Key) Values() []string { - k.s.f.rlock() - defer k.s.f.runlock() + k.s.f.maybeRLock() + defer k.s.f.maybeRUnlock() keepEmpty := k.s.f.options.PreserveEmptyShadowValues vals := make([]string, 0, len(k.shadows)+1) if keepEmpty || k.value != "" { @@ -141,16 +141,16 @@ func (k *Key) Values() []string { // IsBoolean reports whether the key was read as a boolean key, a line // without a delimiter loaded with AllowBooleanKeys. func (k *Key) IsBoolean() bool { - k.s.f.rlock() - defer k.s.f.runlock() + k.s.f.maybeRLock() + defer k.s.f.maybeRUnlock() return k.isBooleanType } // NestedValues returns nested values stored in the key. // It is possible returned value is nil if no nested values stored in the key. func (k *Key) NestedValues() []string { - k.s.f.rlock() - defer k.s.f.runlock() + k.s.f.maybeRLock() + defer k.s.f.maybeRUnlock() return k.nestedValues } @@ -512,8 +512,8 @@ func (k *Key) StringsWithShadows(delim string) []string { // SetValue changes key value. func (k *Key) SetValue(v string) { - k.s.f.lock() - defer k.s.f.unlock() + k.s.f.maybeLock() + defer k.s.f.maybeUnlock() k.value = v } diff --git a/section.go b/section.go index 7cbdc89..3ed2c37 100644 --- a/section.go +++ b/section.go @@ -37,31 +37,31 @@ func (s *Section) Name() string { // Comment returns the comment lines before the section header, with their // comment symbols. func (s *Section) Comment() string { - s.f.rlock() - defer s.f.runlock() + s.f.maybeRLock() + defer s.f.maybeRUnlock() return s.comment } // SetComment sets the comment lines written before the section header. // Lines without a comment symbol get one. func (s *Section) SetComment(comment string) { - s.f.lock() - defer s.f.unlock() + s.f.maybeLock() + defer s.f.maybeUnlock() s.comment = comment } // Body returns rawBody of Section if the section was marked as unparseable. // It still follows the other rules of the INI format surrounding leading/trailing whitespace. func (s *Section) Body() string { - s.f.rlock() - defer s.f.runlock() + s.f.maybeRLock() + defer s.f.maybeRUnlock() return strings.TrimSpace(s.rawBody) } // SetBody updates body content only if section is raw. func (s *Section) SetBody(body string) { - s.f.lock() - defer s.f.unlock() + s.f.maybeLock() + defer s.f.maybeUnlock() if !s.isRawSection { return } @@ -70,8 +70,8 @@ func (s *Section) SetBody(body string) { // NewKey creates a new key to given section. func (s *Section) NewKey(name, val string) (*Key, error) { - s.f.lock() - defer s.f.unlock() + s.f.maybeLock() + defer s.f.maybeUnlock() return s.addKey(name, val) } @@ -102,8 +102,8 @@ func (s *Section) addKey(name, val string) (*Key, error) { // NewBooleanKey creates a new boolean type key to given section. func (s *Section) NewBooleanKey(name string) (*Key, error) { - s.f.lock() - defer s.f.unlock() + s.f.maybeLock() + defer s.f.maybeUnlock() return s.addBooleanKey(name) } @@ -186,8 +186,8 @@ func (s *Section) Key(name string) *Key { // Keys returns list of keys of section. func (s *Section) Keys() []*Key { - s.f.rlock() - defer s.f.runlock() + s.f.maybeRLock() + defer s.f.maybeRUnlock() return slices.Clone(s.keys) } @@ -213,8 +213,8 @@ func (s *Section) ParentKeys() []*Key { // KeyNames returns list of key names of section. func (s *Section) KeyNames() []string { - s.f.rlock() - defer s.f.runlock() + s.f.maybeRLock() + defer s.f.maybeRUnlock() names := make([]string, len(s.keys)) for i, k := range s.keys { names[i] = k.name @@ -256,8 +256,8 @@ func (s *Section) DeleteKey(name string) { // For example, "[parent.child1]" and "[parent.child12]" are child sections // of section "[parent]". func (s *Section) ChildSections() []*Section { - s.f.rlock() - defer s.f.runlock() + s.f.maybeRLock() + defer s.f.maybeRUnlock() prefix := s.name + s.f.options.ChildSectionDelimiter children := make([]*Section, 0, 3) for _, sec := range s.f.sections { diff --git a/struct.go b/struct.go index c7b3e57..b32b373 100644 --- a/struct.go +++ b/struct.go @@ -181,8 +181,8 @@ func formatValue(v reflect.Value) string { // defaultComment sets *c to comment if it is empty, under the file's lock. func (f *File) defaultComment(c *string, comment string) { - f.lock() - defer f.unlock() + f.maybeLock() + defer f.maybeUnlock() if *c == "" { *c = comment } @@ -540,9 +540,9 @@ func encodeSlice(key *Key, field reflect.Value, delim string, allowShadow bool) return err } } - key.s.f.lock() + key.s.f.maybeLock() key.value, key.shadows = tmp.value, tmp.shadows - key.s.f.unlock() + key.s.f.maybeUnlock() return nil } for i := range vals { From df34b76b62ec5a6cbf58c5aeb1f115b5a64d2d64 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 10 Nov 2026 21:21:00 +0000 Subject: [PATCH 41/41] Make the examples cover layered config, defaults with MapTo, and struct tags ExampleLoadSources shows defaults overridden by an optional file, and ExampleSection_MapTo shows that fields keep their defaults. ExampleMarshal uses the comment and omitempty tags instead of writing an unset Timeout as 0, and Example lists the sections, including the default one. --- example_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/example_test.go b/example_test.go index decacd8..9ed5ea6 100644 --- a/example_test.go +++ b/example_test.go @@ -26,6 +26,10 @@ func Example() { panic(err) } + // Keys before the first header are in the default section, "DEFAULT". + for _, sec := range f.Sections() { + fmt.Println(sec.Name(), sec.KeyNames()) + } fmt.Println(f.Section("").Key("app_name").String()) server := f.Section("server") @@ -36,6 +40,8 @@ func Example() { fmt.Println(server.Key("logs").String()) fmt.Println(server.Key("missing").MustString("fallback")) // Output: + // DEFAULT [app_name] + // server [port timeout hosts root logs] // demo // 8080 30s // [a.example.com b.example.com] @@ -43,6 +49,20 @@ func Example() { // fallback } +// Programs often ship defaults and let an optional file override them. +func ExampleLoadSources() { + defaults := []byte("[server]\nport = 8080\nhost = localhost\n") + custom := []byte("[server]\nport = 9090\n") + + f, err := ini.LoadSources([]any{defaults, custom, "missing.ini"}, ini.IgnoreMissingFiles(true)) + if err != nil { + panic(err) + } + fmt.Println(f.Section("server").KeyValues()) + // Output: + // map[host:localhost port:9090] +} + func ExampleLoad_options() { data := []byte(` [unit] @@ -72,10 +92,11 @@ func ExampleList() { // List gives the zero value for an element that doesn't parse, // ValidList leaves it out, and StrictList returns an error. fmt.Println(ini.List[int](k, ",")) - fmt.Println(ini.ValidList[uint16](k, ",")) + fmt.Println(ini.ValidList[int](k, ",")) _, err = ini.StrictList[int](k, ",") fmt.Println(err != nil) + // 1.5 is outside [0, 1], so Range returns the default. ratio := f.Section("").Key("ratio") fmt.Println(ini.Range(ratio, 1.0, 0, 1)) // Output: @@ -88,12 +109,31 @@ func ExampleList() { type Config struct { AppName string `ini:"app_name"` Server struct { - Port int - Timeout time.Duration + Port int `comment:"Port to listen on"` + Timeout time.Duration `ini:",omitempty"` Hosts []string } `ini:"server"` } +// Fields keep their values when the section has no key for them, so a +// struct can be filled with defaults first. +func ExampleSection_MapTo() { + f, err := ini.Load([]byte("[server]\nPort = 9090\n")) + if err != nil { + panic(err) + } + + var cfg Config + cfg.Server.Port = 8080 + cfg.Server.Timeout = 30 * time.Second + if err := f.Section("server").MapTo(&cfg.Server); err != nil { + panic(err) + } + fmt.Println(cfg.Server.Port, cfg.Server.Timeout) + // Output: + // 9090 30s +} + func ExampleUnmarshal() { data := []byte(`app_name = demo @@ -126,9 +166,9 @@ func ExampleMarshal() { // app_name = demo // // [server] - // Port = 8080 - // Timeout = 0 - // Hosts = a.example.com,b.example.com + // ; Port to listen on + // Port = 8080 + // Hosts = a.example.com,b.example.com } func ExampleFile_Write() {