v0.5.0 Unmapped reads any parsed value, UnknownKeys refuses what it names, decode errors name the key path
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
* Unmapped reads any parsed value; decode errors name the key path Unmapped(data, v) names the keys of a value Parse produced that no field of v's type takes, a table inside a document included, so a caller holding a tree (after macro expansion, say) can refuse them as a Document's caller does; Document.Unmapped calls it. A decode error names where it failed by the key path the document spells, rooms[1].width or by_key."a b".width, rather than by Go type and field names and "index n" / "map key" segments; a key that cannot be bare, or runs past 32 bytes, is quoted and cut as parse errors quote one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rsnp29663hDvPHPua37MhN * UnknownKeys, the error naming the keys a caller refuses A caller that refuses what Unmapped names returns UnknownKeys(keys), "unknown key \"a\"" or "unknown keys \"a\", \"b.c\"", so every refusal words them alike. vif's settings already format their own; its FSM dialect would be the second copy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rsnp29663hDvPHPua37MhN --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
170e71f10a
commit
5c0a1c757a
5 files changed
+81
-17
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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-- {
|
||||
b.WriteString(e.path[i])
|
||||
b.WriteString(": ")
|
||||
if i < len(e.path)-1 && e.path[i][0] != '[' {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
b.WriteString(e.path[i])
|
||||
}
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user