Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
### Added
- Add UnwrapFrame function to extract a single frame from an error.
You can use this to implement your own trace formatting logic.

### Fixed
- cmd/errtrace: Don't exit with a non-zero status when `-h` is used.
- cmd/errtrace: Don't panic on imbalanced assignments inside defer blocks.
Expand Down
15 changes: 15 additions & 0 deletions errtrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
//
// log.Printf("error: %+v", err)
//
// # Unwrapping errors
//
// Use the [UnwrapFrame] function to unwrap a single frame from an error.
//
// for err != nil {
// frame, inner, ok := errtrace.UnwrapFrame(err)
// if !ok {
// break // end of trace
// }
// printFrame(frame)
// err = inner
// }
//
// See the [UnwrapFrame] example test for a more complete example.
//
// # See also
//
// https://github.com/bracesdev/errtrace.
Expand Down
44 changes: 44 additions & 0 deletions example_trace_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package errtrace_test

import (
"errors"
"fmt"
"runtime"
"strings"

"braces.dev/errtrace"
"braces.dev/errtrace/internal/tracetest"
Expand Down Expand Up @@ -36,3 +39,44 @@ func Example_trace() {
//braces.dev/errtrace_test.f1
// /path/to/errtrace/example_trace_test.go:1
}

func f4() error {
return errtrace.Wrap(fmt.Errorf("wrapped: %w", f1()))
}

func ExampleUnwrapFrame() {
var frames []runtime.Frame
current := f4()
for current != nil {
frame, inner, ok := errtrace.UnwrapFrame(current)
if !ok {
// If the error is not wrapped with errtrace,
// unwrap it directly with errors.Unwrap.
current = errors.Unwrap(current)
continue
// Note that this example does not handle multi-errors,
// for example those returned by errors.Join.
// To handle those, this loop would need to also check
// for the 'Unwrap() []error' method on the error.
}
frames = append(frames, frame)
current = inner
}

var trace strings.Builder
for _, frame := range frames {
fmt.Fprintf(&trace, "%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line)
}
fmt.Println(tracetest.MustClean(trace.String()))

// Output:
//
//braces.dev/errtrace_test.f4
// /path/to/errtrace/example_trace_test.go:4
//braces.dev/errtrace_test.f1
// /path/to/errtrace/example_trace_test.go:1
//braces.dev/errtrace_test.f2
// /path/to/errtrace/example_trace_test.go:2
//braces.dev/errtrace_test.f3
// /path/to/errtrace/example_trace_test.go:3
}
41 changes: 9 additions & 32 deletions tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@ import (
"strings"
)

// traceFrame is a single frame in a stack trace.
type traceFrame struct {
Name string // function name
File string // file name
Line int // line number
}

// traceTree represents an error and its traces
// as a tree structure.
//
Expand All @@ -31,7 +24,7 @@ type traceTree struct {
// The trace is in the reverse order of the call stack.
// The first element is the deepest call in the stack,
// and the last element is the shallowest call in the stack.
Trace []traceFrame
Trace []runtime.Frame

// Children are the traces for each of the errors
// inside the multi-error.
Expand All @@ -49,32 +42,16 @@ func buildTraceTree(err error) traceTree {
current := traceTree{Err: err}
loop:
for {
switch x := err.(type) {
case *errTrace:
frames := runtime.CallersFrames([]uintptr{x.pc})
for {
f, more := frames.Next()
if f == (runtime.Frame{}) {
break
}

current.Trace = append(current.Trace, traceFrame{
Name: f.Function,
File: f.File,
Line: f.Line,
})

if !more {
break
}
}

err = x.err
if frame, inner, ok := UnwrapFrame(err); ok {
current.Trace = append(current.Trace, frame)
err = inner
continue
}

// We unwrap errors manually instead of using errors.As
// because we don't want to accidentally skip over multi-errors
// or interpret them as part of a single error chain.

switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()

Expand Down Expand Up @@ -131,7 +108,7 @@ func (p *treeWriter) writeTree(t traceTree, path []int) {
p.writeTrace(t.Err, t.Trace, path)
}

func (p *treeWriter) writeTrace(err error, trace []traceFrame, path []int) {
func (p *treeWriter) writeTrace(err error, trace []runtime.Frame, path []int) {
// A trace for a single error takes
// the same form as a stack trace:
//
Expand Down Expand Up @@ -198,7 +175,7 @@ func (p *treeWriter) writeTrace(err error, trace []traceFrame, path []int) {

for _, frame := range trace {
p.pipes(path, "| ")
p.writeString(frame.Name)
p.writeString(frame.Function)
p.writeString("\n")

p.pipes(path, "| ")
Expand Down
28 changes: 22 additions & 6 deletions tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package errtrace

import (
"errors"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -31,11 +32,11 @@ func TestBuildTreeSingle(t *testing.T) {
t.Fatalf("trace length mismatch, want %d, got %d", want, got)
}

if want, got := "braces.dev/errtrace.errorCallee", trace[0].Name; want != got {
if want, got := "braces.dev/errtrace.errorCallee", trace[0].Function; want != got {
t.Errorf("innermost function should be first, want %q, got %q", want, got)
}

if want, got := "braces.dev/errtrace.errorCaller", trace[1].Name; want != got {
if want, got := "braces.dev/errtrace.errorCaller", trace[1].Function; want != got {
t.Errorf("outermost function should be last, want %q, got %q", want, got)
}
}
Expand All @@ -56,23 +57,38 @@ func TestBuildTreeMulti(t *testing.T) {
t.Fatalf("trace length mismatch, want %d, got %d", want, got)
}

if want, got := "braces.dev/errtrace.errorCallee", child.Trace[0].Name; want != got {
if want, got := "braces.dev/errtrace.errorCallee", child.Trace[0].Function; want != got {
t.Errorf("innermost function should be first, want %q, got %q", want, got)
}

if want, got := "braces.dev/errtrace.errorCaller", child.Trace[1].Name; want != got {
if want, got := "braces.dev/errtrace.errorCaller", child.Trace[1].Function; want != got {
t.Errorf("outermost function should be last, want %q, got %q", want, got)
}
}
}

func TestWriteTree(t *testing.T) {
type testFrame struct {
Function string
File string
Line int
}

// Helpers to make tests more readable.
type frames = []traceFrame
type frames = []testFrame
tree := func(err error, trace frames, children ...traceTree) traceTree {
runtimeFrames := make([]runtime.Frame, len(trace))
for i, f := range trace {
runtimeFrames[i] = runtime.Frame{
Function: f.Function,
File: f.File,
Line: f.Line,
}
}

return traceTree{
Err: err,
Trace: trace,
Trace: runtimeFrames,
Children: children,
}
}
Expand Down
26 changes: 26 additions & 0 deletions unwrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package errtrace

import "runtime"

// UnwrapFrame unwraps the outermost frame from the given error,
// returning it and the inner error.
// ok is true if the frame was successfully extracted,
// and false otherwise, or if the error is not an errtrace error.
//
// You can use this for structured access to trace information.
func UnwrapFrame(err error) (frame runtime.Frame, inner error, ok bool) { //nolint:revive // error is intentionally middle return
e, ok := err.(*errTrace)
if !ok {
return runtime.Frame{}, err, false
}

frames := runtime.CallersFrames([]uintptr{e.pc})
f, _ := frames.Next()
if f == (runtime.Frame{}) {
// Unlikely, but if PC didn't yield a frame,
// just return the inner error.
return runtime.Frame{}, e.err, false
}

return f, e.err, true
}
55 changes: 55 additions & 0 deletions unwrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package errtrace

import (
"errors"
"path/filepath"
"strings"
"testing"
)

func TestUnwrapFrame(t *testing.T) {
giveErr := errors.New("great sadness")

t.Run("not wrapped", func(t *testing.T) {
_, inner, ok := UnwrapFrame(giveErr)
if got, want := ok, false; got != want {
t.Errorf("ok: got %v, want %v", got, want)
}

if got, want := inner, giveErr; got != want {
t.Errorf("inner: got %v, want %v", inner, giveErr)
}
})

t.Run("wrapped", func(t *testing.T) {
wrapped := Wrap(giveErr)
frame, inner, ok := UnwrapFrame(wrapped)
if got, want := ok, true; got != want {
t.Errorf("ok: got %v, want %v", got, want)
}

if got, want := inner, giveErr; got != want {
t.Errorf("inner: got %v, want %v", inner, giveErr)
}

if got, want := frame.Function, ".TestUnwrapFrame.func2"; !strings.HasSuffix(got, want) {
t.Errorf("frame.Func: got %q, does not contain %q", got, want)
}

if got, want := filepath.Base(frame.File), "unwrap_test.go"; got != want {
t.Errorf("frame.File: got %v, want %v", got, want)
}
})
}

func TestUnwrapFrame_badPC(t *testing.T) {
giveErr := errors.New("great sadness")
_, inner, ok := UnwrapFrame(wrap(giveErr, 0))
if got, want := ok, false; got != want {
t.Errorf("ok: got %v, want %v", got, want)
}

if got, want := inner, giveErr; got != want {
t.Errorf("inner: got %v, want %v", inner, giveErr)
}
}