diff --git a/CHANGELOG.md b/CHANGELOG.md index cac0f7a..234e6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **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 reflect cells instead of boxing them into interface values first, and skips the per-element resolution for arrays that can never take the `[[header]]` diff --git a/bench_test.go b/bench_test.go index 05261a1..925a909 100644 --- a/bench_test.go +++ b/bench_test.go @@ -4,6 +4,7 @@ package interpres import ( + "context" "fmt" "strings" "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) + } + } +} diff --git a/docs/API.md b/docs/API.md index 0be99a8..99282b1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 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 `ParseContext`, `UnmarshalContext` and `(*Decoder).DecodeContext` accept a diff --git a/fuzz_target_test.go b/fuzz_target_test.go new file mode 100644 index 0000000..ced747b --- /dev/null +++ b/fuzz_target_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Petr Balvín (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) + } + }) +} diff --git a/interpres.go b/interpres.go index d168424..2d07dba 100644 --- a/interpres.go +++ b/interpres.go @@ -305,14 +305,22 @@ func NewSchema[T any]() { // UnmarshalContext is the cancellable variant of Unmarshal. 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 // written key order is read from; every other decode skips building it. tree, doc, err := parseWithOptions(ctx, data, parseOptions{}, typeWantsOrder(reflect.TypeOf(v))) if err != nil { return err } - dec := newDecoder() - dec.ctx = ctx dec.nodes = indexNodes(doc.Root()) 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. 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{ maxDepth: d.maxDepth, maxInputSize: d.maxInputSize, @@ -399,11 +423,7 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { if err != nil { return err } - dec := newDecoder() - dec.disallowUnknown = d.disallowUnknown - dec.ctx = ctx dec.nodes = indexNodes(doc.Root()) - dec.loc = d.localLoc return dec.decode(tree, v) } diff --git a/target.go b/target.go new file mode 100644 index 0000000..51cf7d3 --- /dev/null +++ b/target.go @@ -0,0 +1,1218 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: MIT + +package interpres + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strconv" + "strings" + "sync" +) + +// errTargetFallback aborts a targeted parse and hands the document back to +// the ordinary tree path. It is the contract-keeping device of this file: +// every condition the tree path answers with a decode-stage error, an +// Unmarshaler hook, an embedded map filler or any other machinery the +// targeted skeleton does not model, ends here, and the caller reruns the +// document through the tree path, so the observable behaviour is the tree +// path's, exactly. A targeted parse either completes with the result the +// tree path would give, or it erases itself. +var errTargetFallback = errors.New("interpres: targeted decode falls back to the tree path") + +// targetCache holds whether a destination type may take the targeted parse. +// One computed answer per type, the same trade-off structSchemaCache makes. +var targetCache sync.Map // reflect.Type -> bool + +// mapStringAnyType is the map shape the tree builds for an any destination's +// tables, reused by the any-map element branch. +var mapStringAnyType = reflect.TypeFor[map[string]any]() + +// typeTargetable reports whether decoding into the struct type t can use the +// targeted parse. The one structural ban is untagged embedded maps: their +// filler-key rule lives in the tree decode, and a targeted document that +// meets an unknown table would need a subtree of it. Everything else is safe +// to attempt, because the value layer is the ordinary decode and every +// mismatch falls back. +func typeTargetable(t reflect.Type) bool { + if t == nil || t.Kind() != reflect.Struct || t == orderedMapType { + return false + } + if v, ok := targetCache.Load(t); ok { + return v.(bool) + } + r := scanTargetable(t, make(map[reflect.Type]bool)) + v, _ := targetCache.LoadOrStore(t, r) + return v.(bool) +} + +func scanTargetable(t reflect.Type, seen map[reflect.Type]bool) bool { + if seen[t] { + return true + } + seen[t] = true + if len(cachedStructSchema(t).embedMaps) != 0 { + return false + } + for _, loc := range cachedStructSchema(t).byName { + ft := derefType(t.FieldByIndex(loc.index).Type) + if ft == orderedMapType { + return false + } + if ft.Kind() != reflect.Struct || isScalarStruct(ft) { + continue + } + // A struct field with a custom decode hook receives the whole parsed + // value from the tree decode; the targeted skeleton never builds that + // value for a table it enters directly, so the hook must win. + if implementsDecodeHook(ft) || implementsDecodeHook(reflect.PointerTo(ft)) { + return false + } + if !scanTargetable(ft, seen) { + return false + } + } + return true +} + +func derefType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +// implementsDecodeHook reports whether t carries one of the custom decode +// interfaces the tree decode honours. +func implementsDecodeHook(t reflect.Type) bool { + return t.Implements(unmarshalerType) || t.Implements(ctxUnmarshalerType) || t.Implements(textUnmarshalerType) +} + +// canTargetDecode reports whether the decoder can take the targeted path for +// the destination v: a non-nil pointer to a struct whose graph carries no +// embedded map filler, and that is not itself a custom decode hook (the tree +// decode hands a hook the whole parsed tree). +func canTargetDecode(v any) bool { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return false + } + et := rv.Type().Elem() + if et.Kind() != reflect.Struct || !typeTargetable(et) { + return false + } + return !implementsDecodeHook(et) && !implementsDecodeHook(reflect.PointerTo(et)) +} + +// targetTable is one open table of the targeted parse: the struct (or map) +// value its keys fill, the schema that resolves them (nil for a map or sink +// destination), the absolute path its errors wrap, and whether it collects +// the keys no field claims for the strict check. A sink is the destination +// an unknown subtree gets: its statements parse for the syntax and definition +// contracts, and its values are discarded. +type targetTable struct { + rv reflect.Value + schema *structSchema + path []string + sink bool + keys []string // the keys defined in the table, interned; struct + // tables keep theirs per destination address instead + strict bool + unknown string // strict: the smallest unclaimed key so far + resolvedSeen map[string]bool // the schema keys resolved so far, for required +} + +// targetParser parses a document straight into a struct destination. It +// reuses the parser's scanner, grammar errors and definition maps, and the +// decoder's value assignment; its own work is the table skeleton a struct +// destination needs: which field does this header or key land in. +type targetParser struct { + *parser + d *decoder + root reflect.Value + + tables []*targetTable // every opened table, in document order + rootT *targetTable + cur *targetTable + + // arrayNext tracks how many elements of a fixed-size array the document + // has filled, per field address. + arrayNext map[uintptr]int + + // opened registers every opened table by its path key, sinks included: a + // later header or dotted key meets the table the tree already built. + opened map[string]*targetTable + + // keyAssigned records the fields a key statement assigned directly, per + // table address: an array-of-tables header over such a field is the + // tree's `not an array of tables` error, where a header over a + // header-built array appends. + keyAssignedKeys map[uintptr]map[string]bool + + // tableKeysByAddr holds the defined keys of one struct destination, keyed + // by the value's address: the table object a dotted descent builds is + // transient, the destination is not. + tableKeysByAddr map[uintptr][]string +} + +// markKeyAssigned records that a key statement assigned the field, and +// keyAssigned reports that state. Sinks keep no such bookkeeping. +func (tp *targetParser) markKeyAssigned(t *targetTable, key string) { + if t.sink || t.schema == nil || !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { + return + } + addr := t.rv.Addr().Pointer() + if tp.keyAssignedKeys == nil { + tp.keyAssignedKeys = make(map[uintptr]map[string]bool, 8) + } + if tp.keyAssignedKeys[addr] == nil { + tp.keyAssignedKeys[addr] = make(map[string]bool, 8) + } + tp.keyAssignedKeys[addr][key] = true +} + +func (tp *targetParser) keyAssigned(t *targetTable, key string) bool { + if t.sink || t.schema == nil || !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { + return false + } + addr := t.rv.Addr().Pointer() + return tp.keyAssignedKeys[addr][key] +} + +// tableHas reports whether key is already defined in the table. A struct +// table's keys are interned parser strings, so the linear scan compares +// against a handful of short keys, cheaper than hashing a per-table map. +// Struct tables keep their keys by destination address, because the table +// object a dotted descent builds is transient while the destination is not. +func (tp *targetParser) tableHas(t *targetTable, key string) bool { + switch { + case t.sink: + return slices.Contains(t.keys, key) + case t.schema == nil: + return t.rv.Kind() == reflect.Map && t.rv.MapIndex(reflect.ValueOf(key)).IsValid() + default: + return slices.Contains(tp.tableKeys(t), key) + } +} + +// tableKeys returns the persistent keys slice of a struct table. +func (tp *targetParser) tableKeys(t *targetTable) []string { + if !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { + return nil + } + addr := t.rv.Addr().Pointer() + if tp.tableKeysByAddr == nil { + tp.tableKeysByAddr = make(map[uintptr][]string, 8) + } + return tp.tableKeysByAddr[addr] +} + +// tableMark records the key as defined. +func (tp *targetParser) tableMark(t *targetTable, key string) { + switch { + case t.sink: + t.keys = append(t.keys, key) + case t.schema == nil: + default: + if !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { + return + } + addr := t.rv.Addr().Pointer() + if tp.tableKeysByAddr == nil { + tp.tableKeysByAddr = make(map[uintptr][]string, 8) + } + tp.tableKeysByAddr[addr] = append(tp.tableKeysByAddr[addr], key) + } +} + +// resolvedHas reports whether the resolved schema key has been seen, the +// check a required tag runs: the duplicate bookkeeping tracks the key as the +// document wrote it, the required bookkeeping the key as the schema +// resolved it. +func (t *targetTable) resolvedHas(key string) bool { + return t.resolvedSeen[key] +} + +func (t *targetTable) markResolved(key string) { + if t.schema == nil || len(t.schema.required) == 0 { + return + } + if t.resolvedSeen == nil { + t.resolvedSeen = make(map[string]bool, 8) + } + t.resolvedSeen[key] = true +} + +// recordStrictUnknown remembers the key no field claims when strict decoding +// is on: the smallest one is reported, the tree decode's own choice. +func (t *targetTable) recordStrictUnknown(key string) { + if !t.strict { + return + } + if t.unknown == "" || key < t.unknown { + t.unknown = key + } +} + +// schemaRef hands out the pointer form the target tables hold. The schema +// is immutable once published, so sharing one copy is safe. +func schemaRef(t reflect.Type) *structSchema { + s := cachedStructSchema(t) + return &s +} + +// parseIntoTargeted runs the targeted parse of data into v. It returns +// errTargetFallback when the document or the destination needs the tree +// path, and any parse error the tree path would return. +func parseIntoTargeted(ctx context.Context, data []byte, d *decoder, useNumber bool, maxDepth int, v any) error { + if maxDepth <= 0 { + maxDepth = maxNestingDepth + } + rv := reflect.ValueOf(v) + tp := &targetParser{ + parser: &parser{src: data, line: 1, ctx: ctx, maxDepth: maxDepth, useNumber: useNumber}, + d: d, + root: rv.Elem(), + arrayNext: make(map[uintptr]int, 4), + keyAssignedKeys: make(map[uintptr]map[string]bool, 8), + opened: make(map[string]*targetTable, 8), + } + tp.rootT = &targetTable{rv: tp.root, schema: schemaRef(tp.root.Type()), strict: d.disallowUnknown} + tp.tables = append(tp.tables, tp.rootT) + tp.cur = tp.rootT + if err := tp.run(); err != nil { + return err + } + return tp.reportDeferred() +} + +// run walks the statements; the cadence and the end conditions mirror the +// tree parser's loop. +func (tp *targetParser) run() error { + p := tp.parser + for i := 0; ; i++ { + if i%ctxCheckInterval == 0 { + if err := p.checkCtx(); err != nil { + return err + } + } + if err := p.skipBlank(); err != nil { + return err + } + p.pending = nil + if p.eof() { + break + } + c := p.peek() + switch { + case c == '[': + if err := tp.parseHeader(); err != nil { + return err + } + default: + if err := tp.parseKeyStatement(); err != nil { + return err + } + } + if err := p.expectLineEnd(); err != nil { + return err + } + } + return nil +} + +// reportDeferred raises the decode-stage findings in the tree decode's +// order: the root table first, then the opened tables in document order. The +// tree decode reports them after a full parse, so a later parse error always +// won; here the parse has already completed. +func (tp *targetParser) reportDeferred() error { + for _, t := range append([]*targetTable{tp.rootT}, tp.tables...) { + if t.sink { + continue + } + if t.strict && t.unknown != "" { + return tp.wrapTableErr(t, fmt.Errorf("interpres: unknown field %q for %s", t.unknown, t.rv.Type())) + } + if t.schema != nil { + for _, key := range t.schema.required { + if !t.resolvedHas(key) { + return tp.wrapTableErr(t, fmt.Errorf("interpres: missing required key %q", key)) + } + } + } + } + return nil +} + +// wrapTableErr wraps a table's finding the way the tree decode wraps it: the +// root speaks for itself, a nested table gains its path. +func (tp *targetParser) wrapTableErr(t *targetTable, err error) error { + if len(t.path) == 0 { + return err + } + return &DecodeError{Path: Path(slices.Clone(t.path)), Err: err} +} + +// --- headers --------------------------------------------------------------- + +// parseHeader parses a [table] or [[array of tables]] header and makes it the +// current table. The definition checks and their messages are the tree +// parser's. +func (tp *targetParser) parseHeader() error { + p := tp.parser + array := false + p.pos++ // consume '[' + if !p.eof() && p.peek() == '[' { + array = true + p.pos++ + } + first, rest, err := p.parseKeyPath() + if err != nil { + return err + } + p.skipInline() + if p.eof() || p.peek() != ']' { + return p.errf("expected ']' to close table header") + } + p.pos++ + if array { + if p.eof() || p.peek() != ']' { + return p.errf("expected ']]' to close array-of-tables header") + } + p.pos++ + } + key := p.keyBuf[:1] + key[0] = first + if len(rest) > 0 { + key = append([]string{first}, rest...) + } + + if array { + return tp.appendArrayTable(key) + } + + pk := pathKey(key) + if p.headers[pk] || p.dotted[pk] || p.arrays[pk] { + return p.errf("table %q is defined more than once", strings.Join(key, ".")) + } + p.markHeader(pk) + + tbl, err := tp.openTablePath(key) + if err != nil { + return err + } + if !tbl.sink { + tp.tables = append(tp.tables, tbl) + } + tp.cur = tbl + return nil +} + +// openTablePath walks the header path from the root and returns the table it +// names. The frozen checks are the tree parser's; a segment no field claims +// opens a sink, and a segment whose destination cannot be a table falls +// back, because the tree decode answers with its own type error. +func (tp *targetParser) openTablePath(key []string) (*targetTable, error) { + parent := tp.rootT + for i, k := range key[:len(key)-1] { + if tp.frozenAt(key[:i+1]) { + return nil, tp.errf("cannot extend inline table %q", strings.Join(key[:i+1], ".")) + } + child, err := tp.descendOne(parent, k, key[:i+1]) + if err != nil { + return nil, err + } + tp.tableMark(parent, k) + if !child.sink { + tp.tables = append(tp.tables, child) + } + parent = child + } + leaf := key[len(key)-1] + if tp.frozenAt(key) { + return nil, tp.errf("cannot extend inline table %q", strings.Join(key, ".")) + } + tbl, err := tp.descendOne(parent, leaf, key) + if err != nil { + return nil, err + } + tp.tableMark(parent, leaf) + return tbl, nil +} + +// frozenAt reports whether the path was frozen as an inline table. +func (tp *targetParser) frozenAt(path []string) bool { + return tp.parser.frozen[pathKey(path)] +} + +// elementPath extends a table's path with an array-of-tables element's +// key and bracketed index, the path the tree decode wraps an element's +// errors with. +func elementPath(base []string, key string, index int) []string { + out := make([]string, 0, len(base)+2) + out = append(out, base...) + out = append(out, key, "["+strconv.Itoa(index)+"]") + return out +} + +// descendOne enters the table one header segment names inside parent. +func (tp *targetParser) descendOne(parent *targetTable, key string, abs []string) (*targetTable, error) { + if parent.sink { + return parent, nil + } + if parent.schema == nil { + // A map destination: the entry must be (or become) a table. + if parent.rv.Kind() != reflect.Map { + return nil, errTargetFallback + } + elemT := parent.rv.Type().Elem() + if elemT.Kind() != reflect.Map || elemT.Key().Kind() != reflect.String { + return nil, errTargetFallback + } + if existing := parent.rv.MapIndex(reflect.ValueOf(key)); existing.IsValid() && !existing.IsNil() { + return &targetTable{rv: existing.Elem(), path: abs}, nil + } + next := reflect.MakeMap(elemT) + parent.rv.SetMapIndex(reflect.ValueOf(key), next) + return &targetTable{rv: next, path: abs}, nil + } + resolved := key + loc, ok := parent.schema.byName[key] + if !ok { + resolved = strings.ToLower(key) + loc, ok = parent.schema.byName[resolved] + } + if !ok { + parent.recordStrictUnknown(key) + if opened, ok := tp.opened[pathKey(abs)]; ok { + return opened, nil + } + if tp.tableHas(parent, key) { + return nil, tp.errf("key %q is not a table", key) + } + sink := &targetTable{sink: true, path: abs} + tp.opened[pathKey(abs)] = sink + return sink, nil + } + parent.markResolved(resolved) + fv, err := fieldByIndex(parent.rv, loc.index) + if err != nil { + return nil, errTargetFallback + } + return tp.openValueTable(fv, parent, key, abs, parent.strict) +} + +// openValueTable opens a table scope over a placed field value, allocating a +// nil pointer on the way. The rules mirror the tree decode's own type +// decisions: a struct enters, a map enters (allocated when nil), an array of +// tables enters its last element, and anything else is a type mismatch the +// tree decode reports, so it falls back. A scalar field the table keys +// already define is the tree's `key is not a table` error, checked against +// parent, the table the key belongs to. +func (tp *targetParser) openValueTable(fv reflect.Value, parent *targetTable, key string, abs []string, strict bool) (*targetTable, error) { + if fv.Kind() == reflect.Pointer { + if fv.IsNil() { + if !fv.CanSet() { + return nil, errTargetFallback + } + fv.Set(reflect.New(fv.Type().Elem())) + } + fv = fv.Elem() + } + switch fv.Kind() { + case reflect.Struct: + if isScalarStruct(fv.Type()) { + return nil, errTargetFallback + } + return &targetTable{rv: fv, schema: schemaRef(fv.Type()), path: abs, strict: strict}, nil + case reflect.Map: + if fv.Type().Key().Kind() != reflect.String { + return nil, errTargetFallback + } + if fv.IsNil() { + fv.Set(reflect.MakeMap(fv.Type())) + } + return &targetTable{rv: fv, path: abs}, nil + case reflect.Slice: + if fv.Len() == 0 { + // No [[header]] ever filled it, so the tree holds a map here and + // its decode raises the type mismatch. + return nil, errTargetFallback + } + et := derefType(fv.Type().Elem()) + if et.Kind() != reflect.Struct || isScalarStruct(et) { + return nil, errTargetFallback + } + return &targetTable{rv: fv.Index(fv.Len() - 1), schema: schemaRef(et), path: elementPath(abs, key, fv.Len()-1), strict: strict}, nil + } + if tp.tableHas(parent, key) { + return nil, tp.errf("key %q is not a table", key) + } + return nil, errTargetFallback +} + +// appendArrayTable appends a new element to the array of tables the leaf +// names and makes it the current table. +func (tp *targetParser) appendArrayTable(key []string) error { + parent := tp.rootT + for i, k := range key[:len(key)-1] { + if tp.frozenAt(key[:i+1]) { + return tp.errf("cannot extend inline table %q", strings.Join(key[:i+1], ".")) + } + child, err := tp.descendOne(parent, k, key[:i+1]) + if err != nil { + return err + } + tp.tableMark(parent, k) + parent = child + } + leaf := key[len(key)-1] + if tp.frozenAt(key) { + return tp.errf("cannot extend inline table %q", strings.Join(key, ".")) + } + pk := pathKey(key) + if tp.parser.dotted[pk] || tp.parser.headers[pk] { + return tp.errf("key %q is not an array of tables", leaf) + } + tp.parser.markArray(pk) + + elem, err := tp.appendElement(parent, leaf, key) + if err != nil { + return err + } + tp.tableMark(parent, leaf) + if !elem.sink { + tp.tables = append(tp.tables, elem) + } + tp.cur = elem + return nil +} + +// appendElement appends one element to the array the leaf names in parent +// and returns its table. A leaf no field claims sinks; a field whose array +// element kind cannot be a table falls back, the tree decode owning the type +// error. +func (tp *targetParser) appendElement(parent *targetTable, leaf string, key []string) (*targetTable, error) { + if parent.sink { + return parent, nil + } + if parent.schema == nil { + if parent.rv.Kind() != reflect.Map { + return nil, errTargetFallback + } + elemT := parent.rv.Type().Elem() + gk := reflect.ValueOf(leaf) + var arr reflect.Value + if existing := parent.rv.MapIndex(gk); existing.IsValid() && !existing.IsNil() { + if existing.Elem().Kind() != reflect.Slice { + return nil, tp.errf("key %q is not an array of tables", leaf) + } + arr = existing.Elem() + } + var elem reflect.Value + switch { + case elemT.Kind() == reflect.Slice: + et := elemT.Elem() + if et.Kind() != reflect.Map || et.Key().Kind() != reflect.String { + return nil, errTargetFallback + } + if !arr.IsValid() { + arr = reflect.MakeSlice(elemT, 0, 4) + } + elem = reflect.MakeMap(et) + case elemT.Kind() == reflect.Interface: + // An any-map entry is built exactly as the tree builds it: a + // []map[string]any slice of entry maps. + if !arr.IsValid() { + arr = reflect.ValueOf([]map[string]any{}) + } + elem = reflect.MakeMap(mapStringAnyType) + default: + return nil, errTargetFallback + } + grown := reflect.Append(arr, elem) + parent.rv.SetMapIndex(gk, grown) + return &targetTable{rv: grown.Index(grown.Len() - 1), path: elementPath(parent.path, leaf, grown.Len()-1)}, nil + } + resolved := leaf + loc, ok := parent.schema.byName[leaf] + if !ok { + resolved = strings.ToLower(leaf) + loc, ok = parent.schema.byName[resolved] + } + if !ok { + parent.recordStrictUnknown(leaf) + if opened, ok := tp.opened[pathKey(parent.path)]; ok { + return opened, nil + } + if tp.tableHas(parent, leaf) { + return nil, tp.errf("key %q is not an array of tables", leaf) + } + sink := &targetTable{sink: true, path: parent.path} + tp.opened[pathKey(parent.path)] = sink + return sink, nil + } + parent.markResolved(resolved) + if tp.keyAssigned(parent, resolved) { + // A key statement already assigned the field its own value; the tree + // holds a value array there and its header append is the + // `not an array of tables` parse error. + return nil, tp.errf("key %q is not an array of tables", leaf) + } + fv, err := fieldByIndex(parent.rv, loc.index) + if err != nil { + return nil, errTargetFallback + } + if fv.Kind() == reflect.Array { + // A fixed-size array fills position by position; one element too many + // is the length mismatch the tree decode reports. + et := derefType(fv.Type().Elem()) + switch et.Kind() { + case reflect.Struct: + if isScalarStruct(et) { + return nil, errTargetFallback + } + addr := fv.Addr().Pointer() + n := tp.arrayNext[addr] + if n >= fv.Len() { + return nil, errTargetFallback + } + tp.arrayNext[addr] = n + 1 + return &targetTable{rv: fv.Index(n), schema: schemaRef(et), path: parent.path, strict: parent.strict}, nil + case reflect.Map: + if et.Key().Kind() != reflect.String { + return nil, errTargetFallback + } + addr := fv.Addr().Pointer() + n := tp.arrayNext[addr] + if n >= fv.Len() { + return nil, errTargetFallback + } + tp.arrayNext[addr] = n + 1 + elem := reflect.MakeMap(et) + fv.Index(n).Set(elem) + return &targetTable{rv: elem, path: parent.path}, nil + } + return nil, errTargetFallback + } + if fv.Kind() != reflect.Slice { + return nil, tp.errf("key %q is not an array of tables", leaf) + } + et := derefType(fv.Type().Elem()) + switch et.Kind() { + case reflect.Struct: + if isScalarStruct(et) { + return nil, errTargetFallback + } + grown := reflect.Append(fv, reflect.New(et).Elem()) + fv.Set(grown) + return &targetTable{rv: grown.Index(grown.Len() - 1), schema: schemaRef(et), path: elementPath(parent.path, leaf, grown.Len()-1), strict: parent.strict}, nil + case reflect.Map: + if et.Key().Kind() != reflect.String { + return nil, errTargetFallback + } + grown := reflect.Append(fv, reflect.MakeMap(et)) + fv.Set(grown) + return &targetTable{rv: grown.Index(grown.Len() - 1), path: elementPath(parent.path, leaf, grown.Len()-1)}, nil + } + return nil, errTargetFallback +} + +// --- keys ------------------------------------------------------------------ + +// parseKeyStatement parses one `key = value` statement into the current +// table, mirroring the tree parser's dotted descent and definition checks. +// The value parses after the descent here, straight into the destination +// where the destination is a plain scalar: the descent and the value scan +// are independent, so the only observable difference is which error a line +// with two faults reports. +func (tp *targetParser) parseKeyStatement() error { + p := tp.parser + first, rest, err := p.parseKeyPath() + if err != nil { + return err + } + p.skipInline() + if p.eof() || p.peek() != '=' { + return p.errf("expected '=' after key") + } + p.pos++ + p.skipInline() + + dest := tp.cur + leaf := first + var dst reflect.Value + var mapDst reflect.Value + var leafTable *targetTable + placed := false + if len(rest) == 0 { + dst, mapDst, leafTable, placed, err = tp.leafInTable(dest, first) + if err != nil { + return err + } + } else { + // A dotted key is the one shape whose bookkeeping needs the statement + // path (the segment freeze and definition checks), so only here does + // the path slice get built. A plain key at the root has no path, and + // a plain key inside a table needs none either. + abs := make([]string, 0, len(dest.path)+len(rest)+1) + abs = append(abs, dest.path...) + abs = append(abs, first) + abs = append(abs, rest...) + dst, mapDst, leafTable, placed, err = tp.descendDotted(dest, first, rest, abs) + if err != nil { + return err + } + leaf = rest[len(rest)-1] + } + if !placed { + // A sink or an unknown key: the value parses for the syntax contract + // and is dropped, but the key still takes the duplicate check, the way + // the tree's maps record every key they receive. The sink's flat key + // set tracks the full statement path, because a dotted key inside a + // sink lands in a sub-table of its own in the tree, not beside the + // leaf name. + val, verr := p.parseValue() + if verr != nil { + return verr + } + dupKey := first + if len(dest.path) > 0 || len(rest) > 0 { + full := make([]string, 0, len(dest.path)+len(rest)+1) + full = append(full, dest.path...) + full = append(full, first) + full = append(full, rest...) + dupKey = pathKey(full) + } + if tp.tableHas(leafTable, dupKey) { + return p.errf("duplicate key %q", leaf) + } + tp.tableMark(leafTable, dupKey) + if m, isMap := val.(map[string]any); isMap { + full := make([]string, 0, len(dest.path)+len(leaf)+1) + full = append(full, dest.path...) + full = append(full, leaf) + p.freezeInline(full, m) + } + return nil + } + if tp.tableHas(leafTable, leaf) { + return p.errf("duplicate key %q", leaf) + } + tp.tableMark(leafTable, leaf) + if dst.Kind() == reflect.Slice || dst.Kind() == reflect.Array { + // Only a field a later [[header]] could append to needs the + // key-assigned record; everything else never checks it. + et := derefType(dst.Type().Elem()) + if et.Kind() == reflect.Struct && !isScalarStruct(et) || et.Kind() == reflect.Map { + tp.markKeyAssigned(leafTable, leaf) + } + } + val, err := tp.parseValueInto(dst) + if err != nil { + return err + } + if mapDst.IsValid() { + mapDst.SetMapIndex(reflect.ValueOf(leaf), dst) + } + if m, isMap := val.(map[string]any); isMap { + // An inline table freezes its paths; the abs slice is built for it + // alone, after the parse proved one is needed. + abs := make([]string, 0, len(dest.path)+len(leaf)+1) + abs = append(abs, dest.path...) + abs = append(abs, leaf) + p.freezeInline(abs, m) + } + return nil +} + +// parseValueInto parses the value at the cursor straight into the +// destination and returns the boxed value the freeze bookkeeping may need +// (non-nil only for inline tables and other composites). Scalars are written +// into the destination without the boxing the tree layer requires. +func (tp *targetParser) parseValueInto(dst reflect.Value) (any, error) { + p := tp.parser + if p.eof() { + return nil, p.errf("expected a value") + } + start := p.pos + if dstHasDecodeHook(dst) { + // The custom hooks take the boxed value the tree layer produces. + v, err := p.parseValue() + if err != nil { + return nil, err + } + if err := tp.d.assign(v, dst); err != nil { + return nil, errTargetFallback + } + return v, nil + } + switch c := p.peek(); { + case c == '"' || c == '\'': + if dst.Kind() == reflect.String { + var s string + var err error + if c == '"' { + s, err = p.parseBasicString() + } else { + s, err = p.parseLiteralString() + } + if err != nil { + return nil, err + } + dst.SetString(s) + return nil, nil + } + case c == 't' || c == 'f': + b, ok := tp.scanBool() + if !ok { + return nil, p.errf("invalid value") + } + if dst.Kind() == reflect.Bool { + dst.SetBool(b) + return nil, nil + } + p.pos = start + case (c >= '0' && c <= '9') || c == '+' || c == '-': + // The plain-digit fast path parses the common integer without a + // token copy; anything else takes the token route, where the strict + // number rules live. + if dstNumericKind(dst) && dst.Kind() != reflect.Float32 && dst.Kind() != reflect.Float64 { + if n, ok := tp.tryFastInt(); ok { + if err := setInt(dst, n); err != nil { + return nil, errTargetFallback + } + return nil, nil + } + } + tok := tp.scanNumberToken() + if dtv, dtok := parseDateTime(tok); dtok { + if err := tp.d.assign(dtv, dst); err != nil { + return nil, errTargetFallback + } + return dtv, nil + } + if dstNumericKind(dst) { + fallback, syntaxErr := numberIntoReflect(dst, tok) + if syntaxErr != nil { + return nil, p.errf("%s", syntaxErr) + } + if fallback { + return nil, errTargetFallback + } + return nil, nil + } + p.pos = start + } + v, err := p.parseValue() + if err != nil { + return nil, err + } + if err := tp.d.assign(v, dst); err != nil { + return nil, errTargetFallback + } + return v, nil +} + +// dstHasDecodeHook reports whether the destination carries one of the custom +// decode interfaces, whose hooks need the boxed value the tree layer makes. +func dstHasDecodeHook(dst reflect.Value) bool { + if _, ok := unmarshalerOf(dst); ok { + return true + } + if _, ok := ctxUnmarshalerOf(dst); ok { + return true + } + if _, ok := textUnmarshalerOf(dst); ok { + return true + } + return false +} + +// scanBool consumes true or false and returns the value, reporting whether +// the token was a boolean at all. +func (tp *targetParser) scanBool() (bool, bool) { + if tp.parser.match("true") { + return true, true + } + if tp.parser.match("false") { + return false, true + } + return false, false +} + +// tryFastInt parses a run of plain decimal digits at the cursor into an +// int64 without materialising the token, reporting whether the token was +// one. A leading zero, an underscore, or any trailing character that is not +// a token terminator hands the token back to the strict number rules. +func (tp *targetParser) tryFastInt() (int64, bool) { + p := tp.parser + i := p.pos + if i >= len(p.src) { + return 0, false + } + if p.src[i] == '+' || p.src[i] == '-' { + return 0, false + } + if p.src[i] == '0' && i+1 < len(p.src) && p.src[i+1] >= '0' && p.src[i+1] <= '9' { + return 0, false + } + var n int64 + digits := 0 + for i < len(p.src) && p.src[i] >= '0' && p.src[i] <= '9' { + if digits >= 18 { + return 0, false + } + n = n*10 + int64(p.src[i]-'0') + i++ + digits++ + } + if digits == 0 { + return 0, false + } + if i < len(p.src) { + switch p.src[i] { + case ' ', '\t', '\n', '\r', ',', ']', '}', '#': + default: + return 0, false + } + } + p.pos = i + return n, true +} + +// scanNumberToken scans a bare number (or date-time) token, including the +// space-separated date and time forms, and returns it as text. +func (tp *targetParser) scanNumberToken() string { + p := tp.parser + start := p.pos + p.scanBareToken() + tok := string(p.src[start:p.pos]) + if isDateToken(tok) && !p.eof() && p.peek() == ' ' { + if next, ok := p.peekAt(1); ok && next >= '0' && next <= '9' { + p.pos++ // consume the separating space + timeStart := p.pos + p.scanBareToken() + tok = tok + " " + string(p.src[timeStart:p.pos]) + } + } + return tok +} + +// dstNumericKind reports whether the destination takes a parsed number. +func dstNumericKind(dst reflect.Value) bool { + switch dst.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + } + return false +} + +// numberIntoReflect parses a number token straight into a numeric +// destination, with the strict validation the tree parser applies. The +// fallback flag reports a destination-level overflow, the tree decode's +// own error; syntaxErr is the token-level error the parser reports. +func numberIntoReflect(dst reflect.Value, tok string) (fallback bool, syntaxErr error) { + v, err := decodeNumber(tok) + if err != nil { + return false, err + } + switch n := v.(type) { + case int64: + if err := setInt(dst, n); err != nil { + return true, nil + } + return false, nil + case float64: + if err := setFloat(dst, n); err != nil { + return true, nil + } + return false, nil + } + return true, nil +} + +// descendDotted walks the dotted segments first..rest[:len(rest)-1] and +// returns the leaf destination: a struct field, or a map entry to set after +// the value assigns. A segment no field claims sinks the rest of the dotted +// key. The per-segment checks and their messages are the tree parser's +// descendKey: an existing value that is not a table is the `not a table` +// error, a missing one is created where the tree creates it and falls back +// where the tree's creation meets a decode-stage type error. +func (tp *targetParser) descendDotted(dest *targetTable, first string, rest []string, abs []string) (dst reflect.Value, mapDst reflect.Value, leafTable *targetTable, placed bool, err error) { + leafTable = dest + if dest.sink { + return reflect.Value{}, reflect.Value{}, leafTable, false, nil + } + tbl := dest + segments := append([]string{first}, rest[:len(rest)-1]...) + for i, seg := range segments { + segAbs := abs[:len(dest.path)+i+1] + if tp.frozenAt(segAbs) { + return reflect.Value{}, reflect.Value{}, leafTable, false, tp.errf("cannot extend inline table %q", strings.Join(segAbs, ".")) + } + if tp.parser.headers[pathKey(segAbs)] { + return reflect.Value{}, reflect.Value{}, leafTable, false, tp.errf("cannot extend table %q with a dotted key", strings.Join(segAbs, ".")) + } + tp.parser.markDotted(pathKey(segAbs)) + child, err := tp.dottedEnter(tbl, seg, segAbs) + if err != nil { + return reflect.Value{}, reflect.Value{}, leafTable, false, err + } + tp.tableMark(tbl, seg) + tbl = child + if tbl.sink { + leafTable = tbl + return reflect.Value{}, reflect.Value{}, leafTable, false, nil + } + } + leaf := rest[len(rest)-1] + leafTable = tbl + if tp.tableHas(tbl, leaf) { + return reflect.Value{}, reflect.Value{}, leafTable, false, tp.errf("duplicate key %q", leaf) + } + if tbl.schema == nil { + if tbl.rv.Kind() != reflect.Map { + return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback + } + elem := reflect.New(tbl.rv.Type().Elem()).Elem() + return elem, tbl.rv, leafTable, true, nil + } + resolved := leaf + loc, found := tbl.schema.byName[leaf] + if !found { + resolved = strings.ToLower(leaf) + loc, found = tbl.schema.byName[resolved] + } + if !found { + tbl.recordStrictUnknown(leaf) + return reflect.Value{}, reflect.Value{}, leafTable, false, nil + } + tbl.markResolved(resolved) + fv, ferr := fieldByIndex(tbl.rv, loc.index) + if ferr != nil { + return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback + } + return fv, reflect.Value{}, leafTable, true, nil +} + +// dottedEnter enters one dotted segment inside tbl. It differs from the +// header descent in the slice and assigned-scalar cases, which the tree's +// descendKey answers with `key is not a table`. +func (tp *targetParser) dottedEnter(tbl *targetTable, seg string, segAbs []string) (*targetTable, error) { + if tbl.sink { + return tbl, nil + } + if tbl.schema == nil { + if tbl.rv.Kind() != reflect.Map { + return nil, errTargetFallback + } + elemT := tbl.rv.Type().Elem() + if elemT.Kind() != reflect.Map || elemT.Key().Kind() != reflect.String { + return nil, errTargetFallback + } + if existing := tbl.rv.MapIndex(reflect.ValueOf(seg)); existing.IsValid() && !existing.IsNil() { + ev := existing.Elem() + if ev.Kind() != reflect.Map { + return nil, tp.errf("key %q is not a table", seg) + } + return &targetTable{rv: ev, path: segAbs}, nil + } + next := reflect.MakeMap(elemT) + tbl.rv.SetMapIndex(reflect.ValueOf(seg), next) + return &targetTable{rv: next, path: segAbs}, nil + } + resolved := seg + loc, ok := tbl.schema.byName[seg] + if !ok { + resolved = strings.ToLower(seg) + loc, ok = tbl.schema.byName[resolved] + } + if !ok { + tbl.recordStrictUnknown(seg) + if opened, ok := tp.opened[pathKey(segAbs)]; ok { + return opened, nil + } + if tp.tableHas(tbl, seg) { + return nil, tp.errf("key %q is not a table", seg) + } + sink := &targetTable{sink: true, path: segAbs} + tp.opened[pathKey(segAbs)] = sink + return sink, nil + } + fv, ferr := fieldByIndex(tbl.rv, loc.index) + if ferr != nil { + return nil, errTargetFallback + } + if fv.Kind() == reflect.Pointer { + if fv.IsNil() { + if !fv.CanSet() { + return nil, errTargetFallback + } + fv.Set(reflect.New(fv.Type().Elem())) + } + fv = fv.Elem() + } + switch fv.Kind() { + case reflect.Struct: + if isScalarStruct(fv.Type()) { + if tp.tableHas(tbl, seg) { + return nil, tp.errf("key %q is not a table", seg) + } + return nil, errTargetFallback + } + return &targetTable{rv: fv, schema: schemaRef(fv.Type()), path: segAbs, strict: tbl.strict}, nil + case reflect.Map: + if fv.Type().Key().Kind() != reflect.String { + return nil, errTargetFallback + } + if fv.IsNil() { + fv.Set(reflect.MakeMap(fv.Type())) + } + return &targetTable{rv: fv, path: segAbs}, nil + } + if tp.tableHas(tbl, seg) { + return nil, tp.errf("key %q is not a table", seg) + } + return nil, errTargetFallback +} + +// leafInTable resolves a plain key in the table. +func (tp *targetParser) leafInTable(dest *targetTable, key string) (dst reflect.Value, mapDst reflect.Value, leafTable *targetTable, placed bool, err error) { + leafTable = dest + if dest.sink { + return reflect.Value{}, reflect.Value{}, leafTable, false, nil + } + if dest.schema == nil { + if dest.rv.Kind() != reflect.Map { + return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback + } + if dest.rv.MapIndex(reflect.ValueOf(key)).IsValid() { + return reflect.Value{}, reflect.Value{}, leafTable, false, tp.errf("duplicate key %q", key) + } + elem := reflect.New(dest.rv.Type().Elem()).Elem() + return elem, dest.rv, leafTable, true, nil + } + resolved := key + loc, ok := dest.schema.byName[key] + if !ok { + resolved = strings.ToLower(key) + loc, ok = dest.schema.byName[resolved] + } + if !ok { + dest.recordStrictUnknown(key) + return reflect.Value{}, reflect.Value{}, leafTable, false, nil + } + dest.markResolved(resolved) + fv, ferr := fieldByIndex(dest.rv, loc.index) + if ferr != nil { + return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback + } + return fv, reflect.Value{}, leafTable, true, nil +} diff --git a/target_test.go b/target_test.go new file mode 100644 index 0000000..4882988 --- /dev/null +++ b/target_test.go @@ -0,0 +1,525 @@ +// Copyright (c) 2026 Petr Balvín (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) + } + }) + } +}