diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e09b44..628fa5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,3 +29,25 @@ jobs: - run: npm i - run: npm run build --if-present - run: npm test + + build-go: + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + - name: Build + working-directory: go + run: go build ./... + - name: Test + working-directory: go + run: go test -v ./... diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..9ad43e4 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,5 @@ +module github.com/jsonicjs/ini/go + +go 1.24.7 + +require github.com/jsonicjs/jsonic/go v0.1.4 diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..dc99d17 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,2 @@ +github.com/jsonicjs/jsonic/go v0.1.4 h1:V1KEzmg/jIwk25+JYj8ig1+B7190rHmH8WqZbT7XlgA= +github.com/jsonicjs/jsonic/go v0.1.4/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg= diff --git a/go/ini.go b/go/ini.go new file mode 100644 index 0000000..dcfe285 --- /dev/null +++ b/go/ini.go @@ -0,0 +1,216 @@ +/* Copyright (c) 2021-2025 Richard Rodger, MIT License */ + +package ini + +import ( + jsonic "github.com/jsonicjs/jsonic/go" +) + +// IniOptions configures the INI parser. +type IniOptions struct { + Multiline *MultilineOptions + Section *SectionOptions + Comment *CommentOptions +} + +// MultilineOptions controls multiline value continuation. +type MultilineOptions struct { + // Continuation character before newline. Default: "\\". + // Set to empty string to disable backslash continuation. + Continuation *string + // When true, indented continuation lines extend the previous value. + Indent *bool +} + +// SectionOptions controls section header handling. +type SectionOptions struct { + // How to handle duplicate section headers. + // "merge" (default): combine keys from all occurrences. + // "override": last section occurrence replaces earlier ones. + // "error": throw when a previously declared section header appears again. + Duplicate string +} + +// CommentOptions controls comment behavior. +type CommentOptions struct { + Inline *InlineCommentOptions +} + +// InlineCommentOptions controls inline comment behavior. +type InlineCommentOptions struct { + // Whether inline comments are active. Default: false. + Active *bool + // Characters that start an inline comment. Default: ["#", ";"]. + Chars []string + // Escape mechanisms for literal comment characters in values. + Escape *InlineEscapeOptions +} + +// InlineEscapeOptions controls escaping of inline comment characters. +type InlineEscapeOptions struct { + // Allow \; and \# to produce literal ; and #. Default: true. + Backslash *bool + // Require whitespace before comment char to trigger. Default: false. + Whitespace *bool +} + +// resolved holds fully resolved options with defaults applied. +type resolved struct { + multiline bool + continuation string // "" means disabled + indent bool + dupSection string + inlineActive bool + inlineChars map[rune]bool + inlineCharStr []string + escBackslash bool + escWhitespace bool +} + +func boolOpt(p *bool, def bool) bool { + if p != nil { + return *p + } + return def +} + +func stringOpt(p *string, def string) string { + if p != nil { + return *p + } + return def +} + +func resolve(o *IniOptions) *resolved { + r := &resolved{ + dupSection: "merge", + inlineChars: map[rune]bool{'#': true, ';': true}, + inlineCharStr: []string{"#", ";"}, + escBackslash: true, + } + + if o.Multiline != nil { + r.multiline = true + r.continuation = stringOpt(o.Multiline.Continuation, "\\") + r.indent = boolOpt(o.Multiline.Indent, false) + } + + if o.Section != nil && o.Section.Duplicate != "" { + r.dupSection = o.Section.Duplicate + } + + if o.Comment != nil && o.Comment.Inline != nil { + ic := o.Comment.Inline + r.inlineActive = boolOpt(ic.Active, false) + if ic.Chars != nil && len(ic.Chars) > 0 { + r.inlineChars = make(map[rune]bool) + r.inlineCharStr = ic.Chars + for _, s := range ic.Chars { + if len(s) > 0 { + r.inlineChars[rune(s[0])] = true + } + } + } + if ic.Escape != nil { + r.escBackslash = boolOpt(ic.Escape.Backslash, true) + r.escWhitespace = boolOpt(ic.Escape.Whitespace, false) + } + } + + return r +} + +// Parse parses an INI string and returns a map. +func Parse(src string, opts ...IniOptions) (map[string]any, error) { + var o IniOptions + if len(opts) > 0 { + o = opts[0] + } + j := MakeJsonic(o) + result, err := j.Parse(src) + if err != nil { + return nil, err + } + if result == nil { + return map[string]any{}, nil + } + if m, ok := result.(map[string]any); ok { + return m, nil + } + return map[string]any{}, nil +} + +// MakeJsonic creates a jsonic instance configured for INI parsing. +func MakeJsonic(opts ...IniOptions) *jsonic.Jsonic { + var o IniOptions + if len(opts) > 0 { + o = opts[0] + } + + r := resolve(&o) + + bTrue := true + bFalse := false + + jopts := jsonic.Options{ + Rule: &jsonic.RuleOptions{ + Start: "ini", + }, + Number: &jsonic.NumberOptions{ + Lex: &bFalse, + }, + Value: &jsonic.ValueOptions{ + Lex: &bTrue, + }, + Comment: &jsonic.CommentOptions{ + Lex: &bTrue, + Def: map[string]*jsonic.CommentDef{ + "hash": {Line: true, Start: "#"}, + "semi": {Line: true, Start: ";"}, + }, + }, + String: &jsonic.StringOptions{ + Lex: &bTrue, + Chars: `'"`, + }, + Text: &jsonic.TextOptions{ + Lex: &bFalse, + }, + Lex: &jsonic.LexOptions{ + EmptyResult: map[string]any{}, + }, + } + + j := jsonic.Make(jopts) + + pluginMap := optionsToMap(&o, r) + j.Use(Ini, pluginMap) + + return j +} + +func boolPtr(b bool) *bool { + return &b +} + +func stringPtr(s string) *string { + return &s +} + +// optionsToMap converts IniOptions to a map for the plugin interface. +func optionsToMap(o *IniOptions, r *resolved) map[string]any { + m := make(map[string]any) + m["_resolved"] = r + return m +} + +// mapToOptions extracts resolved options from the plugin map. +func mapToResolved(m map[string]any) *resolved { + if m == nil { + return resolve(&IniOptions{}) + } + if r, ok := m["_resolved"].(*resolved); ok { + return r + } + return resolve(&IniOptions{}) +} diff --git a/go/ini_test.go b/go/ini_test.go new file mode 100644 index 0000000..1295ca1 --- /dev/null +++ b/go/ini_test.go @@ -0,0 +1,375 @@ +/* Copyright (c) 2021-2025 Richard Rodger, MIT License */ + +package ini + +import ( + "reflect" + "testing" +) + +// assert is a test helper that checks deep equality. +func assert(t *testing.T, name string, got, want any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("%s:\n got: %#v\n want: %#v", name, got, want) + } +} + +func TestHappy(t *testing.T) { + j := MakeJsonic() + + r, err := j.Parse("a=1") + if err != nil { + t.Fatal(err) + } + assert(t, "simple", r, map[string]any{"a": "1"}) + + r, err = j.Parse("[A]") + if err != nil { + t.Fatal(err) + } + assert(t, "section", r, map[string]any{"A": map[string]any{}}) + + r, err = j.Parse("a=\nb=") + if err != nil { + t.Fatal(err) + } + assert(t, "empty-values", r, map[string]any{"a": "", "b": ""}) +} + +func TestInlineCommentsOff(t *testing.T) { + // Default: inline comments are off. ; and # mid-value are literal. + result, err := Parse("a = hello ; world") + if err != nil { + t.Fatal(err) + } + assert(t, "semicolon-literal", result, map[string]any{"a": "hello ; world"}) + + result, err = Parse("a = hello # world") + if err != nil { + t.Fatal(err) + } + assert(t, "hash-literal", result, map[string]any{"a": "hello # world"}) + + result, err = Parse("a = x;y;z") + if err != nil { + t.Fatal(err) + } + assert(t, "multi-semi", result, map[string]any{"a": "x;y;z"}) +} + +func TestLineComments(t *testing.T) { + // Line-start comments always work. + result, err := Parse("; comment\na = 1") + if err != nil { + t.Fatal(err) + } + assert(t, "semi-comment", result, map[string]any{"a": "1"}) + + result, err = Parse("# comment\na = 1") + if err != nil { + t.Fatal(err) + } + assert(t, "hash-comment", result, map[string]any{"a": "1"}) +} + +func TestInlineActive(t *testing.T) { + result, err := Parse("a = hello ; comment", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{Active: boolPtr(true)}, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "semi-inline", result, map[string]any{"a": "hello"}) + + result, err = Parse("a = hello # comment", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{Active: boolPtr(true)}, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "hash-inline", result, map[string]any{"a": "hello"}) +} + +func TestInlineCustomChars(t *testing.T) { + result, err := Parse("a = hello ; comment\nb = hello # not a comment", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{ + Active: boolPtr(true), + Chars: []string{";"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "custom-chars", result, map[string]any{ + "a": "hello", + "b": "hello # not a comment", + }) +} + +func TestInlineBackslashEscape(t *testing.T) { + result, err := Parse("a = hello\\; world", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{ + Active: boolPtr(true), + Escape: &InlineEscapeOptions{Backslash: boolPtr(true)}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "backslash-semi", result, map[string]any{"a": "hello; world"}) + + result, err = Parse("a = hello\\# world", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{ + Active: boolPtr(true), + Escape: &InlineEscapeOptions{Backslash: boolPtr(true)}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "backslash-hash", result, map[string]any{"a": "hello# world"}) +} + +func TestInlineWhitespacePrefix(t *testing.T) { + result, err := Parse("a = x;y;z", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{ + Active: boolPtr(true), + Escape: &InlineEscapeOptions{Whitespace: boolPtr(true)}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "no-ws-literal", result, map[string]any{"a": "x;y;z"}) + + result, err = Parse("a = hello ;comment", IniOptions{ + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{ + Active: boolPtr(true), + Escape: &InlineEscapeOptions{Whitespace: boolPtr(true)}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "ws-comment", result, map[string]any{"a": "hello"}) +} + +func TestSections(t *testing.T) { + result, err := Parse("[d]\ne = 2") + if err != nil { + t.Fatal(err) + } + assert(t, "simple-section", result, map[string]any{ + "d": map[string]any{"e": "2"}, + }) +} + +func TestNestedSections(t *testing.T) { + result, err := Parse("[h.i]\nj = 3") + if err != nil { + t.Fatal(err) + } + assert(t, "nested", result, map[string]any{ + "h": map[string]any{ + "i": map[string]any{"j": "3"}, + }, + }) +} + +func TestSectionDuplicateMerge(t *testing.T) { + result, err := Parse("[a]\nx=1\ny=2\n[a]\nz=3") + if err != nil { + t.Fatal(err) + } + assert(t, "merge", result, map[string]any{ + "a": map[string]any{"x": "1", "y": "2", "z": "3"}, + }) +} + +func TestSectionDuplicateOverride(t *testing.T) { + result, err := Parse("[a]\nx=1\ny=2\n[a]\nz=3", IniOptions{ + Section: &SectionOptions{Duplicate: "override"}, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "override", result, map[string]any{ + "a": map[string]any{"z": "3"}, + }) +} + +func TestSectionDuplicateError(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for duplicate section") + } + }() + Parse("[a]\nx=1\n[a]\ny=2", IniOptions{ + Section: &SectionOptions{Duplicate: "error"}, + }) +} + +func TestKeyByItself(t *testing.T) { + result, err := Parse("mykey") + if err != nil { + t.Fatal(err) + } + assert(t, "key-true", result, map[string]any{"mykey": true}) +} + +func TestArraySyntax(t *testing.T) { + result, err := Parse("a[]=1\na[]=2") + if err != nil { + t.Fatal(err) + } + assert(t, "array", result, map[string]any{"a": []any{"1", "2"}}) +} + +func TestMultilineContinuation(t *testing.T) { + result, err := Parse("a = hello \\\nworld", IniOptions{ + Multiline: &MultilineOptions{}, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "backslash-cont", result, map[string]any{"a": "hello world"}) + + // Multiple continuations. + result, err = Parse("a = one \\\ntwo \\\nthree", IniOptions{ + Multiline: &MultilineOptions{}, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "multi-cont", result, map[string]any{"a": "one two three"}) +} + +func TestMultilineIndent(t *testing.T) { + noBackslash := "" + result, err := Parse("a = hello\n world", IniOptions{ + Multiline: &MultilineOptions{ + Indent: boolPtr(true), + Continuation: &noBackslash, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "indent-cont", result, map[string]any{"a": "hello world"}) +} + +func TestMultilineWithInlineComments(t *testing.T) { + result, err := Parse("a = hello \\\nworld ;comment\nb = 2", IniOptions{ + Multiline: &MultilineOptions{}, + Comment: &CommentOptions{ + Inline: &InlineCommentOptions{Active: boolPtr(true)}, + }, + }) + if err != nil { + t.Fatal(err) + } + assert(t, "multiline-inline", result, map[string]any{ + "a": "hello world", + "b": "2", + }) +} + +func TestQuotedValues(t *testing.T) { + result, err := Parse(`a = "hello world"`) + if err != nil { + t.Fatal(err) + } + assert(t, "double-quoted", result, map[string]any{"a": "hello world"}) + + result, err = Parse("a = 'hello world'") + if err != nil { + t.Fatal(err) + } + // Single-quoted values attempt JSON parse. + assert(t, "single-quoted", result, map[string]any{"a": "hello world"}) +} + +func TestEmptyInput(t *testing.T) { + result, err := Parse("") + if err != nil { + t.Fatal(err) + } + assert(t, "empty", result, map[string]any{}) +} + +func TestBooleanValues(t *testing.T) { + result, err := Parse("a = true\nb = false") + if err != nil { + t.Fatal(err) + } + assert(t, "booleans", result, map[string]any{"a": true, "b": false}) +} + +func TestNullValue(t *testing.T) { + result, err := Parse("a = null") + if err != nil { + t.Fatal(err) + } + assert(t, "null", result, map[string]any{"a": nil}) +} + +func TestMultiplePairs(t *testing.T) { + result, err := Parse("a = 1\nb = x\nc = y y") + if err != nil { + t.Fatal(err) + } + assert(t, "multi-pairs", result, map[string]any{ + "a": "1", + "b": "x", + "c": "y y", + }) +} + +func TestMixedSectionsAndPairs(t *testing.T) { + result, err := Parse("x = 0\n[s]\na = 1\nb = 2") + if err != nil { + t.Fatal(err) + } + assert(t, "mixed", result, map[string]any{ + "x": "0", + "s": map[string]any{"a": "1", "b": "2"}, + }) +} + +func TestUsePlugin(t *testing.T) { + // Verify the plugin interface works directly. + j := MakeJsonic() + result, err := j.Parse("a=1\nb=2") + if err != nil { + t.Fatal(err) + } + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", result) + } + assert(t, "plugin", m, map[string]any{"a": "1", "b": "2"}) +} + +func TestEqualsInValue(t *testing.T) { + result, err := Parse("u = v = 5") + if err != nil { + t.Fatal(err) + } + assert(t, "eq-in-value", result, map[string]any{"u": "v = 5"}) +} diff --git a/go/plugin.go b/go/plugin.go new file mode 100644 index 0000000..77d9eb3 --- /dev/null +++ b/go/plugin.go @@ -0,0 +1,794 @@ +/* Copyright (c) 2021-2025 Richard Rodger, MIT License */ + +package ini + +import ( + "fmt" + "strings" + + jsonic "github.com/jsonicjs/jsonic/go" +) + +// lexMode tracks which kind of token the custom matchers should produce. +// Set by grammar rule callbacks, read by matchers. +type lexMode int + +const ( + modeKey lexMode = iota // Scanning a key (until = or newline) + modeVal // Scanning a value (until newline or comment) + modeDive // Scanning section path part (until ] or .) + modeNone // Don't produce custom tokens +) + +// Ini is a jsonic plugin that adds INI parsing support. +func Ini(j *jsonic.Jsonic, pluginOpts map[string]any) { + opts := mapToResolved(pluginOpts) + + // Closure state for context-dependent lexing. + mode := modeKey + + cfg := j.Config() + + // Disable JSON structure tokens except [ and ]. + delete(cfg.FixedTokens, "{") + delete(cfg.FixedTokens, "}") + delete(cfg.FixedTokens, ":") + cfg.SortFixedTokens() + + // Register custom fixed tokens. + EQ := j.Token("#EQ", "=") + DOT := j.Token("#DOT", ".") + cfg.SortFixedTokens() + + // Register custom token types for INI-specific blocks. + HK := j.Token("#HK") // Hoover Key + HV := j.Token("#HV") // Hoover Value + DK := j.Token("#DK") // Dive Key (section path part) + + // Standard tokens. + ZZ := j.Token("#ZZ") + OS := j.Token("#OS") // [ + CS := j.Token("#CS") // ] + ST := j.Token("#ST") // String + _ = DOT // used by grammar + + // Exclude default jsonic grammar rules. + j.Exclude("jsonic", "imp") + + // Set start rule. + cfg.RuleStart = "ini" + + // Disable text lexing (we handle it with custom matchers). + cfg.TextLex = false + + // Add = to ender chars so the built-in matchers don't consume past it. + if cfg.EnderChars == nil { + cfg.EnderChars = make(map[rune]bool) + } + cfg.EnderChars['='] = true + cfg.EnderChars['['] = true + cfg.EnderChars[']'] = true + cfg.EnderChars['.'] = true + + // ---- Custom Matchers ---- + // All at priority < 2e6 so they run before built-in matchers. + // They must return nil for chars they don't handle (spaces, newlines, + // fixed tokens, etc.) so the built-in matchers can process them. + + // Key matcher: reads a key token (until =, newline, [, ], or EOF). + j.AddMatcher("inikey", 100000, func(lex *jsonic.Lex) *jsonic.Token { + if mode != modeKey { + return nil + } + pnt := lex.Cursor() + src := lex.Src + sI := pnt.SI + if sI >= pnt.Len { + return nil + } + + // Pass through to built-in matchers for non-key chars. + ch := src[sI] + if ch == '=' || ch == '.' || + ch == '#' || ch == ';' || + ch == '"' || ch == '\'' || + ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' { + return nil + } + // Pass [ unless it's part of key[] array syntax. + if ch == '[' { + // Allow [ only if preceded by key chars (array syntax). + // At start of key, [ is a section opener. + return nil + } + if ch == ']' { + return nil + } + + startI := sI + for sI < pnt.Len { + c := src[sI] + if c == '=' || c == '\n' || c == '\r' { + break + } + // Allow [] in key for array syntax. + if c == '[' { + if sI+1 < pnt.Len && src[sI+1] == ']' { + sI += 2 + continue + } + break + } + if c == ']' { + break + } + // Inline comment chars in key position. + if opts.inlineActive && opts.inlineChars[rune(c)] { + break + } + // Escape handling in keys. + if c == '\\' && sI+1 < pnt.Len { + next := src[sI+1] + if next == '.' || next == ']' || next == '[' || next == '\\' { + sI += 2 + continue + } + if opts.inlineActive && opts.escBackslash && opts.inlineChars[rune(next)] { + sI += 2 + continue + } + } + sI++ + } + + if sI == startI { + return nil + } + + raw := src[startI:sI] + val := processKeyEscapes(strings.TrimSpace(raw)) + + tkn := lex.Token("#HK", HK, val, raw) + pnt.SI = sI + pnt.CI += sI - startI + return tkn + }) + + // Value matcher: reads a value token (until newline, comment, or EOF). + // Handles leading whitespace, multiline continuation, and inline comments. + // Also handles the empty-value case (newline/EOF immediately after =). + j.AddMatcher("inival", 100001, func(lex *jsonic.Lex) *jsonic.Token { + if mode != modeVal { + return nil + } + pnt := lex.Cursor() + src := lex.Src + sI := pnt.SI + rI := pnt.RI + cI := pnt.CI + if sI >= pnt.Len { + // EOF: empty value. + return nil + } + + // Skip leading whitespace (since we run before the space matcher). + for sI < pnt.Len && (src[sI] == ' ' || src[sI] == '\t') { + sI++ + cI++ + } + + // Check for immediate newline → empty value. + if sI >= pnt.Len || src[sI] == '\n' || src[sI] == '\r' { + // Consume the newline if present. + if sI < pnt.Len { + if src[sI] == '\r' && sI+1 < pnt.Len && src[sI+1] == '\n' { + sI += 2 + } else { + sI++ + } + rI++ + cI = 1 + } + tkn := lex.Token("#HV", HV, "", src[pnt.SI:sI]) + pnt.SI = sI + pnt.RI = rI + pnt.CI = cI + return tkn + } + + // Check for line-start comment chars → empty value. + ch := src[sI] + if ch == '#' || ch == ';' { + // If inline comments are active, these terminate the value. + // If not, these are line-start comment starters which means + // the value ended at the previous newline (already consumed). + // Either way, produce an empty value and let the comment matcher handle it. + // Don't consume the comment char. + tkn := lex.Token("#HV", HV, "", src[pnt.SI:sI]) + pnt.SI = sI + pnt.RI = rI + pnt.CI = cI + return tkn + } + + // Don't match at quotes (let string matcher handle them). + if ch == '"' || ch == '\'' { + // Advance past the whitespace we skipped. + pnt.SI = sI + pnt.CI = cI + return nil + } + // Don't match at [ or ] (let fixed token matcher handle them). + if ch == '[' || ch == ']' { + pnt.SI = sI + pnt.CI = cI + return nil + } + + startI := pnt.SI // Include leading whitespace in src + var chars []byte + + for sI < pnt.Len { + c := src[sI] + + // Check for inline comment characters. + if opts.inlineActive && opts.inlineChars[rune(c)] { + if opts.escWhitespace { + // Only treat as comment if preceded by whitespace. + if len(chars) > 0 && (chars[len(chars)-1] == ' ' || chars[len(chars)-1] == '\t') { + break + } + // Not preceded by whitespace: treat as literal. + chars = append(chars, c) + sI++ + cI++ + continue + } + break + } + + // Check for backslash continuation before newline. + if opts.multiline && opts.continuation != "" && c == opts.continuation[0] { + if sI+1 < pnt.Len && src[sI+1] == '\n' { + sI += 2 + rI++ + cI = 1 + for sI < pnt.Len && (src[sI] == ' ' || src[sI] == '\t') { + sI++ + cI++ + } + continue + } + if sI+2 < pnt.Len && src[sI+1] == '\r' && src[sI+2] == '\n' { + sI += 3 + rI++ + cI = 1 + for sI < pnt.Len && (src[sI] == ' ' || src[sI] == '\t') { + sI++ + cI++ + } + continue + } + } + + // Check for newline. + if c == '\n' || (c == '\r' && sI+1 < pnt.Len && src[sI+1] == '\n') { + // Indent continuation. + if opts.multiline && opts.indent { + var nextI int + if c == '\r' { + nextI = sI + 2 + } else { + nextI = sI + 1 + } + if nextI < pnt.Len && (src[nextI] == ' ' || src[nextI] == '\t') { + rI++ + cI = 1 + sI = nextI + for sI < pnt.Len && (src[sI] == ' ' || src[sI] == '\t') { + sI++ + cI++ + } + chars = append(chars, ' ') + continue + } + } + + // Normal newline: end value and consume it. + if c == '\r' { + sI += 2 + } else { + sI++ + } + rI++ + cI = 1 + break + } + + // Bare \r without \n. + if c == '\r' { + sI++ + rI++ + cI = 1 + break + } + + // Handle escape sequences. + if c == '\\' && sI+1 < pnt.Len { + next := src[sI+1] + if opts.inlineActive && opts.escBackslash && opts.inlineChars[rune(next)] { + chars = append(chars, next) + sI += 2 + cI += 2 + continue + } + if next == '\\' { + chars = append(chars, '\\') + sI += 2 + cI += 2 + continue + } + } + + chars = append(chars, c) + sI++ + cI++ + } + + valStr := strings.TrimSpace(string(chars)) + val := resolveValue(valStr) + + tkn := lex.Token("#HV", HV, val, src[startI:sI]) + pnt.SI = sI + pnt.RI = rI + pnt.CI = cI + return tkn + }) + + // Dive key matcher: reads section path parts (until ] or .). + j.AddMatcher("inidive", 100002, func(lex *jsonic.Lex) *jsonic.Token { + if mode != modeDive { + return nil + } + pnt := lex.Cursor() + src := lex.Src + sI := pnt.SI + if sI >= pnt.Len { + return nil + } + + // Pass through for fixed tokens and whitespace. + ch := src[sI] + if ch == ']' || ch == '.' || ch == '[' || + ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' { + return nil + } + + startI := sI + for sI < pnt.Len { + c := src[sI] + if c == ']' || c == '.' { + break + } + if c == '\n' || c == '\r' { + break + } + if c == '\\' && sI+1 < pnt.Len { + next := src[sI+1] + if next == ']' || next == '.' || next == '\\' { + sI += 2 + continue + } + } + sI++ + } + + if sI == startI { + return nil + } + + raw := src[startI:sI] + val := processDiveEscapes(strings.TrimSpace(raw)) + + tkn := lex.Token("#DK", DK, val, raw) + pnt.SI = sI + pnt.CI += sI - startI + return tkn + }) + + // ---- Grammar Rules ---- + + var declaredSections map[string]bool + + KEY := []jsonic.Tin{HK, ST} + + // ---- ini rule (start) ---- + j.Rule("ini", func(rs *jsonic.RuleSpec) { + rs.Clear() + + rs.AddBO(func(r *jsonic.Rule, ctx *jsonic.Context) { + r.Node = make(map[string]any) + declaredSections = make(map[string]bool) + mode = modeKey + }) + + rs.Open = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{{OS}}, P: "table", B: 1}, + {S: [][]jsonic.Tin{KEY, {EQ}}, P: "table", B: 2}, + {S: [][]jsonic.Tin{KEY}, P: "table", B: 1}, + {S: [][]jsonic.Tin{{HV}, {OS}}, P: "table", B: 2}, + {S: [][]jsonic.Tin{{ZZ}}}, + } + }) + + // ---- table rule ---- + j.Rule("table", func(rs *jsonic.RuleSpec) { + rs.Clear() + + rs.AddBO(func(r *jsonic.Rule, ctx *jsonic.Context) { + r.Node = r.Parent.Node + mode = modeKey + + if r.Prev != nil && r.Prev != jsonic.NoRule { + if dive, ok := r.Prev.U["dive"].([]string); ok && len(dive) > 0 { + sectionKey := strings.Join(dive, "\x00") + isDuplicate := declaredSections[sectionKey] + + if isDuplicate && opts.dupSection == "error" { + panic(fmt.Sprintf("Duplicate section: [%s]", strings.Join(dive, "."))) + } + + node, _ := r.Node.(map[string]any) + for dI := 0; dI < len(dive); dI++ { + if dI == len(dive)-1 && isDuplicate && opts.dupSection == "override" { + newSection := make(map[string]any) + node[dive[dI]] = newSection + node = newSection + } else { + if existing, ok := node[dive[dI]].(map[string]any); ok { + node = existing + } else { + newSection := make(map[string]any) + node[dive[dI]] = newSection + node = newSection + } + } + } + r.Node = node + declaredSections[sectionKey] = true + } + } + }) + + rs.Open = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{{OS}}, P: "dive", + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + mode = modeDive + }}, + {S: [][]jsonic.Tin{KEY, {EQ}}, P: "map", B: 2}, + {S: [][]jsonic.Tin{KEY}, P: "map", B: 1}, + {S: [][]jsonic.Tin{{HV}, {OS}}, P: "map", B: 2}, + {S: [][]jsonic.Tin{{CS}}, P: "map"}, + {S: [][]jsonic.Tin{{ZZ}}}, + } + + rs.AddBC(func(r *jsonic.Rule, ctx *jsonic.Context) { + if childMap, ok := r.Child.Node.(map[string]any); ok { + if nodeMap, ok := r.Node.(map[string]any); ok { + for k, v := range childMap { + nodeMap[k] = v + } + } + } + }) + + rs.Close = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{{OS}}, R: "table", B: 1, + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + mode = modeKey + }}, + {S: [][]jsonic.Tin{{CS}}, R: "table", + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + if r.Child != nil && r.Child != jsonic.NoRule { + if dive, ok := r.Child.U["dive"].([]string); ok { + r.U["dive"] = dive + } + } + mode = modeKey + }}, + {S: [][]jsonic.Tin{{ZZ}}}, + } + }) + + // ---- dive rule ---- + j.Rule("dive", func(rs *jsonic.RuleSpec) { + rs.Clear() + + rs.Open = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{{DK}, {DOT}}, P: "dive", + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + dive := getDive(r.Parent) + val, _ := r.O0.Val.(string) + dive = append(dive, val) + r.U["dive"] = dive + if r.Parent != nil && r.Parent != jsonic.NoRule { + r.Parent.U["dive"] = dive + } + }}, + {S: [][]jsonic.Tin{{DK}}, + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + dive := getDive(r.Parent) + val, _ := r.O0.Val.(string) + dive = append(dive, val) + r.U["dive"] = dive + if r.Parent != nil && r.Parent != jsonic.NoRule { + r.Parent.U["dive"] = dive + } + }}, + } + + rs.Close = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{{CS}}, B: 1, + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + mode = modeKey + }}, + } + }) + + // ---- map rule ---- + j.Rule("map", func(rs *jsonic.RuleSpec) { + rs.Clear() + + rs.Open = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{KEY, {EQ}}, P: "pair", B: 2, + C: func(r *jsonic.Rule, ctx *jsonic.Context) bool { + return r.Parent != nil && r.Parent.Name == "table" + }}, + {S: [][]jsonic.Tin{KEY}, P: "pair", B: 1, + C: func(r *jsonic.Rule, ctx *jsonic.Context) bool { + return r.Parent != nil && r.Parent.Name == "table" + }}, + } + + rs.Close = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{{OS}}, B: 1}, + {S: [][]jsonic.Tin{{ZZ}}}, + } + }) + + // ---- pair rule ---- + j.Rule("pair", func(rs *jsonic.RuleSpec) { + rs.Clear() + + rs.Open = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{KEY, {EQ}}, P: "val", + C: func(r *jsonic.Rule, ctx *jsonic.Context) bool { + return r.Parent != nil && r.Parent.Parent != nil && + r.Parent.Parent.Name == "table" + }, + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + mode = modeVal + key := tokenString(r.O0) + nodeMap, _ := r.Node.(map[string]any) + if nodeMap == nil { + return + } + + if _, isArr := nodeMap[key].([]any); isArr { + r.U["ini_array"] = nodeMap[key] + } else if len(key) > 2 && strings.HasSuffix(key, "[]") { + arrayKey := key[:len(key)-2] + r.U["key"] = arrayKey + if existing, ok := nodeMap[arrayKey].([]any); ok { + r.U["ini_array"] = existing + } else if _, exists := nodeMap[arrayKey]; exists { + r.U["ini_array"] = []any{nodeMap[arrayKey]} + nodeMap[arrayKey] = r.U["ini_array"] + } else { + arr := make([]any, 0) + nodeMap[arrayKey] = arr + r.U["ini_array"] = arr + } + } else { + r.U["key"] = key + r.U["pair"] = true + } + }}, + + // key by itself means key=true + {S: [][]jsonic.Tin{{HK}}, + C: func(r *jsonic.Rule, ctx *jsonic.Context) bool { + return r.Parent != nil && r.Parent.Parent != nil && + r.Parent.Parent.Name == "table" + }, + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + key := tokenString(r.O0) + if key != "" { + if nodeMap, ok := r.Parent.Node.(map[string]any); ok { + nodeMap[key] = true + } + } + }}, + } + + rs.Close = []*jsonic.AltSpec{ + {S: [][]jsonic.Tin{KEY}, B: 1, R: "pair"}, + {S: [][]jsonic.Tin{{OS}}, B: 1}, + } + + rs.AddAC(func(r *jsonic.Rule, ctx *jsonic.Context) { + mode = modeKey + }) + }) + + // ---- val rule ---- + j.Rule("val", func(rs *jsonic.RuleSpec) { + rs.Clear() + + rs.AddBO(func(r *jsonic.Rule, ctx *jsonic.Context) { + r.Node = jsonic.Undefined + }) + + rs.Open = []*jsonic.AltSpec{ + // Bracket chars at start of value: concat with next value. + {S: [][]jsonic.Tin{{OS}}, R: "val", + U: map[string]any{"ini_prev": true}}, + {S: [][]jsonic.Tin{{CS}}, R: "val", + U: map[string]any{"ini_prev": true}}, + // String value. + {S: [][]jsonic.Tin{{ST}}}, + // Hoover value (unquoted text). + {S: [][]jsonic.Tin{{HV}}}, + // End of input: empty value. + {S: [][]jsonic.Tin{{ZZ}}, + A: func(r *jsonic.Rule, ctx *jsonic.Context) { + r.Node = "" + }}, + } + + rs.AddAC(func(r *jsonic.Rule, ctx *jsonic.Context) { + mode = modeKey + + // Resolve value. + if jsonic.IsUndefined(r.Node) || r.Node == nil { + if r.O0 != nil && !r.O0.IsNoToken() { + r.Node = resolveTokenVal(r.O0) + } else { + r.Node = "" + } + } + + // Handle single-quoted JSON parsing. + if r.O0 != nil && r.O0.Tin == ST && len(r.O0.Src) > 0 && r.O0.Src[0] == '\'' { + if s, ok := r.Node.(string); ok { + r.Node = tryParseJSON(s) + } + } + + // Handle ini_prev concatenation. + if r.Prev != nil && r.Prev != jsonic.NoRule { + if _, ok := r.Prev.U["ini_prev"]; ok { + valStr := fmt.Sprintf("%v", r.Node) + r.Node = r.Prev.O0.Src + valStr + r.Prev.Node = r.Node + return + } + } + + // Handle array push. + if r.Parent != nil && r.Parent != jsonic.NoRule { + if arr, ok := r.Parent.U["ini_array"].([]any); ok { + arr = append(arr, r.Node) + r.Parent.U["ini_array"] = arr + if key, ok := r.Parent.U["key"].(string); ok { + if nodeMap, ok := r.Parent.Node.(map[string]any); ok { + nodeMap[key] = arr + } + } + return + } + } + + // Normal pair assignment. + if r.Parent != nil && r.Parent != jsonic.NoRule { + if key, ok := r.Parent.U["key"].(string); ok { + if _, isPair := r.Parent.U["pair"]; isPair { + if nodeMap, ok := r.Parent.Node.(map[string]any); ok { + nodeMap[key] = r.Node + } + } + } + } + }) + }) +} + +func getDive(r *jsonic.Rule) []string { + if r == nil || r == jsonic.NoRule { + return nil + } + if dive, ok := r.U["dive"].([]string); ok { + return dive + } + return nil +} + +func tokenString(t *jsonic.Token) string { + if t == nil || t.IsNoToken() { + return "" + } + if s, ok := t.Val.(string); ok { + return s + } + return t.Src +} + +func resolveTokenVal(t *jsonic.Token) any { + if !jsonic.IsUndefined(t.Val) { + return t.Val + } + return t.Src +} + +func processKeyEscapes(s string) string { + if !strings.ContainsRune(s, '\\') { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + next := s[i+1] + if next == '.' || next == ']' || next == '[' || next == '\\' { + b.WriteByte(next) + i++ + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} + +func processDiveEscapes(s string) string { + if !strings.ContainsRune(s, '\\') { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + next := s[i+1] + if next == ']' || next == '.' || next == '\\' { + b.WriteByte(next) + i++ + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} + +func tryParseJSON(s string) any { + trimmed := strings.TrimSpace(s) + switch trimmed { + case "true": + return true + case "false": + return false + case "null": + return nil + } + return s +} + +func resolveValue(s string) any { + switch s { + case "true": + return true + case "false": + return false + case "null": + return nil + } + return s +}