refactor: unify the error paths in one Path type
Test / test (push) Canceled after 1m31s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 00:34:48 +02:00
parent a8a2fcf8c3
commit b02471c09a
6 changed files with 119 additions and 69 deletions
+6
View File
@@ -94,6 +94,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- `DecodeError` and `EncodeError` carry one `Path` type, a list of segments
(`"items"`, `"[0]"`, `"weight"`) with a `String()` rendering the TOML
notation, `items[0].weight`. The decode error used to hold a bare
`[]string`, the encode error a plain string. Both messages render the same
way now, `interpres: items[0].weight: ...`, with one `interpres:` prefix
where the composition used to double it.
- `omitempty` follows the encoding/json semantics: the field is skipped when - `omitempty` follows the encoding/json semantics: the field is skipped when
it holds an empty string, a zero number, `false`, a nil pointer or it holds an empty string, a zero number, `false`, a nil pointer or
interface, or a nil or empty slice, array or map. In 1.x the option covered interface, or a nil or empty slice, array or map. In 1.x the option covered
+14 -4
View File
@@ -725,8 +725,8 @@ func TestDecodeErrorCarriesPath(t *testing.T) {
if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") { if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") {
t.Fatalf("Err = %v", de.Err) t.Fatalf("Err = %v", de.Err)
} }
// The rendered message keeps its shape: segments joined with ": ". // The rendered message uses the Path notation.
wantMsg := "items: [0]: weight: interpres: integer 300 overflows uint8" wantMsg := "interpres: items[0].weight: integer 300 overflows uint8"
if err.Error() != wantMsg { if err.Error() != wantMsg {
t.Fatalf("message = %q, want %q", err.Error(), wantMsg) t.Fatalf("message = %q, want %q", err.Error(), wantMsg)
} }
@@ -1310,7 +1310,7 @@ func TestDecodeFixedArray(t *testing.T) {
Ports [3]int `toml:"ports"` Ports [3]int `toml:"ports"`
} }
err := Unmarshal([]byte("ports = [8080, 9090]\n"), &cfg) err := Unmarshal([]byte("ports = [8080, 9090]\n"), &cfg)
want := "ports: interpres: cannot assign 2 elements to [3]int" want := "interpres: ports: cannot assign 2 elements to [3]int"
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("err = %v, want %q", err, want) t.Errorf("err = %v, want %q", err, want)
} }
@@ -1344,7 +1344,7 @@ func TestRequiredTag(t *testing.T) {
Server Config `toml:"server"` Server Config `toml:"server"`
} }
err := Unmarshal([]byte("[server]\nradius = 1\n"), &outer) err := Unmarshal([]byte("[server]\nradius = 1\n"), &outer)
want := `server: interpres: missing required key "host"` want := `interpres: server: missing required key "host"`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("err = %v, want %q", err, want) t.Errorf("err = %v, want %q", err, want)
} }
@@ -1477,3 +1477,13 @@ func TestDecodeMergesIntoNonEmptyMap(t *testing.T) {
t.Errorf("lang = %v, want the key added", dst["lang"]) t.Errorf("lang = %v, want the key added", dst["lang"])
} }
} }
func TestPathString(t *testing.T) {
p := Path{"server", "ports", "[2]", "host"}
if got, want := p.String(), "server.ports[2].host"; got != want {
t.Errorf("String() = %q, want %q", got, want)
}
if got := (Path{}).String(); got != "" {
t.Errorf("String() of an empty path = %q, want the empty string", got)
}
}
+15 -9
View File
@@ -738,26 +738,32 @@ if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
followed by a caret line marking the column, for messages the reader sees followed by a caret line marking the column, for messages the reader sees
under the input. under the input.
### `type DecodeError struct{ Path []string; Err error }` ### `type DecodeError struct{ Path Path; Err error }`
Wraps a decoding failure with the key path at which it happened. `Path` lists 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 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 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 `weight` field in the first item reads `["items", "[0]", "weight"]` and its
rendered message is unchanged by the type; read the fields instead of parsing `String()` renders `items[0].weight`. Read the fields instead of parsing the
the message: message:
```go ```go
if de, ok := errors.AsType[*interpres.DecodeError](err); ok { if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
fmt.Println(de.Path, de.Err) fmt.Println(de.Path.String(), de.Err)
} }
``` ```
### `type EncodeError struct{ Path string; Err error }` ### `type EncodeError struct{ Path Path; Err error }`
Wraps an encoding failure with the key path of the value that failed, in the Wraps an encoding failure with the key path of the value that failed, the
document's own notation: `server.ports[2]`. Read it with `errors.AsType` the same `Path` type the decode error carries, so `server.ports[2]` reads the
same way. same on both sides. Read it with `errors.AsType` the same way.
### `type Path []string`
The path both error wrappers carry, one segment per level from the document
root. `String()` renders the TOML notation: keys join with dots, an index
attaches to the previous segment in brackets, `items[0].weight`.
### `type Decoder` ### `type Decoder`
+25 -31
View File
@@ -249,7 +249,7 @@ func buildOrderedDoc(om *OrderedMap, doc *tomlDoc, path encPath) error {
case OrderedMap: case OrderedMap:
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1} sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) { if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(key).String(), Err: errDepthLimit()} return &EncodeError{Path: path.key(key).segments(), Err: errDepthLimit()}
} }
if err := buildOrderedDoc(&x, sub, path.key(key)); err != nil { if err := buildOrderedDoc(&x, sub, path.key(key)); err != nil {
return err return err
@@ -262,7 +262,7 @@ func buildOrderedDoc(om *OrderedMap, doc *tomlDoc, path encPath) error {
} }
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1} sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) { if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(key).String(), Err: errDepthLimit()} return &EncodeError{Path: path.key(key).segments(), Err: errDepthLimit()}
} }
if err := buildOrderedDoc(x, sub, path.key(key)); err != nil { if err := buildOrderedDoc(x, sub, path.key(key)); err != nil {
return err return err
@@ -384,10 +384,14 @@ func (p *encPath) elem(i int) encPath {
return encPath{parent: p, index: i} return encPath{parent: p, index: i}
} }
// String renders the path root first: keys join with dots and an array // String renders the path root first, in the notation Path.String uses.
// element carries its bracketed index, so the third port under server reads
// "server.ports[2]".
func (p encPath) String() string { func (p encPath) String() string {
return Path(p.segments()).String()
}
// segments returns the path's segments, root first: a key contributes its
// name, an array element its bracketed index.
func (p encPath) segments() []string {
var parts []string var parts []string
for s := &p; s != nil; s = s.parent { for s := &p; s != nil; s = s.parent {
switch { switch {
@@ -399,18 +403,8 @@ func (p encPath) String() string {
parts = append(parts, s.name) parts = append(parts, s.name)
} }
} }
var b strings.Builder slices.Reverse(parts)
for _, part := range slices.Backward(parts) { return parts
if strings.HasPrefix(part, "[") {
b.WriteString(part)
continue
}
if b.Len() > 0 {
b.WriteByte('.')
}
b.WriteString(part)
}
return b.String()
} }
// --- reflection walk: struct --------------------------------------------- // --- reflection walk: struct ---------------------------------------------
@@ -637,10 +631,10 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInl
if m, ok := marshalerOf(v); ok { if m, ok := marshalerOf(v); ok {
mv, err := m.MarshalTOML() mv, err := m.MarshalTOML()
if err != nil { if err != nil {
return &EncodeError{Path: path.key(name).String(), Err: err} return &EncodeError{Path: path.key(name).segments(), Err: err}
} }
if mv == nil { if mv == nil {
return &EncodeError{Path: path.key(name).String(), Err: errNilMarshalTOML} return &EncodeError{Path: path.key(name).segments(), Err: errNilMarshalTOML}
} }
v = reflect.ValueOf(mv) v = reflect.ValueOf(mv)
} }
@@ -648,7 +642,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInl
// a scalar kind or a struct. // a scalar kind or a struct.
s, isText, err := textValue(v) s, isText, err := textValue(v)
if err != nil { if err != nil {
return &EncodeError{Path: path.key(name).String(), Err: err} return &EncodeError{Path: path.key(name).segments(), Err: err}
} }
if isText { if isText {
doc.addScalar(name, s) doc.addScalar(name, s)
@@ -668,7 +662,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInl
om := v.Interface().(OrderedMap) om := v.Interface().(OrderedMap)
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1} sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) { if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(name).String(), Err: errDepthLimit()} return &EncodeError{Path: path.key(name).segments(), Err: errDepthLimit()}
} }
if err := buildOrderedDoc(&om, sub, path.key(name)); err != nil { if err := buildOrderedDoc(&om, sub, path.key(name)); err != nil {
return err return err
@@ -700,7 +694,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInl
func addSubTable(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInline bool) error { func addSubTable(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInline bool) error {
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1} sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) { if atDepthLimit(sub.depth) {
return &EncodeError{Path: path.key(name).String(), Err: errDepthLimit()} return &EncodeError{Path: path.key(name).segments(), Err: errDepthLimit()}
} }
switch v.Kind() { switch v.Kind() {
case reflect.Struct: case reflect.Struct:
@@ -775,7 +769,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
// A `,inline` tag on an array of tables asks for a form that would change // A `,inline` tag on an array of tables asks for a form that would change
// the value's Go type on re-parse, so the error is the honest answer. // the value's Go type on re-parse, so the error is the honest answer.
if forceInline && allTables { if forceInline && allTables {
return &EncodeError{Path: path.key(name).String(), Err: errors.New("an array of tables has no inline form")} return &EncodeError{Path: path.key(name).segments(), Err: errors.New("an array of tables has no inline form")}
} }
if allTables { if allTables {
subs := make([]*tomlDoc, n) subs := make([]*tomlDoc, n)
@@ -787,7 +781,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
} }
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1} sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
if atDepthLimit(sub.depth) { if atDepthLimit(sub.depth) {
return &EncodeError{Path: apath.elem(i).String(), Err: errDepthLimit()} return &EncodeError{Path: apath.elem(i).segments(), Err: errDepthLimit()}
} }
switch { switch {
case ev.Type() == orderedMapType: case ev.Type() == orderedMapType:
@@ -797,7 +791,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
} }
case ev.Kind() == reflect.Struct: case ev.Kind() == reflect.Struct:
if isScalarStruct(ev.Type()) { if isScalarStruct(ev.Type()) {
return &EncodeError{Path: apath.elem(i).String(), Err: errors.New("heterogeneous array contains scalar")} return &EncodeError{Path: apath.elem(i).segments(), Err: errors.New("heterogeneous array contains scalar")}
} }
if err := buildStructDoc(ev, sub, apath.elem(i)); err != nil { if err := buildStructDoc(ev, sub, apath.elem(i)); err != nil {
return err return err
@@ -807,7 +801,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
return err return err
} }
default: default:
return &EncodeError{Path: apath.elem(i).String(), Err: errors.New("heterogeneous array, expected table")} return &EncodeError{Path: apath.elem(i).segments(), Err: errors.New("heterogeneous array, expected table")}
} }
subs[i] = sub subs[i] = sub
} }
@@ -826,7 +820,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
} }
val, err := normaliseValue(ev) val, err := normaliseValue(ev)
if err != nil { if err != nil {
return &EncodeError{Path: apath.elem(i).String(), Err: err} return &EncodeError{Path: apath.elem(i).segments(), Err: err}
} }
items[i] = val items[i] = val
} }
@@ -867,7 +861,7 @@ func marshalerOf(v reflect.Value) (Marshaler, bool) {
func resolveElement(v reflect.Value, path encPath) (reflect.Value, error) { func resolveElement(v reflect.Value, path encPath) (reflect.Value, error) {
ev := followPtr(v) ev := followPtr(v)
if !ev.IsValid() { if !ev.IsValid() {
return ev, &EncodeError{Path: path.String(), Err: errors.New("nil element")} return ev, &EncodeError{Path: path.segments(), Err: errors.New("nil element")}
} }
m, ok := marshalerOf(ev) m, ok := marshalerOf(ev)
if !ok { if !ok {
@@ -875,14 +869,14 @@ func resolveElement(v reflect.Value, path encPath) (reflect.Value, error) {
} }
mv, err := m.MarshalTOML() mv, err := m.MarshalTOML()
if err != nil { if err != nil {
return reflect.Value{}, &EncodeError{Path: path.String(), Err: err} return reflect.Value{}, &EncodeError{Path: path.segments(), Err: err}
} }
if mv == nil { if mv == nil {
return reflect.Value{}, &EncodeError{Path: path.String(), Err: errNilMarshalTOML} return reflect.Value{}, &EncodeError{Path: path.segments(), Err: errNilMarshalTOML}
} }
ev = followPtr(reflect.ValueOf(mv)) ev = followPtr(reflect.ValueOf(mv))
if !ev.IsValid() { if !ev.IsValid() {
return ev, &EncodeError{Path: path.String(), Err: errors.New("nil element")} return ev, &EncodeError{Path: path.segments(), Err: errors.New("nil element")}
} }
return ev, nil return ev, nil
} }
+12 -12
View File
@@ -402,8 +402,8 @@ func TestMarshalRejectsNilMarshalerResult(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err) t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
} }
if ee.Path != "f" { if ee.Path.String() != "f" {
t.Fatalf("Path = %q, want %q", ee.Path, "f") t.Fatalf("Path = %v, want f", ee.Path)
} }
// Inside a value array the nil result used to reach reflection as a zero // Inside a value array the nil result used to reach reflection as a zero
@@ -1339,8 +1339,8 @@ func TestEncodeErrorCarriesPath(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err) t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
} }
if ee.Path != "server.port" { if ee.Path.String() != "server.port" {
t.Fatalf("Path = %q, want %q", ee.Path, "server.port") t.Fatalf("Path = %v, want server.port", ee.Path)
} }
if ee.Err == nil || ee.Err.Error() != "bad timestamp" { if ee.Err == nil || ee.Err.Error() != "bad timestamp" {
t.Fatalf("Err = %v", ee.Err) t.Fatalf("Err = %v", ee.Err)
@@ -1359,8 +1359,8 @@ func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err) t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
} }
if ee.Path != "port" { if ee.Path.String() != "port" {
t.Fatalf("Path = %q, want %q", ee.Path, "port") t.Fatalf("Path = %v, want port", ee.Path)
} }
if err.Error() != "interpres: port: bad timestamp" { if err.Error() != "interpres: port: bad timestamp" {
t.Fatalf("message = %q", err.Error()) t.Fatalf("message = %q", err.Error())
@@ -1382,8 +1382,8 @@ func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err) t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
} }
if ee.Path != "items[0]" { if ee.Path.String() != "items[0]" {
t.Fatalf("Path = %q, want %q", ee.Path, "items[0]") t.Fatalf("Path = %v, want items[0]", ee.Path)
} }
} }
@@ -1541,8 +1541,8 @@ func TestMarshalTextErrorCarriesPath(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err) t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
} }
if ee.Path != "inner.f" { if ee.Path.String() != "inner.f" {
t.Fatalf("Path = %q, want %q", ee.Path, "inner.f") t.Fatalf("Path = %v, want inner.f", ee.Path)
} }
} }
@@ -1920,8 +1920,8 @@ func TestMarshalerElementErrorCarriesPath(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err) t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
} }
if ee.Path != "items[1]" { if ee.Path.String() != "items[1]" {
t.Fatalf("Path = %q, want %q", ee.Path, "items[1]") t.Fatalf("Path = %v, want items[1]", ee.Path)
} }
} }
+47 -13
View File
@@ -68,37 +68,65 @@ func (e *SyntaxError) SourceLine(src []byte) string {
return string(src[start:end]) + "\n" + strings.Repeat(" ", off-start) + "^" return string(src[start:end]) + "\n" + strings.Repeat(" ", off-start) + "^"
} }
// A Path names a value in a document, one segment per level from the root:
// a key contributes its name and an array element its bracketed index, so the
// path of the weight field of the first item is the segments
// ["items", "[0]", "weight"]. String renders the TOML notation,
// "items[0].weight".
type Path []string
// String renders the path the way a TOML document writes it: keys join with
// dots and an index attaches to the previous segment in brackets.
func (p Path) String() string {
var b strings.Builder
for _, s := range p {
if strings.HasPrefix(s, "[") {
b.WriteString(s)
continue
}
if b.Len() > 0 {
b.WriteByte('.')
}
b.WriteString(s)
}
return b.String()
}
// A DecodeError wraps a decoding failure with the key path at which it // 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 // happened. Read the path programmatically with errors.AsType:
// 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 { // if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
// fmt.Println(de.Path, de.Err) // fmt.Println(de.Path.String(), de.Err)
// } // }
type DecodeError struct { type DecodeError struct {
// Path is the key path from the document root, outermost key first. // Path is the key path from the document root, outermost key first.
Path []string Path Path
// Err is the failure at that path. // Err is the failure at that path.
Err error Err error
} }
func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() } func (e *DecodeError) Error() string {
msg := strings.TrimPrefix(e.Err.Error(), "interpres: ")
if p := e.Path.String(); p != "" {
return "interpres: " + p + ": " + msg
}
return "interpres: " + msg
}
// Unwrap returns the failure the path points at. // Unwrap returns the failure the path points at.
func (e *DecodeError) Unwrap() error { return e.Err } func (e *DecodeError) Unwrap() error { return e.Err }
// newDecodeError wraps err with one path segment. The rest of the path comes // 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 // 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 // key and index on its way down, so the wrap flattens that inner error's
// segments and each outer wrap prepends one. // segments onto the front and keeps the failure it pointed at, leaving one
// path and one failure to render.
func newDecodeError(key string, err error) *DecodeError { func newDecodeError(key string, err error) *DecodeError {
path := make([]string, 0, 4) path := make(Path, 0, 4)
path = append(path, key) path = append(path, key)
if de, ok := errors.AsType[*DecodeError](err); ok { if de, ok := errors.AsType[*DecodeError](err); ok {
path = append(path, de.Path...) path = append(path, de.Path...)
err = de.Err
} }
return &DecodeError{Path: path, Err: err} return &DecodeError{Path: path, Err: err}
} }
@@ -110,12 +138,18 @@ func newDecodeError(key string, err error) *DecodeError {
// unchanged by the type; read it programmatically with errors.AsType. // unchanged by the type; read it programmatically with errors.AsType.
type EncodeError struct { type EncodeError struct {
// Path is the key path of the failing value. // Path is the key path of the failing value.
Path string Path Path
// Err is the failure at that path. // Err is the failure at that path.
Err error Err error
} }
func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() } func (e *EncodeError) Error() string {
msg := strings.TrimPrefix(e.Err.Error(), "interpres: ")
if p := e.Path.String(); p != "" {
return "interpres: " + p + ": " + msg
}
return "interpres: " + msg
}
// Unwrap returns the failure the path points at. // Unwrap returns the failure the path points at.
func (e *EncodeError) Unwrap() error { return e.Err } func (e *EncodeError) Unwrap() error { return e.Err }