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.
Tags use `toml:"name,omitempty"`; an empty tag name
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
@@ -121,7 +124,8 @@ split at the widest gap.
- `Unmapped` names the keys no field of the destination takes, matched as `Decode`
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;
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
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,
// so output stays linear and still round-trips
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)
}
// pathError names where decoding failed. Each level adds its segment instead
// of wrapping the message, which would copy the message once per level.
// pathError names where decoding failed as the key path a document spells,
// 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 {
path []string // innermost first
path []string // innermost first: a key as keySegment spells it, an index as [n]
err error
}
func (e *pathError) Error() string {
var b strings.Builder
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(": ")
}
b.WriteString(": ")
b.WriteString(e.err.Error())
return b.String()
}
@@ -69,6 +73,14 @@ func atPath(segment string, err error) error {
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 {
if depth > 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++ {
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)
@@ -140,7 +152,7 @@ func decodeAt(data any, val reflect.Value, depth int) error {
for k, vData := range dataMap {
elem.SetZero()
if err := decodeAt(vData, elem, depth+1); err != nil {
return atPath("map key "+brief(k), err)
return atPath(keySegment(k), err)
}
key.SetString(k)
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)
if vData, ok := data[f.key]; ok {
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 {
index int
key string // the tag's name, else the Go name
name string // the Go name
omitempty bool
}
@@ -246,7 +257,7 @@ func fieldsOf(t reflect.Type) []field {
if !f.IsExported() || name == "-" {
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, ",") {
x.omitempty = x.omitempty || o == "omitempty"
}
+24 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"slices"
"strconv"
"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) }
// 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
// a map or interface destination is taken, and one under an array of tables is
// named without its index.
func (d *Document) Unmapped(v any) []Key {
// the function Unmapped does
func (d *Document) Unmapped(v any) []Key { return Unmapped(d.tree, v) }
// 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)
unmapped(d.tree, reflect.TypeOf(v), nil, seen, 0)
unmapped(data, reflect.TypeOf(v), nil, seen, 0)
out := make([]Key, 0, len(seen))
for _, k := range seen {
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,
// 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.
// A parsed tree is read as its document is, and UnknownKeys quotes each.
func TestUnmappedNamesWhatDecodeIgnores(t *testing.T) {
doc := `top = 1
skip = 2
@@ -248,6 +249,12 @@ nope = 1
if !slices.Equal(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