From 52a53c34b5fee219a5ca404ef5e733e272e18e55 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 19:26:59 +0000 Subject: [PATCH 1/3] Fix parsing and writing bugs reported against v1 - Keep comment symbols inside a quoted value (issue 393, issue 328). - Keep comment lines after the last key and write them back (issue 362). - Skip a repeated byte order mark (issue 390). - With SpaceBeforeInlineComment, start the comment at the first " #" or " ;" (issue 426). - With AllowPythonMultilineValues, keep empty lines followed by an indented line (issue 205). - Treat a raw section with an empty body as raw (issue 291). - Don't wrap a value in """ for a backtick in the middle (issue 352). - Add AllowBracketedValues to read a JSON-like value across lines (issue 344). --- README.md | 2 +- doc.go | 8 +- docs/migrate-v1-to-v2.md | 16 +- file.go | 73 +++++-- file_test.go | 29 ++- ini_test.go | 1 + lexer.go | 192 +++++++++++++++--- lexer_test.go | 66 ++++++ options.go | 14 +- section.go | 3 + .../fuzz/FuzzWriteRoundTrip/801ed58288e8abdf | 3 + testdata/inputs.txt | 9 + 12 files changed, 351 insertions(+), 65 deletions(-) create mode 100644 testdata/fuzz/FuzzWriteRoundTrip/801ed58288e8abdf diff --git a/README.md b/README.md index 1f2498e..0ae19a6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/doc.go b/doc.go index d65a548..51eba1f 100644 --- a/doc.go +++ b/doc.go @@ -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 // diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index d3ba877..6c7bf7e 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -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 it. v1 dropped them. +- A second byte order mark at the start of a file is skipped. v1 failed on it. +- 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. +- `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. @@ -70,6 +81,7 @@ For ordinary files, `Write` output is byte-identical to v1's. Where v1 wrote a f - Keys in a section that `RawSections` reads as raw, or in a raw section, are an error. v1 dropped the keys of a raw section. - Multi-line comments containing `\n` are written correctly with `LineBreak("\r\n")`. - A key named `-`, which would read back as an auto-increment key, is an error. +- A value with a backtick inside, but not at its start, is no longer wrapped in `"""` unless it also has a comment symbol. ### Other fixes @@ -107,7 +119,7 @@ Each `LoadOptions` field becomes an option function of the same name, such as `i | `KeyValueDelimiterOnWrite` | `WriteDelimiter(d)`, a write option | | `ReaderBufferSize` | removed, since nothing is cut at a buffer size | -New options: `CommentSymbols`, `PreserveEmptyShadowValues` and `DefaultSectionName`. +New options: `CommentSymbols`, `PreserveEmptyShadowValues`, `DefaultSectionName` and `AllowBracketedValues`, which reads a value such as JSON over several lines up to its closing bracket. ### Writing diff --git a/file.go b/file.go index 1e81438..09ae508 100644 --- a/file.go +++ b/file.go @@ -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 } } @@ -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] == '`') || f.bracketOpens(val): 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: @@ -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 @@ -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 { @@ -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) { @@ -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 @@ -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 @@ -779,7 +797,18 @@ 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 == "" +} + +// bracketOpens reports whether the lexer reads the lines after val, written +// plain, as part of it: with AllowBracketedValues, when val starts with a +// bracket that it doesn't close. +func (f *File) bracketOpens(val string) bool { + if !f.options.AllowBracketedValues || val == "" || (val[0] != '{' && val[0] != '[') { + return false + } + var sc bracketScanner + return sc.scan(val) == -1 } // readsAsRaw reports whether the lexer reads the section name, after its @@ -807,7 +836,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 diff --git a/file_test.go b/file_test.go index 2b955e7..e719999 100644 --- a/file_test.go +++ b/file_test.go @@ -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", @@ -910,7 +910,12 @@ 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 = 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) @@ -919,6 +924,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") diff --git a/ini_test.go b/ini_test.go index 84d19df..90169c8 100644 --- a/ini_test.go +++ b/ini_test.go @@ -103,6 +103,7 @@ func optionsFromBits(flags uint32) []LoadOption { if bit(16) { opts = append(opts, RawSections("raw", "COMMENTS")) } + opts = append(opts, AllowBracketedValues(bit(20))) return append(opts, KeyValueDelimiters([]string{"", "=", ":", "→=", " ", "?!", "=:", ""}[(flags>>17)&7])) } diff --git a/lexer.go b/lexer.go index 6532270..b8d23ae 100644 --- a/lexer.go +++ b/lexer.go @@ -25,8 +25,9 @@ type token struct { kind tokenKind name string value string - comment string - line, column int // where the token starts, both from 1 + comment string // for tokEOF, the comment lines after the last key + raw bool // for tokSection, the section is unparseable + line, column int // where the token starts, both from 1 } // errorf reports err at the position of the token. @@ -35,7 +36,8 @@ func (t token) errorf(err error) error { } // 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. +// apply, are those of gopkg.in/ini.v1, apart from the fixes listed in +// docs/migrate-v1-to-v2.md. type lexer struct { src string pos int // offset of the next unread byte @@ -94,7 +96,7 @@ func (l *lexer) next() (token, error) { if l.raw.Len() > 0 { return l.rawBody(), nil } - return token{kind: tokEOF}, nil + return token{kind: tokEOF, comment: l.takeComment()}, nil } // readLine returns the next line including its '\n', if any. @@ -159,7 +161,7 @@ func (l *lexer) section(line string) (token, error) { break } } - return token{kind: tokSection, name: name, comment: l.takeComment()}, nil + return token{kind: tokSection, name: name, comment: l.takeComment(), raw: l.inRaw}, nil } // key reads a "name = value" line, or a boolean key when the line has no @@ -168,7 +170,7 @@ 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:]) + value, err := l.readValue(line[offset:], true) if err != nil { return token{}, false, err } @@ -177,7 +179,7 @@ func (l *lexer) key(line string) (_ token, ok bool, _ error) { case errors.Is(err, ErrDelimiterNotFound) && l.opts.AllowBooleanKeys: // The whole line is the key name, read like a value. - name, err := l.readValue(line) + name, err := l.readValue(line, false) if err != nil { return token{}, false, err } @@ -190,12 +192,14 @@ 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 -// before surrounding quotes are stripped, so `"a;b"` is `"a` with comment -// `;b"`. -func (l *lexer) readValue(in string) (string, error) { +// and any following lines it spans. bracketed is false for a boolean key's +// name, which AllowBracketedValues doesn't apply to. The rules apply in a +// fixed order, which decides the result when they overlap: a value is either +// quoted, bracketed, continued with a backslash, or plain. For a plain value +// the inline comment is cut before surrounding quotes are stripped, so +// `"a;b" ;c` is `a;b` with comment `;c`, but `"a;b" x` is `"a` with comment +// `;b" x`. +func (l *lexer) readValue(in string, bracketed bool) (string, error) { line := strings.TrimLeftFunc(in, unicode.IsSpace) if line == "" { if l.opts.AllowPythonMultilineValues && strings.HasSuffix(in, "\n") { @@ -208,6 +212,12 @@ func (l *lexer) readValue(in string) (string, error) { return l.readQuoted(line, q) } + if bracketed && l.opts.AllowBracketedValues && (line[0] == '{' || line[0] == '[') { + if v, ok := l.readBracketed(line); ok { + return v, nil + } + } + endsLine := line[len(line)-1] == '\n' line = strings.TrimSpace(line) if !l.opts.IgnoreContinuation && strings.HasSuffix(line, `\`) { @@ -285,19 +295,22 @@ 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 symbols are tried in order, so " #" wins over an earlier " ;". +// the comment. With SpaceBeforeInlineComment the symbol must follow a space. +// A value in single or double quotes keeps the comment symbols inside them, +// when only spaces or a comment follow the closing quote. func (l *lexer) cutInlineComment(line string) string { - i := -1 - if l.opts.SpaceBeforeInlineComment { - for _, c := range l.opts.CommentSymbols { - if i = strings.Index(line, " "+string(c)); i > -1 { - break + if line[0] == '\'' || line[0] == '"' { + if j := strings.IndexByte(line[1:], line[0]); j > -1 { + end := j + 2 + if rest := line[end:]; strings.TrimSpace(rest) == "" { + return line + } else if i := l.inlineCommentIndex(rest); i > -1 && strings.TrimSpace(rest[:i]) == "" { + l.comment = append(l.comment, rest[i:]) + return line[:end] } } - } else { - i = strings.IndexAny(line, l.opts.CommentSymbols) } + i := l.inlineCommentIndex(line) if i == -1 { return line } @@ -305,6 +318,92 @@ func (l *lexer) cutInlineComment(line string) string { return strings.TrimSpace(line[:i]) } +// inlineCommentIndex returns the index of the first comment symbol in s, or +// with SpaceBeforeInlineComment of the first one after a space, or -1. +func (l *lexer) inlineCommentIndex(s string) int { + if !l.opts.SpaceBeforeInlineComment { + return strings.IndexAny(s, l.opts.CommentSymbols) + } + for i := 0; i < len(s); i++ { + if s[i] == ' ' && startsWithAny(s[i+1:], l.opts.CommentSymbols) { + return i + } + } + return -1 +} + +// readBracketed reads a value that starts with the bracket at line[0] up to +// the bracket that closes it, on this line or a later one. ok is false, and +// nothing is read, when no bracket closes it or text other than a comment +// follows the closing bracket. +func (l *lexer) readBracketed(line string) (_ string, ok bool) { + pos, lineNum, eof := l.pos, l.line, l.eof + defer func() { + if !ok { + l.pos, l.line, l.eof = pos, lineNum, eof + } + }() + var b strings.Builder + var sc bracketScanner + for { + if end := sc.scan(line); end > -1 { + b.WriteString(line[:end]) + rest := line[end:] + if strings.TrimSpace(rest) == "" { + return b.String(), true + } + j := -1 + if !l.opts.IgnoreInlineComment { + j = l.inlineCommentIndex(rest) + } + if j == -1 || strings.TrimSpace(rest[:j]) != "" { + return "", false + } + l.comment = append(l.comment, strings.TrimSpace(rest[j:])) + return b.String(), true + } + if l.eof { + return "", false + } + b.WriteString(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + "\n") + line = l.readLine() + } +} + +// bracketScanner follows the brackets of a value that starts with '{' or +// '[', across lines. Brackets in double-quoted strings don't count. +type bracketScanner struct { + depth int + inString, escaped bool +} + +// scan reads s and returns the index just after the bracket that closes the +// first one, or -1 when s doesn't close it. +func (sc *bracketScanner) scan(s string) int { + for i := 0; i < len(s); i++ { + switch c := s[i]; { + case sc.inString: + switch { + case sc.escaped: + sc.escaped = false + case c == '\\': + sc.escaped = true + case c == '"': + sc.inString = false + } + case c == '"': + sc.inString = true + case c == '{' || c == '[': + sc.depth++ + case c == '}' || c == ']': + if sc.depth--; sc.depth == 0 { + return i + 1 + } + } + } + return -1 +} + // 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. @@ -325,28 +424,49 @@ func (l *lexer) readContinuationLines(val string) string { } // readPythonMultilines appends the following lines that start with a space, -// tab or form feed, each on a new line with its indentation kept. +// tab or form feed, each on a new line with its indentation kept. Empty +// lines followed by such a line are part of the value too, as in Python's +// configparser. 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 { + for { + rest := l.src[l.pos:] + empty := len(rest) - len(strings.TrimLeft(rest, "\r\n")) + if empty == len(rest) || strings.IndexByte(" \t\f", rest[empty]) == -1 { + return b.String() + } + if empty > 0 && rest[empty-1] != '\n' { + // A lone "\r" before the indented line isn't an empty line. + return b.String() + } + for range empty { + if l.src[l.pos] == '\n' { + l.line++ + b.WriteByte('\n') + } + l.pos++ + } next := strings.TrimSuffix(l.readLine(), "\n") b.WriteByte('\n') b.WriteString(next) } - return b.String() } -// skipBOM strips a UTF-8, UTF-16 LE or UTF-16 BE byte order mark. +// skipBOM strips the UTF-8, UTF-16 LE and UTF-16 BE byte order marks at +// the start of src. Some tools add a second one to a file that has one. // 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:] + for { + switch { + case len(src) >= 2 && (src[0] == 0xFE && src[1] == 0xFF || src[0] == 0xFF && src[1] == 0xFE): + src = src[2:] + case len(src) >= 3 && src[0] == 0xEF && src[1] == 0xBB && src[2] == 0xBF: + src = src[3:] + default: + return src + } } - return src } // parse reads all of reader and adds its sections and keys to f. The caller @@ -375,6 +495,13 @@ func (f *File) parse(reader io.Reader) error { switch tok.kind { case tokEOF: + if tok.comment != "" { + // Comment lines after the last key stay at the end, after + // the last section, which isn't the one read last when the + // input reopens an earlier section. + last := f.sections[len(f.sections)-1] + last.footer = strings.TrimSpace(last.footer + "\n" + tok.comment) + } return nil case tokSection: @@ -382,6 +509,7 @@ func (f *File) parse(reader io.Reader) error { return tok.errorf(err) } section.comment = tok.comment + section.isRawSection = section.isRawSection || tok.raw count = 1 case tokRawBody: diff --git a/lexer_test.go b/lexer_test.go index 21a54a0..1220b19 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -27,9 +27,66 @@ func TestBOM(t *testing.T) { t.Run("UTF-16-BE-BOM", func(t *testing.T) { }) + + t.Run("two BOMs", func(t *testing.T) { + f, err := Load([]byte("\ufeff\ufeff# comment\n[s]\nk = v\n")) + require.NoError(t, err) + assert.Equal(t, "v", f.Section("s").Key("k").String()) + assert.Equal(t, "# comment", f.Section("s").Comment()) + }) }) } +func TestInlineComments(t *testing.T) { + for _, tc := range []struct { + in string + opts []LoadOption + value, comment string + }{ + // Comment symbols inside quotes stay in the value. + {in: "k = '#e18477'", value: "#e18477"}, + {in: `k = "XXX#123"`, value: "XXX#123"}, + {in: `k = "a;b" ; note`, value: "a;b", comment: "; note"}, + {in: `k = "a#b"`, opts: []LoadOption{PreserveSurroundingQuotes(true)}, value: `"a#b"`}, + // Text after the closing quote cuts at the first symbol, as before. + {in: `k = "a;b" x`, value: `"a`, comment: `;b" x`}, + {in: "k = don't # note", value: "don't", comment: "# note"}, + // With SpaceBeforeInlineComment the first " #" or " ;" cuts. + {in: "k = foo ; note # ticket", opts: []LoadOption{SpaceBeforeInlineComment(true)}, value: "foo", comment: "; note # ticket"}, + {in: "k = a#b ;c", opts: []LoadOption{SpaceBeforeInlineComment(true)}, value: "a#b", comment: ";c"}, + } { + f, err := Load([]byte(tc.in+"\n"), tc.opts...) + require.NoError(t, err, tc.in) + k := f.Section("").Key("k") + assert.Equal(t, tc.value, k.String(), tc.in) + assert.Equal(t, tc.comment, strings.TrimSpace(k.Comment()), tc.in) + } +} + +func TestEmptyRawSection(t *testing.T) { + for _, in := range []string{"[custom]\n", "[a]\nk = v\n[custom]\n", "[custom]\n[b]\nk = v\n"} { + f, err := Load([]byte(in), RawSections("custom")) + require.NoError(t, err) + sec := f.Section("custom") + sec.SetBody("x = 1") + assert.Equal(t, "x = 1", sec.Body(), "an empty raw section is raw: %q", in) + } +} + +func TestBracketedValues(t *testing.T) { + const in = "[server]\ntimeout = {\n \"timeout\": 1000,\n \"url\": \"http://x/#a}\"\n} ; note\nnext = 1\nopen = {\n" + _, err := Load([]byte(in)) + require.Error(t, err) + + f, err := Load([]byte(in), AllowBracketedValues(true)) + require.NoError(t, err) + sec := f.Section("server") + assert.Equal(t, "{\n \"timeout\": 1000,\n \"url\": \"http://x/#a}\"\n}", sec.Key("timeout").String()) + assert.Equal(t, "; note", sec.Key("timeout").Comment()) + assert.Equal(t, "1", sec.Key("next").String()) + assert.Equal(t, "{", sec.Key("open").String(), "a bracket that never closes is read as usual") +} + func TestBadLoad(t *testing.T) { t.Run("load with bad data", func(t *testing.T) { t.Run("bad section name", func(t *testing.T) { @@ -79,3 +136,12 @@ func TestParsePythonMultilineLongerThanBuffer(t *testing.T) { 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()) } + +// Empty lines inside a Python-style multiline value belong to it when an +// indented line follows, as in configparser. +func TestParsePythonMultilineEmptyLines(t *testing.T) { + f, err := Load([]byte("notes = first\n\n second\n\n\n third\n\nnext = 1\n"), AllowPythonMultilineValues(true)) + require.NoError(t, err) + assert.Equal(t, "first\n\n second\n\n\n third", f.Section("").Key("notes").String()) + assert.Equal(t, "1", f.Section("").Key("next").String()) +} diff --git a/options.go b/options.go index 62d7074..c39c88c 100644 --- a/options.go +++ b/options.go @@ -7,8 +7,8 @@ import ( ) // LoadOption configures how data sources are loaded and parsed. Without -// options, parsing behaves exactly as gopkg.in/ini.v1's Load. When two options set the -// same thing, the last one wins. +// options, parsing behaves as gopkg.in/ini.v1's Load, apart from a few +// fixes. When two options set the same thing, the last one wins. type LoadOption func(*loadOptions) // loadOptions holds the settings that LoadOption values apply. A File keeps them, @@ -26,6 +26,7 @@ type loadOptions struct { AllowShadows bool AllowNestedValues bool AllowPythonMultilineValues bool + AllowBracketedValues bool SpaceBeforeInlineComment bool UnescapeValueDoubleQuotes bool UnescapeValueCommentSymbols bool @@ -128,6 +129,15 @@ func AllowPythonMultilineValues(v bool) LoadOption { return func(o *loadOptions) { o.AllowPythonMultilineValues = v } } +// AllowBracketedValues reads a value that starts with '{' or '[', such as +// JSON, up to the bracket that closes it, over several lines if needed. +// Comment symbols inside the brackets are part of the value, and brackets +// in double-quoted strings don't count. A value with no closing bracket, or +// with text other than a comment after it, is read as usual. +func AllowBracketedValues(v bool) LoadOption { + return func(o *loadOptions) { o.AllowBracketedValues = v } +} + // SpaceBeforeInlineComment starts an inline comment only at a comment symbol // that follows a space, so values can contain comment symbols. func SpaceBeforeInlineComment(v bool) LoadOption { diff --git a/section.go b/section.go index 265a94e..6ee54cd 100644 --- a/section.go +++ b/section.go @@ -18,6 +18,9 @@ type Section struct { isRawSection bool rawBody string + // footer is the comment lines at the end of the input, kept on the + // section that is last when they are read, and written after it. + footer string // missing is set on a section File.Section or File.SectionWithIndex // returned for a name, or an index of it, the file doesn't have. The diff --git a/testdata/fuzz/FuzzWriteRoundTrip/801ed58288e8abdf b/testdata/fuzz/FuzzWriteRoundTrip/801ed58288e8abdf new file mode 100644 index 0000000..2ef5390 --- /dev/null +++ b/testdata/fuzz/FuzzWriteRoundTrip/801ed58288e8abdf @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("[note]\n[0]\n[note]\n#") +uint32(581) diff --git a/testdata/inputs.txt b/testdata/inputs.txt index f7c20ee..94aaddc 100644 --- a/testdata/inputs.txt +++ b/testdata/inputs.txt @@ -97,3 +97,12 @@ "\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" + +# Inputs from the v2 fixes of issues 205, 291, 344, 352, 362, 390, 393 and 426. +"\ufeff\ufeff# c\n[s]\nk = v\n" +"a = '#e18477'\nb = \"XXX#123\" ; c\nc = \"a;b\" x\nd = foo ; note # ticket\n" +"notes = first\n\n second\n\n\n third\n\nnext = 1\n" +"[raw]\n[s]\nk = v\n[COMMENTS]\n" +"[server]\ntimeout = {\n \"timeout\": 1000,\n \"u\": \"x/#a}\"\n} ; note\nnext = [1,\n 2]\nopen = {\n" +"k = a`b\nl = `c\n" +"# BEGIN\n[p]\nk = v\n# END\n; end2\n" From 95ce66cb6936db6d84881d0e61d3249296460852 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 14:44:49 -0500 Subject: [PATCH 2/3] Remove AllowBracketedValues A value such as JSON can span lines in triple quotes, so the option isn't needed. Its scan made parsing quadratic and skipped Python multiline continuation. --- docs/migrate-v1-to-v2.md | 2 +- file.go | 13 +----- ini_test.go | 1 - lexer.go | 96 ++++------------------------------------ lexer_test.go | 14 ------ options.go | 10 ----- testdata/inputs.txt | 3 +- 7 files changed, 11 insertions(+), 128 deletions(-) diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 6c7bf7e..f381f20 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -119,7 +119,7 @@ Each `LoadOptions` field becomes an option function of the same name, such as `i | `KeyValueDelimiterOnWrite` | `WriteDelimiter(d)`, a write option | | `ReaderBufferSize` | removed, since nothing is cut at a buffer size | -New options: `CommentSymbols`, `PreserveEmptyShadowValues`, `DefaultSectionName` and `AllowBracketedValues`, which reads a value such as JSON over several lines up to its closing bracket. +New options: `CommentSymbols`, `PreserveEmptyShadowValues` and `DefaultSectionName`. ### Writing diff --git a/file.go b/file.go index 09ae508..5219466 100644 --- a/file.go +++ b/file.go @@ -465,7 +465,7 @@ func (f *File) writeToBuffer(opts []WriteOption) (*bytes.Buffer, error) { if !f.options.AllowBooleanKeys { return line, nil } - case strings.Contains(val, "\n") || (val != "" && val[0] == '`') || f.bracketOpens(val): + case strings.Contains(val, "\n") || (val != "" && val[0] == '`'): line = form + eq + `"""` + val + `"""` + lineBreak case !f.options.IgnoreInlineComment && strings.ContainsAny(val, symbols): if strings.Contains(val, "`") { @@ -800,17 +800,6 @@ func (f *File) readsBack(text string, boolean bool, name, val string, nested []s return err == nil && tok.kind == tokEOF && tok.comment == "" } -// bracketOpens reports whether the lexer reads the lines after val, written -// plain, as part of it: with AllowBracketedValues, when val starts with a -// bracket that it doesn't close. -func (f *File) bracketOpens(val string) bool { - if !f.options.AllowBracketedValues || val == "" || (val[0] != '{' && val[0] != '[') { - return false - } - var sc bracketScanner - return sc.scan(val) == -1 -} - // readsAsRaw reports whether the lexer reads the section name, after its // header, as a raw section. func (f *File) readsAsRaw(name string) bool { diff --git a/ini_test.go b/ini_test.go index 90169c8..84d19df 100644 --- a/ini_test.go +++ b/ini_test.go @@ -103,7 +103,6 @@ func optionsFromBits(flags uint32) []LoadOption { if bit(16) { opts = append(opts, RawSections("raw", "COMMENTS")) } - opts = append(opts, AllowBracketedValues(bit(20))) return append(opts, KeyValueDelimiters([]string{"", "=", ":", "→=", " ", "?!", "=:", ""}[(flags>>17)&7])) } diff --git a/lexer.go b/lexer.go index b8d23ae..aa05e21 100644 --- a/lexer.go +++ b/lexer.go @@ -170,7 +170,7 @@ 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:], true) + value, err := l.readValue(line[offset:]) if err != nil { return token{}, false, err } @@ -179,7 +179,7 @@ func (l *lexer) key(line string) (_ token, ok bool, _ error) { case errors.Is(err, ErrDelimiterNotFound) && l.opts.AllowBooleanKeys: // The whole line is the key name, read like a value. - name, err := l.readValue(line, false) + name, err := l.readValue(line) if err != nil { return token{}, false, err } @@ -192,14 +192,12 @@ 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. bracketed is false for a boolean key's -// name, which AllowBracketedValues doesn't apply to. The rules apply in a -// fixed order, which decides the result when they overlap: a value is either -// quoted, bracketed, continued with a backslash, or plain. For a plain value -// the inline comment is cut before surrounding quotes are stripped, so -// `"a;b" ;c` is `a;b` with comment `;c`, but `"a;b" x` is `"a` with comment -// `;b" x`. -func (l *lexer) readValue(in string, bracketed bool) (string, error) { +// 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" ;c` is `a;b` with comment +// `;c`, but `"a;b" x` is `"a` with comment `;b" x`. +func (l *lexer) readValue(in string) (string, error) { line := strings.TrimLeftFunc(in, unicode.IsSpace) if line == "" { if l.opts.AllowPythonMultilineValues && strings.HasSuffix(in, "\n") { @@ -212,12 +210,6 @@ func (l *lexer) readValue(in string, bracketed bool) (string, error) { return l.readQuoted(line, q) } - if bracketed && l.opts.AllowBracketedValues && (line[0] == '{' || line[0] == '[') { - if v, ok := l.readBracketed(line); ok { - return v, nil - } - } - endsLine := line[len(line)-1] == '\n' line = strings.TrimSpace(line) if !l.opts.IgnoreContinuation && strings.HasSuffix(line, `\`) { @@ -332,78 +324,6 @@ func (l *lexer) inlineCommentIndex(s string) int { return -1 } -// readBracketed reads a value that starts with the bracket at line[0] up to -// the bracket that closes it, on this line or a later one. ok is false, and -// nothing is read, when no bracket closes it or text other than a comment -// follows the closing bracket. -func (l *lexer) readBracketed(line string) (_ string, ok bool) { - pos, lineNum, eof := l.pos, l.line, l.eof - defer func() { - if !ok { - l.pos, l.line, l.eof = pos, lineNum, eof - } - }() - var b strings.Builder - var sc bracketScanner - for { - if end := sc.scan(line); end > -1 { - b.WriteString(line[:end]) - rest := line[end:] - if strings.TrimSpace(rest) == "" { - return b.String(), true - } - j := -1 - if !l.opts.IgnoreInlineComment { - j = l.inlineCommentIndex(rest) - } - if j == -1 || strings.TrimSpace(rest[:j]) != "" { - return "", false - } - l.comment = append(l.comment, strings.TrimSpace(rest[j:])) - return b.String(), true - } - if l.eof { - return "", false - } - b.WriteString(strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + "\n") - line = l.readLine() - } -} - -// bracketScanner follows the brackets of a value that starts with '{' or -// '[', across lines. Brackets in double-quoted strings don't count. -type bracketScanner struct { - depth int - inString, escaped bool -} - -// scan reads s and returns the index just after the bracket that closes the -// first one, or -1 when s doesn't close it. -func (sc *bracketScanner) scan(s string) int { - for i := 0; i < len(s); i++ { - switch c := s[i]; { - case sc.inString: - switch { - case sc.escaped: - sc.escaped = false - case c == '\\': - sc.escaped = true - case c == '"': - sc.inString = false - } - case c == '"': - sc.inString = true - case c == '{' || c == '[': - sc.depth++ - case c == '}' || c == ']': - if sc.depth--; sc.depth == 0 { - return i + 1 - } - } - } - return -1 -} - // 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. diff --git a/lexer_test.go b/lexer_test.go index 1220b19..c62f137 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -73,20 +73,6 @@ func TestEmptyRawSection(t *testing.T) { } } -func TestBracketedValues(t *testing.T) { - const in = "[server]\ntimeout = {\n \"timeout\": 1000,\n \"url\": \"http://x/#a}\"\n} ; note\nnext = 1\nopen = {\n" - _, err := Load([]byte(in)) - require.Error(t, err) - - f, err := Load([]byte(in), AllowBracketedValues(true)) - require.NoError(t, err) - sec := f.Section("server") - assert.Equal(t, "{\n \"timeout\": 1000,\n \"url\": \"http://x/#a}\"\n}", sec.Key("timeout").String()) - assert.Equal(t, "; note", sec.Key("timeout").Comment()) - assert.Equal(t, "1", sec.Key("next").String()) - assert.Equal(t, "{", sec.Key("open").String(), "a bracket that never closes is read as usual") -} - func TestBadLoad(t *testing.T) { t.Run("load with bad data", func(t *testing.T) { t.Run("bad section name", func(t *testing.T) { diff --git a/options.go b/options.go index c39c88c..5744e25 100644 --- a/options.go +++ b/options.go @@ -26,7 +26,6 @@ type loadOptions struct { AllowShadows bool AllowNestedValues bool AllowPythonMultilineValues bool - AllowBracketedValues bool SpaceBeforeInlineComment bool UnescapeValueDoubleQuotes bool UnescapeValueCommentSymbols bool @@ -129,15 +128,6 @@ func AllowPythonMultilineValues(v bool) LoadOption { return func(o *loadOptions) { o.AllowPythonMultilineValues = v } } -// AllowBracketedValues reads a value that starts with '{' or '[', such as -// JSON, up to the bracket that closes it, over several lines if needed. -// Comment symbols inside the brackets are part of the value, and brackets -// in double-quoted strings don't count. A value with no closing bracket, or -// with text other than a comment after it, is read as usual. -func AllowBracketedValues(v bool) LoadOption { - return func(o *loadOptions) { o.AllowBracketedValues = v } -} - // SpaceBeforeInlineComment starts an inline comment only at a comment symbol // that follows a space, so values can contain comment symbols. func SpaceBeforeInlineComment(v bool) LoadOption { diff --git a/testdata/inputs.txt b/testdata/inputs.txt index 94aaddc..589a423 100644 --- a/testdata/inputs.txt +++ b/testdata/inputs.txt @@ -98,11 +98,10 @@ "; 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" -# Inputs from the v2 fixes of issues 205, 291, 344, 352, 362, 390, 393 and 426. +# Inputs from the v2 fixes of issues 205, 291, 352, 362, 390, 393 and 426. "\ufeff\ufeff# c\n[s]\nk = v\n" "a = '#e18477'\nb = \"XXX#123\" ; c\nc = \"a;b\" x\nd = foo ; note # ticket\n" "notes = first\n\n second\n\n\n third\n\nnext = 1\n" "[raw]\n[s]\nk = v\n[COMMENTS]\n" -"[server]\ntimeout = {\n \"timeout\": 1000,\n \"u\": \"x/#a}\"\n} ; note\nnext = [1,\n 2]\nopen = {\n" "k = a`b\nl = `c\n" "# BEGIN\n[p]\nk = v\n# END\n; end2\n" From 132eb25a68d1df4e0035d34e3962d3d69d80c088 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 14:49:44 -0500 Subject: [PATCH 3/3] Keep a section with keys out of raw on an empty raw header A default section with keys followed by an empty [DEFAULT] header under RawSections became raw, and Write then refused it. Also correct the migration notes on write output, repeated byte order marks, Python multiline values and where trailing comments are written. --- docs/migrate-v1-to-v2.md | 9 ++++----- file_test.go | 1 + lexer.go | 11 +++++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index f381f20..9e689a1 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -9,13 +9,13 @@ v2 needs Go 1.26 or later. v1 and v2 can be used in the same program, so you can **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 it. v1 dropped them. -- A second byte order mark at the start of a file is skipped. v1 failed on it. +- 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. +- `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. @@ -70,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. @@ -81,7 +81,6 @@ For ordinary files, `Write` output is byte-identical to v1's. Where v1 wrote a f - Keys in a section that `RawSections` reads as raw, or in a raw section, are an error. v1 dropped the keys of a raw section. - Multi-line comments containing `\n` are written correctly with `LineBreak("\r\n")`. - A key named `-`, which would read back as an auto-increment key, is an error. -- A value with a backtick inside, but not at its start, is no longer wrapped in `"""` unless it also has a comment symbol. ### Other fixes diff --git a/file_test.go b/file_test.go index e719999..9c1a7b6 100644 --- a/file_test.go +++ b/file_test.go @@ -915,6 +915,7 @@ func TestWriteReadsBack(t *testing.T) { {"# 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...) diff --git a/lexer.go b/lexer.go index aa05e21..cc14a00 100644 --- a/lexer.go +++ b/lexer.go @@ -416,9 +416,10 @@ func (f *File) parse(reader io.Reader) error { switch tok.kind { case tokEOF: if tok.comment != "" { - // Comment lines after the last key stay at the end, after - // the last section, which isn't the one read last when the - // input reopens an earlier section. + // Comment lines after the last key are kept on the last + // section, which isn't the one read last when the input + // reopens an earlier section, and are written after it. + // Sections added later are written after them. last := f.sections[len(f.sections)-1] last.footer = strings.TrimSpace(last.footer + "\n" + tok.comment) } @@ -429,7 +430,9 @@ func (f *File) parse(reader io.Reader) error { return tok.errorf(err) } section.comment = tok.comment - section.isRawSection = section.isRawSection || tok.raw + // A section with keys, such as the default one, stays a key section + // when its raw header has no body. + section.isRawSection = section.isRawSection || (tok.raw && len(section.keys) == 0) count = 1 case tokRawBody: