Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Measured with Go 1.26.10 on a 4-core Intel Xeon at 2.60 GHz, as the median of 8

## Migrating from v1

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.
Without options, v2 parses input as v1 does, apart from a few fixes such as `#` inside quoted values. 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.

The [migration guide](docs/migrate-v1-to-v2.md) lists every change.

Expand Down
8 changes: 4 additions & 4 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
// }
// port := f.Section("server").Key("port").MustInt(8080)
//
// Without options, every input parses exactly as it did with
// gopkg.in/ini.v1. [LoadOption] values such as [AllowShadows],
// [CaseInsensitive] and [IgnoreInlineComment] change the syntax that is
// accepted.
// Without options, input parses as it did with gopkg.in/ini.v1, apart from
// a few fixes, such as comment symbols inside quoted values. [LoadOption]
// values such as [AllowShadows], [CaseInsensitive] and [IgnoreInlineComment]
// change the syntax that is accepted.
//
// # Sections and keys
//
Expand Down
15 changes: 13 additions & 2 deletions docs/migrate-v1-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ 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, and v2 reads it in full.
**Parsing is almost unchanged.** Without options, v2 parses input as v1 does: same sections, keys, values, comments and errors. These are the exceptions, each a fix:

- A value in single or double quotes keeps the `#` and `;` inside them, so `k = "a#b"` is `a#b`. v1 read `"a`. Text other than a comment after the closing quote still cuts the value as before.
- Comment lines after the last key of a file are kept and written back after the last section. Sections added later, or loaded from a later source, are written after them. v1 dropped them.
- A byte order mark repeated at the start of a file is skipped. v1 read the second one as part of the first key name, or failed on a section header.
- A Python-style multiline value longer than 4 KiB is read in full. v1 cut it at its read buffer and failed.

With options:

- `AllowPythonMultilineValues`: empty lines followed by an indented line stay in the value, as in Python's configparser. v1 ended the value at the empty line and read the indented line as a key, which usually failed.
- `SpaceBeforeInlineComment`: the first ` #` or ` ;` starts the comment. v1 looked for ` #` first.
- `RawSections`: a raw section with an empty body is raw, so `SetBody` works on it.

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.

