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
- `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
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
+14 -4
View File
@@ -725,8 +725,8 @@ func TestDecodeErrorCarriesPath(t *testing.T) {
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"
// The rendered message uses the Path notation.
wantMsg := "interpres: items[0].weight: integer 300 overflows uint8"
if 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"`
}
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 {
t.Errorf("err = %v, want %q", err, want)
}
@@ -1344,7 +1344,7 @@ func TestRequiredTag(t *testing.T) {
Server Config `toml:"server"`
}
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 {
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"])
}
}
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
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
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:
`weight` field in the first item reads `["items", "[0]", "weight"]` and its
`String()` renders `items[0].weight`. Read the fields instead of parsing the
message:
```go
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
document's own notation: `server.ports[2]`. Read it with `errors.AsType` the
same way.
Wraps an encoding failure with the key path of the value that failed, the
same `Path` type the decode error carries, so `server.ports[2]` reads the
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`
+25 -31
View File
@@ -249,7 +249,7 @@ func buildOrderedDoc(om *OrderedMap, doc *tomlDoc, path encPath) error {
case OrderedMap:
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
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 {
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}
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 {
return err
@@ -384,10 +384,14 @@ func (p *encPath) elem(i int) encPath {
return encPath{parent: p, index: i}
}
// String renders the path root first: keys join with dots and an array
// element carries its bracketed index, so the third port under server reads
// "server.ports[2]".
// String renders the path root first, in the notation Path.String uses.
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
for s := &p; s != nil; s = s.parent {
switch {
@@ -399,18 +403,8 @@ func (p encPath) String() string {
parts = append(parts, s.name)
}
}
var b strings.Builder
for _, part := range slices.Backward(parts) {
if strings.HasPrefix(part, "[") {
b.WriteString(part)
continue
}
if b.Len() > 0 {
b.WriteByte('.')
}
b.WriteString(part)
}
return b.String()
slices.Reverse(parts)
return parts
}
// --- 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 {
mv, err := m.MarshalTOML()
if err != nil {
return &EncodeError{Path: path.key(name).String(), Err: err}
return &EncodeError{Path: path.key(name).segments(), Err: err}
}
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)
}
@@ -648,7 +642,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInl
// a scalar kind or a struct.
s, isText, err := textValue(v)
if err != nil {
return &EncodeError{Path: path.key(name).String(), Err: err}
return &EncodeError{Path: path.key(name).segments(), Err: err}
}
if isText {
doc.addScalar(name, s)
@@ -668,7 +662,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, path encPath, forceInl
om := v.Interface().(OrderedMap)
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
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 {
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 {
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts, depth: doc.depth + 1}
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() {
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
// the value's Go type on re-parse, so the error is the honest answer.
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 {
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}
if atDepthLimit(sub.depth) {
return &EncodeError{Path: apath.elem(i).String(), Err: errDepthLimit()}
return &EncodeError{Path: apath.elem(i).segments(), Err: errDepthLimit()}
}
switch {
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:
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 {
return err
@@ -807,7 +801,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
return err
}
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
}
@@ -826,7 +820,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, path encPath, for
}
val, err := normaliseValue(ev)
if err != nil {
return &EncodeError{Path: apath.elem(i).String(), Err: err}
return &EncodeError{Path: apath.elem(i).segments(), Err: err}
}
items[i] = val
}
@@ -867,7 +861,7 @@ func marshalerOf(v reflect.Value) (Marshaler, bool) {
func resolveElement(v reflect.Value, path encPath) (reflect.Value, error) {
ev := followPtr(v)
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)
if !ok {
@@ -875,14 +869,14 @@ func resolveElement(v reflect.Value, path encPath) (reflect.Value, error) {
}
mv, err := m.MarshalTOML()
if err != nil {
return reflect.Value{}, &EncodeError{Path: path.String(), Err: err}
return reflect.Value{}, &EncodeError{Path: path.segments(), Err: err}
}
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))
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
}
+12 -12
View File
@@ -402,8 +402,8 @@ func TestMarshalRejectsNilMarshalerResult(t *testing.T) {
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "f" {
t.Fatalf("Path = %q, want %q", ee.Path, "f")
if ee.Path.String() != "f" {
t.Fatalf("Path = %v, want f", ee.Path)
}
// 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 {
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.Path.String() != "server.port" {
t.Fatalf("Path = %v, want server.port", ee.Path)
}
if ee.Err == nil || ee.Err.Error() != "bad timestamp" {
t.Fatalf("Err = %v", ee.Err)
@@ -1359,8 +1359,8 @@ func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) {
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 ee.Path.String() != "port" {
t.Fatalf("Path = %v, want port", ee.Path)
}
if err.Error() != "interpres: port: bad timestamp" {
t.Fatalf("message = %q", err.Error())
@@ -1382,8 +1382,8 @@ func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
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]")
if ee.Path.String() != "items[0]" {
t.Fatalf("Path = %v, want items[0]", ee.Path)
}
}
@@ -1541,8 +1541,8 @@ func TestMarshalTextErrorCarriesPath(t *testing.T) {
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "inner.f" {
t.Fatalf("Path = %q, want %q", ee.Path, "inner.f")
if ee.Path.String() != "inner.f" {
t.Fatalf("Path = %v, want inner.f", ee.Path)
}
}
@@ -1920,8 +1920,8 @@ func TestMarshalerElementErrorCarriesPath(t *testing.T) {
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "items[1]" {
t.Fatalf("Path = %q, want %q", ee.Path, "items[1]")
if ee.Path.String() != "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) + "^"
}
// 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
// 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:
// happened. Read the path programmatically with errors.AsType:
//
// 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 {
// Path is the key path from the document root, outermost key first.
Path []string
Path Path
// Err is the failure at that path.
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.
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.
// key and index on its way down, so the wrap flattens that inner error's
// 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 {
path := make([]string, 0, 4)
path := make(Path, 0, 4)
path = append(path, key)
if de, ok := errors.AsType[*DecodeError](err); ok {
path = append(path, de.Path...)
err = de.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.
type EncodeError struct {
// Path is the key path of the failing value.
Path string
Path Path
// Err is the failure at that path.
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.
func (e *EncodeError) Unwrap() error { return e.Err }