From b45f4d65da8d3c1703fe9884faf0f7ba3d034f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 22 Sep 2026 21:15:00 +0200 Subject: [PATCH] fix(decode): keep the targeted parse on the tree path's contract Assisted-by: GLM 5.3 --- target.go | 560 ++++++++++++++++++++++++++++++++----------------- target_test.go | 350 +++++++++++++++++++++++++++++++ 2 files changed, 719 insertions(+), 191 deletions(-) diff --git a/target.go b/target.go index 51cf7d3..3c3d892 100644 --- a/target.go +++ b/target.go @@ -22,6 +22,14 @@ import ( // 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. +// +// One difference the two paths cannot share: a parse error or a cancellation +// deep in the document leaves the statements before it already written into +// the destination, where the tree path, which parses the whole document +// before it decodes any of it, writes nothing. The value layer shares this +// with encoding/json, whose Unmarshal also leaves a partial destination +// behind a mid-document failure; a destination that must stay untouched on +// error is a destination the caller resets. var errTargetFallback = errors.New("interpres: targeted decode falls back to the tree path") // targetCache holds whether a destination type may take the targeted parse. @@ -33,10 +41,12 @@ var targetCache sync.Map // reflect.Type -> bool 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 +// targeted parse. The structural bans are the shapes whose tree behaviour +// the skeleton cannot model: untagged embedded maps, an OrderedMap anywhere a +// table opens, and a custom decode hook on any table the parse would enter +// directly (a struct field, a map field, or the element of a table slice), +// because the tree hands a hook the whole parsed value. 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 { @@ -59,22 +69,41 @@ func scanTargetable(t reflect.Type, seen map[reflect.Type]bool) bool { return false } for _, loc := range cachedStructSchema(t).byName { - ft := derefType(t.FieldByIndex(loc.index).Type) + if !scanTargetableField(derefType(t.FieldByIndex(loc.index).Type), seen) { + return false + } + } + return true +} + +// scanTargetableField reports whether one field's type is safe for the +// targeted skeleton to fill directly. +func scanTargetableField(ft reflect.Type, seen map[reflect.Type]bool) bool { + switch ft.Kind() { + case reflect.Struct: if ft == orderedMapType { return false } - if ft.Kind() != reflect.Struct || isScalarStruct(ft) { - continue + if isScalarStruct(ft) { + return true } - // 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. + // A struct the parse enters directly never builds the whole value + // the tree hands a hook, so the hook must win. if implementsDecodeHook(ft) || implementsDecodeHook(reflect.PointerTo(ft)) { return false } - if !scanTargetable(ft, seen) { + return scanTargetable(ft, seen) + case reflect.Map: + return !implementsDecodeHook(ft) && !implementsDecodeHook(reflect.PointerTo(ft)) + case reflect.Slice, reflect.Array: + et := derefType(ft.Elem()) + if et == orderedMapType { return false } + if et.Kind() == reflect.Struct && !isScalarStruct(et) { + return scanTargetableField(et, seen) + } + return true } return true } @@ -110,20 +139,45 @@ func canTargetDecode(v any) bool { // 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. +// destination), and the absolute path its errors wrap. A sink is the +// destination an unknown subtree gets: its statements parse for the syntax +// and definition contracts, and its values are discarded. The strict and +// required findings live in the parser's per-address store, not here, +// because the table objects a dotted descent builds are transient while the +// destination is not. 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 + // strict is the strict-decode setting the table was opened with, carried + // into the per-address strict state on first sight. + strict bool + // arrayElem marks a sink created as the element of an unknown array of + // tables: a dotted key may not enter it, the tree's own rule for an + // array, while a [sub-table] header may, through the last element. + arrayElem bool + keys []string // the keys defined in a sink, as full path keys +} + +// strictState is the strict and required bookkeeping of one struct +// destination, keyed by the value's address. +type strictState struct { + path []string + typ reflect.Type + schema *structSchema + strict bool + unknown string // strict: the smallest unclaimed key so far + resolved map[string]bool +} + +// arrayFill tracks how many elements of one fixed-size array the document +// has filled, with what the length mismatch the tree decode reports needs: +// the array's type and its field's path. +type arrayFill struct { + next int + typ reflect.Type + path []string } // targetParser parses a document straight into a struct destination. It @@ -139,61 +193,53 @@ type targetParser struct { 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 + // arrayFills counts the elements of each fixed-size array the document + // has filled, per field address, in the order the arrays were met. + arrayFills map[uintptr]*arrayFill + fillOrder []*arrayFill + + // appendedHere records the slice fields this document's [[headers]] have + // filled: a slice the caller prefilled is replaced by the tree decode, + // not appended to, so the first header over one falls back. + appendedHere map[uintptr]bool // 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 -} + // mapKeysByAddr holds the keys the document has defined in one map + // destination, keyed the same way: the destination map the caller + // prefilled is not the parser's state, and a key it holds is not the + // duplicate a key the document repeats is. + mapKeysByAddr map[uintptr]map[string]bool -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] + // strictByAddr holds each destination's strict and required findings, + // with strictOrder keeping the document order they first appeared in. + strictByAddr map[uintptr]*strictState + strictOrder []uintptr } // 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. +// Struct tables keep their keys by destination address, and map tables keep +// theirs there too, because the table object a dotted descent builds is +// transient while the destination is not, and the destination map's own +// contents are the caller's, not the document's. 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() + if t.rv.Kind() != reflect.Map || !t.rv.CanAddr() { + return false + } + return tp.mapKeysByAddr[t.rv.Addr().Pointer()][key] default: return slices.Contains(tp.tableKeys(t), key) } @@ -217,6 +263,17 @@ func (tp *targetParser) tableMark(t *targetTable, key string) { case t.sink: t.keys = append(t.keys, key) case t.schema == nil: + if !t.rv.CanAddr() || t.rv.Kind() != reflect.Map { + return + } + addr := t.rv.Addr().Pointer() + if tp.mapKeysByAddr == nil { + tp.mapKeysByAddr = make(map[uintptr]map[string]bool, 8) + } + if tp.mapKeysByAddr[addr] == nil { + tp.mapKeysByAddr[addr] = make(map[string]bool, 8) + } + tp.mapKeysByAddr[addr][key] = true default: if !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { return @@ -229,32 +286,49 @@ func (tp *targetParser) tableMark(t *targetTable, key string) { } } -// 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] +// strictState returns the strict and required bookkeeping of the struct +// destination t fills, registering it on first sight so a finding recorded +// on a transient table survives the table. +func (tp *targetParser) strictState(t *targetTable) *strictState { + addr := t.rv.Addr().Pointer() + if st, ok := tp.strictByAddr[addr]; ok { + return st + } + st := &strictState{ + path: slices.Clone(t.path), + typ: t.rv.Type(), + schema: t.schema, + strict: t.strict, + resolved: make(map[string]bool, 8), + } + tp.strictByAddr[addr] = st + tp.strictOrder = append(tp.strictOrder, addr) + return st } -func (t *targetTable) markResolved(key string) { +// markResolved records that the document resolved the schema key, 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 (tp *targetParser) markResolved(t *targetTable, key string) { if t.schema == nil || len(t.schema.required) == 0 { return } - if t.resolvedSeen == nil { - t.resolvedSeen = make(map[string]bool, 8) + if !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { + return } - t.resolvedSeen[key] = true + tp.strictState(t).resolved[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 { +func (tp *targetParser) recordStrictUnknown(t *targetTable, key string) { + if !t.strict || t.schema == nil || !t.rv.CanAddr() || t.rv.Kind() != reflect.Struct { return } - if t.unknown == "" || key < t.unknown { - t.unknown = key + st := tp.strictState(t) + if st.unknown == "" || key < st.unknown { + st.unknown = key } } @@ -274,12 +348,13 @@ func parseIntoTargeted(ctx context.Context, data []byte, d *decoder, useNumber b } 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), + parser: &parser{src: data, line: 1, ctx: ctx, maxDepth: maxDepth, useNumber: useNumber}, + d: d, + root: rv.Elem(), + arrayFills: make(map[uintptr]*arrayFill, 4), + appendedHere: make(map[uintptr]bool, 4), + opened: make(map[string]*targetTable, 8), + strictByAddr: make(map[uintptr]*strictState, 8), } tp.rootT = &targetTable{rv: tp.root, schema: schemaRef(tp.root.Type()), strict: d.disallowUnknown} tp.tables = append(tp.tables, tp.rootT) @@ -326,35 +401,57 @@ func (tp *targetParser) run() error { } // 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. +// order. A fixed-size array the document under-filled is the length mismatch +// the tree decode raises, and it comes first. Then the unknown keys, before +// the required ones, because the tree decode meets an unknown key while it +// assigns and checks a table's required keys only once the whole table has +// been; within each class the order is the order the destinations first +// appeared in, the document's own order. func (tp *targetParser) reportDeferred() error { - for _, t := range append([]*targetTable{tp.rootT}, tp.tables...) { - if t.sink { + for _, f := range tp.fillOrder { + if f.next != f.typ.Len() { + return wrapTablePath(f.path, fmt.Errorf("interpres: cannot assign %d elements to %s", f.next, f.typ)) + } + } + for _, addr := range tp.strictOrder { + if st := tp.strictByAddr[addr]; st.strict && st.unknown != "" { + return wrapTablePath(st.path, fmt.Errorf("interpres: unknown field %q for %s", st.unknown, st.typ)) + } + } + for _, addr := range tp.strictOrder { + st := tp.strictByAddr[addr] + if st.schema == nil { 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)) - } + for _, key := range st.schema.required { + if !st.resolved[key] { + return wrapTablePath(st.path, fmt.Errorf("interpres: missing required key %q", key)) } } } return nil } -// wrapTableErr wraps a table's finding the way the tree decode wraps it: the +// wrapTablePath 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 { +func wrapTablePath(path []string, err error) error { + if len(path) == 0 { return err } - return &DecodeError{Path: Path(slices.Clone(t.path)), Err: err} + return &DecodeError{Path: Path(slices.Clone(path)), Err: err} +} + +// arrayFillFor returns the fill record of the fixed-size array fv, keyed by +// its address, created with its field's path on first sight. +func (tp *targetParser) arrayFillFor(fv reflect.Value, path []string) *arrayFill { + addr := fv.Addr().Pointer() + if f, ok := tp.arrayFills[addr]; ok { + return f + } + f := &arrayFill{typ: fv.Type(), path: path} + tp.arrayFills[addr] = f + tp.fillOrder = append(tp.fillOrder, f) + return f } // --- headers --------------------------------------------------------------- @@ -474,11 +571,11 @@ func (tp *targetParser) descendOne(parent *targetTable, key string, abs []string return nil, errTargetFallback } if existing := parent.rv.MapIndex(reflect.ValueOf(key)); existing.IsValid() && !existing.IsNil() { - return &targetTable{rv: existing.Elem(), path: abs}, nil + return &targetTable{rv: existing, path: slices.Clone(abs)}, nil } next := reflect.MakeMap(elemT) parent.rv.SetMapIndex(reflect.ValueOf(key), next) - return &targetTable{rv: next, path: abs}, nil + return &targetTable{rv: next, path: slices.Clone(abs)}, nil } resolved := key loc, ok := parent.schema.byName[key] @@ -487,18 +584,18 @@ func (tp *targetParser) descendOne(parent *targetTable, key string, abs []string loc, ok = parent.schema.byName[resolved] } if !ok { - parent.recordStrictUnknown(key) + tp.recordStrictUnknown(parent, 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} + sink := &targetTable{sink: true, path: slices.Clone(abs)} tp.opened[pathKey(abs)] = sink return sink, nil } - parent.markResolved(resolved) + tp.markResolved(parent, resolved) fv, err := fieldByIndex(parent.rv, loc.index) if err != nil { return nil, errTargetFallback @@ -509,10 +606,12 @@ func (tp *targetParser) descendOne(parent *targetTable, key string, abs []string // 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. +// tables enters its last filled element, a slice enters the last element of +// an array this document's [[headers]] built (a prefilled slice is a table +// the tree decode rejects, so it falls back), and anything else is a type +// mismatch the tree decode reports, so it falls back too. 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() { @@ -528,7 +627,7 @@ func (tp *targetParser) openValueTable(fv reflect.Value, parent *targetTable, ke if isScalarStruct(fv.Type()) { return nil, errTargetFallback } - return &targetTable{rv: fv, schema: schemaRef(fv.Type()), path: abs, strict: strict}, nil + return &targetTable{rv: fv, schema: schemaRef(fv.Type()), path: slices.Clone(abs), strict: strict}, nil case reflect.Map: if fv.Type().Key().Kind() != reflect.String { return nil, errTargetFallback @@ -536,11 +635,12 @@ func (tp *targetParser) openValueTable(fv reflect.Value, parent *targetTable, ke if fv.IsNil() { fv.Set(reflect.MakeMap(fv.Type())) } - return &targetTable{rv: fv, path: abs}, nil + return &targetTable{rv: fv, path: slices.Clone(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. + if !tp.appendedHere[fv.Addr().Pointer()] { + // No [[header]] of this document filled it, so the tree holds a + // map here, whose decode raises the type mismatch; a prefilled + // slice is the tree's replacement case, not a table to enter. return nil, errTargetFallback } et := derefType(fv.Type().Elem()) @@ -548,6 +648,22 @@ func (tp *targetParser) openValueTable(fv reflect.Value, parent *targetTable, ke return nil, errTargetFallback } return &targetTable{rv: fv.Index(fv.Len() - 1), schema: schemaRef(et), path: elementPath(abs, key, fv.Len()-1), strict: strict}, nil + case reflect.Array: + fill := tp.arrayFillFor(fv, append(slices.Clone(parent.path), key)) + if fill.next == 0 { + return nil, errTargetFallback + } + et := derefType(fv.Type().Elem()) + if et.Kind() == reflect.Struct { + if isScalarStruct(et) { + return nil, errTargetFallback + } + return &targetTable{rv: fv.Index(fill.next - 1), schema: schemaRef(et), path: elementPath(abs, key, fill.next-1), strict: strict}, nil + } + if et.Kind() == reflect.Map && et.Key().Kind() == reflect.String { + return &targetTable{rv: fv.Index(fill.next - 1), path: elementPath(abs, key, fill.next-1)}, nil + } + return nil, errTargetFallback } if tp.tableHas(parent, key) { return nil, tp.errf("key %q is not a table", key) @@ -578,12 +694,30 @@ func (tp *targetParser) appendArrayTable(key []string) error { if tp.parser.dotted[pk] || tp.parser.headers[pk] { return tp.errf("key %q is not an array of tables", leaf) } + // A leaf the document already defined as a value or a table is the tree + // parser's own parse error, and a leaf an earlier [[header]] defined + // opens a new element; the arrays map, read before this header marks it, + // is what tells the two apart. A sink parent holds no destination state + // worth consulting. + if !parent.sink && !tp.parser.arrays[pk] && tp.tableHas(parent, leaf) { + return tp.errf("key %q is not an array of tables", leaf) + } + // A new element starts a fresh scope, exactly as the tree parser's own + // header does: sub-headers, inline freezes and nested arrays from the + // previous element no longer apply. + tp.parser.resetScopeUnder(key) tp.parser.markArray(pk) elem, err := tp.appendElement(parent, leaf, key) if err != nil { return err } + if elem.sink { + // A sink the element scope reuses ([[a.b]] over an unknown a, the + // parent sink) starts the new element with no keys, the way a known + // array's element does. + elem.keys = nil + } tp.tableMark(parent, leaf) if !elem.sink { tp.tables = append(tp.tables, elem) @@ -593,9 +727,9 @@ func (tp *targetParser) appendArrayTable(key []string) error { } // 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. +// and returns its table. A leaf no field claims sinks, a fresh namespace per +// element; 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 @@ -608,10 +742,15 @@ func (tp *targetParser) appendElement(parent *targetTable, leaf string, key []st gk := reflect.ValueOf(leaf) var arr reflect.Value if existing := parent.rv.MapIndex(gk); existing.IsValid() && !existing.IsNil() { - if existing.Elem().Kind() != reflect.Slice { + // A map[string]any destination boxes its arrays in the + // interface; a typed map hands the slice itself. + if existing.Kind() == reflect.Interface { + existing = existing.Elem() + } + if existing.Kind() != reflect.Slice { return nil, tp.errf("key %q is not an array of tables", leaf) } - arr = existing.Elem() + arr = existing } var elem reflect.Value switch { @@ -645,62 +784,64 @@ func (tp *targetParser) appendElement(parent *targetTable, leaf string, key []st 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 + tp.recordStrictUnknown(parent, leaf) + // Every element is a fresh namespace, the way a known array's is, + // registered under the header's path so a [sub-table] header reaches + // the last element, the tree's rule for a header under an array of + // tables; a dotted key skips it, the tree's rule for an array. + sink := &targetTable{sink: true, arrayElem: true, path: slices.Clone(key)} + tp.opened[pathKey(key)] = 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) - } + tp.markResolved(parent, resolved) fv, err := fieldByIndex(parent.rv, loc.index) if err != nil { return nil, errTargetFallback } + fieldPath := append(slices.Clone(parent.path), leaf) 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. + // is the length mismatch the tree decode reports, and one too few is + // the same mismatch, checked when the parse completes. + fill := tp.arrayFillFor(fv, fieldPath) et := derefType(fv.Type().Elem()) - switch et.Kind() { - case reflect.Struct: - if isScalarStruct(et) { + switch { + case et.Kind() == reflect.Struct && !isScalarStruct(et): + if fill.next >= fv.Len() { return nil, errTargetFallback } - addr := fv.Addr().Pointer() - n := tp.arrayNext[addr] - if n >= fv.Len() { + n := fill.next + fill.next = n + 1 + return &targetTable{rv: fv.Index(n), schema: schemaRef(et), path: elementPath(parent.path, leaf, n), strict: parent.strict}, nil + case et.Kind() == reflect.Map && et.Key().Kind() == reflect.String: + if fill.next >= 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 + n := fill.next + fill.next = n + 1 elem := reflect.MakeMap(et) fv.Index(n).Set(elem) - return &targetTable{rv: elem, path: parent.path}, nil + return &targetTable{rv: elem, path: elementPath(parent.path, leaf, n)}, nil } return nil, errTargetFallback } if fv.Kind() != reflect.Slice { - return nil, tp.errf("key %q is not an array of tables", leaf) + if tp.tableHas(parent, leaf) { + return nil, tp.errf("key %q is not an array of tables", leaf) + } + // The tree builds an array here without asking the destination, and + // its decode answers with the type mismatch; the fallback keeps the + // message the tree path gives. + return nil, errTargetFallback + } + addr := fv.Addr().Pointer() + if !tp.appendedHere[addr] { + if fv.Len() > 0 { + // A slice the caller prefilled is replaced by the tree decode, + // not appended to; the fallback runs the document the tree's way. + return nil, errTargetFallback + } + tp.appendedHere[addr] = true } et := derefType(fv.Type().Elem()) switch et.Kind() { @@ -708,16 +849,38 @@ func (tp *targetParser) appendElement(parent *targetTable, leaf string, key []st if isScalarStruct(et) { return nil, errTargetFallback } - grown := reflect.Append(fv, reflect.New(et).Elem()) + // A pointer element is appended as the allocated pointer and filled + // through its pointee, so []*T takes the same path []T does. The + // element the table fills is the slice's own: the value a New built + // stands apart from the backing array. + var el, appended reflect.Value + if fv.Type().Elem().Kind() == reflect.Pointer { + p := reflect.New(et) + el, appended = p.Elem(), p + } else { + el = reflect.New(et).Elem() + appended = el + } + grown := reflect.Append(fv, appended) 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 + if fv.Type().Elem().Kind() != reflect.Pointer { + el = grown.Index(grown.Len() - 1) + } + return &targetTable{rv: el, 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)) + m := reflect.MakeMap(et) + var appended reflect.Value = m + if fv.Type().Elem().Kind() == reflect.Pointer { + p := reflect.New(et) + p.Elem().Set(m) + appended = p + } + grown := reflect.Append(fv, appended) fv.Set(grown) - return &targetTable{rv: grown.Index(grown.Len() - 1), path: elementPath(parent.path, leaf, grown.Len()-1)}, nil + return &targetTable{rv: m, path: elementPath(parent.path, leaf, grown.Len()-1)}, nil } return nil, errTargetFallback } @@ -780,22 +943,18 @@ func (tp *targetParser) parseKeyStatement() error { 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) + full := append([]string{first}, rest...) + if len(dest.path) > 0 { + full = append(slices.Clone(dest.path), full...) } - if tp.tableHas(leafTable, dupKey) { + if tp.tableHas(leafTable, pathKey(full)) { return p.errf("duplicate key %q", leaf) } - tp.tableMark(leafTable, dupKey) + tp.tableMark(leafTable, pathKey(full)) 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) + // An inline table freezes the whole path the statement wrote, + // intermediate segments included, so no later header or dotted + // key can extend it at any depth. p.freezeInline(full, m) } return nil @@ -804,27 +963,29 @@ func (tp *targetParser) parseKeyStatement() error { 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 { + if errors.Is(err, errTargetFallback) { + return err + } + if _, isSyntax := errors.AsType[*SyntaxError](err); !isSyntax { + // A hook's own failure, which the fallback must not rerun: it + // returns wrapped the way the tree decode wraps a field's. + return newDecodeError(leaf, err) + } 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) + // An inline table freezes the whole path the statement wrote, + // intermediate segments included; the slice is built for it alone, + // after the parse proved one is needed. + abs := make([]string, 0, len(dest.path)+len(rest)+1) abs = append(abs, dest.path...) - abs = append(abs, leaf) + abs = append(abs, first) + abs = append(abs, rest...) p.freezeInline(abs, m) } return nil @@ -847,7 +1008,10 @@ func (tp *targetParser) parseValueInto(dst reflect.Value) (any, error) { return nil, err } if err := tp.d.assign(v, dst); err != nil { - return nil, errTargetFallback + // The hook has run; falling back would run it a second time on + // tree path, so its error returns as the tree path's own, for + // the caller to wrap the way the tree decode wraps a field's. + return nil, err } return v, nil } @@ -890,7 +1054,16 @@ func (tp *targetParser) parseValueInto(dst reflect.Value) (any, error) { } } tok := tp.scanNumberToken() - if dtv, dtok := parseDateTime(tok); dtok { + if hasHighByte(tok) && invalidUTF8Offset(tok) >= 0 { + // The token route the targeted parse takes validates UTF-8 the + // way the tree scanner does, on the byte that does not decode. + return nil, p.errf("invalid UTF-8 in value at byte offset %d", start+invalidUTF8Offset(tok)) + } + dtv, isDT, dterr := parseDateTime(tok) + if dterr != nil { + return nil, p.errf("%s", dterr) + } + if isDT { if err := tp.d.assign(dtv, dst); err != nil { return nil, errTargetFallback } @@ -1091,10 +1264,10 @@ func (tp *targetParser) descendDotted(dest *targetTable, first string, rest []st loc, found = tbl.schema.byName[resolved] } if !found { - tbl.recordStrictUnknown(leaf) + tp.recordStrictUnknown(tbl, leaf) return reflect.Value{}, reflect.Value{}, leafTable, false, nil } - tbl.markResolved(resolved) + tp.markResolved(tbl, resolved) fv, ferr := fieldByIndex(tbl.rv, loc.index) if ferr != nil { return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback @@ -1118,15 +1291,14 @@ func (tp *targetParser) dottedEnter(tbl *targetTable, seg string, segAbs []strin return nil, errTargetFallback } if existing := tbl.rv.MapIndex(reflect.ValueOf(seg)); existing.IsValid() && !existing.IsNil() { - ev := existing.Elem() - if ev.Kind() != reflect.Map { + if existing.Kind() != reflect.Map { return nil, tp.errf("key %q is not a table", seg) } - return &targetTable{rv: ev, path: segAbs}, nil + return &targetTable{rv: existing, path: slices.Clone(segAbs)}, nil } next := reflect.MakeMap(elemT) tbl.rv.SetMapIndex(reflect.ValueOf(seg), next) - return &targetTable{rv: next, path: segAbs}, nil + return &targetTable{rv: next, path: slices.Clone(segAbs)}, nil } resolved := seg loc, ok := tbl.schema.byName[seg] @@ -1135,17 +1307,21 @@ func (tp *targetParser) dottedEnter(tbl *targetTable, seg string, segAbs []strin loc, ok = tbl.schema.byName[resolved] } if !ok { - tbl.recordStrictUnknown(seg) - if opened, ok := tp.opened[pathKey(segAbs)]; ok { + tp.recordStrictUnknown(tbl, seg) + // A sink an array element created is entered by a [sub-table] + // header, through the last element, but never by a dotted key: the + // tree's descendKey rejects an array where tableAt follows it. + if opened, ok := tp.opened[pathKey(segAbs)]; ok && !opened.arrayElem { 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} + sink := &targetTable{sink: true, path: slices.Clone(segAbs)} tp.opened[pathKey(segAbs)] = sink return sink, nil } + tp.markResolved(tbl, resolved) fv, ferr := fieldByIndex(tbl.rv, loc.index) if ferr != nil { return nil, errTargetFallback @@ -1167,7 +1343,7 @@ func (tp *targetParser) dottedEnter(tbl *targetTable, seg string, segAbs []strin } return nil, errTargetFallback } - return &targetTable{rv: fv, schema: schemaRef(fv.Type()), path: segAbs, strict: tbl.strict}, nil + return &targetTable{rv: fv, schema: schemaRef(fv.Type()), path: slices.Clone(segAbs), strict: tbl.strict}, nil case reflect.Map: if fv.Type().Key().Kind() != reflect.String { return nil, errTargetFallback @@ -1175,7 +1351,7 @@ func (tp *targetParser) dottedEnter(tbl *targetTable, seg string, segAbs []strin if fv.IsNil() { fv.Set(reflect.MakeMap(fv.Type())) } - return &targetTable{rv: fv, path: segAbs}, nil + return &targetTable{rv: fv, path: slices.Clone(segAbs)}, nil } if tp.tableHas(tbl, seg) { return nil, tp.errf("key %q is not a table", seg) @@ -1193,7 +1369,9 @@ func (tp *targetParser) leafInTable(dest *targetTable, key string) (dst reflect. if dest.rv.Kind() != reflect.Map { return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback } - if dest.rv.MapIndex(reflect.ValueOf(key)).IsValid() { + if tp.tableHas(dest, key) { + // The duplicate check reads the keys the document defined, not + // the destination map's own contents, which are the caller's. return reflect.Value{}, reflect.Value{}, leafTable, false, tp.errf("duplicate key %q", key) } elem := reflect.New(dest.rv.Type().Elem()).Elem() @@ -1206,10 +1384,10 @@ func (tp *targetParser) leafInTable(dest *targetTable, key string) (dst reflect. loc, ok = dest.schema.byName[resolved] } if !ok { - dest.recordStrictUnknown(key) + tp.recordStrictUnknown(dest, key) return reflect.Value{}, reflect.Value{}, leafTable, false, nil } - dest.markResolved(resolved) + tp.markResolved(dest, resolved) fv, ferr := fieldByIndex(dest.rv, loc.index) if ferr != nil { return reflect.Value{}, reflect.Value{}, leafTable, false, errTargetFallback diff --git a/target_test.go b/target_test.go index 5256b07..3edfdf5 100644 --- a/target_test.go +++ b/target_test.go @@ -4,9 +4,12 @@ package interpres import ( + "errors" + "maps" "net" "reflect" "strings" + "sync/atomic" "testing" "time" ) @@ -522,3 +525,350 @@ func TestTargetedMapTableShapes(t *testing.T) { }) } } + +// TestTargetedNestedMapDescents pins the descents into a map of maps that +// meet entries the document built earlier: a dotted key twice through the +// same sub-table, a header into a dotted-built sub-table, and a typed array +// under a map key. Each shape once panicked on a reflect Elem of a map. +func TestTargetedNestedMapDescents(t *testing.T) { + t.Run("dotted key through one sub-table twice", func(t *testing.T) { + var cfg struct { + M map[string]map[string]any `toml:"m"` + } + err := Unmarshal([]byte("m.a.b = 1\nm.a.c = 2\n"), &cfg) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.M["a"]["b"] != int64(1) || cfg.M["a"]["c"] != int64(2) { + t.Errorf("m = %#v", cfg.M) + } + }) + t.Run("header under a dotted-built sub-table", func(t *testing.T) { + var cfg struct { + M map[string]map[string]any `toml:"m"` + } + err := Unmarshal([]byte("m.a.b = 1\n[m.a.deep]\nx = 2\n"), &cfg) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.M["a"]["b"] != int64(1) || cfg.M["a"]["deep"].(map[string]any)["x"] != int64(2) { + t.Errorf("m = %#v", cfg.M) + } + }) + t.Run("typed array under a map key", func(t *testing.T) { + var cfg struct { + M map[string][]map[string]any `toml:"m"` + } + err := Unmarshal([]byte("[[m.arr]]\nx = 1\n\n[[m.arr]]\ny = 2\n"), &cfg) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.M["arr"]) != 2 || cfg.M["arr"][1]["y"] != int64(2) { + t.Errorf("m = %#v", cfg.M) + } + }) +} + +// TestTargetedPointerElementSlice pins that an array of tables over a slice +// of pointer elements fills the pointed-to structs. +func TestTargetedPointerElementSlice(t *testing.T) { + type item struct { + N int `toml:"n"` + } + var cfg struct { + Items []*item `toml:"items"` + } + err := Unmarshal([]byte("[[items]]\nn = 1\n\n[[items]]\nn = 2\n"), &cfg) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Items) != 2 || cfg.Items[0] == nil || cfg.Items[1].N != 2 { + t.Errorf("items = %#v", cfg.Items) + } +} + +// TestTargetedArrayScopeResets pins that a new element of an array of tables +// starts a fresh definition scope, the contract the changelog documents. +func TestTargetedArrayScopeResets(t *testing.T) { + doc := "[[a]]\nb.c = 1\n\n[[a]]\n\n[a.b]\nx = 1\n" + var ref, tgt targetCfg + refErr := treeDecodeInto([]byte(doc), &ref) + if refErr != nil { + t.Fatalf("tree decode: %v", refErr) + } + if err := Unmarshal([]byte(doc), &tgt); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(ref, tgt) { + t.Errorf("targeted = %#v, tree = %#v", tgt, ref) + } +} + +// TestTargetedUnknownArrayElements pins that every element of an unknown +// array of tables is a fresh namespace, and a sub-table header reaches the +// last element the way the tree parser's does. +func TestTargetedUnknownArrayElements(t *testing.T) { + doc := "[[zz]]\nk = 1\n\n[[zz]]\nk = 2\n\n[zz.sub]\nx = 3\n" + var ref, tgt targetCfg + refErr := treeDecodeInto([]byte(doc), &ref) + tgtErr := Unmarshal([]byte(doc), &tgt) + if (refErr == nil) != (tgtErr == nil) { + t.Fatalf("error presence disagrees: tree %v, targeted %v", refErr, tgtErr) + } + if refErr != nil { + return + } + if !reflect.DeepEqual(ref, tgt) { + t.Errorf("targeted = %#v, tree = %#v", tgt, ref) + } + // A dotted key may not enter the array: the tree's own rule. + var dotted targetCfg + dErr := Unmarshal([]byte("[[zz]]\nk = 1\nzz.x = 2\n"), &dotted) + refDotted := treeDecodeInto([]byte("[[zz]]\nk = 1\nzz.x = 2\n"), &dotted) + if (dErr == nil) != (refDotted == nil) { + t.Errorf("dotted into an array: targeted %v, tree %v", dErr, refDotted) + } +} + +// TestTargetedFixedArrayUnderFill pins that a fixed-size array the document +// under-fills is the length mismatch the tree decode raises, with the +// field's path. +func TestTargetedFixedArrayUnderFill(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"), &cfg) + if err == nil { + t.Fatal("unmarshal accepted an under-filled array") + } + want := `interpres: items: cannot assign 1 elements to [2]interpres.item` + if err.Error() != want { + t.Errorf("err = %v\nwant %q", err, want) + } +} + +// TestTargetedPrefilledSliceReplaced pins that a prefilled slice is replaced +// by the document's elements on both paths, not appended to. +func TestTargetedPrefilledSliceReplaced(t *testing.T) { + type item struct { + N int `toml:"n"` + } + doc := []byte("[[items]]\nn = 1\n") + var ref struct { + Items []item `toml:"items"` + } + ref.Items = []item{{N: 9}} + if err := treeDecodeInto(doc, &ref); err != nil { + t.Fatalf("tree decode: %v", err) + } + var tgt struct { + Items []item `toml:"items"` + } + tgt.Items = []item{{N: 9}} + if err := Unmarshal(doc, &tgt); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(ref, tgt) { + t.Errorf("targeted = %#v, tree = %#v", tgt, ref) + } + if len(tgt.Items) != 1 || tgt.Items[0].N != 1 { + t.Errorf("items = %#v, want the prefilled element replaced", tgt.Items) + } +} + +// TestTargetedHeaderOverValueArrayKeepsCase pins that a value array assigned +// under a differently cased key than the field's name still blocks the +// array-of-tables header over it, the tree parse error. +func TestTargetedHeaderOverValueArrayKeepsCase(t *testing.T) { + var cfg struct { + Arr []targetNested `toml:"arr"` + } + err := Unmarshal([]byte("Arr = [{x = 1}]\n[[Arr]]\nx = 2\n"), &cfg) + if err == nil || err.Error() != `interpres: line 2: key "Arr" is not an array of tables` { + t.Errorf("err = %v, want the parse error over the assigned field", err) + } +} + +// TestTargetedDottedInlineFreezePath pins that an inline table assigned by a +// dotted key freezes the whole path the statement wrote: a later header +// under that path is the extension error, and a key outside it stays free. +func TestTargetedDottedInlineFreezePath(t *testing.T) { + var cfg targetCfg + err := Unmarshal([]byte("m.a.b = {x = 1}\nb.y = 2\n"), &cfg) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + err = Unmarshal([]byte("m.a.b = {x = 1}\n[m.a.b]\ny = 2\n"), &cfg) + want := `interpres: line 2: cannot extend inline table "m.a.b"` + if err == nil || err.Error() != want { + t.Errorf("err = %v\nwant %q", err, want) + } +} + +// TestTargetedStrictThroughDottedKeys pins that strict and required findings +// survive the transient tables a dotted descent builds. +func TestTargetedStrictThroughDottedKeys(t *testing.T) { + var cfg targetCfg + err := Unmarshal([]byte("tab.zz = 1\n"), &cfg, RejectUnknownFields(true)) + if err == nil || !strings.Contains(err.Error(), `unknown field "zz"`) { + t.Errorf("err = %v, want the strict failure through the dotted key", err) + } + if err == nil || !strings.HasPrefix(err.Error(), "interpres: tab:") { + t.Errorf("err = %v, want the path through the dotted key", err) + } +} + +// TestTargetedRequiredThroughDottedKeys pins that a required tag is honoured +// when the table is reached only through dotted keys. +func TestTargetedRequiredThroughDottedKeys(t *testing.T) { + type nested struct { + X int `toml:"x,required"` + Y int `toml:"y"` + } + var cfg struct { + Tab nested `toml:"tab"` + } + err := Unmarshal([]byte("tab.y = 1\n"), &cfg) + if err == nil || !strings.Contains(err.Error(), `missing required key "x"`) { + t.Errorf("err = %v, want the missing required key through the dotted key", err) + } +} + +// TestTargetedOrderedMapSliceFallsBack pins that a slice of OrderedMap +// elements takes the tree path, whose fill keeps the written order. +func TestTargetedOrderedMapSliceFallsBack(t *testing.T) { + var cfg struct { + Items []OrderedMap `toml:"items"` + } + err := Unmarshal([]byte("[[items]]\nk = \"v\"\n"), &cfg) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Items) != 1 || cfg.Items[0].Keys()[0] != "k" { + t.Errorf("items = %#v, want the element filled in written order", cfg.Items) + } +} + +// hookMap is a named map type whose decode hook counts its calls. +type hookMap map[string]any + +var hookMapCalls atomic.Int32 + +func (h *hookMap) UnmarshalTOML(data any) error { + hookMapCalls.Add(1) + m, _ := data.(map[string]any) + if *h == nil { + *h = hookMap{} + } + maps.Copy((*h), m) + return nil +} + +// TestTargetedMapFieldHookGetsWholeTable pins that a named map field with a +// decode hook receives the whole parsed table, even in its header form. +func TestTargetedMapFieldHookGetsWholeTable(t *testing.T) { + type cfg struct { + M hookMap `toml:"m"` + } + var c cfg + hookMapCalls.Store(0) + err := Unmarshal([]byte("[m]\na = 1\nb = 2\n"), &c) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if hookMapCalls.Load() != 1 { + t.Errorf("hook calls = %d, want exactly one with the whole table", hookMapCalls.Load()) + } + if c.M["a"] != int64(1) || c.M["b"] != int64(2) { + t.Errorf("m = %#v", c.M) + } +} + +// errHook fails every decode with a fixed error and counts its calls. +type errHook struct{ calls *int } + +func (e *errHook) UnmarshalTOML(any) error { + if e.calls != nil { + *e.calls++ + } + return errors.New("boom") +} + +// TestTargetedHookErrorRunsOnce pins that a failing hook's error is the +// tree path's own, wrapped with the key, and that the hook is not run a +// second time by a fallback. +func TestTargetedHookErrorRunsOnce(t *testing.T) { + calls := 0 + cfg := struct { + F errHook `toml:"f"` + }{F: errHook{calls: &calls}} + err := Unmarshal([]byte("f = 1\n"), &cfg) + if err == nil || err.Error() != "interpres: f: unmarshal: boom" { + t.Errorf("err = %v, want the wrapped hook failure", err) + } + if calls != 1 { + t.Errorf("hook calls = %d, want one", calls) + } +} + +// TestTargetedUnknownBeforeRequired pins the report order the tree decode +// produces: an unknown key wins over a missing required one. +func TestTargetedUnknownBeforeRequired(t *testing.T) { + type inner struct { + X int `toml:"x,required"` + } + var cfg struct { + Tab inner `toml:"tab"` + } + err := Unmarshal([]byte("[tab]\nzz = 1\n"), &cfg, RejectUnknownFields(true)) + if err == nil || !strings.Contains(err.Error(), `unknown field "zz"`) { + t.Errorf("err = %v, want the unknown key reported before the required one", err) + } +} + +// TestTargetedStrictPathStableAcrossHeaders pins that the path a strict +// finding wraps does not alias the parser's key buffer: the table that owns +// the unknown key keeps its name after a later header. +func TestTargetedStrictPathStableAcrossHeaders(t *testing.T) { + var cfg targetCfg + err := Unmarshal([]byte("[tab]\nzz = 1\n\n[lims]\nx = 1\n"), &cfg, RejectUnknownFields(true)) + if err == nil || !strings.HasPrefix(err.Error(), "interpres: tab:") { + t.Errorf("err = %v, want the finding on tab, not the later header", err) + } +} + +// TestTargetedPrefilledMapFieldMergesUnderHeader pins that a prefilled map +// field merges the document's header-form table into it on both paths, the +// rule the root map has always followed. +func TestTargetedPrefilledMapFieldMergesUnderHeader(t *testing.T) { + doc := []byte("[lims]\nnew = 3\n") + var ref, tgt targetCfg + ref.Lims = map[string]any{"keep": "yes"} + if err := treeDecodeInto(doc, &ref); err != nil { + t.Fatalf("tree decode: %v", err) + } + tgt.Lims = map[string]any{"keep": "yes"} + if err := Unmarshal(doc, &tgt); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(ref, tgt) { + t.Errorf("targeted = %#v, tree = %#v", tgt, ref) + } + if tgt.Lims["keep"] != "yes" || tgt.Lims["new"] != int64(3) { + t.Errorf("lims = %#v, want the merge", tgt.Lims) + } +} + +// TestTargetedNumberTokenValidatesUTF8 pins that the token route the +// targeted parse takes reports invalid UTF-8 with the scanner's own message +// and position. +func TestTargetedNumberTokenValidatesUTF8(t *testing.T) { + var cfg targetCfg + err := Unmarshal([]byte("num = 12\xff\n"), &cfg) + if err == nil || !strings.Contains(err.Error(), "invalid UTF-8 in value at byte offset 8") { + t.Errorf("err = %v, want the UTF-8 complaint on the invalid byte", err) + } +}