// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package interpres import ( "errors" "math" "strings" "testing" "time" ) func TestParseScalars(t *testing.T) { tree, err := ParseMap([]byte(` title = "interpres" count = 42 ratio = 3.14 enabled = true disabled = false hexv = 0xFF octv = 0o755 binv = 0b1010 grouped = 1_000_000 neg = -17 expv = 1e3 `)) if err != nil { t.Fatalf("parse: %v", err) } cases := map[string]any{ "title": "interpres", "count": int64(42), "ratio": 3.14, "enabled": true, "disabled": false, "hexv": int64(255), "octv": int64(493), "binv": int64(10), "grouped": int64(1000000), "neg": int64(-17), "expv": 1000.0, } for k, want := range cases { if got := tree[k]; got != want { t.Errorf("%s = %#v (%T), want %#v (%T)", k, got, got, want, want) } } } func TestParseInfNan(t *testing.T) { tree, err := ParseMap([]byte("pos = inf\nneg = -inf\nbad = nan\n")) if err != nil { t.Fatalf("parse: %v", err) } if v := tree["pos"].(float64); !math.IsInf(v, 1) { t.Errorf("pos = %v, want +Inf", v) } if v := tree["neg"].(float64); !math.IsInf(v, -1) { t.Errorf("neg = %v, want -Inf", v) } if v := tree["bad"].(float64); !math.IsNaN(v) { t.Errorf("bad = %v, want NaN", v) } } func TestParseStrings(t *testing.T) { tree, err := ParseMap([]byte(` basic = "a\tb\nc" literal = 'C:\path\no\escape' quote = "say \"hi\"" unicode = "\u00e9" `)) if err != nil { t.Fatalf("parse: %v", err) } if tree["basic"] != "a\tb\nc" { t.Errorf("basic = %q", tree["basic"]) } if tree["literal"] != `C:\path\no\escape` { t.Errorf("literal = %q", tree["literal"]) } if tree["quote"] != `say "hi"` { t.Errorf("quote = %q", tree["quote"]) } if tree["unicode"] != "é" { t.Errorf("unicode = %q", tree["unicode"]) } } func TestParseMultilineString(t *testing.T) { tree, err := ParseMap([]byte("text = \"\"\"\nfirst\nsecond\"\"\"\n")) if err != nil { t.Fatalf("parse: %v", err) } if tree["text"] != "first\nsecond" { t.Errorf("text = %q, want %q", tree["text"], "first\nsecond") } } func TestParseMultilineLineEndingBackslash(t *testing.T) { tree, err := ParseMap([]byte("text = \"\"\"\\\n one \\\n two\"\"\"\n")) if err != nil { t.Fatalf("parse: %v", err) } if tree["text"] != "one two" { t.Errorf("text = %q, want %q", tree["text"], "one two") } } func TestParseTablesAndDottedKeys(t *testing.T) { tree, err := ParseMap([]byte(` owner.name = "Petr" [server] host = "localhost" port = 9090 [server.tls] enabled = true `)) if err != nil { t.Fatalf("parse: %v", err) } server := tree["server"].(map[string]any) if server["host"] != "localhost" || server["port"] != int64(9090) { t.Errorf("server = %#v", server) } tls := server["tls"].(map[string]any) if tls["enabled"] != true { t.Errorf("tls = %#v", tls) } owner := tree["owner"].(map[string]any) if owner["name"] != "Petr" { t.Errorf("owner = %#v", owner) } } func TestParseArrayOfTables(t *testing.T) { tree, err := ParseMap([]byte(` [[forms]] name = "contact" [[forms]] name = "feedback" `)) if err != nil { t.Fatalf("parse: %v", err) } forms := tree["forms"].([]map[string]any) if len(forms) != 2 { t.Fatalf("len(forms) = %d, want 2", len(forms)) } if forms[0]["name"] != "contact" || forms[1]["name"] != "feedback" { t.Errorf("forms = %#v", forms) } } func TestParseArraysAndInlineTables(t *testing.T) { tree, err := ParseMap([]byte(` ports = [80, 443] mixed = [ "a", "b", ] point = { x = 1, y = 2 } `)) if err != nil { t.Fatalf("parse: %v", err) } ports := tree["ports"].([]any) if len(ports) != 2 || ports[0] != int64(80) || ports[1] != int64(443) { t.Errorf("ports = %#v", ports) } mixed := tree["mixed"].([]any) if len(mixed) != 2 || mixed[0] != "a" || mixed[1] != "b" { t.Errorf("mixed = %#v", mixed) } point := tree["point"].(map[string]any) if point["x"] != int64(1) || point["y"] != int64(2) { t.Errorf("point = %#v", point) } } func TestParseDateTime(t *testing.T) { tree, err := ParseMap([]byte(` offset = 1979-05-27T07:32:00Z local = 1979-05-27T07:32:00 day = 1979-05-27 clock = 07:32:00 `)) if err != nil { t.Fatalf("parse: %v", err) } if off, ok := tree["offset"].(OffsetDateTime); !ok || off.Year() != 1979 || off.Hour() != 7 { t.Errorf("offset = %#v (%T)", tree["offset"], tree["offset"]) } if ldt, ok := tree["local"].(LocalDateTime); !ok || ldt.Year() != 1979 || ldt.Hour() != 7 { t.Errorf("local = %#v (%T)", tree["local"], tree["local"]) } if d, ok := tree["day"].(LocalDate); !ok || d.Month() != time.May || d.Day() != 27 { t.Errorf("day = %#v (%T)", tree["day"], tree["day"]) } if clk, ok := tree["clock"].(LocalTime); !ok || clk.Hour() != 7 || clk.Minute() != 32 { t.Errorf("clock = %#v (%T)", tree["clock"], tree["clock"]) } } func TestDateTimeFormats(t *testing.T) { tree, err := ParseMap([]byte("a = 1987-07-05 17:45:00Z\nb = 1987-07-05t17:45:00z\nc = 1977-12-21T10:32:00.555\n")) if err != nil { t.Fatalf("parse: %v", err) } if _, ok := tree["a"].(OffsetDateTime); !ok { t.Errorf("a is %T, want OffsetDateTime", tree["a"]) } if _, ok := tree["b"].(OffsetDateTime); !ok { t.Errorf("b is %T, want OffsetDateTime", tree["b"]) } if _, ok := tree["c"].(LocalDateTime); !ok { t.Errorf("c is %T, want LocalDateTime", tree["c"]) } } func TestUnmarshalDateTime(t *testing.T) { type Doc struct { Created time.Time `toml:"created"` Day LocalDate `toml:"day"` } var d Doc if err := Unmarshal([]byte("created = 2026-06-20T10:00:00Z\nday = 2026-06-20\n"), &d); err != nil { t.Fatalf("unmarshal: %v", err) } if d.Created.Year() != 2026 || d.Created.Hour() != 10 { t.Errorf("Created = %v", d.Created) } if d.Day.Year() != 2026 || d.Day.Month() != time.June || d.Day.Day() != 20 { t.Errorf("Day = %v", d.Day) } } func TestUnmarshalStruct(t *testing.T) { type SMTP struct { Host string `toml:"host"` Port int `toml:"port"` } type Form struct { Name string `toml:"name"` SMTP SMTP `toml:"smtp"` Origins []string `toml:"allowed_origins"` } type Config struct { Port int `toml:"port"` Forms []Form `toml:"forms"` } data := []byte(` port = 8080 [[forms]] name = "contact" allowed_origins = ["https://example.com"] [forms.smtp] host = "smtp.example.com" port = 587 `) var cfg Config if err := Unmarshal(data, &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if cfg.Port != 8080 { t.Errorf("Port = %d", cfg.Port) } if len(cfg.Forms) != 1 { t.Fatalf("len(Forms) = %d", len(cfg.Forms)) } f := cfg.Forms[0] if f.Name != "contact" || f.SMTP.Host != "smtp.example.com" || f.SMTP.Port != 587 { t.Errorf("form = %#v", f) } if len(f.Origins) != 1 || f.Origins[0] != "https://example.com" { t.Errorf("origins = %#v", f.Origins) } } func TestUnmarshalCaseInsensitiveAndUntagged(t *testing.T) { type Config struct { Title string Count int } var cfg Config if err := Unmarshal([]byte("title = \"x\"\ncount = 3\n"), &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } if cfg.Title != "x" || cfg.Count != 3 { t.Errorf("cfg = %#v", cfg) } } func TestDisallowUnknownFields(t *testing.T) { type C struct { Known string `toml:"known"` } data := []byte("known = \"x\"\nbogus = 1\n") var lenient C if err := Unmarshal(data, &lenient); err != nil { t.Fatalf("lenient unmarshal: %v", err) } var strict C err := NewDecoder().DisallowUnknownFields().Decode(data, &strict) if err == nil { t.Fatal("expected error for unknown field, got nil") } } func TestDisallowUnknownFieldsReportsSmallestKey(t *testing.T) { // Map iteration order is random, so the reported key must be chosen // deterministically: the smallest unknown key, whichever order the map // iterates in. type C struct { Known string `toml:"known"` } data := []byte("known = \"x\"\nzeta = 1\nalpha = 2\nmu = 3\n") for range 20 { var c C err := NewDecoder().DisallowUnknownFields().Decode(data, &c) if err == nil { t.Fatal("expected error for unknown fields") } if !strings.Contains(err.Error(), `unknown field "alpha"`) { t.Fatalf("err = %v, want the smallest unknown key alpha", err) } } } func TestSkippedFieldTag(t *testing.T) { type C struct { Keep string `toml:"keep"` Skip string `toml:"-"` } var c C if err := Unmarshal([]byte("keep = \"y\"\n"), &c); err != nil { t.Fatalf("unmarshal: %v", err) } if c.Keep != "y" || c.Skip != "" { t.Errorf("c = %#v", c) } } func TestSyntaxErrorReportsLine(t *testing.T) { _, err := ParseMap([]byte("a = 1\nb = \nc = 3\n")) if err == nil { t.Fatal("expected a syntax error") } se, ok := err.(*SyntaxError) if !ok { t.Fatalf("error is %T, want *SyntaxError", err) } if se.Line != 2 { t.Errorf("Line = %d, want 2", se.Line) } } func TestComments(t *testing.T) { tree, err := ParseMap([]byte(` # a leading comment key = "value" # trailing comment # another `)) if err != nil { t.Fatalf("parse: %v", err) } if tree["key"] != "value" { t.Errorf("key = %q", tree["key"]) } } func TestDuplicateKeyRejected(t *testing.T) { _, err := ParseMap([]byte("a = 1\na = 2\n")) if err == nil { t.Fatal("expected duplicate key error") } } func TestRejectsInvalidNumbers(t *testing.T) { for _, tok := range []string{ "01", "-01", "00", "1__0", "_1", "1_", "0x_1", "1_.0", "1.", ".5", "1.2.3", "1.e2", "1e", "1e+", "1e-", "0.0E", "0.0e", "1.5e+", "0x", "0o", "0b", "0b2", "0o8", "0xG", "+0x1", } { if _, err := ParseMap([]byte("v = " + tok + "\n")); err == nil { t.Errorf("%q: expected an error, got none", tok) } } } func TestParseRejectsOffsetOutOfRange(t *testing.T) { for _, tok := range []string{ "1979-05-27T07:32:00+00:60", "1979-05-27T07:32:00-00:99", "1979-05-27T07:32:00+24:00", "1979-05-27T07:32:00+99:99", } { if _, err := ParseMap([]byte("v = " + tok + "\n")); err == nil { t.Errorf("%q: expected an error, got none", tok) } } } func TestParseAcceptsOffsetBounds(t *testing.T) { tree, err := ParseMap([]byte("a = 1979-05-27T07:32:00+23:59\nb = 1979-05-27T07:32:00-23:59\n")) if err != nil { t.Fatalf("parse: %v", err) } a := tree["a"].(OffsetDateTime) if _, offset := a.Zone(); offset != 23*3600+59*60 { t.Fatalf("a offset = %d, want %d", offset, 23*3600+59*60) } b := tree["b"].(OffsetDateTime) if _, offset := b.Zone(); offset != -(23*3600 + 59*60) { t.Fatalf("b offset = %d", offset) } } func TestAcceptsNumberEdgeCases(t *testing.T) { cases := map[string]any{ "0": int64(0), "-0": int64(0), "+99": int64(99), "1_000": int64(1000), "0xDEAD_BEEF": int64(0xDEADBEEF), "0o755": int64(493), "0b1010": int64(10), "0.0": 0.0, "3.14": 3.14, "6.022e23": 6.022e23, "1e10": 1e10, "1e0": 1.0, "1e06": 1e6, "0e00": 0.0, "2E-3": 2e-3, "-2.5E-3": -2.5e-3, } for tok, want := range cases { tree, err := ParseMap([]byte("v = " + tok + "\n")) if err != nil { t.Errorf("%q: %v", tok, err) continue } if got := tree["v"]; got != want { t.Errorf("%q = %#v (%T), want %#v", tok, got, got, want) } } } func TestRejectsTableRedefinition(t *testing.T) { _, err := ParseMap([]byte("[a]\nx = 1\n\n[a]\ny = 2\n")) if err == nil { t.Fatal("expected a table-redefinition error") } } func TestAllowsImplicitThenExplicitTable(t *testing.T) { tree, err := ParseMap([]byte("[a.b]\nx = 1\n\n[a]\ny = 2\n")) if err != nil { t.Fatalf("parse: %v", err) } a := tree["a"].(map[string]any) if a["y"] != int64(2) { t.Errorf("a.y = %#v", a["y"]) } if b := a["b"].(map[string]any); b["x"] != int64(1) { t.Errorf("a.b.x = %#v", b["x"]) } } func TestRejectsControlCharInString(t *testing.T) { if _, err := ParseMap([]byte("v = \"a\x01b\"\n")); err == nil { t.Fatal("expected a control-character error") } } func TestAllowsEscapedControlChar(t *testing.T) { tree, err := ParseMap([]byte(`v = "\u0000"`)) if err != nil { t.Fatalf("parse: %v", err) } if tree["v"] != "\x00" { t.Errorf("v = %q", tree["v"]) } } func TestMultilineQuotesAtDelimiter(t *testing.T) { tree, err := ParseMap([]byte("a = '''''two quotes'''''\n")) if err != nil { t.Fatalf("parse: %v", err) } if tree["a"] != "''two quotes''" { t.Errorf("a = %q", tree["a"]) } } func TestRejectsInlineTableExtension(t *testing.T) { cases := map[string]string{ "by header": "a = { b = 1 }\n[a.c]\nx = 2\n", "by dotted key": "a = { b = 1 }\na.c = 2\n", "header over it": "a = { b = 1 }\n[a]\nx = 2\n", // The frozen check must cover the intermediate steps of an array-of-tables // header, not only the leaf: [[a.b.c]] walks through a and a.b. "by nested array header": "a = { b = {} }\n[[a.b.c]]\nx = 2\n", } for name, doc := range cases { if _, err := ParseMap([]byte(doc)); err == nil { t.Errorf("%s: expected an inline-table extension error", name) } } } // A new element of an array of tables starts a fresh scope: sub-table headers, // nested arrays of tables, and dotted-key paths recorded for the previous // element must not block the same paths in the next one. func TestArrayOfTablesFreshScopePerElement(t *testing.T) { cases := map[string]string{ "nested array of tables": "[[a]]\n[[a.b]]\nx = 1\n[[a]]\n[a.b]\ny = 2\n", "dotted key": "[[a]]\nb.c = 1\n[[a]]\n[a.b]\nd = 2\n", } for name, doc := range cases { tree, err := ParseMap([]byte(doc)) if err != nil { t.Errorf("%s: %v", name, err) continue } elements := tree["a"].([]map[string]any) if len(elements) != 2 { t.Errorf("%s: len(a) = %d, want 2", name, len(elements)) } } // Within one element the redefinition rules keep applying. for name, doc := range map[string]string{ "header over dotted in one element": "[[a]]\nb.c = 1\n[a.b]\nd = 2\n", "table over nested array": "[[a]]\n[[a.b]]\n[a.b]\nx = 1\n", } { if _, err := ParseMap([]byte(doc)); err == nil { t.Errorf("%s: expected an error, got none", name) } } } func TestRejectsSpecInvalid(t *testing.T) { cases := map[string]string{ "single-digit hour": "a = 2023-10-01T1:32:00Z\n", "inline duplicate key": "a = { b = 1, b = 2 }\n", "inline dotted overwrite": "a = { b = 1, b.c = 2 }\n", "dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n", "table over array": "[[t]]\n[t]\n", "truncated datetime": "a = 2026-01-02T\n", // "datetime no seconds" moved to the acceptance tests: TOML 1.1 // makes the seconds optional. } for name, doc := range cases { if _, err := ParseMap([]byte(doc)); err == nil { t.Errorf("%s: expected an error", name) } } } func TestArrayOfTablesPerElementSubtable(t *testing.T) { tree, err := ParseMap([]byte(` [[forms]] name = "a" [forms.smtp] host = "h1" [[forms]] name = "b" [forms.smtp] host = "h2" `)) if err != nil { t.Fatalf("parse: %v", err) } forms := tree["forms"].([]map[string]any) if len(forms) != 2 { t.Fatalf("len(forms) = %d", len(forms)) } if h := forms[0]["smtp"].(map[string]any)["host"]; h != "h1" { t.Errorf("forms[0].smtp.host = %v", h) } if h := forms[1]["smtp"].(map[string]any)["host"]; h != "h2" { t.Errorf("forms[1].smtp.host = %v", h) } } // --- TOML 1.1 -------------------------------------------------------------- func TestParseAcceptsNoSecondsDatetimes(t *testing.T) { tree, err := ParseMap([]byte(`t = 13:37 dt = 1979-05-27T07:32 odt1 = 1979-05-27 07:32Z odt2 = 1979-05-27 07:32-07:00 `)) if err != nil { t.Fatalf("parse: %v", err) } // A value written without seconds comes back without them: the seconds are // only written when the value carries them. if got := tree["t"].(LocalTime).String(); got != "13:37" { t.Errorf("t = %q, want %q", got, "13:37") } if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" { t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32") } if got := tree["odt1"].(OffsetDateTime).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" { t.Errorf("odt1 = %q", got) } if got := tree["odt2"].(OffsetDateTime).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00-07:00" { t.Errorf("odt2 = %q", got) } // The fraction still requires the seconds it belongs to. if _, err := ParseMap([]byte("a = 07:32.5\n")); err == nil { t.Error("07:32.5: expected an error, got none") } } func TestParseAcceptsEscapeAndHexEscapes(t *testing.T) { tree, err := ParseMap([]byte(`esc = "\e" hex = "\x20\x7f\xf8" nul = "\x00" multi = """\x68\x65""" lit = '\x20' `)) if err != nil { t.Fatalf("parse: %v", err) } if got := tree["esc"].(string); got != "\x1b" { t.Errorf("esc = %q, want the escape character", got) } if got := tree["hex"].(string); got != " \x7f\u00f8" { t.Errorf("hex = %q", got) } if got := tree["nul"].(string); got != "\x00" { t.Errorf("nul = %q", got) } if got := tree["multi"].(string); got != "he" { t.Errorf("multi = %q", got) } // A literal string carries the sequence verbatim. if got := tree["lit"].(string); got != `\x20` { t.Errorf("lit = %q, want the verbatim sequence", got) } // Two digits exactly; a short or non-hex escape is an error. for _, doc := range []string{`a = "\x4"`, `a = "\x"`, `a = "\xgg"`} { if _, err := ParseMap([]byte(doc)); err == nil { t.Errorf("%s: expected an error, got none", doc) } } } func TestParseAcceptsMultilineInlineTables(t *testing.T) { tree, err := ParseMap([]byte("tbl = {\n\thello = \"world\",\n\tarr = [1,\n\t\t2,\n\t],\n\tsub = {\n\t\tk = 1,\n\t},\n\tbare = 2}\n")) if err != nil { t.Fatalf("parse: %v", err) } tbl := tree["tbl"].(map[string]any) if tbl["hello"] != "world" || tbl["bare"] != int64(2) { t.Fatalf("tbl = %#v", tbl) } if arr := tbl["arr"].([]any); len(arr) != 2 { t.Errorf("arr = %#v", tbl["arr"]) } if sub := tbl["sub"].(map[string]any); sub["k"] != int64(1) { t.Errorf("sub = %#v", tbl["sub"]) } // Comments inside the table, and a trailing comma at both depths. tree, err = ParseMap([]byte("m = { # one\n\t# two\n\ta = 1, # three\n\t# four\n}\n")) if err != nil { t.Fatalf("parse with comments: %v", err) } if m := tree["m"].(map[string]any); m["a"] != int64(1) { t.Errorf("m = %#v", m) } // The old single-line shapes keep working, with and without the comma. if _, err := ParseMap([]byte("a = { b = 1, c = 2 }\n")); err != nil { t.Errorf("single line: %v", err) } // Still rejected: two commas, a missing value, and an unclosed table. for name, doc := range map[string]string{ "double comma": "a = { b = 1,, c = 2 }\n", "missing value": "a = {\n\tb =\n}\n", "unterminated": "a = { b = 1,\n", } { if _, err := ParseMap([]byte(doc)); err == nil { t.Errorf("%s: expected an error, got none", name) } } } func TestParseNestingLimit(t *testing.T) { // The parser is a recursive descent, so a document that nests without bound // is rejected instead of exhausting the stack. deep := func(n int) []byte { return []byte("v = " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\n") } if _, err := ParseMap(deep(100)); err != nil { t.Fatalf("a document well inside the limit: %v", err) } _, err := ParseMap(deep(maxNestingDepth + 1)) if err == nil { t.Fatal("expected a nesting error") } var se *SyntaxError if !errors.As(err, &se) { t.Fatalf("expected a *SyntaxError, got %T: %v", err, err) } if !strings.Contains(se.Msg, "nesting") { t.Errorf("Msg = %q, want it to name the nesting limit", se.Msg) } }