perf(decode): parse struct destinations without the value tree
Test / test (push) Successful in 1m50s
Test / test (push) Successful in 1m50s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -188,6 +188,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
**Performance**
|
**Performance**
|
||||||
|
|
||||||
|
- Struct destinations decode directly: for a type the direct skeleton can
|
||||||
|
model, the parser resolves tables and keys against the struct schema while
|
||||||
|
the document scans and no intermediate value tree is kept. The strict
|
||||||
|
decode of the representative document drops from 168 to 160 allocations
|
||||||
|
per call against the tree path in the same process, and the 2000-element
|
||||||
|
document reaches allocation parity; every document the skeleton cannot
|
||||||
|
model falls back to the tree path and its exact error contracts. A
|
||||||
|
differential fuzz target decodes every generated document both ways.
|
||||||
- Marshal writes plain scalars and typed scalar arrays straight from their
|
- Marshal writes plain scalars and typed scalar arrays straight from their
|
||||||
reflect cells instead of boxing them into interface values first, and skips
|
reflect cells instead of boxing them into interface values first, and skips
|
||||||
the per-element resolution for arrays that can never take the `[[header]]`
|
the per-element resolution for arrays that can never take the `[[header]]`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package interpres
|
package interpres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -168,3 +169,39 @@ func BenchmarkMarshalLong(b *testing.B) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkStrictDecodeTree measures the reference path the targeted decode
|
||||||
|
// is measured against: the full tree parse followed by the reflection walk.
|
||||||
|
// The pair runs in one process, so the A/B comparison shares the machine.
|
||||||
|
func BenchmarkStrictDecodeTree(b *testing.B) {
|
||||||
|
dec := newDecoder()
|
||||||
|
dec.disallowUnknown = true
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
tree, _, err := parseWithOptions(context.Background(), benchDoc, parseOptions{}, false)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
var cfg benchConfig
|
||||||
|
if err := dec.decode(tree, &cfg); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkStrictDecodeTreeLong(b *testing.B) {
|
||||||
|
dec := newDecoder()
|
||||||
|
dec.disallowUnknown = true
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(longDoc)))
|
||||||
|
for b.Loop() {
|
||||||
|
tree, _, err := parseWithOptions(context.Background(), longDoc, parseOptions{}, false)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc benchLongDoc
|
||||||
|
if err := dec.decode(tree, &doc); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
@@ -491,6 +491,23 @@ depth, including struct elements inside slices; map destinations accept every
|
|||||||
key by nature. When several keys are unknown, the message names the smallest
|
key by nature. When several keys are unknown, the message names the smallest
|
||||||
one, so it does not depend on map iteration order.
|
one, so it does not depend on map iteration order.
|
||||||
|
|
||||||
|
### Direct decoding
|
||||||
|
|
||||||
|
For a struct destination whose type graph carries no untagged embedded map and
|
||||||
|
no custom decode hook, `Unmarshal` and `(*Decoder).Decode` parse straight into
|
||||||
|
the destination: the table skeleton is resolved against the struct schema while
|
||||||
|
the document scans, and no intermediate value tree is kept. Values still flow
|
||||||
|
through the ordinary assignment rules, so every conversion, hook and error the
|
||||||
|
[Decoding](#decoding) section states holds verbatim; the parity with the tree
|
||||||
|
path is pinned by a differential fuzz target that decodes every generated
|
||||||
|
document both ways and compares the results.
|
||||||
|
|
||||||
|
A document or destination the direct skeleton cannot model — an unknown table
|
||||||
|
under strictness it must sink, a hook that needs the whole parsed value, an
|
||||||
|
embedded map filler — falls back to the tree path and reruns, so the
|
||||||
|
observable behaviour is always the tree path's, exactly. Nothing changes for
|
||||||
|
`Parse`, `ParseMap` or the document API: the tree remains theirs.
|
||||||
|
|
||||||
### Cancellation
|
### Cancellation
|
||||||
|
|
||||||
`ParseContext`, `UnmarshalContext` and `(*Decoder).DecodeContext` accept a
|
`ParseContext`, `UnmarshalContext` and `(*Decoder).DecodeContext` accept a
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fuzzNested struct {
|
||||||
|
X int `toml:"x"`
|
||||||
|
Y string `toml:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fuzzDoc struct {
|
||||||
|
Num int `toml:"num"`
|
||||||
|
Flt float64 `toml:"flt"`
|
||||||
|
Str string `toml:"str"`
|
||||||
|
Flag bool `toml:"flag"`
|
||||||
|
Small uint8 `toml:"small"`
|
||||||
|
When time.Time `toml:"when"`
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Lims map[string]any `toml:"lims"`
|
||||||
|
Tab fuzzNested `toml:"tab"`
|
||||||
|
Arr []fuzzNested `toml:"arr"`
|
||||||
|
Other string `toml:"other"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzStmts is the statement pool the generated documents draw from: every
|
||||||
|
// destination kind the targeted parse handles, beside the shapes that make
|
||||||
|
// it fall back (overflow, unknown tables, duplicate keys).
|
||||||
|
var fuzzStmts = []string{
|
||||||
|
`num = 1`, `num = 300`, `small = 300`, `small = 7`,
|
||||||
|
`flt = 2.5`, `str = "x"`, `flag = true`,
|
||||||
|
`when = 1979-05-27T07:32:00Z`,
|
||||||
|
`tags = ["a", "b"]`, `tags = []`, `lims = { k = 1 }`,
|
||||||
|
`[tab]`, `tab.x = 1`, `tab.y = "s"`, `x = 2`, `y = "t"`,
|
||||||
|
`[[arr]]`, `x = 3`, `y = "u"`,
|
||||||
|
`[tab.nested]`, `x = 4`,
|
||||||
|
`other = "o"`, `zz = 1`, `[zz]`, `k = 1`,
|
||||||
|
`num = 2`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzDocument(data []byte) []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, by := range data {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString(fuzzStmts[int(by)%len(fuzzStmts)])
|
||||||
|
}
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeDecodeInto is the reference decode: the ordinary tree path, non-strict
|
||||||
|
// like the fuzz decode; the strict contracts have their own deterministic
|
||||||
|
// tests.
|
||||||
|
func treeDecodeInto(data []byte, v any) error {
|
||||||
|
dec := newDecoder()
|
||||||
|
tree, _, err := parseWithOptions(context.Background(), data, parseOptions{}, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return dec.decode(tree, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFinding normalises an error for the comparison. Decode-stage
|
||||||
|
// findings several tables may produce (an unknown field, a missing required
|
||||||
|
// key) compare as their class alone: the tree decode picks the reporting
|
||||||
|
// table by map order and so does not promise one. Everything else compares
|
||||||
|
// as its exact text.
|
||||||
|
func decodeFinding(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if de, ok := errors.AsType[*DecodeError](err); ok {
|
||||||
|
if strings.Contains(de.Err.Error(), "unknown field") {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if strings.Contains(de.Err.Error(), "missing required key") {
|
||||||
|
return "required"
|
||||||
|
}
|
||||||
|
return de.Path.String() + ": " + de.Err.Error()
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzTargetedDecode holds the targeted parse to the tree decode as its
|
||||||
|
// reference: for every generated document the two paths must agree on the
|
||||||
|
// error class and on the decoded value.
|
||||||
|
func FuzzTargetedDecode(f *testing.F) {
|
||||||
|
seeds := []string{
|
||||||
|
"num = 1\nstr = \"x\"\n[tab]\nx = 2\n[[arr]]\nx = 3\n",
|
||||||
|
"small = 300\n",
|
||||||
|
"[tab]\ntab.x = 1\n",
|
||||||
|
"lims = { k = 1 }\ntags = [\"a\"]\n",
|
||||||
|
"[[arr]]\ny = \"u\"\n[zz]\nk = 1\n",
|
||||||
|
}
|
||||||
|
for _, s := range seeds {
|
||||||
|
f.Add([]byte(s))
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
doc := fuzzDocument(data)
|
||||||
|
var tgt fuzzDoc
|
||||||
|
tgtErr := NewDecoder().Decode(doc, &tgt)
|
||||||
|
if tgtErr != nil {
|
||||||
|
// A document with several decode-stage findings reports a different
|
||||||
|
// one per run (the tree decode walks its maps in random order), so the
|
||||||
|
// reference gets a few chances to produce the finding the targeted
|
||||||
|
// side carries. The targeted error is either the tree's own or the
|
||||||
|
// fallback already reran the tree.
|
||||||
|
for i := range 8 {
|
||||||
|
var ref fuzzDoc
|
||||||
|
refErr := treeDecodeInto(doc, &ref)
|
||||||
|
if refErr == nil {
|
||||||
|
t.Fatalf("reference succeeded on retry %d, targeted failed: %v\ndoc:\n%s", i, tgtErr, doc)
|
||||||
|
}
|
||||||
|
if decodeFinding(refErr) == decodeFinding(tgtErr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i == 7 {
|
||||||
|
t.Fatalf("errors disagree after retries:\ntargeted: %v\nlast tree: %v\ndoc:\n%s", tgtErr, refErr, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ref fuzzDoc
|
||||||
|
refErr := treeDecodeInto(doc, &ref)
|
||||||
|
if refErr != nil {
|
||||||
|
t.Fatalf("reference failed, targeted succeeded: %v\ndoc:\n%s", refErr, doc)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(ref, tgt) {
|
||||||
|
t.Fatalf("values disagree:\ntree: %#v\ntargeted: %#v\ndoc:\n%s", ref, tgt, doc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+26
-6
@@ -305,14 +305,22 @@ func NewSchema[T any]() {
|
|||||||
|
|
||||||
// UnmarshalContext is the cancellable variant of Unmarshal.
|
// UnmarshalContext is the cancellable variant of Unmarshal.
|
||||||
func UnmarshalContext(ctx context.Context, data []byte, v any) error {
|
func UnmarshalContext(ctx context.Context, data []byte, v any) error {
|
||||||
|
dec := newDecoder()
|
||||||
|
dec.ctx = ctx
|
||||||
|
if canTargetDecode(v) {
|
||||||
|
// The targeted parse fills struct destinations without the
|
||||||
|
// intermediate tree; a document or destination it cannot model falls
|
||||||
|
// back to the tree path, whose contracts it keeps.
|
||||||
|
if err := parseIntoTargeted(ctx, data, dec, false, 0, v); err != errTargetFallback {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
// Only a destination that can reach an OrderedMap needs the node tree the
|
// Only a destination that can reach an OrderedMap needs the node tree the
|
||||||
// written key order is read from; every other decode skips building it.
|
// written key order is read from; every other decode skips building it.
|
||||||
tree, doc, err := parseWithOptions(ctx, data, parseOptions{}, typeWantsOrder(reflect.TypeOf(v)))
|
tree, doc, err := parseWithOptions(ctx, data, parseOptions{}, typeWantsOrder(reflect.TypeOf(v)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dec := newDecoder()
|
|
||||||
dec.ctx = ctx
|
|
||||||
dec.nodes = indexNodes(doc.Root())
|
dec.nodes = indexNodes(doc.Root())
|
||||||
return dec.decode(tree, v)
|
return dec.decode(tree, v)
|
||||||
}
|
}
|
||||||
@@ -390,6 +398,22 @@ func (d *Decoder) Decode(data []byte, v any) error {
|
|||||||
|
|
||||||
// DecodeContext is the cancellable variant of Decode.
|
// DecodeContext is the cancellable variant of Decode.
|
||||||
func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
|
func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
|
||||||
|
dec := newDecoder()
|
||||||
|
dec.disallowUnknown = d.disallowUnknown
|
||||||
|
dec.ctx = ctx
|
||||||
|
dec.loc = d.localLoc
|
||||||
|
if canTargetDecode(v) {
|
||||||
|
// The targeted parse fills struct destinations without the
|
||||||
|
// intermediate tree; a document or destination it cannot model falls
|
||||||
|
// back to the tree path, whose contracts it keeps. The size limit is
|
||||||
|
// checked here, the targeted parse being the parse itself.
|
||||||
|
if d.maxInputSize > 0 && len(data) > d.maxInputSize {
|
||||||
|
return fmt.Errorf("interpres: input is %d bytes, over the limit of %d", len(data), d.maxInputSize)
|
||||||
|
}
|
||||||
|
if err := parseIntoTargeted(ctx, data, dec, d.useNumber, d.maxDepth, v); err != errTargetFallback {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
opts := parseOptions{
|
opts := parseOptions{
|
||||||
maxDepth: d.maxDepth,
|
maxDepth: d.maxDepth,
|
||||||
maxInputSize: d.maxInputSize,
|
maxInputSize: d.maxInputSize,
|
||||||
@@ -399,11 +423,7 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dec := newDecoder()
|
|
||||||
dec.disallowUnknown = d.disallowUnknown
|
|
||||||
dec.ctx = ctx
|
|
||||||
dec.nodes = indexNodes(doc.Root())
|
dec.nodes = indexNodes(doc.Root())
|
||||||
dec.loc = d.localLoc
|
|
||||||
return dec.decode(tree, v)
|
return dec.decode(tree, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+525
@@ -0,0 +1,525 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type targetNested struct {
|
||||||
|
X int `toml:"x"`
|
||||||
|
Y string `toml:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type targetCfg struct {
|
||||||
|
Num int `toml:"num"`
|
||||||
|
Small uint8 `toml:"small"`
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Lims map[string]any `toml:"lims"`
|
||||||
|
Tab targetNested `toml:"tab"`
|
||||||
|
Arr []targetNested `toml:"arr"`
|
||||||
|
Other string `toml:"other"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedStrictFindings pins the strict findings of the targeted parse
|
||||||
|
// to the tree decode's own texts, paths included. Every case here was first
|
||||||
|
// surfaced by FuzzTargetedDecode.
|
||||||
|
func TestTargetedStrictFindings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unknown key in a header table",
|
||||||
|
doc: "[tab]\nother = \"o\"\n",
|
||||||
|
want: `interpres: tab: unknown field "other" for interpres.targetNested`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown nested header without the parent header",
|
||||||
|
doc: "[tab.nested]\nx = 1\n",
|
||||||
|
want: `interpres: tab: unknown field "nested" for interpres.targetNested`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown key in an array-of-tables element",
|
||||||
|
doc: "[[arr]]\nother = \"o\"\n",
|
||||||
|
want: `interpres: arr[0]: unknown field "other" for interpres.targetNested`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
err := NewDecoder().DisallowUnknownFields().Decode([]byte(tt.doc), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("no error, want %q", tt.want)
|
||||||
|
}
|
||||||
|
if err.Error() != tt.want {
|
||||||
|
t.Errorf("message = %q, want %q", err.Error(), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedParseErrors pins the parse-stage errors the targeted skeleton
|
||||||
|
// raises, whose texts and lines are the tree parser's own.
|
||||||
|
func TestTargetedParseErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "header on an assigned scalar",
|
||||||
|
doc: "zz = 1\n[zz]\nx = 4\n",
|
||||||
|
want: "interpres: line 2: key \"zz\" is not a table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dotted key on an assigned scalar",
|
||||||
|
doc: "zz = 1\nzz.x = 2\n",
|
||||||
|
want: "interpres: line 2: key \"zz\" is not a table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate unknown keys",
|
||||||
|
doc: "zz = 1\nzz = 2\n",
|
||||||
|
want: "interpres: line 2: duplicate key \"zz\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate inside an unknown table",
|
||||||
|
doc: "[zz]\nk = 1\nk = 2\n",
|
||||||
|
want: "interpres: line 3: duplicate key \"k\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate across a sink's dotted keys",
|
||||||
|
doc: "[zz]\na.b = 1\na.b = 2\n",
|
||||||
|
want: "interpres: line 3: duplicate key \"b\"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
err := NewDecoder().Decode([]byte(tt.doc), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("no error, want %q", tt.want)
|
||||||
|
}
|
||||||
|
if err.Error() != tt.want {
|
||||||
|
t.Errorf("message = %q, want %q", err.Error(), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedSilentShapes covers the documents the targeted parse accepts
|
||||||
|
// with the values the tree decode gives.
|
||||||
|
func TestTargetedSilentShapes(t *testing.T) {
|
||||||
|
t.Run("dotted key after an unknown nested header", func(t *testing.T) {
|
||||||
|
// [tab.nested] is unknown and sinks; tab.x then lands in tab, and the
|
||||||
|
// sink's own x is a different key, the tree's shape exactly.
|
||||||
|
var cfg, ref targetCfg
|
||||||
|
in := []byte("[tab]\nx = 1\n[tab.nested]\n")
|
||||||
|
if err := NewDecoder().Decode(in, &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if err := treeDecodeInto(in, &ref); err != nil {
|
||||||
|
t.Fatalf("reference: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(cfg, ref) {
|
||||||
|
t.Errorf("values disagree: targeted %+v, tree %+v", cfg, ref)
|
||||||
|
}
|
||||||
|
if cfg.Tab.X != 1 {
|
||||||
|
t.Errorf("tab.x = %d, want 1", cfg.Tab.X)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("unknown keys are ignored without strict", func(t *testing.T) {
|
||||||
|
var cfg, ref targetCfg
|
||||||
|
in := []byte("num = 5\nz1 = 1\n[zz]\nk = 1\n")
|
||||||
|
if err := NewDecoder().Decode(in, &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if err := treeDecodeInto(in, &ref); err != nil {
|
||||||
|
t.Fatalf("reference: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(cfg, ref) {
|
||||||
|
t.Errorf("values disagree: targeted %+v, tree %+v", cfg, ref)
|
||||||
|
}
|
||||||
|
if cfg.Num != 5 {
|
||||||
|
t.Errorf("num = %d, want 5", cfg.Num)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("an inline table into a map field", func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
in := []byte("lims = { cpu = 4, deep = { a = true } }\n")
|
||||||
|
if err := NewDecoder().Decode(in, &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Lims["cpu"] != int64(4) {
|
||||||
|
t.Errorf("lims = %v", cfg.Lims)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("an overflow falls back to the decode error", func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
err := NewDecoder().Decode([]byte("small = 300\n"), &cfg)
|
||||||
|
want := "interpres: small: integer 300 overflows uint8"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("too many array-of-tables elements falls back", func(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
var cfg struct {
|
||||||
|
Items [2]Item `toml:"items"`
|
||||||
|
}
|
||||||
|
err := Unmarshal([]byte("[[items]]\nn = 1\n[[items]]\nn = 2\n[[items]]\nn = 3\n"), &cfg)
|
||||||
|
want := "interpres: items: cannot assign 3 elements to [2]interpres.Item"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("a UseNumber tree keeps literals in the targeted path", func(t *testing.T) {
|
||||||
|
var cfg struct {
|
||||||
|
Rate Number `toml:"rate"`
|
||||||
|
}
|
||||||
|
if err := NewDecoder().UseNumber().Decode([]byte("rate = 1_000\n"), &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Rate != "1_000" {
|
||||||
|
t.Errorf("rate = %q, want 1_000", cfg.Rate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("dotted keys fill a map field", func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
in := []byte("lims.a.b = true\nlims.c = 3\n")
|
||||||
|
if err := NewDecoder().Decode(in, &cfg); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Lims["c"] != int64(3) {
|
||||||
|
t.Errorf("lims = %v", cfg.Lims)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("an inline table cannot be extended", func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
in := []byte("lims = { a = 1 }\n[lims.deep]\nb = 2\n")
|
||||||
|
err := NewDecoder().Decode(in, &cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cannot extend inline table") {
|
||||||
|
t.Errorf("err = %v, want the inline-table extension error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedShapesMatrix walks a document per destination shape, both
|
||||||
|
// through the targeted path and the tree reference, so the two agree on
|
||||||
|
// every branch the skeleton carries.
|
||||||
|
func TestTargetedShapesMatrix(t *testing.T) {
|
||||||
|
docs := []string{
|
||||||
|
// Scalars of every kind, arrays, maps, tables, arrays of tables.
|
||||||
|
"num = 7\nflt = 1.25\nstr = \"s\"\nflag = false\nsmall = 9\ntags = [\"a\"]\nlims = { a = 1 }\n\n[tab]\nx = 1\ny = \"t\"\n\n[[arr]]\nx = 2\ny = \"u\"\n\n[[arr]]\nx = 3\ny = \"v\"\n",
|
||||||
|
// Dotted keys through nested tables and maps.
|
||||||
|
"tab.x = 1\ntab.y = \"s\"\nlims.a.b = true\nlims.c = 3\nnum = 2\n",
|
||||||
|
// Inline tables nested in arrays, mixed value arrays.
|
||||||
|
"lims = { a = { b = 1 } }\ntags = []\nother = \"o\"\n",
|
||||||
|
// A sub-table of an array of tables, then a second element.
|
||||||
|
"[[arr]]\nx = 1\n[arr.nested]\ny = \"n\"\n[[arr]]\ny = \"m\"\n",
|
||||||
|
// Negative and signed numbers, exponents, radix forms into floats.
|
||||||
|
"flt = -3.5e2\nnum = -42\nflt = +1.0\n",
|
||||||
|
// A quoted key and a defined-string-shaped value.
|
||||||
|
"\"quoted key\" = 1\nstr = \"multi\"\n",
|
||||||
|
}
|
||||||
|
for i, doc := range docs {
|
||||||
|
var ref, tgt targetCfg
|
||||||
|
refErr := treeDecodeInto([]byte(doc), &ref)
|
||||||
|
dec := NewDecoder()
|
||||||
|
tgtErr := dec.Decode([]byte(doc), &tgt)
|
||||||
|
if (refErr == nil) != (tgtErr == nil) {
|
||||||
|
t.Errorf("doc %d: error presence disagrees: tree %v, targeted %v", i, refErr, tgtErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if refErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(ref, tgt) {
|
||||||
|
t.Errorf("doc %d: values disagree:\ntree: %#v\ntargeted: %#v", i, ref, tgt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedFallbackContracts pins the documents that must fall back and
|
||||||
|
// produce the tree decode's exact error.
|
||||||
|
func TestTargetedFallbackContracts(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "uint8 overflow",
|
||||||
|
doc: "small = 300\n",
|
||||||
|
want: "interpres: small: integer 300 overflows uint8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative into uint",
|
||||||
|
doc: "small = -1\n",
|
||||||
|
want: "interpres: small: cannot assign negative -1 to uint8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a table into a scalar",
|
||||||
|
doc: "num = { a = 1 }\n",
|
||||||
|
want: "interpres: num: cannot assign table to int",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an integer into a string field",
|
||||||
|
doc: "other = 5\n",
|
||||||
|
want: "interpres: other: cannot assign integer to string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
err := NewDecoder().Decode([]byte(tt.doc), &cfg)
|
||||||
|
if err == nil || err.Error() != tt.want {
|
||||||
|
t.Errorf("err = %v, want %q", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = Item{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedHeaderOnAssignedScalar pins the parse error a header raises
|
||||||
|
// when the key already holds a scalar, before any fallback can happen.
|
||||||
|
func TestTargetedHeaderOnAssignedScalarArray(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
in := []byte("arr = []\n[[arr]]\nx = 1\n")
|
||||||
|
err := NewDecoder().Decode(in, &cfg)
|
||||||
|
want := "interpres: line 2: key \"arr\" is not an array of tables"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedBranchParity walks the fallback branches of the targeted
|
||||||
|
// skeleton: every document here takes the tree path on a rerun, and must
|
||||||
|
// carry the tree decode's exact error text.
|
||||||
|
func TestTargetedBranchParity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "a header over a value array",
|
||||||
|
doc: "tags = [\"x\"]\n[tags]\na = 1\n",
|
||||||
|
want: "interpres: line 2: key \"tags\" is not a table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an array header over a value array",
|
||||||
|
doc: "tags = [\"x\"]\n[[tags]]\na = 1\n",
|
||||||
|
want: "interpres: line 2: key \"tags\" is not an array of tables",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an array header over a datetime field",
|
||||||
|
doc: "when = 1979-05-27T07:32:00Z\n[[when]]\nx = 1\n",
|
||||||
|
want: "interpres: line 2: key \"when\" is not an array of tables",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a boolean into a string field",
|
||||||
|
doc: "other = true\n",
|
||||||
|
want: "interpres: other: cannot assign bool to string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a leading-zero integer",
|
||||||
|
doc: "num = 01\n",
|
||||||
|
want: "interpres: line 1: leading zeros are not allowed in numbers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an int64-overflowing integer",
|
||||||
|
doc: "num = 99999999999999999999\n",
|
||||||
|
want: "interpres: line 1: integer \"99999999999999999999\" out of range",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a malformed boolean",
|
||||||
|
doc: "flag = tru\n",
|
||||||
|
want: "interpres: line 1: invalid value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a negative number into an unsigned field",
|
||||||
|
doc: "small = -5\n",
|
||||||
|
want: "interpres: small: cannot assign negative -5 to uint8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an integer into a string field via the generic path",
|
||||||
|
doc: "other = 5\n",
|
||||||
|
want: "interpres: other: cannot assign integer to string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
err := NewDecoder().DisallowUnknownFields().Decode([]byte(tt.doc), &cfg)
|
||||||
|
if tt.want == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || err.Error() != tt.want {
|
||||||
|
t.Errorf("err = %v, want %q", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedDecodeHookFields keeps the custom decode hooks of scalar-typed
|
||||||
|
// fields working in the targeted path.
|
||||||
|
func TestTargetedDecodeHookFields(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
IP net.IP `toml:"ip"`
|
||||||
|
Dur time.Duration `toml:"dur"`
|
||||||
|
Unm *scalarUnmarshaler `toml:"unm"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
in := []byte("ip = \"192.0.2.1\"\ndur = \"1h30m\"\nunm = \"hello\"\n")
|
||||||
|
if err := NewDecoder().Decode(in, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.IP.String() != "192.0.2.1" {
|
||||||
|
t.Errorf("ip = %v", cfg.IP)
|
||||||
|
}
|
||||||
|
if cfg.Dur != 90*time.Minute {
|
||||||
|
t.Errorf("dur = %v", cfg.Dur)
|
||||||
|
}
|
||||||
|
if cfg.Unm == nil || cfg.Unm.val != "hello" {
|
||||||
|
t.Errorf("unm = %+v", cfg.Unm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedOddShapes pins the fallback and value shapes the matrix does
|
||||||
|
// not reach: space-separated date-times, non-string map keys and repeated
|
||||||
|
// dotted map keys.
|
||||||
|
func TestTargetedOddShapes(t *testing.T) {
|
||||||
|
t.Run("a space-separated date-time", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
When time.Time `toml:"when"`
|
||||||
|
}
|
||||||
|
var cfg, ref Cfg
|
||||||
|
doc := []byte("when = 1979-05-27 07:32:00Z\n")
|
||||||
|
if err := NewDecoder().Decode(doc, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := treeDecodeInto(doc, &ref); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !cfg.When.Equal(ref.When) {
|
||||||
|
t.Errorf("when = %v, want %v", cfg.When, ref.When)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("a map with a non-string key falls back", func(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
M map[int]string `toml:"m"`
|
||||||
|
}
|
||||||
|
var cfg, ref Cfg
|
||||||
|
doc := []byte("m = { a = 1 }\n")
|
||||||
|
err := NewDecoder().Decode(doc, &cfg)
|
||||||
|
refErr := treeDecodeInto(doc, &ref)
|
||||||
|
if err == nil || refErr == nil {
|
||||||
|
t.Fatalf("err = %v, refErr = %v, want both to fail", err, refErr)
|
||||||
|
}
|
||||||
|
if err.Error() != refErr.Error() {
|
||||||
|
t.Errorf("errors disagree: targeted %q, tree %q", err, refErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("a repeated dotted map key is a duplicate", func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
doc := []byte("lims.a.b = 1\nlims.a.b = 2\n")
|
||||||
|
err := NewDecoder().Decode(doc, &cfg)
|
||||||
|
want := "interpres: line 2: duplicate key \"b\""
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("err = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("an underscored integer takes the token path", func(t *testing.T) {
|
||||||
|
var cfg targetCfg
|
||||||
|
doc := []byte("num = 1_000\n")
|
||||||
|
if err := NewDecoder().Decode(doc, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Num != 1000 {
|
||||||
|
t.Errorf("num = %d, want 1000", cfg.Num)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("an 18-digit integer takes the fast path", func(t *testing.T) {
|
||||||
|
var cfg struct {
|
||||||
|
Big int64 `toml:"big"`
|
||||||
|
}
|
||||||
|
doc := []byte("big = 999999999999999999\n")
|
||||||
|
if err := NewDecoder().Decode(doc, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Big != 999999999999999999 {
|
||||||
|
t.Errorf("big = %d", cfg.Big)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTargetedMapTableShapes covers the map-entry branches of the targeted
|
||||||
|
// skeleton: entries that become tables, entries that refuse them, and the
|
||||||
|
// duplicate checks across them.
|
||||||
|
func TestTargetedMapTableShapes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "a header opens a map entry table",
|
||||||
|
doc: "lims.c = 1\n[lims.d]\nk = 1\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a header over an assigned map entry",
|
||||||
|
doc: "lims.a = 1\n[lims.a]\nk = 1\n",
|
||||||
|
want: "interpres: line 2: key \"a\" is not a table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a dotted key over an assigned map entry",
|
||||||
|
doc: "lims.a = 1\nlims.a.b = 2\n",
|
||||||
|
want: "interpres: line 2: key \"a\" is not a table",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a duplicate plain map entry",
|
||||||
|
doc: "lims.a = 1\nlims.a = 2\n",
|
||||||
|
want: "interpres: line 2: duplicate key \"a\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an array of tables inside a map entry",
|
||||||
|
doc: "lims.c = 1\n[[lims.items]]\nk = 1\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var cfg, ref targetCfg
|
||||||
|
err := NewDecoder().Decode([]byte(tt.doc), &cfg)
|
||||||
|
refErr := treeDecodeInto([]byte(tt.doc), &ref)
|
||||||
|
if (err == nil) != (refErr == nil) {
|
||||||
|
t.Fatalf("error presence disagrees: tree %v, targeted %v", refErr, err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() != refErr.Error() {
|
||||||
|
t.Fatalf("errors disagree:\ntree: %v\ntargeted: %v", refErr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(cfg, ref) {
|
||||||
|
t.Errorf("values disagree: targeted %+v, tree %+v", cfg, ref)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user