v0.4.0 Unmapped reads any parsed value, UnknownKeys refuses what it names, decode errors name the key path
Test / test (push) Canceled after 0s

This commit is contained in:
Lixen authored and lixen committed 2026-10-26 03:33:39 -04:00
1 parent 170e71f10a
commit e5d5567beb
5 files changed
+80 -16

No files matched your search

+6 -2
View File
@@ -20,7 +20,10 @@ replace old containers. Existing pointer defaults are retained without writing
through the original pointer. Fixed arrays require an exact input length. through the original pointer. Fixed arrays require an exact input length.
Tags use `toml:"name,omitempty"`; an empty tag name Tags use `toml:"name,omitempty"`; an empty tag name
falls back to the Go field name. Matching is case sensitive. Unknown input keys falls back to the Go field name. Matching is case sensitive. Unknown input keys
and unexported fields are ignored. and unexported fields are ignored; `Unmapped(data, &target)` names the keys a
parsed value holds that no field takes, and a caller refusing them returns
`UnknownKeys(keys)`, which quotes each. A decode error names where it failed by
the key path the document spells: `rooms[1].width: cannot convert string to int`.
## Values and conversions ## Values and conversions
@@ -121,7 +124,8 @@ split at the widest gap.
- `Unmapped` names the keys no field of the destination takes, matched as `Decode` - `Unmapped` names the keys no field of the destination takes, matched as `Decode`
matches them (tags, then field names; unexported and `-` fields take nothing), matches them (tags, then field names; unexported and `-` fields take nothing),
each once and sorted. A map or interface destination takes every key under it; each once and sorted. A map or interface destination takes every key under it;
a key under an array of tables is named without its index. a key under an array of tables is named without its index. The function of the
same name reads any value `Parse` produced, a table inside one included.
- `Key` is a dotted path, one segment per part; `Key.String` spells it as a - `Key` is a dotted path, one segment per part; `Key.String` spells it as a
document would, quoting segments that need it. document would, quoting segments that need it.
+23
View File
@@ -303,6 +303,29 @@ func TestDecodeErrorPathIsLinear(t *testing.T) {
} }
} }
// A decode error names where it failed by the key path the document spells
func TestDecodeErrorNamesItsKeyPath(t *testing.T) {
type room struct {
Width int `toml:"width"`
}
var v struct {
Play struct{ Sound bool } `toml:"play"`
Rooms []room `toml:"rooms"`
ByKey map[string]room `toml:"by_key"`
}
for _, tt := range []struct {
doc, want string
}{
{"play.Sound = 1", "play.Sound: cannot convert int64 to bool"},
{"rooms = [{ width = 1 }, { width = \"x\" }]", "rooms[1].width: cannot convert string to int"},
{"by_key.\"a b\".width = true", `by_key."a b".width: cannot convert bool to int`},
} {
if err := Unmarshal([]byte(tt.doc+"\n"), &v); err == nil || err.Error() != tt.want {
t.Errorf("%s: error %v, want %s", tt.doc, err, tt.want)
}
}
}
// Header paths are bounded: deep nesting is written inline past the bound, // Header paths are bounded: deep nesting is written inline past the bound,
// so output stays linear and still round-trips // so output stays linear and still round-trips
func TestMarshal_HeaderLengthBounded(t *testing.T) { func TestMarshal_HeaderLengthBounded(t *testing.T) {
+20 -9
View File
@@ -42,19 +42,23 @@ func decodeValue(data any, val reflect.Value) error {
return decodeAt(data, val, 0) return decodeAt(data, val, 0)
} }
// pathError names where decoding failed. Each level adds its segment instead // pathError names where decoding failed as the key path a document spells,
// of wrapping the message, which would copy the message once per level. // play.sound or rooms[0].width. Each level adds its segment instead of wrapping
// the message, which would copy the message once per level.
type pathError struct { type pathError struct {
path []string // innermost first path []string // innermost first: a key as keySegment spells it, an index as [n]
err error err error
} }
func (e *pathError) Error() string { func (e *pathError) Error() string {
var b strings.Builder var b strings.Builder
for i := len(e.path) - 1; i >= 0; i-- { for i := len(e.path) - 1; i >= 0; i-- {
if i < len(e.path)-1 && e.path[i][0] != '[' {
b.WriteByte('.')
}
b.WriteString(e.path[i]) b.WriteString(e.path[i])
b.WriteString(": ")
} }
b.WriteString(": ")
b.WriteString(e.err.Error()) b.WriteString(e.err.Error())
return b.String() return b.String()
} }
@@ -69,6 +73,14 @@ func atPath(segment string, err error) error {
return &pathError{path: []string{segment}, err: err} return &pathError{path: []string{segment}, err: err}
} }
// keySegment spells a key in an error path: bare when it can be, else quoted and cut
func keySegment(k string) string {
if len(k) <= 32 && isBareKey(k) {
return k
}
return brief(k)
}
func decodeAt(data any, val reflect.Value, depth int) error { func decodeAt(data any, val reflect.Value, depth int) error {
if depth > maxValueDepth { if depth > maxValueDepth {
return fmt.Errorf("decode nesting exceeds %d", maxValueDepth) return fmt.Errorf("decode nesting exceeds %d", maxValueDepth)
@@ -120,7 +132,7 @@ func decodeAt(data any, val reflect.Value, depth int) error {
} }
for i := 0; i < len(dataSlice); i++ { for i := 0; i < len(dataSlice); i++ {
if err := decodeAt(dataSlice[i], newSlice.Index(i), depth+1); err != nil { if err := decodeAt(dataSlice[i], newSlice.Index(i), depth+1); err != nil {
return atPath(fmt.Sprintf("index %d", i), err) return atPath(fmt.Sprintf("[%d]", i), err)
} }
} }
val.Set(newSlice) val.Set(newSlice)
@@ -140,7 +152,7 @@ func decodeAt(data any, val reflect.Value, depth int) error {
for k, vData := range dataMap { for k, vData := range dataMap {
elem.SetZero() elem.SetZero()
if err := decodeAt(vData, elem, depth+1); err != nil { if err := decodeAt(vData, elem, depth+1); err != nil {
return atPath("map key "+brief(k), err) return atPath(keySegment(k), err)
} }
key.SetString(k) key.SetString(k)
newMap.SetMapIndex(key, elem) newMap.SetMapIndex(key, elem)
@@ -216,7 +228,7 @@ func decodeStruct(data map[string]any, val reflect.Value, depth int) error {
// Look up in data map (case sensitive) // Look up in data map (case sensitive)
if vData, ok := data[f.key]; ok { if vData, ok := data[f.key]; ok {
if err := decodeAt(vData, val.Field(f.index), depth+1); err != nil { if err := decodeAt(vData, val.Field(f.index), depth+1); err != nil {
return atPath(val.Type().Name()+"."+f.name, err) return atPath(keySegment(f.key), err)
} }
} }
} }
@@ -227,7 +239,6 @@ func decodeStruct(data map[string]any, val reflect.Value, depth int) error {
type field struct { type field struct {
index int index int
key string // the tag's name, else the Go name key string // the tag's name, else the Go name
name string // the Go name
omitempty bool omitempty bool
} }
@@ -246,7 +257,7 @@ func fieldsOf(t reflect.Type) []field {
if !f.IsExported() || name == "-" { if !f.IsExported() || name == "-" {
continue continue
} }
x := field{index: i, key: cmp.Or(name, f.Name), name: f.Name} x := field{index: i, key: cmp.Or(name, f.Name)}
for o := range strings.SplitSeq(opts, ",") { for o := range strings.SplitSeq(opts, ",") {
x.omitempty = x.omitempty || o == "omitempty" x.omitempty = x.omitempty || o == "omitempty"
} }
+24 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"slices" "slices"
"strconv"
"strings" "strings"
) )
@@ -253,12 +254,30 @@ func (d *Document) Tree() map[string]any { return d.tree }
func (d *Document) Decode(v any) error { return Decode(d.tree, v) } func (d *Document) Decode(v any) error { return Decode(d.tree, v) }
// Unmapped names the keys of the document that no field of v's type takes, as // Unmapped names the keys of the document that no field of v's type takes, as
// Decode, which ignores them, would match them; each once and sorted. A key under // the function Unmapped does
// a map or interface destination is taken, and one under an array of tables is func (d *Document) Unmapped(v any) []Key { return Unmapped(d.tree, v) }
// named without its index.
func (d *Document) Unmapped(v any) []Key { // UnknownKeys is the error of a caller that refuses the keys Unmapped names
type UnknownKeys []Key
func (u UnknownKeys) Error() string {
q := make([]string, len(u))
for i, k := range u {
q[i] = strconv.Quote(k.String())
}
if len(q) == 1 {
return "unknown key " + q[0]
}
return "unknown keys " + strings.Join(q, ", ")
}
// Unmapped names the keys of data, a value Parse produced, that no field of v's
// type takes, as Decode, which ignores them, would match them; each once and
// sorted. A key under a map or interface destination is taken, and one under an
// array of tables is named without its index.
func Unmapped(data any, v any) []Key {
seen := make(map[string]Key) seen := make(map[string]Key)
unmapped(d.tree, reflect.TypeOf(v), nil, seen, 0) unmapped(data, reflect.TypeOf(v), nil, seen, 0)
out := make([]Key, 0, len(seen)) out := make([]Key, 0, len(seen))
for _, k := range seen { for _, k := range seen {
out = append(out, k) out = append(out, k)
+7
View File
@@ -195,6 +195,7 @@ func TestDocumentRefusesAnEditItCannotPlace(t *testing.T) {
// TestUnmappedNamesWhatDecodeIgnores: keys no field takes, by tag or field name, // TestUnmappedNamesWhatDecodeIgnores: keys no field takes, by tag or field name,
// unexported and "-" fields included, are named once each, under arrays of tables // unexported and "-" fields included, are named once each, under arrays of tables
// without their index; a map or interface destination takes every key under it. // without their index; a map or interface destination takes every key under it.
// A parsed tree is read as its document is, and UnknownKeys quotes each.
func TestUnmappedNamesWhatDecodeIgnores(t *testing.T) { func TestUnmappedNamesWhatDecodeIgnores(t *testing.T) {
doc := `top = 1 doc := `top = 1
skip = 2 skip = 2
@@ -248,6 +249,12 @@ nope = 1
if !slices.Equal(got, want) { if !slices.Equal(got, want) {
t.Fatalf("unmapped %q, want %q", got, want) t.Fatalf("unmapped %q, want %q", got, want)
} }
if tree := Unmapped(d.Tree(), &v); !slices.EqualFunc(tree, d.Unmapped(&v), slices.Equal) {
t.Fatalf("the tree's unmapped %q differ from the document's", tree)
}
if err := UnknownKeys(d.Unmapped(&v)[:2]).Error(); err != `unknown keys "byname.one.nope", "hidden"` {
t.Fatalf("error %s", err)
}
} }
// TestKeyStringQuotesWhatABareKeyCannotHold // TestKeyStringQuotesWhatABareKeyCannotHold