From 3f4126671033562fb411397f07d571970c415988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Thu, 17 Sep 2026 21:20:47 +0200 Subject: [PATCH] feat: typed decode and encode errors with the key path Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 5 ++++ decode.go | 14 +++++----- decode_test.go | 48 ++++++++++++++++++++++++++++++++++ docs/API.md | 32 ++++++++++++++++++++--- encode.go | 15 ++++++----- encode_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ interpres.go | 53 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cabbe..682e0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `DecodeError` and `EncodeError`: decode and encode failures are wrapped in + typed errors carrying the key path, read with `errors.AsType` instead of + parsing the message text. The rendered messages keep their shape; the only + visible change is that an encode failure on a top-level field no longer + gains a meaningless leading dot in its path. - `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"` skips a field whose value is the zero value of its type (a type with an `IsZero() bool` method decides through the method), and diff --git a/decode.go b/decode.go index 5660d8d..13d49df 100644 --- a/decode.go +++ b/decode.go @@ -117,20 +117,20 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error { // of the encoder inlining that map's entries. mv, err := fieldByIndex(dst, schema.embedMaps[0]) if err != nil { - return fmt.Errorf("%s: %w", key, err) + return newDecodeError(key, err) } if err := d.assignMap(map[string]any{key: val}, mv); err != nil { - return fmt.Errorf("%s: %w", key, err) + return newDecodeError(key, err) } } continue } fv, err := fieldByIndex(dst, field.index) if err != nil { - return fmt.Errorf("%s: %w", key, err) + return newDecodeError(key, err) } if err := d.assign(val, fv); err != nil { - return fmt.Errorf("%s: %w", key, err) + return newDecodeError(key, err) } } return nil @@ -147,7 +147,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error { for key, val := range tbl { elem := reflect.New(elemType).Elem() if err := d.assign(val, elem); err != nil { - return fmt.Errorf("%s: %w", key, err) + return newDecodeError(key, err) } dst.SetMapIndex(reflect.ValueOf(key), elem) } @@ -161,7 +161,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error { out := reflect.MakeSlice(dst.Type(), len(items), len(items)) for i, item := range items { if err := d.assign(item, out.Index(i)); err != nil { - return fmt.Errorf("[%d]: %w", i, err) + return newDecodeError(fmt.Sprintf("[%d]", i), err) } } dst.Set(out) @@ -175,7 +175,7 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er out := reflect.MakeSlice(dst.Type(), len(items), len(items)) for i, item := range items { if err := d.assign(item, out.Index(i)); err != nil { - return fmt.Errorf("[%d]: %w", i, err) + return newDecodeError(fmt.Sprintf("[%d]", i), err) } } dst.Set(out) diff --git a/decode_test.go b/decode_test.go index cbc5313..85ed86c 100644 --- a/decode_test.go +++ b/decode_test.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math" + "slices" "strings" "testing" ) @@ -606,3 +607,50 @@ func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) { t.Fatalf("expected unknown field error, got: %v", err) } } + +func TestDecodeErrorCarriesPath(t *testing.T) { + type Item struct { + Name string `toml:"name"` + Weight uint8 `toml:"weight"` + } + type Cfg struct { + Tags []string `toml:"tags"` + Items []Item `toml:"items"` + } + var cfg Cfg + err := Unmarshal([]byte("[[items]]\nname = \"a\"\nweight = 300\n"), &cfg) + if err == nil { + t.Fatal("expected an overflow error") + } + de, ok := errors.AsType[*DecodeError](err) + if !ok { + t.Fatalf("expected a *DecodeError, got %T: %v", err, err) + } + want := []string{"items", "[0]", "weight"} + if !slices.Equal(de.Path, want) { + t.Fatalf("Path = %v, want %v", de.Path, want) + } + if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") { + t.Fatalf("Err = %v", de.Err) + } + // The rendered message keeps its shape: segments joined with ": ". + wantMsg := "items: [0]: weight: interpres: integer 300 overflows uint8" + if err.Error() != wantMsg { + t.Fatalf("message = %q, want %q", err.Error(), wantMsg) + } +} + +func TestDecodeErrorOnMapDestination(t *testing.T) { + var m map[string]uint8 + err := Unmarshal([]byte("count = -1\n"), &m) + if err == nil { + t.Fatal("expected an error") + } + de, ok := errors.AsType[*DecodeError](err) + if !ok { + t.Fatalf("expected a *DecodeError, got %T: %v", err, err) + } + if !slices.Equal(de.Path, []string{"count"}) { + t.Fatalf("Path = %v", de.Path) + } +} diff --git a/docs/API.md b/docs/API.md index 3250801..601c99a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -404,6 +404,27 @@ if se, ok := errors.AsType[*interpres.SyntaxError](err); ok { } ``` +### `type DecodeError struct{ Path []string; Err error }` + +Wraps a decoding failure with the key path at which it happened. `Path` lists +one segment per level from the document root, the outermost key first: a key +contributes its name, an array element its bracketed index, so the path of the +`weight` field in the first item reads `["items", "[0]", "weight"]`. The +rendered message is unchanged by the type; read the fields instead of parsing +the message: + +```go +if de, ok := errors.AsType[*interpres.DecodeError](err); ok { + fmt.Println(de.Path, de.Err) +} +``` + +### `type EncodeError struct{ Path string; Err error }` + +Wraps an encoding failure with the key path of the value that failed, in the +document's own notation: `server.ports[2]`. Read it with `errors.AsType` the +same way. + ### `type Decoder` Configurable strictness for decoding, constructed with `NewDecoder`. Set up @@ -460,11 +481,14 @@ types are produced by `Parse` and accepted by `Marshal`. The entry points return: - `*SyntaxError` for a malformed document, with the 1-based line -- a plain error for everything else: a non-pointer decode target, a type - mismatch, an overflow, a marshal policy violation, a cancelled context +- `*DecodeError` for a decoding failure, with the key path in `Path` +- `*EncodeError` for an encoding failure, with the key path in `Path` +- a plain error for the rest: a non-pointer decode target, a cancelled + context, a key that is not valid UTF-8 -Decode and encode failures are wrapped with the key path or element index using -`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them. +Decode and encode failures carry the key path or element index in the typed +wrappers above, so `errors.Is` and `errors.AsType` see through them and the +path reads from a field instead of the message text. ## Notes diff --git a/encode.go b/encode.go index 03c1c54..1d4aa05 100644 --- a/encode.go +++ b/encode.go @@ -6,6 +6,7 @@ package interpres import ( "bytes" "context" + "errors" "fmt" "maps" "math" @@ -287,7 +288,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error { if m, ok := v.Interface().(Marshaler); ok { mv, err := m.MarshalTOML() if err != nil { - return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err) + return &EncodeError{Path: joinKey(ctx, name), Err: err} } v = reflect.ValueOf(mv) } @@ -380,13 +381,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error } ev := followPtr(v.Index(i)) if !ev.IsValid() { - return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i) + return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")} } sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts} switch ev.Kind() { case reflect.Struct: if isScalarStruct(ev.Type()) { - return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i) + return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("heterogeneous array contains scalar")} } if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil { return err @@ -396,7 +397,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error return err } default: - return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name) + return &EncodeError{Path: joinKey(ctx, name), Err: errors.New("heterogeneous array, expected table")} } subs[i] = sub } @@ -415,13 +416,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error } ev := followPtr(v.Index(i)) if !ev.IsValid() { - return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i) + return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")} } if ev.CanInterface() { if m, ok := ev.Interface().(Marshaler); ok { mv, err := m.MarshalTOML() if err != nil { - return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err) + return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err} } ev = reflect.ValueOf(mv) ev = followPtr(ev) @@ -429,7 +430,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error } val, err := normaliseValue(ev) if err != nil { - return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err) + return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err} } items[i] = val } diff --git a/encode_test.go b/encode_test.go index bbfd42a..cb5c8f8 100644 --- a/encode_test.go +++ b/encode_test.go @@ -1160,3 +1160,73 @@ type Custom struct { } func (c Custom) MarshalTOML() (any, error) { return c.tag, nil } + +// encodeErrBad is a Marshaler whose MarshalTOML always fails. +type encodeErrBad struct { + msg string +} + +func (encodeErrBad) MarshalTOML() (any, error) { return nil, errors.New("bad timestamp") } + +func TestEncodeErrorCarriesPath(t *testing.T) { + type Inner struct { + Port encodeErrBad `toml:"port"` + } + type Cfg struct { + Server Inner `toml:"server"` + } + _, err := Marshal(Cfg{Server: Inner{Port: encodeErrBad{}}}) + if err == nil { + t.Fatal("expected a marshal error") + } + ee, ok := errors.AsType[*EncodeError](err) + if !ok { + t.Fatalf("expected an *EncodeError, got %T: %v", err, err) + } + if ee.Path != "server.port" { + t.Fatalf("Path = %q, want %q", ee.Path, "server.port") + } + if ee.Err == nil || ee.Err.Error() != "bad timestamp" { + t.Fatalf("Err = %v", ee.Err) + } + if err.Error() != "interpres: server.port: bad timestamp" { + t.Fatalf("message = %q", err.Error()) + } +} + +func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) { + type Cfg struct { + Port encodeErrBad `toml:"port"` + } + _, err := Marshal(Cfg{}) + ee, ok := errors.AsType[*EncodeError](err) + if !ok { + t.Fatalf("expected an *EncodeError, got %T: %v", err, err) + } + if ee.Path != "port" { + t.Fatalf("Path = %q, want %q", ee.Path, "port") + } + if err.Error() != "interpres: port: bad timestamp" { + t.Fatalf("message = %q", err.Error()) + } +} + +func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) { + type Item struct { + N int `toml:"n"` + } + cfg := map[string]any{ + "items": []any{Item{}, 3}, + } + _, err := Marshal(cfg) + if err == nil { + t.Fatal("expected a heterogeneous array error") + } + ee, ok := errors.AsType[*EncodeError](err) + if !ok { + t.Fatalf("expected an *EncodeError, got %T: %v", err, err) + } + if ee.Path != "items[0]" { + t.Fatalf("Path = %q, want %q", ee.Path, "items[0]") + } +} diff --git a/interpres.go b/interpres.go index 8d8df87..8d4b3d3 100644 --- a/interpres.go +++ b/interpres.go @@ -21,6 +21,7 @@ package interpres import ( "context" + "errors" "fmt" "unicode/utf8" ) @@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string { return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg) } +// A DecodeError wraps a decoding failure with the key path at which it +// happened. Path lists one segment per level from the document root, the +// outermost key first: a key contributes its name and an array element its +// bracketed index, so the path of the weight field in the first item reads +// ["items", "[0]", "weight"]. The rendered message is unchanged by the type; +// read it programmatically with errors.AsType: +// +// if de, ok := errors.AsType[*interpres.DecodeError](err); ok { +// fmt.Println(de.Path, de.Err) +// } +type DecodeError struct { + // Path is the key path from the document root, outermost key first. + Path []string + // Err is the failure at that path. + Err error +} + +func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() } + +// Unwrap returns the failure the path points at. +func (e *DecodeError) Unwrap() error { return e.Err } + +// newDecodeError wraps err with one path segment. The rest of the path comes +// from the DecodeError err already carries, if any: the decoder wraps each +// key and index on its way down, so the innermost wrap holds the deepest +// segments and each outer wrap prepends one. +func newDecodeError(key string, err error) *DecodeError { + path := make([]string, 0, 4) + path = append(path, key) + if de, ok := errors.AsType[*DecodeError](err); ok { + path = append(path, de.Path...) + } + return &DecodeError{Path: path, Err: err} +} + +// An EncodeError wraps an encoding failure with the key path of the value +// that failed, in the notation of a TOML document: fields join with dots and +// an array element carries its bracketed index, so the path of the third +// port under server reads "server.ports[2]". The rendered message is +// unchanged by the type; read it programmatically with errors.AsType. +type EncodeError struct { + // Path is the key path of the failing value. + Path string + // Err is the failure at that path. + Err error +} + +func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() } + +// Unwrap returns the failure the path points at. +func (e *EncodeError) Unwrap() error { return e.Err } + // Parse decodes a TOML document into a nested map[string]any. // // Values are mapped to Go types as follows: strings to string, integers to