From f44d48e9be9da1a7f24f2dfd2ccded99001b58c3 Mon Sep 17 00:00:00 2001 From: Keepers Date: Fri, 12 Jul 2024 15:13:18 -0600 Subject: [PATCH] rearrange code and improve commenting general cleanup for readability and code layout. - dataNode handling has been moved into its own file. - errCore handling has been moved into its own file. - other files have been rearranged. no logical changes have occurred. The current changes are purely superficial. --- clues.go | 372 ++++---------------- datanode.go | 407 ++++++++++++++++++++++ err.go | 965 +++++++++++++++++++++++++++------------------------- errcore.go | 139 ++++++++ 4 files changed, 1103 insertions(+), 780 deletions(-) create mode 100644 datanode.go create mode 100644 errcore.go diff --git a/clues.go b/clues.go index 57db965..fe40ee7 100644 --- a/clues.go +++ b/clues.go @@ -2,322 +2,71 @@ package clues import ( "context" - "fmt" - "path" - "reflect" - "strings" - - "github.com/google/uuid" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" ) -type Adder interface { - Add(key string, n int64) -} - // --------------------------------------------------------------------------- -// structure data storage and namespaces +// key-value metadata // --------------------------------------------------------------------------- -// dataNodes contain the data tracked by both clues in contexts and in errors. -// -// These nodes create an inverted tree, such that nodes can walk their ancestry -// path from leaf (the current node) to root (the highest ancestor), but not -// from root to child. This allows clues to establish sets of common ancestor -// data with unique branches for individual descendants, making the addition of -// new data inherently theadsafe. -// -// For collisions during aggregation, distance from the root denotes priority, -// with the root having the lowest priority. IE: if a child overwrites a key -// declared by an ancestor, the child's entry takes priority. -type dataNode struct { - parent *dataNode - id string - vs map[string]any - labelCounter Adder - comment comment -} - -func makeNodeID() string { - uns := uuid.NewString() - return uns[:4] + uns[len(uns)-4:] -} - -func (dn *dataNode) add(m map[string]any) *dataNode { - if m == nil { - m = map[string]any{} - } - - return &dataNode{ - parent: dn, - id: makeNodeID(), - vs: maps.Clone(m), - labelCounter: dn.labelCounter, - } -} - -func (dn *dataNode) trace(name string) *dataNode { - if name == "" { - name = makeNodeID() - } - - return &dataNode{ - parent: dn, - id: name, - vs: map[string]any{}, - labelCounter: dn.labelCounter, - } -} - -// lineage runs the fn on every valueNode in the ancestry tree, -// starting at the root and ending at the dataNode. -func (dn *dataNode) lineage(fn func(id string, vs map[string]any)) { - if dn == nil { - return - } - - if dn.parent != nil { - dn.parent.lineage(fn) - } - - fn(dn.id, dn.vs) -} - -func (dn *dataNode) Slice() []any { - m := dn.Map() - s := make([]any, 0, 2*len(m)) - - for k, v := range m { - s = append(s, k, v) - } - - return s -} - -func (dn *dataNode) Map() map[string]any { - var ( - m = map[string]any{} - idsl = []string{} - ) - - dn.lineage(func(id string, vs map[string]any) { - if len(id) > 0 { - idsl = append(idsl, id) - } - - for k, v := range vs { - m[k] = v - } - }) - - if len(idsl) > 0 { - m["clues_trace"] = strings.Join(idsl, ",") - } - - return m -} - -type comment struct { - Caller string - Dir string - File string - Message string -} - -func (c comment) isEmpty() bool { - return len(c.Message) == 0 -} - -func newComment( - depth int, - template string, - values ...any, -) comment { - caller := getCaller(depth + 1) - longTrace := getTrace(depth + 1) - dir, file := path.Split(longTrace) - - return comment{ - Caller: caller, - Dir: dir, - File: file, - Message: fmt.Sprintf(template, values...), - } -} - -func (dn *dataNode) addComment( - depth int, - msg string, - vs ...any, -) *dataNode { - if len(msg) == 0 { - return dn - } - - return &dataNode{ - parent: dn, - labelCounter: dn.labelCounter, - comment: newComment(depth+1, msg, vs...), - } -} - -type comments []comment - -func (cs comments) String() string { - result := []string{} - - for _, c := range cs { - result = append(result, c.Caller+" - "+c.File) - result = append(result, "\t"+c.Message) - } - - return strings.Join(result, "\n") -} - -// Comments retrieves the full ancestor comment chain. -// The return value is ordered from the first added comment -// to the last one. -func (dn *dataNode) Comments() comments { - result := comments{} - - if !dn.comment.isEmpty() { - result = append(result, dn.comment) - } - - for dn.parent != nil { - dn = dn.parent - if !dn.comment.isEmpty() { - result = append(result, dn.comment) - } - } - - slices.Reverse(result) - - return result -} - -// --------------------------------------------------------------------------- -// ctx handling -// --------------------------------------------------------------------------- - -type cluesCtxKey string - -const defaultNamespace cluesCtxKey = "default_clues_namespace_key" - -func ctxKey(namespace string) cluesCtxKey { - return cluesCtxKey(namespace) -} - -func from(ctx context.Context, namespace cluesCtxKey) *dataNode { - dn := ctx.Value(namespace) - - if dn == nil { - return &dataNode{} - } - - return dn.(*dataNode) -} - -func set(ctx context.Context, dn *dataNode) context.Context { - return context.WithValue(ctx, defaultNamespace, dn) -} - -func setTo(ctx context.Context, namespace string, dn *dataNode) context.Context { - return context.WithValue(ctx, ctxKey(namespace), dn) -} - -// --------------------------------------------------------------------------- -// data normalization and aggregating -// --------------------------------------------------------------------------- - -func normalize(kvs ...any) map[string]any { - norm := map[string]any{} - - for i := 0; i < len(kvs); i += 2 { - key := marshal(kvs[i], true) - - var value any - if i+1 < len(kvs) { - value = marshal(kvs[i+1], true) - } - - norm[key] = value - } - - return norm -} - -func marshal(a any, conceal bool) string { - if a == nil { - return "" - } - - // protect against nil pointer values with value-receiver funcs - rvo := reflect.ValueOf(a) - if rvo.Kind() == reflect.Ptr && rvo.IsNil() { - return "" - } - - if as, ok := a.(Concealer); conceal && ok { - return as.Conceal() - } - - if as, ok := a.(string); ok { - return as - } - - if as, ok := a.(fmt.Stringer); ok { - return as.String() - } - - return fmt.Sprintf("%+v", a) -} - // Add adds all key-value pairs to the clues. func Add(ctx context.Context, kvs ...any) context.Context { - nc := from(ctx, defaultNamespace) - return set(ctx, nc.add(normalize(kvs...))) + nc := nodeFromCtx(ctx, defaultNamespace) + return setDefaultNodeInCtx(ctx, nc.addValues(normalize(kvs...))) +} + +// AddTo adds all key-value pairs to a namespaced set of clues. +func AddTo( + ctx context.Context, + namespace string, + kvs ...any, +) context.Context { + nc := nodeFromCtx(ctx, ctxKey(namespace)) + return setNodeInCtx(ctx, namespace, nc.addValues(normalize(kvs...))) } // AddMap adds a shallow clone of the map to a namespaced set of clues. -func AddMap[K comparable, V any](ctx context.Context, m map[K]V) context.Context { - nc := from(ctx, defaultNamespace) +func AddMap[K comparable, V any]( + ctx context.Context, + m map[K]V, +) context.Context { + nc := nodeFromCtx(ctx, defaultNamespace) kvs := make([]any, 0, len(m)*2) for k, v := range m { kvs = append(kvs, k, v) } - return set(ctx, nc.add(normalize(kvs...))) -} - -// AddTo adds all key-value pairs to a namespaced set of clues. -func AddTo(ctx context.Context, namespace string, kvs ...any) context.Context { - nc := from(ctx, ctxKey(namespace)) - return setTo(ctx, namespace, nc.add(normalize(kvs...))) + return setDefaultNodeInCtx(ctx, nc.addValues(normalize(kvs...))) } // AddMapTo adds a shallow clone of the map to a namespaced set of clues. -func AddMapTo[K comparable, V any](ctx context.Context, namespace string, m map[K]V) context.Context { - nc := from(ctx, ctxKey(namespace)) +func AddMapTo[K comparable, V any]( + ctx context.Context, + namespace string, + m map[K]V, +) context.Context { + nc := nodeFromCtx(ctx, ctxKey(namespace)) kvs := make([]any, 0, len(m)*2) for k, v := range m { kvs = append(kvs, k, v) } - return setTo(ctx, namespace, nc.add(normalize(kvs...))) + return setNodeInCtx(ctx, namespace, nc.addValues(normalize(kvs...))) } +// --------------------------------------------------------------------------- +// traces +// --------------------------------------------------------------------------- + // AddTrace stacks a clues node onto this context. Adding a node ensures // that this point in code is identified by an ID, which can later be // used to correlate and isolate logs to certain trace branches. // AddTrace is only needed for layers that don't otherwise call Add() or // similar functions, since those funcs already attach a new node. func AddTrace(ctx context.Context) context.Context { - nc := from(ctx, defaultNamespace) - return set(ctx, nc.trace("")) + nc := nodeFromCtx(ctx, defaultNamespace) + return setDefaultNodeInCtx(ctx, nc.trace("")) } // AddTraceTo stacks a clues node onto this context within the specified @@ -327,8 +76,8 @@ func AddTrace(ctx context.Context) context.Context { // otherwise call AddTo() or similar functions, since those funcs already // attach a new node. func AddTraceTo(ctx context.Context, namespace string) context.Context { - nc := from(ctx, ctxKey(namespace)) - return setTo(ctx, namespace, nc.trace("")) + nc := nodeFromCtx(ctx, ctxKey(namespace)) + return setNodeInCtx(ctx, namespace, nc.trace("")) } // AddTraceName stacks a clues node onto this context and uses the provided @@ -339,17 +88,17 @@ func AddTraceName( name string, kvs ...any, ) context.Context { - nc := from(ctx, defaultNamespace) + nc := nodeFromCtx(ctx, defaultNamespace) var node *dataNode if len(kvs) > 0 { - node = nc.add(normalize(kvs...)) + node = nc.addValues(normalize(kvs...)) node.id = name } else { node = nc.trace(name) } - return set(ctx, node) + return setDefaultNodeInCtx(ctx, node) } // AddTraceNameTo stacks a clues node onto this context and uses the provided @@ -360,29 +109,22 @@ func AddTraceNameTo( name, namespace string, kvs ...any, ) context.Context { - nc := from(ctx, ctxKey(namespace)) + nc := nodeFromCtx(ctx, ctxKey(namespace)) var node *dataNode if len(kvs) > 0 { - node = nc.add(normalize(kvs...)) + node = nc.addValues(normalize(kvs...)) node.id = name } else { node = nc.trace(name) } - return setTo(ctx, namespace, node) + return setNodeInCtx(ctx, namespace, node) } -// AddLabelCounter embeds an Adder interface into this context. Any already -// embedded Adder will get replaced. When adding Labels to a clues.Err the -// LabelCounter will use the label as the key for the Add call, and increment -// the count of that label by one. -func AddLabelCounter(ctx context.Context, counter Adder) context.Context { - nc := from(ctx, defaultNamespace) - nn := nc.add(nil) - nn.labelCounter = counter - return set(ctx, nn) -} +// --------------------------------------------------------------------------- +// comments +// --------------------------------------------------------------------------- // Comments are special case additions to the context. They're here to, well, // let you add comments! Why? Because sometimes it's not sufficient to have a @@ -403,23 +145,29 @@ func AddLabelCounter(ctx context.Context, counter Adder) context.Context { // order of appearance, and prefixed by the file and line in which they appeared. // This means comments are always added to the context and never clobber each // other, regardless of their location. IE: don't add them to a loop. -func AddComment(ctx context.Context, msg string, vs ...any) context.Context { - nc := from(ctx, defaultNamespace) +func AddComment( + ctx context.Context, + msg string, + vs ...any, +) context.Context { + nc := nodeFromCtx(ctx, defaultNamespace) nn := nc.addComment(1, msg, vs...) - return set(ctx, nn) + return setDefaultNodeInCtx(ctx, nn) } // --------------------------------------------------------------------------- -// data retrieval +// hooks // --------------------------------------------------------------------------- -// In returns the map of values in the default namespace. -func In(ctx context.Context) *dataNode { - return from(ctx, defaultNamespace) -} +// AddLabelCounter embeds an Adder interface into this context. Any already +// embedded Adder will get replaced. When adding Labels to a clues.Err the +// LabelCounter will use the label as the key for the Add call, and increment +// the count of that label by one. +func AddLabelCounter(ctx context.Context, counter Adder) context.Context { + nc := nodeFromCtx(ctx, defaultNamespace) + nn := nc.addValues(nil) + nn.labelCounter = counter -// InNamespace returns the map of values in the given namespace. -func InNamespace(ctx context.Context, namespace string) *dataNode { - return from(ctx, ctxKey(namespace)) + return setDefaultNodeInCtx(ctx, nn) } diff --git a/datanode.go b/datanode.go new file mode 100644 index 0000000..24f1949 --- /dev/null +++ b/datanode.go @@ -0,0 +1,407 @@ +package clues + +import ( + "context" + "fmt" + "path" + "reflect" + "runtime" + "slices" + "strings" + + "github.com/google/uuid" + "golang.org/x/exp/maps" +) + +// --------------------------------------------------------------------------- +// data nodes +// --------------------------------------------------------------------------- + +type Adder interface { + Add(key string, n int64) +} + +// dataNodes contain the data tracked by both clues in contexts and in errors. +// +// These nodes compose a tree, such that nodes can walk their ancestry path from +// leaf (the current node) to root (the highest ancestor), but not from root to +// child. This allows clues to establish sets of common ancestor data with unique +// branches for individual descendants, making the addition of new data inherently +// theadsafe. +// +// For collisions during aggregation, distance from the root denotes priority, +// with the root having the lowest priority. IE: if a child overwrites a key +// declared by an ancestor, the child's entry takes priority. +type dataNode struct { + parent *dataNode + + // ids are optional and are used primarily as tracing markers. + // if empty, the trace for that node will get skipped when building the + // full trace along the node's ancestry path in the tree. + id string + + // values are they arbitrary key:value pairs that appear in clues when callers + // use the Add(ctx, k, v) or err.With(k, v) adders. Each key-value pair added + // to the node is used to produce the final set of Values() in the dataNode, + // with lower nodes in the tree taking priority over higher nodes for any + // collision resolution. + values map[string]any + + // each node can hold a single commment. The history of comments produced + // by the ancestry path through the tree will get concatenated from oldest + // ancestor to the current node to produce the comment history. + comment comment + + // labelCounter is a func hook that allows a caller to automatically count the + // number of times a label appears. DataNodes themselves have no labels, so + // in this case the presence of a labelCounter will be used to count the labels + // appearing in errors which attach this data node to the error. + // + // Errors will only utilize the first labelCounter they find. The tree is searched + // from leaf to root when looking for populated labelCounters. + labelCounter Adder +} + +// --------------------------------------------------------------------------- +// setters +// --------------------------------------------------------------------------- + +// normalize ensures that the variadic of key-value pairs is even in length, +// and then transforms that slice of values into a map[string]any, where all +// keys are transformed to string using the marshal() func. +func normalize(kvs ...any) map[string]any { + norm := map[string]any{} + + for i := 0; i < len(kvs); i += 2 { + key := marshal(kvs[i], true) + + var value any + if i+1 < len(kvs) { + value = marshal(kvs[i+1], true) + } + + norm[key] = value + } + + return norm +} + +// marshal is the central marshalling handler for the entire package. All +// stringification of values comes down to this function. Priority for +// stringification follows this order: +// 1. nil -> "" +// 2. conceal all concealer interfaces +// 3. flat string values +// 4. string all stringer interfaces +// 5. fmt.sprintf the rest +func marshal(a any, conceal bool) string { + if a == nil { + return "" + } + + // protect against nil pointer values with value-receiver funcs + rvo := reflect.ValueOf(a) + if rvo.Kind() == reflect.Ptr && rvo.IsNil() { + return "" + } + + if as, ok := a.(Concealer); conceal && ok { + return as.Conceal() + } + + if as, ok := a.(string); ok { + return as + } + + if as, ok := a.(fmt.Stringer); ok { + return as.String() + } + + return fmt.Sprintf("%+v", a) +} + +// addValues adds all entries in the map to the dataNode's values. +func (dn *dataNode) addValues(m map[string]any) *dataNode { + if m == nil { + m = map[string]any{} + } + + return &dataNode{ + parent: dn, + id: makeNodeID(), + values: maps.Clone(m), + labelCounter: dn.labelCounter, + } +} + +// trace adds a new leaf containing a trace ID and no other values. +func (dn *dataNode) trace(name string) *dataNode { + if name == "" { + name = makeNodeID() + } + + return &dataNode{ + parent: dn, + id: name, + values: map[string]any{}, + labelCounter: dn.labelCounter, + } +} + +// --------------------------------------------------------------------------- +// getters +// --------------------------------------------------------------------------- + +// lineage runs the fn on every valueNode in the ancestry tree, +// starting at the root and ending at the dataNode. +func (dn *dataNode) lineage(fn func(id string, vs map[string]any)) { + if dn == nil { + return + } + + if dn.parent != nil { + dn.parent.lineage(fn) + } + + fn(dn.id, dn.values) +} + +// In returns the default dataNode from the context. +// TODO: turn return an interface instead of a dataNode, have dataNodes +// and errors both comply with that wrapper. +func In(ctx context.Context) *dataNode { + return nodeFromCtx(ctx, defaultNamespace) +} + +// InNamespace returns the map of values in the given namespace. +// TODO: turn return an interface instead of a dataNode, have dataNodes +// and errors both comply with that wrapper. +func InNamespace(ctx context.Context, namespace string) *dataNode { + return nodeFromCtx(ctx, ctxKey(namespace)) +} + +// Map flattens the tree of dataNode.values into a map. Descendant nodes +// take priority over ancestors in cases of collision. +func (dn *dataNode) Map() map[string]any { + var ( + m = map[string]any{} + idsl = []string{} + ) + + dn.lineage(func(id string, vs map[string]any) { + if len(id) > 0 { + idsl = append(idsl, id) + } + + for k, v := range vs { + m[k] = v + } + }) + + if len(idsl) > 0 { + m["clues_trace"] = strings.Join(idsl, ",") + } + + return m +} + +// Slice flattens the tree of dataNode.values into a Slice where all even +// indices contain the keys, and all odd indices contain values. Descendant +// nodes take priority over ancestors in cases of collision. +func (dn *dataNode) Slice() []any { + m := dn.Map() + s := make([]any, 0, 2*len(m)) + + for k, v := range m { + s = append(s, k, v) + } + + return s +} + +// --------------------------------------------------------------------------- +// comments +// --------------------------------------------------------------------------- + +type comment struct { + // the func name in which the comment was created. + Caller string + // the directory path of the file owning the Caller. + Dir string + // the name of the file owning the caller. + File string + // the comment message itself. + Message string +} + +// shorthand for checking if an empty comment was generated. +func (c comment) isEmpty() bool { + return len(c.Message) == 0 +} + +// newComment formats the provided values, and grabs the caller and trace +// info according to the depth. Depth is a skip-caller count, and any func +// calling this one should provide either `1` (for itself) or `depth+1` (if +// it was already given a depth value). +func newComment( + depth int, + template string, + values ...any, +) comment { + caller := getCaller(depth + 1) + longTrace := getTrace(depth + 1) + dir, file := path.Split(longTrace) + + return comment{ + Caller: caller, + Dir: dir, + File: file, + Message: fmt.Sprintf(template, values...), + } +} + +// addComment creates a new dataNode with a comment but no other properties. +func (dn *dataNode) addComment( + depth int, + msg string, + vs ...any, +) *dataNode { + if len(msg) == 0 { + return dn + } + + return &dataNode{ + parent: dn, + labelCounter: dn.labelCounter, + comment: newComment(depth+1, msg, vs...), + } +} + +// comments allows us to put a stringer on a slice of comments. +type comments []comment + +// String formats the slice of comments as a stack, much like you'd see +// with an error stacktrace. Comments are listed top-to-bottom from first- +// to-last. +// +// The format for each comment in the stack is: +// +// - : +// +func (cs comments) String() string { + result := []string{} + + for _, c := range cs { + result = append(result, c.Caller+" - "+c.File) + result = append(result, "\t"+c.Message) + } + + return strings.Join(result, "\n") +} + +// Comments retrieves the full ancestor comment chain. +// The return value is ordered from the first added comment (closest to +// the root) to the most recent one (closest to the leaf). +func (dn *dataNode) Comments() comments { + result := comments{} + + if !dn.comment.isEmpty() { + result = append(result, dn.comment) + } + + for dn.parent != nil { + dn = dn.parent + if !dn.comment.isEmpty() { + result = append(result, dn.comment) + } + } + + slices.Reverse(result) + + return result +} + +// --------------------------------------------------------------------------- +// ctx handling +// --------------------------------------------------------------------------- + +type cluesCtxKey string + +const defaultNamespace cluesCtxKey = "default_clues_namespace_key" + +func ctxKey(namespace string) cluesCtxKey { + return cluesCtxKey(namespace) +} + +// nodeFromCtx pulls the datanode within a given namespace out of the context. +func nodeFromCtx( + ctx context.Context, + namespace cluesCtxKey, +) *dataNode { + dn := ctx.Value(namespace) + + if dn == nil { + return &dataNode{} + } + + return dn.(*dataNode) +} + +// setDefaultNodeInCtx adds the context to the dataNode within the given +// namespace and returns the updated context. +func setDefaultNodeInCtx( + ctx context.Context, + dn *dataNode, +) context.Context { + return context.WithValue(ctx, defaultNamespace, dn) +} + +// setNodeInCtx adds the context to the dataNode within the given namespace +// and returns the updated context. +func setNodeInCtx( + ctx context.Context, + namespace string, + dn *dataNode, +) context.Context { + return context.WithValue(ctx, ctxKey(namespace), dn) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// makeNodeID generates a random hash of 8 characters for use as a node ID. +func makeNodeID() string { + uns := uuid.NewString() + return uns[:4] + uns[len(uns)-4:] +} + +// getTrace retrieves the file and line number of the caller. Depth is the +// skip-caller count. Clues funcs that call this one should provide either +// `1` (if they do not already have a depth value), or `depth+1` otherwise`. +// +// Formats to: `:` +func getTrace(depth int) string { + _, file, line, _ := runtime.Caller(depth + 1) + return fmt.Sprintf("%s:%d", file, line) +} + +// getCaller retrieves the func name of the caller. Depth is the skip-caller +// count. Clues funcs that call this one should provide either `1` (if they +// do not already have a depth value), or `depth+1` otherwise.` +func getCaller(depth int) string { + pc, _, _, ok := runtime.Caller(depth + 1) + if !ok { + return "" + } + + funcPath := runtime.FuncForPC(pc).Name() + base := path.Base(funcPath) + parts := strings.Split(base, ".") + + if len(parts) < 2 { + return base + } + + return parts[len(parts)-1] +} diff --git a/err.go b/err.go index bffcb74..40feb00 100644 --- a/err.go +++ b/err.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "io" - "path" "reflect" - "runtime" "strings" "golang.org/x/exp/maps" @@ -43,92 +41,199 @@ type Err struct { data *dataNode } -func asErr(err error, msg string, m map[string]any, traceDepth int) *Err { +// --------------------------------------------------------------------------- +// constructors +// --------------------------------------------------------------------------- + +// newErr generates a new *Err from the parameters. +// traceDepth should always be `1` or `depth+1`. +func newErr( + e error, + msg string, + m map[string]any, + traceDepth int, +) *Err { + return &Err{ + e: e, + location: getTrace(traceDepth + 1), + msg: msg, + data: &dataNode{ + id: makeNodeID(), + values: m, + }, + } +} + +// tryExtendErr checks if err is an *Err. If it is, it extends the Err +// with a child containing the provided parameters. If not, it creates +// a new Err containing the parameters. +// traceDepth should always be `1` or `depth+1`. +func tryExtendErr( + err error, + msg string, + m map[string]any, + traceDepth int, +) *Err { if isNilErrIface(err) { return nil } e, ok := err.(*Err) if !ok { - e = toErr(err, msg, m, traceDepth+1) + e = newErr(err, msg, m, traceDepth+1) } return e } -func toErr(e error, msg string, m map[string]any, traceDepth int) *Err { +// newStack creates a new *Err containing the provided stack of errors. +// traceDepth should always be `1` or `depth+1`. +func toStack( + e error, + stack []error, + traceDepth int, +) *Err { return &Err{ e: e, location: getTrace(traceDepth + 1), - msg: msg, - data: &dataNode{id: makeNodeID(), vs: m}, + stack: stack, + data: &dataNode{ + id: makeNodeID(), + values: map[string]any{}, + }, } } -func toStack(e error, stack []error, traceDepth int) *Err { - return &Err{ - e: e, - location: getTrace(traceDepth + 1), - stack: stack, - data: &dataNode{id: makeNodeID(), vs: map[string]any{}}, +// makeStack creates a new *Err from the provided stack of errors. +// nil values are filtered out of the errs slice. If all errs are nil, +// returns nil. +// traceDepth should always be `1` or `depth+1`. +func makeStack( + traceDepth int, + errs ...error, +) *Err { + filtered := []error{} + for _, err := range errs { + if !isNilErrIface(err) { + filtered = append(filtered, err) + } } + + switch len(filtered) { + case 0: + return nil + case 1: + return newErr(filtered[0], "", nil, traceDepth+1) + } + + return toStack(filtered[0], filtered[1:], traceDepth+1) } -func getTrace(depth int) string { - _, file, line, _ := runtime.Caller(depth + 1) - return fmt.Sprintf("%s:%d", file, line) +// ------------------------------------------------------------ +// getters +// TODO: transform all this to comply with a standard interface +// ------------------------------------------------------------ + +// TODO: this will need some cleanup in a follow-up PR. +// +// ancestors builds out the ancestor lineage of this +// particular error. This follows standard layout rules +// already established elsewhere: +// * the first entry is the oldest ancestor, the last is +// the current error. +// * Stacked errors get visited before wrapped errors. +func ancestors(err error) []error { + return stackAncestorsOntoSelf(err) } -func getCaller(depth int) string { - pc, _, _, ok := runtime.Caller(depth + 1) - if !ok { - return "" +// a recursive function, purely for building out ancestorStack. +func stackAncestorsOntoSelf(err error) []error { + if err == nil { + return []error{} + } + + errs := []error{} + + ce, ok := err.(*Err) + + if ok { + for _, e := range ce.stack { + errs = append(errs, stackAncestorsOntoSelf(e)...) + } } - funcPath := runtime.FuncForPC(pc).Name() - base := path.Base(funcPath) - parts := strings.Split(base, ".") + unwrapped := Unwrap(err) - if len(parts) < 2 { - return base + if unwrapped != nil { + errs = append(errs, stackAncestorsOntoSelf(unwrapped)...) } - return parts[len(parts)-1] + errs = append(errs, err) + + return errs } -func getLabelCounter(e error) Adder { - if e == nil { - return nil +// InErr returns the map of contextual values in the error. +// Each error in the stack is unwrapped and all maps are +// unioned. In case of collision, lower level error data +// take least priority. +// TODO: remove this in favor of a type-independent In() +// that returns an interface which both dataNodes and Err +// comply with. +func InErr(err error) *dataNode { + if isNilErrIface(err) { + return &dataNode{values: map[string]any{}} } - ce, ok := e.(*Err) - if !ok { - return nil + return &dataNode{values: inErr(err)} +} + +func inErr(err error) map[string]any { + if isNilErrIface(err) { + return map[string]any{} } - for i := len(ce.stack) - 1; i >= 0; i-- { - lc := getLabelCounter(ce.stack[i]) - if lc != nil { - return lc - } + if e, ok := err.(*Err); ok { + return e.values() } - if ce.e != nil { - lc := getLabelCounter(ce.e) - if lc != nil { - return lc - } + return inErr(Unwrap(err)) +} + +// ------------------------------------------------------------ +// getters - k:v store +// ------------------------------------------------------------ + +// Values returns a copy of all of the contextual data in +// the error. Each error in the stack is unwrapped and all +// maps are unioned. In case of collision, lower level error +// data take least priority. +func (err *Err) Values() *dataNode { + if isNilErrIface(err) { + return &dataNode{values: map[string]any{}} } - if ce.data != nil && ce.data.labelCounter != nil { - return ce.data.labelCounter + return &dataNode{values: err.values()} +} + +func (err *Err) values() map[string]any { + if isNilErrIface(err) { + return map[string]any{} } - return nil + vals := map[string]any{} + maps.Copy(vals, err.data.Map()) + maps.Copy(vals, inErr(err.e)) + + for _, se := range err.stack { + maps.Copy(vals, inErr(se)) + } + + return vals } // ------------------------------------------------------------ -// labels +// getters - labels // ------------------------------------------------------------ func (err *Err) HasLabel(label string) bool { @@ -187,7 +292,7 @@ func (err *Err) Label(labels ...string) *Err { } func Label(err error, label string) *Err { - return asErr(err, "", nil, 1).Label(label) + return tryExtendErr(err, "", nil, 1).Label(label) } func (err *Err) Labels() map[string]struct{} { @@ -224,236 +329,9 @@ func Labels(err error) map[string]struct{} { } // ------------------------------------------------------------ -// data +// getters - comments // ------------------------------------------------------------ -// With adds every pair of values as a key,value pair to -// the Err's data map. -func (err *Err) With(kvs ...any) *Err { - if isNilErrIface(err) { - return nil - } - - if len(kvs) > 0 { - err.data = err.data.add(normalize(kvs...)) - } - - return err -} - -// With adds every two values as a key,value pair to -// the Err's data map. -// If err is not an *Err intance, returns the error wrapped -// into an *Err struct. -func With(err error, kvs ...any) *Err { - return asErr(err, "", nil, 1).With(kvs...) -} - -// WithTrace sets the error trace to a certain depth. -// A depth of 0 traces to the func where WithTrace is -// called. 1 sets the trace to its parent, etc. -// Error traces are already generated for the location -// where clues.Wrap or clues.Stack was called. This -// call is for cases where Wrap or Stack calls are handled -// in a helper func and are not reporting the actual -// error origin. -func (err *Err) WithTrace(depth int) *Err { - if isNilErrIface(err) { - return nil - } - - if depth < 0 { - depth = 0 - } - - err.location = getTrace(depth + 1) - - return err -} - -// WithTrace sets the error trace to a certain depth. -// A depth of 0 traces to the func where WithTrace is -// called. 1 sets the trace to its parent, etc. -// Error traces are already generated for the location -// where clues.Wrap or clues.Stack was called. This -// call is for cases where Wrap or Stack calls are handled -// in a helper func and are not reporting the actual -// error origin. -// If err is not an *Err intance, returns the error wrapped -// into an *Err struct. -func WithTrace(err error, depth int) *Err { - if isNilErrIface(err) { - return nil - } - - // needed both here and in withTrace() to - // correct for the extra call depth. - if depth < 0 { - depth = 0 - } - - e, ok := err.(*Err) - if !ok { - return toErr(err, "", map[string]any{}, depth+1) - } - - return e.WithTrace(depth + 1) -} - -// WithMap copies the map to the Err's data map. -func (err *Err) WithMap(m map[string]any) *Err { - if isNilErrIface(err) { - return nil - } - - if len(m) > 0 { - err.data = err.data.add(m) - } - - return err -} - -// WithMap copies the map to the Err's data map. -// If err is not an *Err intance, returns the error wrapped -// into an *Err struct. -func WithMap(err error, m map[string]any) *Err { - return asErr(err, "", m, 1).WithMap(m) -} - -// WithClues is syntactical-sugar that assumes you're using -// the clues package to store structured data in the context. -// The values in the default namespace are retrieved and added -// to the error. -// -// clues.Stack(err).WithClues(ctx) adds the same data as -// clues.Stack(err).WithMap(clues.Values(ctx)). -// -// If the context contains a clues LabelCounter, that counter is -// passed to the error. WithClues must always be called first in -// order to count labels. -func (err *Err) WithClues(ctx context.Context) *Err { - if isNilErrIface(err) { - return nil - } - - dn := In(ctx) - e := err.WithMap(dn.Map()) - e.data.labelCounter = dn.labelCounter - - return e -} - -// WithClues is syntactical-sugar that assumes you're using -// the clues package to store structured data in the context. -// The values in the default namespace are retrieved and added -// to the error. -// -// clues.WithClues(err, ctx) adds the same data as -// clues.WithMap(err, clues.Values(ctx)). -// -// If the context contains a clues LabelCounter, that counter is -// passed to the error. WithClues must always be called first in -// order to count labels. -func WithClues(err error, ctx context.Context) *Err { - if isNilErrIface(err) { - return nil - } - - return WithMap(err, In(ctx).Map()) -} - -// Comments are special case additions to the error. They're here to, well, -// let you add comments! Why? Because sometimes it's not sufficient to have -// an error message describe what that error really means. Even a bunch of -// clues to describe system state may not be enough. Sometimes what you need -// in order to debug the situation is a long-form explanation (you do already -// add that to your code, don't you?). Or, even better, a linear history of -// long-form explanations, each one building on the prior (which you can't -// easily do in code). -// -// Unlike other additions, which are added as top-level key:value pairs to the -// context, the whole history of comments gets retained, persisted in order of -// appearance and prefixed by the file and line in which they appeared. This -// means comments are always added to the error and never clobber each other, -// regardless of their location. -func (err *Err) WithComment(msg string, vs ...any) *Err { - if isNilErrIface(err) { - return nil - } - - return &Err{ - e: err, - // have to do a new dataNode here, or else comments will duplicate - data: &dataNode{comment: newComment(1, msg, vs...)}, - } -} - -// Comments are special case additions to the error. They're here to, well, -// let you add comments! Why? Because sometimes it's not sufficient to have -// an error message describe what that error really means. Even a bunch of -// clues to describe system state may not be enough. Sometimes what you need -// in order to debug the situation is a long-form explanation (you do already -// add that to your code, don't you?). Or, even better, a linear history of -// long-form explanations, each one building on the prior (which you can't -// easily do in code). -// -// Unlike other additions, which are added as top-level key:value pairs to the -// context, the whole history of comments gets retained, persisted in order of -// appearance and prefixed by the file and line in which they appeared. This -// means comments are always added to the error and never clobber each other, -// regardless of their location. -func WithComment(err error, msg string, vs ...any) *Err { - if isNilErrIface(err) { - return nil - } - - return &Err{ - e: err, - // have to do a new dataNode here, or else comments will duplicate - data: &dataNode{comment: newComment(1, msg, vs...)}, - } -} - -// OrNil is a workaround for golang's infamous "an interface -// holding a nil value is not nil" gotcha. You can use it at -// the end of error formatting chains to ensure a correct nil -// return value. -func (err *Err) OrNil() error { - if isNilErrIface(err) { - return nil - } - - return err -} - -// Values returns a copy of all of the contextual data in -// the error. Each error in the stack is unwrapped and all -// maps are unioned. In case of collision, lower level error -// data take least priority. -func (err *Err) Values() *dataNode { - if isNilErrIface(err) { - return &dataNode{vs: map[string]any{}} - } - - return &dataNode{vs: err.values()} -} - -func (err *Err) values() map[string]any { - if isNilErrIface(err) { - return map[string]any{} - } - - vals := map[string]any{} - maps.Copy(vals, err.data.Map()) - maps.Copy(vals, inErr(err.e)) - - for _, se := range err.stack { - maps.Copy(vals, inErr(se)) - } - - return vals -} - // Comments retrieves all comments in the error. func (err *Err) Comments() comments { return Comments(err) @@ -465,90 +343,28 @@ func Comments(err error) comments { return comments{} } - ancs := ancestors(err) - result := comments{} - - for _, ancestor := range ancs { - ce, ok := ancestor.(*Err) - if !ok { - continue - } - - result = append(result, ce.data.Comments()...) - } - - return result -} - -// TODO: this will need some cleanup in a follow-up PR. -// -// ancestors builds out the ancestor lineage of this -// particular error. This follows standard layout rules -// already established elsewhere: -// * the first entry is the oldest ancestor, the last is -// the current error. -// * Stacked errors get visited before wrapped errors. -func ancestors(err error) []error { - return stackAncestorsOntoSelf(err) -} - -// a recursive function, purely for building out ancestorStack. -func stackAncestorsOntoSelf(err error) []error { - if err == nil { - return []error{} - } - - errs := []error{} - - ce, ok := err.(*Err) - - if ok { - for _, e := range ce.stack { - errs = append(errs, stackAncestorsOntoSelf(e)...) - } - } - - unwrapped := Unwrap(err) - - if unwrapped != nil { - errs = append(errs, stackAncestorsOntoSelf(unwrapped)...) - } - - errs = append(errs, err) - - return errs -} - -// InErr returns the map of contextual values in the error. -// Each error in the stack is unwrapped and all maps are -// unioned. In case of collision, lower level error data -// take least priority. -func InErr(err error) *dataNode { - if isNilErrIface(err) { - return &dataNode{vs: map[string]any{}} - } - - return &dataNode{vs: inErr(err)} -} - -func inErr(err error) map[string]any { - if isNilErrIface(err) { - return map[string]any{} - } - - if e, ok := err.(*Err); ok { - return e.values() + ancs := ancestors(err) + result := comments{} + + for _, ancestor := range ancs { + ce, ok := ancestor.(*Err) + if !ok { + continue + } + + result = append(result, ce.data.Comments()...) } - return inErr(Unwrap(err)) + return result } // ------------------------------------------------------------ -// eror interface compliance +// eror interface compliance and stringers // ------------------------------------------------------------ var _ error = &Err{} +// Error allows Err to be used as a standard error interface. func (err *Err) Error() string { if isNilErrIface(err) { return "" @@ -571,6 +387,7 @@ func (err *Err) Error() string { return strings.Join(msg, ": ") } +// format is the fallback formatting of an error func format(err error, s fmt.State, verb rune) { if isNilErrIface(err) { return @@ -655,11 +472,7 @@ func (err *Err) Format(s fmt.State, verb rune) { formatReg(err, s, verb) } -func write( - s fmt.State, - verb rune, - msgs ...string, -) { +func write(s fmt.State, verb rune, msgs ...string) { if len(msgs) == 0 || len(msgs[0]) == 0 { return } @@ -685,10 +498,14 @@ func write( } } +// ------------------------------------------------------------ +// common interface compliance +// ------------------------------------------------------------ + // Is overrides the standard Is check for Err.e, allowing us to check -// the conditional for both Err.e and Err.next. This allows clues to -// Stack() maintain multiple error pointers without failing the otherwise -// linear errors.Is check. +// the conditional for both Err.e and Err.stack. This allows clues to +// Stack() multiple error pointers without failing the otherwise linear +// errors.Is check. func (err *Err) Is(target error) bool { if isNilErrIface(err) { return false @@ -708,9 +525,9 @@ func (err *Err) Is(target error) bool { } // As overrides the standard As check for Err.e, allowing us to check -// the conditional for both Err.e and Err.next. This allows clues to -// Stack() maintain multiple error pointers without failing the otherwise -// linear errors.As check. +// the conditional for both Err.e and Err.stack. This allows clues to +// Stack() multiple error pointers without failing the otherwise linear +// errors.As check. func (err *Err) As(target any) bool { if isNilErrIface(err) { return false @@ -777,45 +594,133 @@ func Unwrap(err error) error { // constructors // ------------------------------------------------------------ +// New creates an *Err with the provided Msg. +// +// If you have a `ctx` containing other clues data, it is recommended +// that you call `NewWC(ctx, msg)` to ensure that data gets added to +// the error. +// +// The returned *Err is an error-compliant builder that can aggregate +// additional data using funcs like With(...) or Label(...). func New(msg string) *Err { - return toErr(nil, msg, nil, 1) + return newErr(nil, msg, nil, 1) } -// NewWC is equivalent to clues.New("msg").WithClues(ctx) +// NewWC creates an *Err with the provided Msg, and additionally +// extracts all of the clues data in the context into the error. +// +// NewWC is equivalent to clues.New("msg").WithClues(ctx). +// +// The returned *Err is an error-compliant builder that can aggregate +// additional data using funcs like With(...) or Label(...). func NewWC(ctx context.Context, msg string) *Err { - return toErr(nil, msg, nil, 1).WithClues(ctx) + return newErr(nil, msg, nil, 1).WithClues(ctx) } -// Wrap returns a clues.Err with a new message wrapping the old error. +// Wrap extends an error with the provided message. It is a replacement +// for `errors.Wrap`, and complies with all golang unwrapping behavior. +// +// If you have a `ctx` containing other clues data, it is recommended +// that you call `WrapWC(ctx, err, msg)` to ensure that data gets added to +// the error. +// +// The returned *Err is an error-compliant builder that can aggregate +// additional data using funcs like With(...) or Label(...). There is +// no Wrapf func in clues; we prefer that callers use Wrap().With() +// instead. +// +// Wrap can be given a `nil` error value, and will return a nil *Err. +// To avoid golang footguns when returning nil structs as interfaces +// (such as error), callers should always return Wrap().OrNil() in cases +// where the input error could be nil. func Wrap(err error, msg string) *Err { if isNilErrIface(err) { return nil } - return toErr(err, msg, nil, 1) + return newErr(err, msg, nil, 1) } -// WrapWC is equivalent to clues.Wrap(err, "msg").WithClues(ctx) -// Wrap returns a clues.Err with a new message wrapping the old error. +// WrapWC extends an error with the provided message. It is a replacement +// for `errors.Wrap`, and complies with all golang unwrapping behavior. +// +// WrapWC is equivalent to clues.Wrap(err, "msg").WithClues(ctx). +// +// If you have a `ctx` containing other clues data, it is recommended +// that you call `WrapWC(ctx, err, msg)` to ensure that data gets added to +// the error. +// +// The returned *Err is an error-compliant builder that can aggregate +// additional data using funcs like With(...) or Label(...). There is +// no WrapWCf func in clues; we prefer that callers use WrapWC().With() +// instead. +// +// Wrap can be given a `nil` error value, and will return a nil *Err. +// To avoid golang footguns when returning nil structs as interfaces +// (such as error), callers should always return WrapWC().OrNil() in cases +// where the input error could be nil. func WrapWC(ctx context.Context, err error, msg string) *Err { if isNilErrIface(err) { return nil } - return toErr(err, msg, nil, 1).WithClues(ctx) + return newErr(err, msg, nil, 1).WithClues(ctx) } -// Stack returns the error as a clues.Err. If additional errors are -// provided, the entire stack is flattened and returned as a single -// error chain. All messages and stored structure is aggregated into -// the returned err. +// Stack composes a stack of one or more errors. The first message in the +// parameters is considered the "most recent". Ex: a construction like +// clues.Stack(errFoo, io.EOF, errSmarf), the resulting Error message would +// be "foo: end-of-file: smarf". // -// Ex: Stack(sentinel, errors.New("base")).Error() => "sentinel: base" +// Unwrapping a Stack follows the same order. This allows callers to inject +// sentinel errors into error chains (ex: clues.Stack(io.EOF, myErr)) without +// losing errors.Is or errors.As checks on lower errors. +// +// If given a single error, Stack acts as a thin wrapper around the error to +// provide an *Err, giving the caller access to all the builder funcs and error +// tracing. It is always recommended that callers `return clues.Stack(err)` +// instead of the plain `return err`. +// +// The returned *Err is an error-compliant builder that can aggregate +// additional data using funcs like With(...) or Label(...). +// +// Stack can be given one or more `nil` error values. Nil errors will be +// automatically filered from the retained stack of errors. Ex: +// clues.Stack(errFoo, nil, errSmarf) == clues.Stack(errFoo, errSmarf). +// If all input errors are nil, stack will return nil. To avoid golang +// footguns when returning nil structs as interfaces (such as error), callers +// should always return Stack().OrNil() in cases where the input error could +// be nil. func Stack(errs ...error) *Err { return makeStack(1, errs...) } +// StackWC composes a stack of one or more errors. The first message in the +// parameters is considered the "most recent". Ex: a construction like +// clues.StackWC(errFoo, io.EOF, errSmarf), the resulting Error message would +// be "foo: end-of-file: smarf". +// +// Unwrapping a Stack follows the same order. This allows callers to inject +// sentinel errors into error chains (ex: clues.StackWC(io.EOF, myErr)) without +// losing errors.Is or errors.As checks on lower errors. +// +// If given a single error, Stack acts as a thin wrapper around the error to +// provide an *Err, giving the caller access to all the builder funcs and error +// tracing. It is always recommended that callers `return clues.StackWC(err)` +// instead of the plain `return err`. +// // StackWC is equivalent to clues.Stack(errs...).WithClues(ctx) +// +// The returned *Err is an error-compliant builder that can aggregate +// additional data using funcs like With(...) or Label(...). +// +// Stack can be given one or more `nil` error values. Nil errors will be +// automatically filered from the retained stack of errors. Ex: +// clues.StackWC(ctx, errFoo, nil, errSmarf) == clues.StackWC(ctx, errFoo, errSmarf). +// If all input errors are nil, stack will return nil. To avoid golang +// footguns when returning nil structs as interfaces (such as error), callers +// should always return StackWC().OrNil() in cases where the input error could +// be nil. func StackWC(ctx context.Context, errs ...error) *Err { err := makeStack(1, errs...) @@ -826,154 +731,278 @@ func StackWC(ctx context.Context, errs ...error) *Err { return err.WithClues(ctx) } -func makeStack(traceDepth int, errs ...error) *Err { - filtered := []error{} - for _, err := range errs { - if !isNilErrIface(err) { - filtered = append(filtered, err) - } - } - - switch len(filtered) { - case 0: +// OrNil is a workaround for golang's infamous "an interface +// holding a nil value is not nil" gotcha. You should use it +// to ensure the error value to produce is properly nil whenever +// your wrapped or stacked error values could also possibly be +// nil. +// +// ie: +// ``` +// return clues.Stack(maybeNilErrValue).OrNil() +// // or +// return clues.Wrap(maybeNilErrValue, "msg").OrNil() +// ``` +func (err *Err) OrNil() error { + if isNilErrIface(err) { return nil - case 1: - return toErr(filtered[0], "", nil, traceDepth+1) } - return toStack(filtered[0], filtered[1:], traceDepth+1) + return err } -// returns true if the error is nil, or is a non-nil interface containing a nil value. -func isNilErrIface(err error) bool { - if err == nil { - return true +// ------------------------------------------------------------ +// builders - clues ctx +// ------------------------------------------------------------ + +// WithClues is syntactical-sugar that assumes you're using +// the clues package to store structured data in the context. +// The values in the default namespace are retrieved and added +// to the error. +// +// clues.Stack(err).WithClues(ctx) adds the same data as +// clues.Stack(err).WithMap(clues.Values(ctx)). +// +// If the context contains a clues LabelCounter, that counter is +// passed to the error. WithClues must always be called first in +// order to count labels. +func (err *Err) WithClues(ctx context.Context) *Err { + if isNilErrIface(err) { + return nil } - val := reflect.ValueOf(err) + dn := In(ctx) + e := err.WithMap(dn.Map()) + e.data.labelCounter = dn.labelCounter - return ((val.Kind() == reflect.Pointer || val.Kind() == reflect.Interface) && val.IsNil()) + return e } -// --------------------------------------------------------------------------- -// error core -// --------------------------------------------------------------------------- +// WithClues is syntactical-sugar that assumes you're using +// the clues package to store structured data in the context. +// The values in the default namespace are retrieved and added +// to the error. +// +// clues.WithClues(err, ctx) adds the same data as +// clues.WithMap(err, clues.Values(ctx)). +// +// If the context contains a clues LabelCounter, that counter is +// passed to the error. WithClues must always be called first in +// order to count labels. +func WithClues(err error, ctx context.Context) *Err { + if isNilErrIface(err) { + return nil + } -// ErrCore is a minimized version of an Err{}. Primarily intended for -// serializing a flattened version of the error stack -type ErrCore struct { - Msg string `json:"msg"` - Labels map[string]struct{} `json:"labels"` - Values map[string]any `json:"values"` - Comments comments `json:"comments"` + return WithMap(err, In(ctx).Map()) } -// Core transforms the Err to an ErrCore, flattening all the errors in -// the stack into a single struct. -func (err *Err) Core() *ErrCore { +// ------------------------------------------------------------ +// builders - k:v store +// ------------------------------------------------------------ + +// With adds every pair of values as a key,value pair to +// the Err's data map. +func (err *Err) With(kvs ...any) *Err { if isNilErrIface(err) { return nil } - return &ErrCore{ - Msg: err.Error(), - Labels: err.Labels(), - Values: err.values(), - Comments: err.Comments(), + if len(kvs) > 0 { + err.data = err.data.addValues(normalize(kvs...)) } + + return err +} + +// With adds every two values as a key,value pair to +// the Err's data map. +// If err is not an *Err intance, a new *Err is generated +// containing the original err. +func With(err error, kvs ...any) *Err { + return tryExtendErr(err, "", nil, 1).With(kvs...) } -// ToCore transforms the Err to an ErrCore, flattening all the errors in -// the stack into a single struct -func ToCore(err error) *ErrCore { +// WithMap copies the map to the Err's data map. +func (err *Err) WithMap(m map[string]any) *Err { if isNilErrIface(err) { return nil } - e, ok := err.(*Err) - if !ok { - e = toErr(err, "", nil, 1) + if len(m) > 0 { + err.data = err.data.addValues(m) } - return e.Core() + return err } -func (ec *ErrCore) String() string { - if ec == nil { - return "" - } - - return ec.stringer(false) +// WithMap copies the map to the Err's data map. +// If err is not an *Err intance, returns the error wrapped +// into an *Err struct. +func WithMap(err error, m map[string]any) *Err { + return tryExtendErr(err, "", m, 1).WithMap(m) } -func (ec *ErrCore) stringer(fancy bool) string { - sep := ", " - ls := strings.Join(maps.Keys(ec.Labels), sep) +// ------------------------------------------------------------ +// builders - tracing +// ------------------------------------------------------------ + +// WithTrace sets the error trace to a certain depth. +// A depth of 0 traces to the func where WithTrace is +// called. 1 sets the trace to its parent, etc. +// Error traces are already generated for the location +// where clues.Wrap or clues.Stack was called. This +// call is for cases where Wrap or Stack calls are handled +// in a helper func and are not reporting the actual +// error origin. +// TODO: rename to `SkipCaller`. +func (err *Err) WithTrace(depth int) *Err { + if isNilErrIface(err) { + return nil + } - vsl := []string{} - for k, v := range ec.Values { - vsl = append(vsl, k+":"+marshal(v, true)) + if depth < 0 { + depth = 0 } - vs := strings.Join(vsl, sep) + err.location = getTrace(depth + 1) + + return err +} - csl := []string{} - for _, c := range ec.Comments { - csl = append(csl, c.Caller+" - "+c.File+" - "+c.Message) +// WithTrace sets the error trace to a certain depth. +// A depth of 0 traces to the func where WithTrace is +// called. 1 sets the trace to its parent, etc. +// Error traces are already generated for the location +// where clues.Wrap or clues.Stack was called. This +// call is for cases where Wrap or Stack calls are handled +// in a helper func and are not reporting the actual +// error origin. +// If err is not an *Err intance, returns the error wrapped +// into an *Err struct. +// TODO: rename to `SkipCaller`. +func WithTrace(err error, depth int) *Err { + if isNilErrIface(err) { + return nil } - cs := strings.Join(csl, sep) + // needed both here and in withTrace() to + // correct for the extra call depth. + if depth < 0 { + depth = 0 + } - if fancy { - return `{msg:"` + ec.Msg + `", labels:[` + ls + `], values:{` + vs + `}, comments:[` + cs + `]}` + e, ok := err.(*Err) + if !ok { + return newErr(err, "", map[string]any{}, depth+1) } - s := []string{} + return e.WithTrace(depth + 1) +} - if len(ec.Msg) > 0 { - s = append(s, `"`+ec.Msg+`"`) - } +// ------------------------------------------------------------ +// builders - comments +// ------------------------------------------------------------ - if len(ls) > 0 { - s = append(s, "["+ls+"]") +// Comments are special case additions to the error. They're here to, well, +// let you add comments! Why? Because sometimes it's not sufficient to have +// an error message describe what that error really means. Even a bunch of +// clues to describe system state may not be enough. Sometimes what you need +// in order to debug the situation is a long-form explanation (you do already +// add that to your code, don't you?). Or, even better, a linear history of +// long-form explanations, each one building on the prior (which you can't +// easily do in code). +// +// Unlike other additions, which are added as top-level key:value pairs to the +// context, the whole history of comments gets retained, persisted in order of +// appearance and prefixed by the file and line in which they appeared. This +// means comments are always added to the error and never clobber each other, +// regardless of their location. +func (err *Err) WithComment(msg string, vs ...any) *Err { + if isNilErrIface(err) { + return nil } - if len(vs) > 0 { - s = append(s, "{"+vs+"}") + return &Err{ + e: err, + // have to do a new dataNode here, or else comments will duplicate + data: &dataNode{comment: newComment(1, msg, vs...)}, } +} - if len(cs) > 0 { - s = append(s, "["+cs+"]") +// Comments are special case additions to the error. They're here to, well, +// let you add comments! Why? Because sometimes it's not sufficient to have +// an error message describe what that error really means. Even a bunch of +// clues to describe system state may not be enough. Sometimes what you need +// in order to debug the situation is a long-form explanation (you do already +// add that to your code, don't you?). Or, even better, a linear history of +// long-form explanations, each one building on the prior (which you can't +// easily do in code). +// +// Unlike other additions, which are added as top-level key:value pairs to the +// context, the whole history of comments gets retained, persisted in order of +// appearance and prefixed by the file and line in which they appeared. This +// means comments are always added to the error and never clobber each other, +// regardless of their location. +func WithComment(err error, msg string, vs ...any) *Err { + if isNilErrIface(err) { + return nil } - return "{" + strings.Join(s, ", ") + "}" + return &Err{ + e: err, + // have to do a new dataNode here, or else comments will duplicate + data: &dataNode{comment: newComment(1, msg, vs...)}, + } } -// Format provides cleaner printing of an ErrCore struct. -// -// %s only populated values are printed, without printing the property name. -// %v same as %s. -// -// Format accepts flags that alter the printing of some verbs, as follows: -// -// %+v prints the full struct, including empty values and property names. -func (ec *ErrCore) Format(s fmt.State, verb rune) { - if ec == nil { - write(s, verb, "") - return +// ------------------------------------------------------------ +// helpers +// ------------------------------------------------------------ + +// getLabelCounter retrieves the a labelCounter from the provided +// error. The algorithm works from the current error up the +// hierarchy, looking into each dataNode tree along the way, and +// eagerly takes the first available counter. +func getLabelCounter(e error) Adder { + if e == nil { + return nil } - if verb == 'v' { - if s.Flag('+') { - write(s, verb, ec.stringer(true)) - return + ce, ok := e.(*Err) + if !ok { + return nil + } + + for i := len(ce.stack) - 1; i >= 0; i-- { + lc := getLabelCounter(ce.stack[i]) + if lc != nil { + return lc } + } - if s.Flag('#') { - write(s, verb, ec.stringer(true)) - return + if ce.e != nil { + lc := getLabelCounter(ce.e) + if lc != nil { + return lc } } - write(s, verb, ec.stringer(false)) + if ce.data != nil && ce.data.labelCounter != nil { + return ce.data.labelCounter + } + + return nil +} + +// returns true if the error is nil, or if it is a non-nil interface +// containing a nil value. +func isNilErrIface(err error) bool { + if err == nil { + return true + } + + val := reflect.ValueOf(err) + + return ((val.Kind() == reflect.Pointer || val.Kind() == reflect.Interface) && val.IsNil()) } diff --git a/errcore.go b/errcore.go new file mode 100644 index 0000000..47b9070 --- /dev/null +++ b/errcore.go @@ -0,0 +1,139 @@ +package clues + +import ( + "fmt" + "strings" + + "golang.org/x/exp/maps" +) + +// ErrCore is a minimized version of an Err{}. It produces a concrete, storable +// version of the clues error data. Rather than expose the underlying error +// structure that's used for building metadata, an error core synthesizes the +// hierarchical storage of errors and data nodes into a flat, easily consumed +// set of properties. +type ErrCore struct { + Msg string `json:"msg"` + Labels map[string]struct{} `json:"labels"` + Values map[string]any `json:"values"` + Comments comments `json:"comments"` +} + +// Core transforms the error into an ErrCore. +// ErrCore is a minimized version of an Err{}. It produces a concrete, storable +// version of the clues error data. Rather than expose the underlying error +// structure that's used for building metadata, an error core synthesizes the +// hierarchical storage of errors and data nodes into a flat, easily consumed +// set of properties. +func (err *Err) Core() *ErrCore { + if isNilErrIface(err) { + return nil + } + + return &ErrCore{ + Msg: err.Error(), + Labels: err.Labels(), + Values: err.values(), + Comments: err.Comments(), + } +} + +// ToCore transforms the error into an ErrCore. +// ErrCore is a minimized version of an Err{}. It produces a concrete, storable +// version of the clues error data. Rather than expose the underlying error +// structure that's used for building metadata, an error core synthesizes the +// hierarchical storage of errors and data nodes into a flat, easily consumed +// set of properties. +func ToCore(err error) *ErrCore { + if isNilErrIface(err) { + return nil + } + + e, ok := err.(*Err) + if !ok { + e = newErr(err, "", nil, 1) + } + + return e.Core() +} + +func (ec *ErrCore) String() string { + if ec == nil { + return "" + } + + return ec.stringer(false) +} + +// stringer handles all the fancy formatting of an errorCore. +func (ec *ErrCore) stringer(fancy bool) string { + sep := ", " + ls := strings.Join(maps.Keys(ec.Labels), sep) + + vsl := []string{} + for k, v := range ec.Values { + vsl = append(vsl, k+":"+marshal(v, true)) + } + + vs := strings.Join(vsl, sep) + + csl := []string{} + for _, c := range ec.Comments { + csl = append(csl, c.Caller+" - "+c.File+" - "+c.Message) + } + + cs := strings.Join(csl, sep) + + if fancy { + return `{msg:"` + ec.Msg + `", labels:[` + ls + `], values:{` + vs + `}, comments:[` + cs + `]}` + } + + s := []string{} + + if len(ec.Msg) > 0 { + s = append(s, `"`+ec.Msg+`"`) + } + + if len(ls) > 0 { + s = append(s, "["+ls+"]") + } + + if len(vs) > 0 { + s = append(s, "{"+vs+"}") + } + + if len(cs) > 0 { + s = append(s, "["+cs+"]") + } + + return "{" + strings.Join(s, ", ") + "}" +} + +// Format provides cleaner printing of an ErrCore struct. +// +// %s only populated values are printed, without printing the property name. +// %v same as %s. +// +// Format accepts flags that alter the printing of some verbs, as follows: +// +// %+v prints the full struct, including empty values and property names. +func (ec *ErrCore) Format(s fmt.State, verb rune) { + if ec == nil { + write(s, verb, "") + return + } + + if verb == 'v' { + if s.Flag('+') { + write(s, verb, ec.stringer(true)) + return + } + + if s.Flag('#') { + write(s, verb, ec.stringer(true)) + return + } + } + + write(s, verb, ec.stringer(false)) +}