From d2ab1c0006155c009a67782f6f28b195f1c902eb Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Fri, 9 Oct 2026 14:58:28 +0300 Subject: [PATCH] fix(parser): preserve comment symbols inside quoted string values Signed-off-by: AdamMagued --- ini_test.go | 107 +++++++++++++++++++++++++++++++++++++++ parser.go | 65 +++++++++++++++++++----- parser_test.go | 132 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 12 deletions(-) diff --git a/ini_test.go b/ini_test.go index a3d3b4c..367f5b6 100644 --- a/ini_test.go +++ b/ini_test.go @@ -1676,3 +1676,110 @@ key2 = assert.Equal(t, "value3", f.Section("section2").Key("key3").String()) }) } + +func TestQuotedInlineComments(t *testing.T) { + t.Run("single and double quoted values with comment symbols inside quotes", func(t *testing.T) { + f, err := Load([]byte(` +[background] +primary-color = '#e18477' +secondary-color = '#000000' +accent-color = "#123456" +symbol-hash = '#' +symbol-semi = ';' +symbol-hash-double = "#" +symbol-semi-double = ";" + +[urls] +endpoint-single = 'http://example.com/api;v=1' +endpoint-double = "http://example.com/api;v=1" +`)) + require.NoError(t, err) + require.NotNil(t, f) + + bg := f.Section("background") + assert.Equal(t, "#e18477", bg.Key("primary-color").String()) + assert.Equal(t, "#000000", bg.Key("secondary-color").String()) + assert.Equal(t, "#123456", bg.Key("accent-color").String()) + assert.Equal(t, "#", bg.Key("symbol-hash").String()) + assert.Equal(t, ";", bg.Key("symbol-semi").String()) + assert.Equal(t, "#", bg.Key("symbol-hash-double").String()) + assert.Equal(t, ";", bg.Key("symbol-semi-double").String()) + + urls := f.Section("urls") + assert.Equal(t, "http://example.com/api;v=1", urls.Key("endpoint-single").String()) + assert.Equal(t, "http://example.com/api;v=1", urls.Key("endpoint-double").String()) + }) + + t.Run("quoted values with comment symbols followed by real comments", func(t *testing.T) { + f, err := Load([]byte(` +[background] +primary-color = '#e18477' # primary color comment +secondary-color = "#000000" ; secondary color comment +mixed = 'val # 1' ; comment with # and ; symbols +`)) + require.NoError(t, err) + require.NotNil(t, f) + + bg := f.Section("background") + assert.Equal(t, "#e18477", bg.Key("primary-color").String()) + assert.Equal(t, "# primary color comment", bg.Key("primary-color").Comment) + assert.Equal(t, "#000000", bg.Key("secondary-color").String()) + assert.Equal(t, "; secondary color comment", bg.Key("secondary-color").Comment) + assert.Equal(t, "val # 1", bg.Key("mixed").String()) + assert.Equal(t, "; comment with # and ; symbols", bg.Key("mixed").Comment) + }) + + t.Run("quoted values with PreserveSurroundedQuote", func(t *testing.T) { + f, err := LoadSources(LoadOptions{ + PreserveSurroundedQuote: true, + }, []byte(` +[background] +primary-color = '#e18477' +secondary-color = "#000000" +with-comment = '#e18477' # color +`)) + require.NoError(t, err) + require.NotNil(t, f) + + bg := f.Section("background") + assert.Equal(t, "'#e18477'", bg.Key("primary-color").String()) + assert.Equal(t, `"#000000"`, bg.Key("secondary-color").String()) + assert.Equal(t, "'#e18477'", bg.Key("with-comment").String()) + assert.Equal(t, "# color", bg.Key("with-comment").Comment) + }) + + t.Run("quoted values with SpaceBeforeInlineComment", func(t *testing.T) { + f, err := LoadSources(LoadOptions{ + SpaceBeforeInlineComment: true, + }, []byte(` +[colors] +with-space = '#e18477' # has space +without-space = '#e18477'#no space +`)) + require.NoError(t, err) + require.NotNil(t, f) + + sec := f.Section("colors") + assert.Equal(t, "#e18477", sec.Key("with-space").String()) + assert.Equal(t, "# has space", sec.Key("with-space").Comment) + assert.Equal(t, `'#e18477'#no space`, sec.Key("without-space").String()) + assert.Equal(t, "", sec.Key("without-space").Comment) + }) + + t.Run("unquoted apostrophe and escaped quotes", func(t *testing.T) { + f, err := Load([]byte(` +[notes] +apostrophe = don't drop this # important note +escaped = 'it\'s #value' # real comment +`)) + require.NoError(t, err) + require.NotNil(t, f) + + sec := f.Section("notes") + assert.Equal(t, "don't drop this", sec.Key("apostrophe").String()) + assert.Equal(t, "# important note", sec.Key("apostrophe").Comment) + assert.Equal(t, `'it\'s #value'`, sec.Key("escaped").String()) + assert.Equal(t, "# real comment", sec.Key("escaped").Comment) + }) +} + diff --git a/parser.go b/parser.go index 513b19e..ac35424 100644 --- a/parser.go +++ b/parser.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" ) const minReaderBufferSize = 4096 @@ -233,6 +234,57 @@ func hasSurroundedQuote(in string, quote byte) bool { strings.IndexByte(in[1:], quote) == len(in)-2 } +func isWordChar(prefix string) bool { + if len(prefix) == 0 { + return false + } + r, _ := utf8.DecodeLastRuneInString(prefix) + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' +} + +// findInlineComment returns the index of the inline comment delimiter in line, +// or -1 if no inline comment exists outside of quoted string literals. +func findInlineComment(line string, spaceBefore bool) int { + for j := 0; j < len(line); j++ { + ch := line[j] + if ch == '\\' { + j++ + continue + } + + if (ch == '"' || ch == '\'') && (j == 0 || !isWordChar(line[:j])) { + quote := ch + closed := false + k := j + 1 + for ; k < len(line); k++ { + if line[k] == '\\' { + k++ + continue + } + if line[k] == quote { + closed = true + break + } + } + if closed { + j = k + continue + } + } + + if spaceBefore { + if ch == ' ' && j+1 < len(line) && (line[j+1] == '#' || line[j+1] == ';') { + return j + } + } else { + if ch == '#' || ch == ';' { + return j + } + } + } + return -1 +} + func (p *parser) readValue(in []byte, bufferSize int) (string, error) { line := strings.TrimLeftFunc(string(in), unicode.IsSpace) @@ -278,22 +330,11 @@ func (p *parser) readValue(in []byte, bufferSize int) (string, error) { // 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, "#;") - } - + i := findInlineComment(line, p.options.SpaceBeforeInlineComment) if i > -1 { p.comment.WriteString(line[i:]) line = strings.TrimSpace(line[:i]) } - } // Trim single and double quotes diff --git a/parser_test.go b/parser_test.go index 7016d67..80904f5 100644 --- a/parser_test.go +++ b/parser_test.go @@ -75,3 +75,135 @@ func TestBadLoad(t *testing.T) { }) }) } + +func Test_findInlineComment(t *testing.T) { + tests := []struct { + name string + line string + spaceBefore bool + expected int + }{ + { + name: "no comment", + line: "hello world", + spaceBefore: false, + expected: -1, + }, + { + name: "hash comment without space requirement", + line: "hello#world", + spaceBefore: false, + expected: 5, + }, + { + name: "hash comment with space before", + line: "hello #world", + spaceBefore: true, + expected: 5, + }, + { + name: "hash without space when space required", + line: "hello#world", + spaceBefore: true, + expected: -1, + }, + { + name: "semicolon comment without space requirement", + line: "hello;world", + spaceBefore: false, + expected: 5, + }, + { + name: "semicolon comment with space before", + line: "hello ;world", + spaceBefore: true, + expected: 5, + }, + { + name: "hash inside single quotes", + line: "'#e18477'", + spaceBefore: false, + expected: -1, + }, + { + name: "hash inside double quotes", + line: `"#e18477"`, + spaceBefore: false, + expected: -1, + }, + { + name: "semicolon inside single quotes", + line: "'val;ue'", + spaceBefore: false, + expected: -1, + }, + { + name: "semicolon inside double quotes", + line: `"val;ue"`, + spaceBefore: false, + expected: -1, + }, + { + name: "comment after single quoted string", + line: "'#e18477' # comment", + spaceBefore: false, + expected: 10, + }, + { + name: "comment after single quoted string with space required", + line: "'#e18477' # comment", + spaceBefore: true, + expected: 9, + }, + { + name: "comment after double quoted string", + line: `"#e18477" ; comment`, + spaceBefore: false, + expected: 10, + }, + { + name: "comment after double quoted string with space required", + line: `"#e18477" ; comment`, + spaceBefore: true, + expected: 9, + }, + { + name: "multiple quoted strings with comment", + line: `'#123', "#456" # comment`, + spaceBefore: false, + expected: 15, + }, + { + name: "escaped quote inside quotes", + line: `"hello \"#\" world" # real comment`, + spaceBefore: false, + expected: 20, + }, + { + name: "apostrophe in word followed by comment", + line: "don't touch # comment", + spaceBefore: false, + expected: 12, + }, + { + name: "unclosed quote falls back to scanning", + line: `"unclosed # comment`, + spaceBefore: false, + expected: 10, + }, + { + name: "escaped comment symbol outside quotes", + line: `value\#not_comment # real`, + spaceBefore: false, + expected: 19, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := findInlineComment(tt.line, tt.spaceBefore) + assert.Equal(t, tt.expected, actual) + }) + } +} +