fix(decode): allocate embedded pointer maps and settle case collisions

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-22 21:15:00 +02:00
parent e677e34508
commit f7e427ae3f
2 changed files with 103 additions and 4 deletions
+21 -4
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"encoding" "encoding"
"fmt" "fmt"
"maps"
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
@@ -337,7 +338,8 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
if len(schema.required) > 0 { if len(schema.required) > 0 {
seen = make(map[string]bool, len(tbl)) seen = make(map[string]bool, len(tbl))
} }
for key, val := range tbl { for _, key := range d.tableKeys(tbl) {
val := tbl[key]
// A key that is already lowercase, which document keys usually are, // A key that is already lowercase, which document keys usually are,
// hits the map directly; only a miss pays for the case fold. // hits the map directly; only a miss pays for the case fold.
resolved := key resolved := key
@@ -349,12 +351,15 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
if !ok { if !ok {
if schema.embedMaps != nil { if schema.embedMaps != nil {
// Leftover keys land in an untagged embedded map, the inverse // Leftover keys land in an untagged embedded map, the inverse
// of the encoder inlining that map's entries. // of the encoder inlining that map's entries. The assign call
// rather than assignMap itself lets it allocate the embedded
// pointer the field may be, the way any other destination is
// reached.
mv, err := fieldByIndex(dst, schema.embedMaps[0]) mv, err := fieldByIndex(dst, schema.embedMaps[0])
if err != nil { if err != nil {
return newDecodeError(key, err) return newDecodeError(key, err)
} }
if err := d.assignMap(map[string]any{key: val}, mv); err != nil { if err := d.assign(map[string]any{key: val}, mv); err != nil {
return newDecodeError(key, err) return newDecodeError(key, err)
} }
} }
@@ -379,6 +384,18 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
return nil return nil
} }
// tableKeys returns the keys of tbl in the order the document wrote them
// when the node index knows it, and in sorted order otherwise, the order a
// hand-built tree or a node-free parse offers. The order settles which of
// two keys that differ only in case wins one field: the same key wins every
// run, instead of whichever a map iteration happened to hand out.
func (d *decoder) tableKeys(tbl map[string]any) []string {
if node := d.nodeOf(tbl); node != nil {
return node.Keys()
}
return slices.Sorted(maps.Keys(tbl))
}
func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error { func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
if dst.Type().Key().Kind() != reflect.String { if dst.Type().Key().Kind() != reflect.String {
return fmt.Errorf("interpres: map key must be a string, got %s", dst.Type().Key()) return fmt.Errorf("interpres: map key must be a string, got %s", dst.Type().Key())
@@ -499,7 +516,7 @@ func (d *decoder) setLocalTimeValue(t time.Time, dst reflect.Value) error {
t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), d.loc))) t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), d.loc)))
return nil return nil
} }
return fmt.Errorf("interpres: cannot assign local date-time to time.Time; set Decoder.LocalTimeLocation to choose the zone") return fmt.Errorf("interpres: cannot assign local date-time to time.Time; set LocalTimeLocation to choose the zone")
} }
return fmt.Errorf("interpres: cannot assign local date-time to %s", dst.Type()) return fmt.Errorf("interpres: cannot assign local date-time to %s", dst.Type())
} }
+82
View File
@@ -1690,3 +1690,85 @@ func TestErrorMessagesGolden(t *testing.T) {
}) })
} }
} }
// TestLocalTimeLocationLeavesOffsetsAlone pins that the option's zone is
// used for local date-times only: an offset date-time keeps the offset the
// document wrote.
func TestLocalTimeLocationLeavesOffsetsAlone(t *testing.T) {
var cfg struct {
Stamp time.Time `toml:"stamp"`
}
err := Unmarshal([]byte("stamp = 1979-05-27T07:32:00-07:00\n"), &cfg,
LocalTimeLocation(time.FixedZone("Prague", 2*60*60)))
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, off := cfg.Stamp.Zone(); off != -7*60*60 {
t.Errorf("offset = %d, want the document's -07:00", off/3600)
}
}
// TestUnmarshalCaseCollisionIsDeterministic pins that two keys differing
// only in case, both matching one field, resolve the same way on every run
// and on both decode paths.
func TestUnmarshalCaseCollisionIsDeterministic(t *testing.T) {
type cfg struct {
Host string `toml:"host"`
}
in := []byte("Host = \"upper\"\nhost = \"lower\"\n")
// The tree path iterates a map, so pin the winner across many runs.
var want string
for range 50 {
var viaTree cfg
if err := treeDecodeInto(in, &viaTree); err != nil {
t.Fatalf("tree decode: %v", err)
}
if want == "" {
want = viaTree.Host
} else if viaTree.Host != want {
t.Fatalf("tree decode is not deterministic: %q then %q", want, viaTree.Host)
}
}
var targeted cfg
if err := Unmarshal(in, &targeted); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if targeted.Host != want {
t.Errorf("targeted Host = %q, tree %q", targeted.Host, want)
}
}
// TestUnmarshalEmbeddedPointerMap pins that leftover keys reach an embedded
// pointer to a map, allocating it, rather than panicking on the pointer.
func TestUnmarshalEmbeddedPointerMap(t *testing.T) {
type Extra map[string]int
type cfg struct {
*Extra
Name string `toml:"name"`
}
var c cfg
err := Unmarshal([]byte("name = \"x\"\nrogue = 7\n"), &c)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if c.Extra == nil || (*c.Extra)["rogue"] != 7 {
t.Errorf("embedded map = %v, want rogue allocated and filled", c.Extra)
}
}
// TestUnmarshalIgnoresEncodeTagOptions pins that the emission-only tag
// options change nothing on the decode side.
func TestUnmarshalIgnoresEncodeTagOptions(t *testing.T) {
type cfg struct {
Name string `toml:"name,omitempty"`
Port int `toml:"port,omitzero,comment=The port"`
}
var c cfg
err := Unmarshal([]byte("name = \"x\"\nport = 8080\n"), &c)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if c.Name != "x" || c.Port != 8080 {
t.Errorf("cfg = %+v, want both fields filled", c)
}
}