fix(encode): emit one key when two fields resolve to one name

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-17 23:08:42 +02:00
parent aaea68efc9
commit 30b28fe7fc
4 changed files with 69 additions and 1 deletions
+5
View File
@@ -49,6 +49,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
schema walk recursed through the embedded type forever, so such a
`Unmarshal` call hung the process; the walk now tracks the struct types on
the current path and stops when one repeats.
- `Marshal` emits exactly one key when two struct fields resolve to the same
TOML name, picking the field the decoder would fill (the shallower one, the
later declaration at equal depth). Such a struct previously marshalled into
a duplicate key, and the output never re-parsed, breaking the round-trip
guarantee.
- An array-of-tables header whose path runs through an inline table
(`a = {b = {}}` followed by `[[a.b.c]]`) is rejected. The frozen-inline-table
check covered `[table]` headers and dotted keys but not the intermediate
+10
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"math"
"reflect"
"slices"
"strings"
"sync"
"time"
@@ -323,6 +324,15 @@ func newStructSchema(t reflect.Type) structSchema {
return s
}
// ownsKey reports whether the field at path is the one that resolves key.
// The encoder consults it to emit exactly the field the decoder would fill,
// so a struct with two fields mapping to one key does not marshal into a
// duplicate TOML key.
func (s structSchema) ownsKey(key string, path []int) bool {
loc, ok := s.byName[key]
return ok && slices.Equal(loc.index, path)
}
// fieldByIndex walks an index path from a struct value, allocating nil
// pointers along the way so a key can reach through an embedded pointer
// struct. Every field on the path is exported, so each step is settable.
+18 -1
View File
@@ -144,6 +144,16 @@ func (d *tomlDoc) partitionedEntries() (scalars []entry, tables []entry, arrays
// --- reflection walk: struct ---------------------------------------------
func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
return walkStructDoc(v, doc, ctx, nil, cachedStructSchema(v.Type()))
}
// walkStructDoc emits the fields of v into doc. prefix is v's index path from
// the struct whose schema resolves key conflicts; an embedded struct is walked
// with the outer schema and a longer prefix, so every leaf competes under the
// decoder's rule: the shallower field wins, the later declaration at equal
// depth. A field another field shadows is skipped, because emitting both
// would duplicate the key and the output would not re-parse.
func walkStructDoc(v reflect.Value, doc *tomlDoc, ctx string, prefix []int, schema structSchema) error {
t := v.Type()
for i := range t.NumField() {
if i%ctxCheckInterval == 0 {
@@ -155,6 +165,7 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
if f.PkgPath != "" {
continue
}
path := append(append([]int{}, prefix...), i)
if f.Anonymous {
tag, _ := f.Tag.Lookup("toml")
if tag == "-" {
@@ -169,12 +180,15 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
case reflect.Struct:
if isScalarStruct(fv.Type()) {
name := strings.ToLower(f.Name)
if !schema.ownsKey(name, path) {
continue
}
if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil {
return err
}
continue
}
if err := buildStructDoc(fv, doc, ctx); err != nil {
if err := walkStructDoc(fv, doc, ctx, path, schema); err != nil {
return err
}
continue
@@ -190,6 +204,9 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
if name == "-" {
continue
}
if !schema.ownsKey(strings.ToLower(name), path) {
continue
}
if fieldOmitted(f, v.Field(i)) {
continue
}
+36
View File
@@ -383,6 +383,42 @@ func TestMarshalerErrorPropagates(t *testing.T) {
}
}
// Two fields that resolve to one TOML key must marshal as one key, resolved
// the way the decoder resolves it, or the output would carry a duplicate key
// and never re-parse.
func TestMarshalDuplicateKeyResolvesToOneField(t *testing.T) {
type SameLevel struct {
First int `toml:"v"`
Second string `toml:"v"`
}
out, err := Marshal(SameLevel{First: 1, Second: "s"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if want := "v = \"s\"\n"; string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
type Base struct {
Name string `toml:"name"`
}
type Embedded struct {
Base
Name string `toml:"name"`
}
out, err = Marshal(Embedded{Base: Base{Name: "inner"}, Name: "outer"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
// The shallower field wins, matching the decoder.
if want := "name = \"outer\"\n"; string(out) != want {
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
}
if _, err := Parse(out); err != nil {
t.Errorf("re-parse: %v\ndoc:\n%s", err, out)
}
}
func TestMarshalEmbeddedScalarStruct(t *testing.T) {
// A field declared directly as a scalar-struct type (here LocalDateTime)
// must be encoded as a TOML scalar at the parent level, not rendered as