diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b15082..30676d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/decode_test.go b/decode_test.go index 5ee3e6b..f93b0f3 100644 --- a/decode_test.go +++ b/decode_test.go @@ -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) + } +} diff --git a/docs/API.md b/docs/API.md index f5595fa..caeebfe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` diff --git a/encode.go b/encode.go index 55d5fe5..6b89a20 100644 --- a/encode.go +++ b/encode.go @@ -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 } diff --git a/encode_test.go b/encode_test.go index 3b4d1c9..af3a087 100644 --- a/encode_test.go +++ b/encode_test.go @@ -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) } } diff --git a/interpres.go b/interpres.go index 5db82f1..f9d21bd 100644 --- a/interpres.go +++ b/interpres.go @@ -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 }