feat: TOML 1.0 parser and encoder
Assisted-by: GLM 5.3 Flash
This commit is contained in:
+496
@@ -0,0 +1,496 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSyntaxErrorMessage(t *testing.T) {
|
||||
err := &SyntaxError{Line: 7, Msg: "expected '=' after key"}
|
||||
want := "interpres: line 7: expected '=' after key"
|
||||
if got := err.Error(); got != want {
|
||||
t.Errorf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsInvalidUTF8(t *testing.T) {
|
||||
_, err := Parse([]byte("v = \"\xff\"\n"))
|
||||
if err == nil {
|
||||
t.Fatal("expected a UTF-8 validation error")
|
||||
}
|
||||
se, ok := err.(*SyntaxError)
|
||||
if !ok {
|
||||
t.Fatalf("err is %T, want *SyntaxError", err)
|
||||
}
|
||||
if !strings.Contains(se.Msg, "UTF-8") {
|
||||
t.Errorf("Msg = %q, want it to mention UTF-8", se.Msg)
|
||||
}
|
||||
if se.Line != 1 {
|
||||
t.Errorf("Line = %d, want 1", se.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoMap(t *testing.T) {
|
||||
var m map[string]any
|
||||
if err := Unmarshal([]byte(`name = "x"
|
||||
count = 3
|
||||
`), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if m["name"] != "x" {
|
||||
t.Errorf("name = %#v", m["name"])
|
||||
}
|
||||
if m["count"] != int64(3) {
|
||||
t.Errorf("count = %#v (%T)", m["count"], m["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoMapNested(t *testing.T) {
|
||||
var m map[string]any
|
||||
if err := Unmarshal([]byte("[a]\nb = 2\n"), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
a, ok := m["a"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("a = %T, want map[string]any", m["a"])
|
||||
}
|
||||
if a["b"] != int64(2) {
|
||||
t.Errorf("a.b = %#v", a["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalPrefilledMap(t *testing.T) {
|
||||
m := map[string]any{"keep": "yes"}
|
||||
if err := Unmarshal([]byte(`name = "x"`), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if m["keep"] != "yes" {
|
||||
t.Errorf("keep = %#v", m["keep"])
|
||||
}
|
||||
if m["name"] != "x" {
|
||||
t.Errorf("name = %#v", m["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalTableToNonStructOrMap(t *testing.T) {
|
||||
var s string
|
||||
if err := Unmarshal([]byte("v = 1\n"), &s); err == nil {
|
||||
t.Fatal("expected an error for table-to-scalar")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoAny(t *testing.T) {
|
||||
// A non-nil any destination must accept the parsed tree.
|
||||
var x any
|
||||
if err := Unmarshal([]byte("[a]\nb = 2\n"), &x); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
tree, ok := x.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("x is %T, want map[string]any", x)
|
||||
}
|
||||
a, ok := tree["a"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("a is %T, want map[string]any", tree["a"])
|
||||
}
|
||||
if a["b"] != int64(2) {
|
||||
t.Errorf("a.b = %#v", a["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoNilAny(t *testing.T) {
|
||||
// A nil any target must still receive the parsed tree without
|
||||
// panicking.
|
||||
var x any
|
||||
if err := Unmarshal([]byte("v = 1\n"), &x); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if x == nil {
|
||||
t.Fatal("x is still nil after Unmarshal")
|
||||
}
|
||||
tree, ok := x.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("x is %T, want map[string]any", x)
|
||||
}
|
||||
if tree["v"] != int64(1) {
|
||||
t.Errorf("v = %#v", tree["v"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContextHonoursCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := ParseContext(ctx, []byte("a = 1\n")); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ParseContext returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalContextHonoursCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var cfg map[string]any
|
||||
if err := UnmarshalContext(ctx, []byte("a = 1\n"), &cfg); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("UnmarshalContext returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoderDecodeContextHonoursCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var cfg map[string]any
|
||||
err := NewDecoder().DecodeContext(ctx, []byte("a = 1\n"), &cfg)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("DecodeContext returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextRoundTrip(t *testing.T) {
|
||||
// The *Context variants with a Background context must produce the same
|
||||
// result as the non-context variants for ordinary inputs.
|
||||
in := []byte(`title = "x"
|
||||
count = 3
|
||||
`)
|
||||
if _, err := ParseContext(context.Background(), in); err != nil {
|
||||
t.Fatalf("ParseContext: %v", err)
|
||||
}
|
||||
var out struct {
|
||||
Title string `toml:"title"`
|
||||
Count int `toml:"count"`
|
||||
}
|
||||
if err := UnmarshalContext(context.Background(), in, &out); err != nil {
|
||||
t.Fatalf("UnmarshalContext: %v", err)
|
||||
}
|
||||
if out.Title != "x" || out.Count != 3 {
|
||||
t.Errorf("out = %#v", out)
|
||||
}
|
||||
if err := NewDecoder().DecodeContext(context.Background(), in, &map[string]any{}); err != nil {
|
||||
t.Fatalf("Decoder.DecodeContext: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToUintOverflow(t *testing.T) {
|
||||
type C struct {
|
||||
X uint8 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("x = 300\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected overflow error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overflow") {
|
||||
t.Errorf("err = %v, want substring 'overflow'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToUint16Boundary(t *testing.T) {
|
||||
type C struct {
|
||||
X uint16 `toml:"x"`
|
||||
}
|
||||
// Exactly 65535 fits, 65536 does not.
|
||||
var ok C
|
||||
if err := Unmarshal([]byte("x = 65535\n"), &ok); err != nil {
|
||||
t.Fatalf("65535 should fit uint16, got %v", err)
|
||||
}
|
||||
if ok.X != 65535 {
|
||||
t.Errorf("X = %d, want 65535", ok.X)
|
||||
}
|
||||
|
||||
var bad C
|
||||
if err := Unmarshal([]byte("x = 65536\n"), &bad); err == nil {
|
||||
t.Fatal("65536 must not fit uint16")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToUint64FitsMaxInt64(t *testing.T) {
|
||||
type C struct {
|
||||
X uint64 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
tok := "x = 9223372036854775807\n" // math.MaxInt64
|
||||
if err := Unmarshal([]byte(tok), &c); err != nil {
|
||||
t.Fatalf("MaxInt64 should fit uint64, got %v", err)
|
||||
}
|
||||
if c.X != math.MaxInt64 {
|
||||
t.Errorf("X = %d, want %d", uint64(c.X), uint64(math.MaxInt64))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalNegativeIntToUint(t *testing.T) {
|
||||
type C struct {
|
||||
X uint8 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("x = -1\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected negative-to-uint error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "negative") {
|
||||
t.Errorf("err = %v, want substring 'negative'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToFloat(t *testing.T) {
|
||||
type C struct {
|
||||
X float64 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = 5\n"), &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if c.X != 5.0 {
|
||||
t.Errorf("X = %v, want 5.0", c.X)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToStringFails(t *testing.T) {
|
||||
type C struct {
|
||||
X string `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte(`x = 5`), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalStringToIntFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte(`x = "hello"`), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalArrayToScalarFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = [1, 2]\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalArrayOfTablesToScalarFails(t *testing.T) {
|
||||
type Item struct {
|
||||
Name string `toml:"name"`
|
||||
}
|
||||
type C struct {
|
||||
X Item `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("[[x]]\nname = \"a\"\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalFloatToIntFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = 1.5\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalBoolToIntFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = true\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalNonPointerRejected(t *testing.T) {
|
||||
var v int
|
||||
if err := Unmarshal([]byte("x = 1\n"), v); err == nil {
|
||||
t.Fatal("expected error for non-pointer target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignErrorWrapped(t *testing.T) {
|
||||
// assignStruct wraps inner assignment errors with the key name.
|
||||
type C struct {
|
||||
Inner struct {
|
||||
X int `toml:"x"`
|
||||
} `toml:"inner"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("[inner]\nx = \"oops\"\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an assignment error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "x") {
|
||||
t.Errorf("err = %v, want it to mention key x", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignMapErrorWrapped(t *testing.T) {
|
||||
// assignMap wraps inner errors with the key of the bad element.
|
||||
m := map[string]int{}
|
||||
err := Unmarshal([]byte("[s]\nx = 1\n"), &m)
|
||||
if err == nil {
|
||||
t.Fatal("expected an assignment error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "s") {
|
||||
t.Errorf("err = %v, want it to mention key 's'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignSliceErrorWrapped(t *testing.T) {
|
||||
// assignSlice wraps inner errors with the bad element's index.
|
||||
type C struct {
|
||||
Items []int `toml:"items"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte(`items = [1, "oops"]`), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an assignment error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[1]") {
|
||||
t.Errorf("err = %v, want it to mention index [1]", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignTimeToWrongTypeFails(t *testing.T) {
|
||||
// assign maps time.Time to time.Time only.
|
||||
type C struct {
|
||||
T string `toml:"t"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("t = 2026-01-01T00:00:00Z\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected time-to-string assignment to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalerUsesCustomEncoding(t *testing.T) {
|
||||
// A type that implements Marshaler can be encoded through a wrapping struct.
|
||||
val := inlineMarshaler(func() (any, error) {
|
||||
return map[string]any{"k": "v"}, nil
|
||||
})
|
||||
type wrap struct {
|
||||
Inner inlineMarshaler `toml:"inner"`
|
||||
}
|
||||
out, err := Marshal(wrap{Inner: val})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
if !strings.Contains(got, "[inner]") || !strings.Contains(got, "k = \"v\"") {
|
||||
t.Errorf("out = %q, want a [inner] table with k = \"v\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
type inlineMarshaler func() (any, error)
|
||||
|
||||
func (i inlineMarshaler) MarshalTOML() (any, error) { return i() }
|
||||
|
||||
// --- Unmarshaler -----------------------------------------------------------
|
||||
|
||||
// receiverSetter implements *Unmarshaler by reshaping a parsed table.
|
||||
type receiverSetter struct {
|
||||
Field string
|
||||
Received any
|
||||
}
|
||||
|
||||
func (r *receiverSetter) UnmarshalTOML(data any) error {
|
||||
m, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("interpres: receiverSetter expects table, got %T", data)
|
||||
}
|
||||
if v, ok := m["field"].(string); ok {
|
||||
r.Field = v
|
||||
}
|
||||
r.Received = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUnmarshalerByPointer(t *testing.T) {
|
||||
type Cfg struct {
|
||||
R receiverSetter `toml:"r"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`[r]
|
||||
field = "x"
|
||||
`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.R.Field != "x" {
|
||||
t.Errorf("Field = %q, want \"x\"", cfg.R.Field)
|
||||
}
|
||||
if cfg.R.Received == nil {
|
||||
t.Error("Receiver did not see parsed data")
|
||||
}
|
||||
}
|
||||
|
||||
type scalarUnmarshaler struct{ val string }
|
||||
|
||||
func (s *scalarUnmarshaler) UnmarshalTOML(data any) error {
|
||||
str, ok := data.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("interpres: scalarUnmarshaler expects string, got %T", data)
|
||||
}
|
||||
s.val = str
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUnmarshalerReceivesRawScalar(t *testing.T) {
|
||||
// Place the Unmarshaler-implementing field inside a wrapper struct so
|
||||
// the decoder dispatches the scalar value to its UnmarshalTOML.
|
||||
type Cfg struct {
|
||||
S scalarUnmarshaler `toml:"s"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`s = "hello"`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.S.val != "hello" {
|
||||
t.Errorf("cfg.S.val = %q, want \"hello\"", cfg.S.val)
|
||||
}
|
||||
}
|
||||
|
||||
type failingUnmarshaler struct{}
|
||||
|
||||
func (f *failingUnmarshaler) UnmarshalTOML(_ any) error { return errors.New("boom") }
|
||||
|
||||
func TestUnmarshalerErrorPropagates(t *testing.T) {
|
||||
type Cfg struct {
|
||||
F failingUnmarshaler `toml:"f"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte("f = 1"), &cfg); err == nil {
|
||||
t.Fatal("expected error from UnmarshalTOML")
|
||||
} else if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("err = %v, want substring \"boom\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalerTakesPrecedenceOverDefault(t *testing.T) {
|
||||
// Even when the field is a scalar type and the value is a table,
|
||||
// UnmarshalTOML wins, because the receiver decides.
|
||||
type Cfg struct {
|
||||
R receiverSetter `toml:"r"`
|
||||
}
|
||||
var cfg Cfg
|
||||
in := []byte(`[r]
|
||||
field = "y"
|
||||
`)
|
||||
if err := Unmarshal(in, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.R.Field != "y" {
|
||||
t.Errorf("Field = %q, want \"y\"", cfg.R.Field)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user