Expand Down Expand Up @@ -59,7 +70,7 @@ In v1, `f.Section("server").Key("PORT")` added an empty `[server]` section and `

### 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. Comments load back with the same text, though the spacing after a comment symbol can change. Output meant for other readers isn't checked: keys written with a `WriteDelimiter` the file wasn't loaded with, boolean keys without `AllowBooleanKeys`, and raw sections without `RawSections`. For example:
For ordinary files, `Write` output is byte-identical to v1's, apart from the parsing fixes above and a value with a backtick inside but not at its start: v1 wrapped it in `"""`, and v2 doesn't unless it also has a comment symbol. Where v1 wrote a file that loaded back differently, v2 writes one that loads back the same, or returns an error. Comments load back with the same text, though the spacing after a comment symbol can change. Output meant for other readers isn't checked: keys written with a `WriteDelimiter` the file wasn't loaded with, boolean keys without `AllowBooleanKeys`, and raw sections without `RawSections`. 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.
Expand Down
62 changes: 40 additions & 22 deletions file.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ func (f *File) Reload() error {
}
}
o.comment, o.keys, o.byName = sec.comment, sec.keys, sec.byName
o.isRawSection, o.rawBody = sec.isRawSection, sec.rawBody
o.isRawSection, o.rawBody, o.footer = sec.isRawSection, sec.rawBody, sec.footer
secs[i] = o
}
}
Expand Down Expand Up @@ -465,10 +465,14 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) {
if !f.options.AllowBooleanKeys {
return line, nil
}
case strings.ContainsAny(val, "\n`"):
case strings.Contains(val, "\n") || (val != "" && val[0] == '`'):
line = form + eq + `"""` + val + `"""` + lineBreak
case !f.options.IgnoreInlineComment && strings.ContainsAny(val, symbols):
line = form + eq + "`" + val + "`" + lineBreak
if strings.Contains(val, "`") {
line = form + eq + `"""` + val + `"""` + lineBreak
} else {
line = form + eq + "`" + val + "`" + lineBreak
}
case len(strings.TrimSpace(val)) != len(val):
line = form + eq + `"` + val + `"` + lineBreak
default:
Expand Down Expand Up @@ -511,6 +515,23 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) {

// Use buffer to make sure target is safe until finish encoding.
buf := bytes.NewBuffer(nil)

// writeComment writes the lines of a section's comment or footer. They
// are split at "\n" too, so a comment set with "\n" and written with
// another lineBreak has no line without a comment symbol.
writeComment := func(comment string) error {
if comment == "" {
return nil
}
for _, line := range splitLines(comment, lineBreak) {
line, err := formatComment(line, false)
if err != nil {
return err
}
buf.WriteString(line + lineBreak)
}
return nil
}
// Per-key buffers, reused for every key.
var vals, lines []string
var empty []bool
Expand All @@ -520,21 +541,8 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) {
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. They are split at "\n" too, so a
// comment set with "\n" and written with another lineBreak has
// no line without a comment symbol.
lines := splitLines(sec.comment, lineBreak)
for i := range lines {
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 {
return nil, err
}
}
if err := writeComment(sec.comment); err != nil {
return nil, err
}

if sec.isRawSection && len(sec.keys) > 0 {
Expand All @@ -558,6 +566,9 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) {
return nil, err
}
} else if len(sec.keys) == 0 {
if err := writeComment(sec.footer); err != nil {
return nil, err
}
continue
}
if writeHeader && !sec.isRawSection && len(sec.keys) > 0 && f.readsAsRaw(sname) {
Expand All @@ -573,10 +584,14 @@ 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.
if (!isLastSection || sec.footer != "") && sec.rawBody != "" && !strings.HasSuffix(sec.rawBody, "\n") {
// The next section header, or the footer, starts on its
// own line.
buf.WriteString(lineBreak)
}
if err := writeComment(sec.footer); err != nil {
return nil, err
}

if o.blankLines && !isLastSection {
// Put a line between sections
Expand Down Expand Up @@ -745,6 +760,9 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) {
}
swallows = f.options.AllowPythonMultilineValues || (f.options.AllowNestedValues && empty[last])
}
if err := writeComment(sec.footer); err != nil {
return nil, err
}

if o.blankLines && !isLastSection {
// Put a line between sections
Expand Down Expand Up @@ -779,7 +797,7 @@ func (f *File) readsBack(text string, boolean bool, name, val string, nested []s
}
}
tok, err = l.next()
return err == nil && tok.kind == tokEOF && len(l.comment) == 0
return err == nil && tok.kind == tokEOF && tok.comment == ""
}

// readsAsRaw reports whether the lexer reads the section name, after its
Expand Down Expand Up @@ -807,7 +825,7 @@ func (f *File) rawBodyReadsBack(name, body string) bool {
if err == nil && tok.kind == tokRawBody {
tok, err = l.next()
}
return err == nil && tok.kind == tokEOF && len(l.comment) == 0
return err == nil && tok.kind == tokEOF && tok.comment == ""
}

// splitLines splits s at lineBreak, and then at "\n", so that no line has
Expand Down
30 changes: 28 additions & 2 deletions file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ func describeFile(f *File) string {
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"))
fmt.Fprintf(&b, "[%q] comment=%q raw=%v body=%q footer=%q\n", s.name, normalizeComment(s.comment), s.isRawSection, strings.TrimRight(s.rawBody, "\n"), normalizeComment(s.footer))
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",
Expand Down Expand Up @@ -910,7 +910,13 @@ func TestWriteReadsBack(t *testing.T) {
{"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
{"[DEFAULT] ; c\n[s]\nk = v\n", nil}, // comment of an empty default section
{"# BEGIN\n[p]\nk = v\n# END\n", nil}, // comment after the last key
{"# only a comment\n", nil}, // comment of an empty file
{"[raw]\nbody\n# c\n", []LoadOption{RawSections("raw")}}, // comment after a raw body
{"[custom]\n", []LoadOption{RawSections("custom")}}, // empty raw section
{"k = v\n[DEFAULT]\n", []LoadOption{RawSections("DEFAULT")}}, // empty raw header of a section with keys
{"k = a\n\n b\nn = 1\n", []LoadOption{AllowPythonMultilineValues(true)}}, // empty line in a multiline value
} {
f, err := Load([]byte(tc.data), tc.opts...)
require.NoError(t, err)
Expand All @@ -919,6 +925,26 @@ func TestWriteReadsBack(t *testing.T) {
checkRoundTrip(t, []byte(tc.data), tc.opts)
}

t.Run("trailing comment stays after its section", func(t *testing.T) {
f, err := Load([]byte("# BEGIN\n[profile test]\nregion = us-east-1\n# END\n"))
require.NoError(t, err)
f.Section("new").Key("k").SetValue("v")
var b bytes.Buffer
_, err = f.Write(&b)
require.NoError(t, err)
assert.Equal(t, "# BEGIN\n[profile test]\nregion = us-east-1\n# END\n\n[new]\nk = v\n", b.String())
})

t.Run("backtick inside a value isn't quoted", func(t *testing.T) {
f, err := Load([]byte("[Files]\nCmd = skapon.cmd\t\t;a`b\n"), IgnoreInlineComment(true))
require.NoError(t, err)
f.Section("Files").Key("movelist").SetValue("x`y")
var b bytes.Buffer
_, err = f.Write(&b)
require.NoError(t, err)
assert.Equal(t, "[Files]\nCmd = skapon.cmd\t\t;a`b\nmovelist = x`y\n", b.String())
})

t.Run("raw body without line break", func(t *testing.T) {
f := Empty(RawSections("raw"))
_, _ = f.NewRawSection("raw", "body")
Expand Down
Loading
Loading