From e5d5567beb80670c873b145ec1185b0a435f8fb3 Mon Sep 17 00:00:00 2001 From: Lixen <41302669+lixenwraith@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:30:55 -0400 Subject: [PATCH] v0.4.0 Unmapped reads any parsed value, UnknownKeys refuses what it names, decode errors name the key path --- README.md | 8 ++++++-- audit_fix_test.go | 23 +++++++++++++++++++++++ decode.go | 29 ++++++++++++++++++++--------- document.go | 29 ++++++++++++++++++++++++----- document_test.go | 7 +++++++ 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 50185a3..7ba87b7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/audit_fix_test.go b/audit_fix_test.go index 3414b93..1b2b091 100644 --- a/audit_fix_test.go +++ b/audit_fix_test.go @@ -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) { diff --git a/decode.go b/decode.go index e528471..fcbf9df 100644 --- a/decode.go +++ b/decode.go @@ -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" } diff --git a/document.go b/document.go index 3ecd7c9..b11e67e 100644 --- a/document.go +++ b/document.go @@ -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) diff --git a/document_test.go b/document_test.go index b5c2944..06f416f 100644 --- a/document_test.go +++ b/document_test.go @@ -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