diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 8c82e75..9fc7376 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -2,22 +2,22 @@ name: Go on: push: - branches: [ "master" ] + branches: [ master ] + tags: [ 'v*' ] pull_request: - branches: [ "master" ] jobs: CI: strategy: matrix: - go_version: [ "1.13.x", "1.18.x" ] + go_version: [ "1.18.x", "1.24.x" ] runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - name: "Set up Go" - uses: "actions/setup-go@v3" + uses: "actions/setup-go@v5" with: go-version: ${{ matrix.go_version }} @@ -28,7 +28,22 @@ jobs: run: "go test -v ./..." - name: "Linter" - uses: "golangci/golangci-lint-action@v3" + uses: "golangci/golangci-lint-action@v7" with: - version: "v1.48" + version: "v2.0" + Release: + if: startsWith(github.ref, 'refs/tags/') + needs: CI + runs-on: ubuntu-latest + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false diff --git a/.golangci.yml b/.golangci.yml index c74b857..387c5e4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,4 @@ +version: "2" linters: disable: - - "errcheck" + - errcheck diff --git a/LICENSE b/LICENSE index e752b05..b0911f9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Michał Dobaczewski +Copyright (c) 2025 Michał Dobaczewski Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b994717..dda6f14 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,337 @@ # go-xerrors -`go-xerrors` is an idiomatic and lightweight package that provides a set of functions to make working with errors -easier. It adds support for stack traces, multierrors, and simplifies working with wrapped errors and panics. -The `go-xerrors` package is fully compatible with Go errors 1.13, supporting the `errors.As`, `errors.Is`, -and `errors.Unwrap` functions. +`go-xerrors` is a simple, idiomatic, lightweight Go package that provides utilities for error handling. It offers functions and types to support stack traces, multi-errors, and simplified panic handling. The package is compatible with Go's standard error handling mechanisms, such as `errors.As`, `errors.Is`, and `errors.Unwrap`, including features from Go 1.13 and 1.20. -**Main features:** +**Main Features:** -- Stack traces -- Multierrors -- More flexible error warping -- Simplified panic handling +- **Stack Traces**: Captures stack traces when creating errors to help locate the origin of issues during debugging +- **Multi-Errors**: Aggregates multiple errors into a single error instance while maintaining individual error context +- **Error Wrapping**: Wraps errors with additional context while preserving compatibility with `errors.Is`, `errors.As`, and `errors.Unwrap` +- **Panic Handling**: Converts panic values to standard Go errors with stack traces for structured error recovery +- **Zero Dependencies**: Implements error handling utilities with no external dependencies beyond the Go standard library --- ## Installation -`go get -u github.com/mdobak/go-xerrors` +```bash +go get -u github.com/mdobak/go-xerrors +``` ## Usage -### Basic errors and stack traces +### Creating Errors with Stack Traces -The most basic usage of `go-xerrors` is to create a new error with a stack trace. This can be done with the -`xerrors.New` function. The simplest usage is to pass a string, which will be used as the error message. +The primary way to create an error in `go-xerrors` is by using the `xerrors.New` or `xerrors.Newf` functions: ```go +// Create a new error with a stack trace err := xerrors.New("something went wrong") + +// Create a formatted error with a stack trace +err := xerrors.Newf("something went wrong: %s", reason) ``` -However, calling the `Error` method on this error will only return the error message, not the stack trace. To -get the stack trace, the `xerrors.StackTrace` function can be used. This function will return an `xerrors.Callers` -object, which contains the stack trace. The `String` method on this object can be used to get a string representation -of the stack trace. +Calling the standard `Error()` method on `err` returns only the message ("something went wrong"), adhering to the Go convention of providing a concise error description. + +### Displaying Detailed Errors + +To display the error with the associated stack trace and additional details, use the `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint` functions: ```go -trace := xerrors.StackTrace(err) -fmt.Print(trace) +xerrors.Print(err) ``` Output: ``` - at main.TestMain (/home/user/app/main_test.go:10) - at testing.tRunner (/home/user/go/src/testing/testing.go:1259) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) +Error: something went wrong + at main.main (/home/user/app/main.go:10) + at runtime.main (/usr/local/go/src/runtime/proc.go:225) + at runtime.goexit (/usr/local/go/src/runtime/asm_amd64.s:1371) ``` -Another way to display the stack trace is to use the `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint` functions. -These functions will detect if the error passed contains a stack trace and print it to the stderr if it does. It is done -by checking if the error implements the `xerrors.DetailedError` interface. This interface has a single method, -`ErrorDetails`, that returns an additional information about the error, such as the stack trace. +### Working with Stack Traces + +To retrieve only the stack trace information programmatically: ```go -xerrors.Print(err) +trace := xerrors.StackTrace(err) +fmt.Print(trace) ``` Output: ``` -Error: access denied - at main.TestMain (/home/user/app/main_test.go:10) - at testing.tRunner (/home/user/go/src/testing/testing.go:1259) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) +at main.TestMain (/home/user/app/main_test.go:10) +at testing.tRunner (/home/user/go/src/testing/testing.go:1259) +at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) ``` -The reason why standard `Error()` method does not return the stack trace is because most developers expect the `Error()` -method to return only a one-line error message without punctuation at the end. This library follows this convention. +You can also explicitly add a stack trace to an existing error: -### Sentinel errors +```go +err := someFunction() +errWithStack := xerrors.WithStackTrace(err, 0) // 0 skips no frames +``` + +### Wrapping Errors -Sentinel errors are errors that are defined as constants. They are useful to check if an error is of a specific type -without having to compare the error message. This library provides a `xerrors.Message` function that can be used to -create a sentinel error. +The `xerrors.New` and `xerrors.Newf` functions can also wrap existing errors: ```go -var ErrAccessDenied = xerrors.Message("access denied") -// ... -if errors.Is(err, ErrAccessDenied) { - // ... +output, err := json.Marshal(data) +if err != nil { + return xerrors.New("failed to marshal data", err) } ``` -### Error wrapping - -The `xerrors.New` function accepts not only strings but also other errors. For example, it can be used to add a stack -trace to sentinel errors. +With formatted messages: ```go -var ErrAccessDenied = xerrors.Message("access denied") -// ... -err := xerrors.New(ErrAccessDenied) -// -if errors.Is(err, ErrAccessDenied) { - xerrors.Print(err) // prints error along with the stack trace +output, err := json.Marshal(data) +if err != nil { + return xerrors.Newf("failed to marshal data %v: %w", data, err) } ``` -Another way to use the `xerrors.New` function is to wrap an existing error with a new error message. +Note that wrapping multiple errors with `xerrors.Newf` is possible on Go 1.20 and later. + +### Creating Error Chains Without Stack Traces + +For situations where you don't need a stack trace (such as creating sentinel errors), use `xerrors.Join` and `xerrors.Joinf`: ```go -err := xerrors.New("unable to open resource", ErrAccessDenied) -fmt.Print(err.Error()) // unable to open resource: access denied +err := xerrors.Join("operation failed", otherErr) ``` -It is also possible to wrap an error with another error. Unlike the `fmt.Errorf` function, references to both errors -will be preserved, so it is possible to check if the new error is one of the wrapped errors. +With formatted messages: + +```go +err := xerrors.Joinf("operation failed: %w", otherErr) +``` + +Note that wrapping multiple errors with `xerrors.Joinf` is possible on Go 1.20 and later. + +The main difference between Go's `fmt.Errorf` and `xerrors.Newf`/`xerrors.Joinf` is that the latter functions preserve the error chain, whereas `fmt.Errorf` flattens it (i.e., its `Unwrap` method returns all underlying errors at once instead of just the next one in the chain). + +### Sentinel Errors + +Sentinel errors are predefined error values representing specific, known failure conditions. `go-xerrors` provides `xerrors.Message` to create distinct sentinel error values: ```go var ErrAccessDenied = xerrors.Message("access denied") -var ErrResourceOpenFailed = xerrors.Message("unable to open resource") + // ... -err := xerrors.New(ErrResourceOpenFailed, ErrAccessDenied) -fmt.Print(err.Error()) // unable to open resource: access denied -errors.Is(err, ErrResourceOpenFailed) // true -errors.Is(err, ErrAccessDenied) // true + +func performAction() error { + // ... + return ErrAccessDenied +} + +// ... + +err := performAction() +if errors.Is(err, ErrAccessDenied) { + log.Println("Operation failed due to access denial.") +} ``` -### Multierrors +For formatted sentinel errors: -Multierrors are a set of errors that can be treated as a single error. The `xerrors` package provides the -`xerrors.Append` function to create them. It works similarly to the append function in the Go language. The function -accepts a variadic number of errors and returns a new error that contains all of them. The returned error supports -`errors.Is` and `errors.As` methods. However, the `errors.Unwrap`method is not supported. +```go +const MaxLength = 10 +var ErrInvalidInput = xerrors.Messagef("max length of %d exceeded", MaxLength) +``` + +### Multi-Errors + +When performing multiple independent operations where several might fail, use `xerrors.Append` to collect these individual errors into a single multi-error instance: ```go var err error -if len(unsername) == 0 { + +if input.Username == "" { err = xerrors.Append(err, xerrors.New("username cannot be empty")) } -if len(password) < 8 { - err = xerrors.Append(err, xerrors.New("password is too short")) +if len(input.Password) < 8 { + err = xerrors.Append(err, xerrors.New("password must be at least 8 characters")) } -``` -The error list can be displayed in several ways. The simplest way is to use the `Error` method, which will display -errors as a long, one-line string: +if err != nil { + fmt.Println(err.Error()) // [username cannot be empty, password must be at least 8 characters] + // Detailed output using xerrors.Print: + xerrors.Print(err) + // Output: + // Error: [username cannot be empty, password must be at least 8 characters] + // 1. Error: username cannot be empty + // at main.validateInput (/path/to/your/file.go:XX) + // ... stack trace ... + // 2. Error: password must be at least 8 characters + // at main.validateInput (/path/to/your/file.go:YY) + // ... stack trace ... +} ``` -the following errors occurred: [username cannot be empty, password is too short] -``` -Another way is to use one of the following functions: `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint`. The -advantage of using these functions is that they will also print additional details, such as stack traces, and the -error message is much easier to read: +The resulting multi-error implements the standard `error` interface as well as `errors.Is`, `errors.As`, and `errors.Unwrap`, allowing you to check for specific errors or extract them. + +**Comparison with Go 1.20 `errors.Join`:** + +Go 1.20 introduced `errors.Join` for error aggregation. While serving a similar purpose, `xerrors.Append` offers: + +1. **Individual Stack Traces**: Preserves the individual stack traces associated with each appended error +2. **Enhanced Formatting**: Provides detailed, structured output for multi-errors +3. **Consistent `Error()` Output**: Produces a concise, single-line summary + +### Simplified Panic Handling + +`go-xerrors` provides utilities to convert panic values into proper errors with stack traces. + +**Using `xerrors.Recover`:** + +```go +func handleTask() (err error) { + defer xerrors.Recover(func(err error) { + log.Printf("Recovered from panic during task handling: %s", xerrors.Sprint(err)) + }) + + // ... potentially panicking code ... + return nil +} ``` -Error: the following errors occurred: [username cannot be empty, password is too short] -1. Error: username cannot be empty - at xerrors.TestFprint (/home/user/app/main_test.go:10) - at testing.tRunner (/home/user/go/src/testing/testing.go:1439) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1259) -2. Error: password is too short - at xerrors.TestFprint (/home/user/app/main_test.go:13) - at testing.tRunner (/home/user/go/src/testing/testing.go:1439) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1259) + +**Using `xerrors.FromRecover`:** + +```go +func handleTask() (err error) { + defer func() { + if r := recover(); r != nil { + err = xerrors.FromRecover(r) // Convert recovered value to error with stack trace + log.Printf("Recovered from panic during task handling: %s", xerrors.Sprint(err)) + } + }() + + // ... potentially panicking code ... + + return nil +} ``` -Finally, multierror implements the `xerrors.MultiError` interface, which provides the `Errors` method that returns a -list of errors. +The returned error implements the `PanicError` interface, which provides access to the original panic value via the `Panic()` method. + +### When to use `New`, `Join`, or `Append` + +While all three functions can be used to aggregate errors, they serve different purposes: -### Recovered panics +- **`xerrors.New`**: Create errors with stack traces, useful for wrapping existing errors to add context +- **`xerrors.Join`**: Create chained errors without stack traces, useful for defining sentinel errors +- **`xerrors.Append`**: Create multi-errors by aggregating independent errors -In Go, the values returned by the `recover` built-in do not implement the `error` interface, which may be inconvenient. -This library provides two functions to easily convert a recovered value into an error. +#### Examples -The first function, `xerrors.Recover`, works similarly to the `recover` built-in. This function must always be called -directly using the `defer` keyword. The callback will only be called during a panic, and the provided error will contain -a stack trace: +##### Error with Stack Trace ```go -defer xerrors.Recover(func (err error) { - xerrors.Print(err) -}) +func (m *MyStruct) MarshalJSON() ([]byte, error) { + output, err := json.Marshal(m) + if err != nil { + // Wrap the error with additional context and capture a stack trace. + return nil, xerrors.New("failed to marshal data", err) + } + return output, nil +} ``` -The second function allows converting a value returned from `recover` built-in to an error with a stack trace: +##### Sentinel Errors ```go -defer func() { - if r := recover(); r != nil { - err := xerrors.FromRecover(r) - xerrors.Print(err) - } -}() +var ( + // Using xerrors.Join lets us create sentinel errors that can be + // checked with errors.Is against both ErrValidation and the + // specific validation error. We do not want to capture a stack trace + // here; hence, we use xerrors.Join instead of xerrors.New. + ErrValidation = xerrors.Message("validation error") + ErrInvalidName = xerrors.Join(ErrValidation, "name is invalid") + ErrInvalidAge = xerrors.Join(ErrValidation, "age is invalid") + ErrInvalidEmail = xerrors.Join(ErrValidation, "email is invalid") +) + +func (m *MyStruct) Validate() error { + if !m.isNameValid() { + return xerrors.New(ErrInvalidName) + } + if !m.isAgeValid() { + return xerrors.New(ErrInvalidAge) + } + if !m.isEmailValid() { + return xerrors.New(ErrInvalidEmail) + } + return nil +} ``` -### Documentation +##### Multi-Error Validation + +```go +func (m *MyStruct) Validate() error { + var err error + if m.Name == "" { + err = xerrors.Append(err, xerrors.New("name cannot be empty")) + } + if m.Age < 0 { + err = xerrors.Append(err, xerrors.New("age cannot be negative")) + } + if m.Email == "" { + err = xerrors.Append(err, xerrors.New("email cannot be empty")) + } + return err +} +``` + +## API Reference + +### Core Functions + +- `xerrors.New(errors ...any) error`: Creates a new error with a stack trace +- `xerrors.Newf(format string, args ...any) error`: Creates a formatted error with a stack trace +- `xerrors.Join(errors ...any) error`: Creates a chained error without a stack trace +- `xerrors.Joinf(format string, args ...any) error`: Creates a formatted chained error without a stack trace +- `xerrors.Message(message string) error`: Creates a simple sentinel error +- `xerrors.Messagef(format string, args ...any) error`: Creates a simple formatted sentinel error + +### Multi-Error Functions + +- `xerrors.Append(err error, errs ...error) error`: Aggregates errors into a multi-error + +### Panic Handling + +- `xerrors.Recover(callback func(err error))`: Recovers from panics and invokes a callback with the error +- `xerrors.FromRecover(recoveredValue any) error`: Converts a recovered value to an error with a stack trace + +### Formatting Functions + +- `xerrors.Print(err error)`: Prints a formatted error to stderr +- `xerrors.Sprint(err error) string`: Returns a formatted error as a string +- `xerrors.Fprint(w io.Writer, err error)`: Writes a formatted error to the provided writer + +### Stack Trace Functions + +- `xerrors.StackTrace(err error) Callers`: Extracts the stack trace from an error +- `xerrors.WithStackTrace(err error, skip int) error`: Wraps an error with a stack trace + +### Key Interfaces + +- `DetailedError`: For errors that provide detailed information +- `PanicError`: For errors created from panic values with access to the original panic value + +## Documentation -This package offers a few additional functions and interfaces that may be useful in some use cases. More information -about them can be found in the documentation: +For comprehensive details on all functions and types, please refer to the full documentation available at: [https://pkg.go.dev/github.com/mdobak/go-xerrors](https://pkg.go.dev/github.com/mdobak/go-xerrors) -### License +## License -Licensed under MIT License +Licensed under the MIT License. diff --git a/doc.go b/doc.go index a352abb..27500ca 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,7 @@ -// Package xerrors is an idiomatic and lightweight package that provides a set -// of functions to make working with errors easier. It adds support for stack -// traces, multierrors, and simplifies working with wrapped errors and panics. -// The `go-xerrors` package is fully compatible with Go errors 1.13, supporting -// the `errors.As`, `errors.Is`, and `errors.Unwrap` functions. +// Package go-xerrors is a simple, idiomatic, lightweight Go package that +// provides utilities for error handling. It offers functions and types to +// support stack traces, multi-errors, and simplified panic handling. The +// package is compatible with Go's standard error handling mechanisms, such +// as errors.As, errors.Is, and errors.Unwrap, including features from Go +// 1.13 and 1.20. package xerrors diff --git a/format.go b/format.go index 0cef447..c32ace2 100644 --- a/format.go +++ b/format.go @@ -1,7 +1,6 @@ package xerrors import ( - "bytes" "fmt" "io" "os" @@ -11,76 +10,93 @@ import ( var errWriter io.Writer = os.Stderr -// Print formats an error and prints it on stderr. +// Print writes a formatted error to stderr. // -// If the error implements the DetailedError interface, the result from the -// ErrorDetails method is used for each wrapped error, otherwise the standard -// Error method is used. A formatted error can be multi-line and always ends -// with a newline. +// If the error implements the [DetailedError] interface and returns +// a non-empty string, the returned details are added to each error +// in the chain. +// +// The formatted error can span multiple lines and always ends with +// a newline. func Print(err error) { - fprint(errWriter, err) + buf := &strings.Builder{} + writeErr(buf, err) + errWriter.Write([]byte(buf.String())) } -// Sprint formats an error and returns it as a string. +// Sprint returns a formatted error as a string. +// +// If the error implements the [DetailedError] interface and returns +// a non-empty string, the returned details are added to each error +// in the chain. // -// If the error implements the DetailedError interface, the result from the -// ErrorDetails method is used for each wrapped error, otherwise the standard -// Error method is used. A formatted error can be multi-line and always ends -// with a newline. +// The formatted error can span multiple lines and always ends with +// a newline. func Sprint(err error) string { - s := &strings.Builder{} - fprint(s, err) - return s.String() + buf := &strings.Builder{} + writeErr(buf, err) + return buf.String() } -// Fprint formats an error and writes it to the given writer. +// Fprint writes a formatted error to the provided [io.Writer]. +// +// If the error implements the [DetailedError] interface and returns +// a non-empty string, the returned details are added to each error +// in the chain. // -// If the error implements the DetailedError interface, the result from the -// ErrorDetails method is used for each wrapped error, otherwise the standard -// Error method is used. A formatted error can be multi-line and always ends -// with a newline. +// The formatted error can span multiple lines and always ends with +// a newline. func Fprint(w io.Writer, err error) (int, error) { - return fprint(w, err) + buf := &strings.Builder{} + writeErr(buf, err) + return w.Write([]byte(buf.String())) } -func fprint(w io.Writer, e error) (n int, err error) { +// writeErr writes a formatted error to the provided strings.Builder. +func writeErr(buf *strings.Builder, err error) { const firstErrorPrefix = "Error: " const previousErrorPrefix = "Previous error: " - b := &bytes.Buffer{} - f := true - for e != nil { - switch terr := e.(type) { - case DetailedError: - if f { - b.WriteString(firstErrorPrefix) + first := true + for err != nil { + errMsg := err.Error() + errDetails := "" + if dErr, ok := err.(DetailedError); ok { + errDetails = dErr.ErrorDetails() + } + if errDetails != "" { + if first { + buf.WriteString(firstErrorPrefix) } else { - b.WriteString(previousErrorPrefix) + buf.WriteString(previousErrorPrefix) + } + buf.WriteString(errMsg) + buf.WriteString("\n\t") + buf.WriteString(indent(errDetails)) + if !strings.HasSuffix(errDetails, "\n") { + buf.WriteByte('\n') } - b.WriteString(terr.Error()) - b.WriteByte('\n') - b.WriteString(terr.ErrorDetails()) - default: - // If an error does not implement the DetailedError interface, - // then the Error() method will print all errors separated - // with ":", so there is no need to render each error other than - // the first one. - if f { - b.WriteString(firstErrorPrefix) - b.WriteString(terr.Error()) - b.WriteByte('\n') + } else { + // If an error does not contain any details, do not print + // it, except for the first one. This is to avoid printing + // every wrapped error on a single line. + if first { + buf.WriteString(firstErrorPrefix) + buf.WriteString(errMsg) + buf.WriteByte('\n') } } - f = false - if we, ok := e.(Wrapper); ok { - e = we.Unwrap() + first = false + if wErr, ok := err.(interface{ Unwrap() error }); ok { + err = wErr.Unwrap() continue } break } - return w.Write(b.Bytes()) } -func format(s fmt.State, verb rune, v interface{}) { +// format is a helper function that formats a value according to the provided +// format state and verb. +func format(s fmt.State, verb rune, v any) { f := []rune{'%'} for _, c := range []int{'-', '+', '#', ' ', '0'} { if s.Flag(c) { @@ -97,3 +113,16 @@ func format(s fmt.State, verb rune, v interface{}) { f = append(f, verb) fmt.Fprintf(s, string(f), v) } + +// indent indents every line, except the first one, with a tab. +func indent(s string) string { + nl := strings.HasSuffix(s, "\n") + if nl { + s = s[:len(s)-1] + } + s = strings.ReplaceAll(s, "\n", "\n\t") + if nl { + s += "\n" + } + return s +} diff --git a/format_test.go b/format_test.go index fa8f1d0..1fbed76 100644 --- a/format_test.go +++ b/format_test.go @@ -32,20 +32,17 @@ func TestFormat(t *testing.T) { { err: Message("foo"), want: "Error: foo\n", }, - { - err: WithWrapper(Message("foo"), Message("bar")), want: "Error: foo: bar\n", - }, { err: testErr{err: "err", details: "details"}, - want: "Error: err\ndetails\n", + want: "Error: err\n\tdetails\n", }, { err: testErr{err: "err", details: "details", wrapped: Message("wrapped")}, - want: "Error: err\ndetails\n", + want: "Error: err\n\tdetails\n", }, { err: testErr{err: "err", details: "details", wrapped: testErr{err: "wrapped err", details: "wrapped details"}}, - want: "Error: err\ndetails\nPrevious error: wrapped err\nwrapped details\n", + want: "Error: err\n\tdetails\nPrevious error: wrapped err\n\twrapped details\n", }, } for n, tt := range tests { @@ -73,7 +70,7 @@ func TestPrint(t *testing.T) { } func TestSprint(t *testing.T) { - a := New("access denided") + a := New("access denied") Print(a) err := Message("foo") diff --git a/go.mod b/go.mod index 3787d9a..64df58a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/mdobak/go-xerrors -go 1.13 +go 1.18 diff --git a/multierror.go b/multierror.go index e0cca2f..8726bdb 100644 --- a/multierror.go +++ b/multierror.go @@ -6,59 +6,51 @@ import ( "strings" ) -// Append adds more errors to an existing list of errors. If err is not a list -// of errors, then it will be converted into a list. Nil errors are ignored. -// It does not record a stack trace. +// Append appends the provided errors to an existing error or list of +// errors. If `err` is not a [multiError], it will be converted into +// one. Nil errors are ignored. It does not record a stack trace. // -// If the list is empty, nil is returned. If the list contains only one error, -// that error is returned instead of list. +// If the resulting error list is empty, nil is returned. If the +// resulting error list contains only one error, that error is +// returned instead of the list. // -// The returned list of errors is compatible with Go 1.13 errors, and it -// supports the errors.Is and errors.As methods. However, the errors.Unwrap -// method is not supported. +// The returned error is compatible with Go errors, supporting +// [errors.Is], [errors.As], and the Go 1.20 `Unwrap() []error` +// method. // -// Append is not thread-safe. +// To create a chained error, use [New], [Newf], [Join], or +// [Joinf] instead. func Append(err error, errs ...error) error { - if err == nil && len(errs) == 0 { - return nil - } - switch errTyp := err.(type) { - case multiError: - for _, e := range errs { - if e != nil { - errTyp = append(errTyp, e) - } - } - return errTyp - default: - var me multiError - if err != nil { + var me multiError + if err != nil { + if mErr, ok := err.(multiError); ok { + me = mErr + } else { me = multiError{err} } - for _, e := range errs { - if e != nil { - me = append(me, e) - } - } - if len(me) == 1 { - return me[0] - } - if len(me) == 0 { - return nil + } + for _, e := range errs { + if e != nil { + me = append(me, e) } + } + switch len(me) { + case 0: + return nil + case 1: + return me[0] + default: return me } } -const multiErrorErrorPrefix = "the following errors occurred: " - -// multiError is a slice of errors that can be used as a single error. +// multiError is a slice of errors that can be treated as a single +// error. type multiError []error -// Error implements the error interface. +// Error implements the [error] interface. func (e multiError) Error() string { - s := &strings.Builder{} - s.WriteString(multiErrorErrorPrefix) + var s strings.Builder s.WriteString("[") for n, err := range e { s.WriteString(err.Error()) @@ -70,25 +62,32 @@ func (e multiError) Error() string { return s.String() } -// ErrorDetails implements the DetailedError interface. +// ErrorDetails returns additional details about the error for +// the [ErrorDetails] function. func (e multiError) ErrorDetails() string { - s := &strings.Builder{} - for n, err := range e.Errors() { - s.WriteString(strconv.Itoa(n + 1)) - s.WriteString(". ") - s.WriteString(indent(Sprint(err))) + if len(e) == 0 { + return "" } - return s.String() + buf := &strings.Builder{} + for n, err := range e.Unwrap() { + buf.WriteString(strconv.Itoa(n + 1)) + buf.WriteString(". ") + writeErr(buf, err) + } + return buf.String() } -// Errors implements the MultiError interface. -func (e multiError) Errors() []error { +// Unwrap implements the Go 1.20 `Unwrap() []error` method, returning +// a slice containing all errors in the list. +func (e multiError) Unwrap() []error { s := make([]error, len(e)) copy(s, e) return s } -func (e multiError) As(target interface{}) bool { +// As implements the Go 1.13 `errors.As` method, allowing type +// assertions on all errors in the list. +func (e multiError) As(target any) bool { for _, err := range e { if errors.As(err, target) { return true @@ -97,6 +96,8 @@ func (e multiError) As(target interface{}) bool { return false } +// Is implements the Go 1.13 `errors.Is` method, allowing +// comparisons with all errors in the list. func (e multiError) Is(target error) bool { for _, err := range e { if errors.Is(err, target) { @@ -105,13 +106,3 @@ func (e multiError) Is(target error) bool { } return false } - -// indent idents every line, except the first one, with tab. -func indent(s string) string { - end := "" - if strings.HasSuffix(s, "\n") { - end = "\n" - s = s[:len(s)-1] - } - return strings.ReplaceAll(s, "\n", "\n\t") + end -} diff --git a/multierror_test.go b/multierror_test.go index 58d2830..ef2ccce 100644 --- a/multierror_test.go +++ b/multierror_test.go @@ -14,13 +14,13 @@ func TestAppend(t *testing.T) { want string wantNil bool }{ - {err: nil, errs: []error{Message("a"), Message("b")}, want: "the following errors occurred: [a, b]"}, + {err: nil, errs: []error{Message("a"), Message("b")}, want: "[a, b]"}, {err: nil, errs: []error{nil, Message("a")}, want: "a"}, - {err: Message("a"), errs: []error{Message("b"), Message("c")}, want: "the following errors occurred: [a, b, c]"}, + {err: Message("a"), errs: []error{Message("b"), Message("c")}, want: "[a, b, c]"}, {err: Message("a"), errs: nil, want: "a"}, - {err: multiError{Message("a"), Message("b")}, errs: nil, want: "the following errors occurred: [a, b]"}, - {err: multiError{Message("a"), Message("b")}, errs: []error{Message("c")}, want: "the following errors occurred: [a, b, c]"}, - {err: multiError{}, errs: []error{Message("a"), nil}, want: "the following errors occurred: [a]"}, + {err: multiError{Message("a"), Message("b")}, errs: nil, want: "[a, b]"}, + {err: multiError{Message("a"), Message("b")}, errs: []error{Message("c")}, want: "[a, b, c]"}, + {err: multiError{}, errs: []error{Message("a"), nil}, want: "a"}, {err: nil, errs: nil, wantNil: true}, {err: nil, errs: []error{nil, nil}, wantNil: true}, } @@ -69,7 +69,7 @@ func TestMultiError_ErrorDetails(t *testing.T) { {errs: []error{}, want: ``}, {errs: []error{Message("a")}, want: "1. Error: a\n"}, {errs: []error{Message("a"), Message("b")}, want: "1. Error: a\n2. Error: b\n"}, - {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "1. Error: a\n2. Error: the following errors occurred: [b, c]\n\t1. Error: b\n\t2. Error: c\n"}, + {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "1. Error: a\n2. Error: [b, c]\n\t1. Error: b\n\t2. Error: c\n"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { diff --git a/panic.go b/panic.go index 47cd573..604c76d 100644 --- a/panic.go +++ b/panic.go @@ -4,12 +4,23 @@ import ( "fmt" ) -// Recover wraps the recover() built-in and converts a value returned by it to -// an error with a stack trace. The fn callback will be invoked only during -// panicking. +// PanicError represents an error that occurs during a panic. It is +// returned by the [Recover] and [FromRecover] functions. It provides +// access to the original panic value via the [Panic] method. +type PanicError interface { + error + + // Panic returns the raw panic value. + Panic() any +} + +// Recover wraps the built-in `recover()` function, converting the +// recovered value into an error with a stack trace. The provided `fn` +// callback is only invoked when a panic occurs. The error passed to +// `fn` implements [PanicError]. // -// This function must always be used *directly* with the "defer" keyword. -// Otherwise, it will not work. +// This function must always be used directly with the `defer` +// keyword; otherwise, it will not function correctly. func Recover(fn func(err error)) { if r := recover(); r != nil { fn(&withStackTrace{ @@ -19,12 +30,13 @@ func Recover(fn func(err error)) { } } -// FromRecover takes the result of the recover() built-in and converts it to -// an error with a stack trace. +// FromRecover converts the result of the built-in `recover()` into +// an error with a stack trace. The returned error implements +// [PanicError]. Returns nil if `r` is nil. // -// This function must be invoked in the same function as recover(), otherwise -// the returned stack trace will not be correct. -func FromRecover(r interface{}) error { +// This function must be called in the same function as `recover()` +// to ensure the stack trace is accurate. +func FromRecover(r any) error { if r == nil { return nil } @@ -34,18 +46,18 @@ func FromRecover(r interface{}) error { } } -// panicError is an error constructed from a value returned by the recover() -// built-in during panicking. +// panicError represents an error that occurs during a panic, +// constructed from the value returned by `recover()`. type panicError struct { - panic interface{} + panic any } -// Panic returns the value from the recover() function. -func (e *panicError) Panic() interface{} { +// Panic implements the [PanicError] interface. +func (e *panicError) Panic() any { return e.panic } -// Error implements the error interface. +// Error implements the [error] interface. func (e *panicError) Error() string { return fmt.Sprintf("panic: %v", e.panic) } diff --git a/panic_test.go b/panic_test.go index a957112..4d4d9ec 100644 --- a/panic_test.go +++ b/panic_test.go @@ -9,7 +9,7 @@ import ( func TestRecover(t *testing.T) { tests := []struct { - panic interface{} + panic any want string }{ {panic: nil, want: ""}, @@ -53,7 +53,7 @@ func TestRecover(t *testing.T) { func TestFromRecover(t *testing.T) { tests := []struct { - panic interface{} + panic any want string }{ {panic: nil, want: ""}, diff --git a/stacktrace.go b/stacktrace.go index 90e05d2..c700f09 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -8,31 +8,31 @@ import ( "strings" ) -const stackTraceDepth = 32 +const stackTraceDepth = 128 -// StackTrace returns a stack trace from given error or the first stack trace -// from the wrapped errors. +// StackTrace extracts the stack trace from the provided error. +// It traverses the error chain, looking for the last error that +// has a stack trace. func StackTrace(err error) Callers { + var callers Callers for err != nil { - if e, ok := err.(StackTracer); ok { - return e.StackTrace() + if st, ok := err.(interface{ StackTrace() Callers }); ok { + callers = st.StackTrace() } - if e, ok := err.(Wrapper); ok { - err = e.Unwrap() + if wErr, ok := err.(interface{ Unwrap() error }); ok { + err = wErr.Unwrap() continue } break } - return nil + return callers } -// WithStackTrace adds a stack trace to the error at the point it was called. -// The skip argument is the number of stack frames to skip. +// WithStackTrace wraps the provided error with a stack trace, +// capturing the stack at the point of the call. The `skip` argument +// specifies how many stack frames to skip. // -// This function is useful when you want to skip the first few frames in a -// stack trace. To add a stack trace to a sentinel error, use the New function. -// -// If err is nil, then nil is returned. +// If err is nil, WithStackTrace returns nil. func WithStackTrace(err error, skip int) error { if err == nil { return nil @@ -43,55 +43,58 @@ func WithStackTrace(err error, skip int) error { } } -// withStackTrace adds a stack trace to en error. +// withStackTrace wraps an error with a captured stack trace. type withStackTrace struct { err error stack Callers } -// Error implements the error interface. +// Error implements the [error] interface. func (e *withStackTrace) Error() string { return e.err.Error() } -// ErrorDetails implements the DetailedError interface. +// ErrorDetails implements the [DetailedError] interface. func (e *withStackTrace) ErrorDetails() string { return e.stack.String() } -// Unwrap implements the Wrapper interface. +// Unwrap implements the Go 1.13 `Unwrap() error` method, returning +// the wrapped error. func (e *withStackTrace) Unwrap() error { return e.err } -// StackTrace implements the StackTracer interface. +// StackTrace returns the stack trace captured at the point of the +// error creation. func (e *withStackTrace) StackTrace() Callers { return e.stack } +// Frame represents a single stack frame with file, line, and +// function details. type Frame struct { File string Line int Function string } -// String implements the fmt.Stringer interface. +// String implements the [fmt.Stringer] interface. func (f Frame) String() string { s := &strings.Builder{} f.writeFrame(s) return s.String() } -// Format implements the fmt.Formatter interface. -// -// The verbs: +// Format implements the [fmt.Formatter] interface. // -// %s function, file and line number in a single line -// %f filename -// %d line number -// %n function name, the plus flag adds a package name -// %v same as %s, the plus or hash flags print struct details -// %q a double-quoted Go string with same contents as %s +// Supported verbs: +// - %s function, file, and line number in a single line +// - %f filename +// - %d line number +// - %n function name, with '+' flag adding the package name +// - %v same as %s; '+' or '#' flags print struct details +// - %q double-quoted Go string, same as %s func (f Frame) Format(s fmt.State, verb rune) { type _Frame Frame switch verb { @@ -122,8 +125,8 @@ func (f Frame) Format(s fmt.State, verb rune) { } } +// writeFrame writes a formatted stack frame to the given [io.Writer]. func (f Frame) writeFrame(w io.Writer) { - io.WriteString(w, "\tat ") io.WriteString(w, shortname(f.Function)) io.WriteString(w, " (") io.WriteString(w, f.File) @@ -132,10 +135,12 @@ func (f Frame) writeFrame(w io.Writer) { io.WriteString(w, ")") } -// Callers is a list of program counters returned by the runtime.Callers. +// Callers represents a list of program counters from the +// [runtime.Callers] function. type Callers []uintptr -// Frames returns a slice of structures with a function/file/line information. +// Frames returns a slice of [Frame] structs with function, file, and +// line information. func (c Callers) Frames() []Frame { r := make([]Frame, len(c)) f := runtime.CallersFrames(c) @@ -155,20 +160,19 @@ func (c Callers) Frames() []Frame { return r } -// String implements the fmt.Stringer interface. +// String implements the [fmt.Stringer] interface. func (c Callers) String() string { s := &strings.Builder{} c.writeTrace(s) return s.String() } -// Format implements the fmt.Formatter interface. -// -// The verbs: +// Format implements the [fmt.Formatter] interface. // -// %s a stack trace -// %v same as %s, the plus or hash flags print struct details -// %q a double-quoted Go string with same contents as %s +// Supported verbs: +// - %s complete stack trace +// - %v same as %s; '+' or '#' flags print struct details +// - %q double-quoted Go string, same as %s func (c Callers) Format(s fmt.State, verb rune) { type _Callers Callers switch verb { @@ -188,20 +192,25 @@ func (c Callers) Format(s fmt.State, verb rune) { } } +// writeTrace writes the stack trace to the provided [io.Writer]. func (c Callers) writeTrace(w io.Writer) { - frames := c.Frames() - for _, frame := range frames { + for _, frame := range c.Frames() { + io.WriteString(w, "at ") frame.writeFrame(w) io.WriteString(w, "\n") } } +// callers captures the current stack trace, skipping the specified +// number of frames. func callers(skip int) Callers { b := make([]uintptr, stackTraceDepth) l := runtime.Callers(skip+2, b[:]) return b[:l] } +// shortname extracts the short name of a function, removing the +// package path. func shortname(name string) string { i := strings.LastIndex(name, "/") return name[i+1:] diff --git a/stacktrace_test.go b/stacktrace_test.go index 2748afd..c85b1a1 100644 --- a/stacktrace_test.go +++ b/stacktrace_test.go @@ -85,16 +85,16 @@ func TestFrameFormat(t *testing.T) { want string regexp bool }{ - {format: "%s", want: "\tat function (file:42)"}, + {format: "%s", want: "function (file:42)"}, {format: "%f", want: "file"}, {format: "%d", want: "42"}, {format: "%n", want: "function"}, {format: "%+n", want: "package/function"}, {format: "%+n", want: "package/function"}, - {format: "%v", want: "\tat function (file:42)"}, + {format: "%v", want: "function (file:42)"}, {format: "%+v", want: "{File:file Line:42 Function:package/function}"}, {format: "%#v", want: "xerrors._Frame{File:\"file\", Line:42, Function:\"package/function\"}"}, - {format: "%q", want: "\"\\tat function (file:42)\""}, + {format: "%q", want: "\"function (file:42)\""}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { @@ -111,11 +111,11 @@ func TestCallersFormat(t *testing.T) { format string want string }{ - {format: "%s", want: `^\tat .*(\n\tat .*)+\n$`}, - {format: "%v", want: `^\tat .*(\n\tat .*)+\n$`}, + {format: "%s", want: `^at .*(\nat .*)+\n$`}, + {format: "%v", want: `^at .*(\nat .*)+\n$`}, {format: "%+v", want: `\[([0-9 ])+\]`}, {format: "%#v", want: `^xerrors\._Callers\{(0x[a-f0-9]+, )*(0x[a-f0-9]+)\}$`}, - {format: "%q", want: `^"\\tat .*(\\n\\tat .*)+\\n"$`}, + {format: "%q", want: `^"at .*(\\nat .*)+\\n"$`}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { diff --git a/wrapper.go b/wrapper.go index 8ecb61c..7a4b66a 100644 --- a/wrapper.go +++ b/wrapper.go @@ -5,53 +5,64 @@ import ( "strings" ) -// WithWrapper wraps err with wrapper. -// -// The error used as wrapper should be a simple error, preferably a sentinel -// error. This is because details such as the wrapper's stack trace are ignored. -// -// The Unwrap method will unwrap only err but errors.Is, errors.As works with -// both of the errors. -// -// If wrapper is nil, then err is returned. -// If err is nil, then nil is returned. -func WithWrapper(wrapper error, err error) error { - if err == nil { - return nil - } - if wrapper == nil { - return err - } - return &withWrapper{ - wrapper: wrapper, - err: err, - } -} - // withWrapper wraps an error with another error. +// +// It is intended to be build error chains, e.g. if we have a +// following error chain: `err1: err2: err3`, the wrapper is `err1`, +// and the err is another withWrapper containing `err2` and `err3`. type withWrapper struct { - wrapper error - err error + wrapper error // wrapper is the error that wraps the next error in the chain, may be nil + err error // err is the next error in the chain, must not be nil + msg string // msg overwrites the error message, if set } -// Error implements the error interface. +// Error implements the [error] interface. func (e *withWrapper) Error() string { + if e.msg != "" { + return e.msg + } s := &strings.Builder{} - s.WriteString(e.wrapper.Error()) - s.WriteString(": ") + if e.wrapper != nil { + s.WriteString(e.wrapper.Error()) + s.WriteString(": ") + } s.WriteString(e.err.Error()) return s.String() } -// Unwrap implements the Wrapper interface. +// ErrorDetails implements the [DetailedError] interface. +func (e *withWrapper) ErrorDetails() string { + err := e.wrapper + for err != nil { + if dErr, ok := err.(DetailedError); ok { + return dErr.ErrorDetails() + } + if wErr, ok := err.(interface{ Unwrap() error }); ok { + err = wErr.Unwrap() + continue + } + break + } + return "" +} + +// Unwrap implements the Go 1.13 `Unwrap() error` method, returning +// the wrapped error. +// +// Since withWrapper represents a chain of errors, the Unwrap method +// returns the next error in the chain, not both the wrapper and the error. func (e *withWrapper) Unwrap() error { return e.err } -func (e *withWrapper) As(target interface{}) bool { +// As implements the Go 1.13 `errors.As` method, allowing type +// assertions on all errors in the list. +func (e *withWrapper) As(target any) bool { return errors.As(e.wrapper, target) || errors.As(e.err, target) } +// Is implements the Go 1.13 `errors.Is` method, allowing +// comparisons with all errors in the list. func (e *withWrapper) Is(target error) bool { return errors.Is(e.wrapper, target) || errors.Is(e.err, target) } diff --git a/wrapper_test.go b/wrapper_test.go index 191dd9d..7caf6ab 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -8,45 +8,42 @@ import ( "testing" ) -func TestWrap(t *testing.T) { +func TestWithWrapper(t *testing.T) { tests := []struct { - err error wrapper error + err error + msg string want string - wantNil bool }{ - {err: Message("err"), wrapper: Message("wrapper"), want: "wrapper: err"}, - {err: io.EOF, wrapper: Message("wrapper"), want: "wrapper: EOF"}, - {err: nil, wrapper: Message("wrapper"), wantNil: true}, - {err: Message("err"), wrapper: nil, want: "err"}, + {wrapper: Message("wrapper"), err: Message("err"), want: "wrapper: err"}, + {wrapper: Message("wrapper"), err: io.EOF, want: "wrapper: EOF"}, + {wrapper: nil, err: Message("err"), want: "err"}, + {wrapper: Message("wrapper"), err: Message("err"), msg: "msg", want: "msg"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { - got := WithWrapper(tt.wrapper, tt.err) - switch { - case tt.wantNil: - if got != nil { - t.Errorf("WithWrapper(%#v, %#v): expected nil", tt.wrapper, tt.err) - } - default: - if got.Error() != tt.want { - t.Errorf("WithWrapper(%#v, %#v): got: %q, want %q", tt.wrapper, tt.err, got, tt.want) - } - if len(StackTrace(got)) != 0 { - t.Errorf("WithWrapper(%#v, %#v): returned error must not contain a stack trace", tt.wrapper, tt.err) - } - if !errors.Is(got, tt.err) { - t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for err", tt.wrapper, tt.err) - } - if tt.wrapper != nil && !errors.Is(got, tt.wrapper) { - t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for wrapper", tt.wrapper, tt.err) - } - if tt.err != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.err)).Interface()) { - t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the err error type", tt.wrapper, tt.err) - } - if tt.wrapper != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.wrapper)).Interface()) { - t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the wrapper error type", tt.wrapper, tt.err) - } + got := &withWrapper{ + wrapper: tt.wrapper, + err: tt.err, + msg: tt.msg, + } + if got.Error() != tt.want { + t.Errorf("WithWrapper(%#v, %#v): got: %q, want %q", tt.wrapper, tt.err, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("WithWrapper(%#v, %#v): returned error must not contain a stack trace", tt.wrapper, tt.err) + } + if !errors.Is(got, tt.err) { + t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for err", tt.wrapper, tt.err) + } + if tt.wrapper != nil && !errors.Is(got, tt.wrapper) { + t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for wrapper", tt.wrapper, tt.err) + } + if tt.err != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.err)).Interface()) { + t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the err error type", tt.wrapper, tt.err) + } + if tt.wrapper != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.wrapper)).Interface()) { + t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the wrapper error type", tt.wrapper, tt.err) } }) } diff --git a/xerrors.go b/xerrors.go index 66c2134..66b03c9 100644 --- a/xerrors.go +++ b/xerrors.go @@ -4,119 +4,202 @@ import ( "fmt" ) -// Wrapper provides context around another error. -type Wrapper interface { - error - Unwrap() error -} - -// StackTracer provides a stack trace for an error. -type StackTracer interface { - error - StackTrace() Callers -} - -// MultiError is an error that contains multiple errors. -type MultiError interface { - error - Errors() []error -} - -// DetailedError provides extended information about an error. -// The ErrorDetails method returns a longer, multi-line description of -// the error. It always ends with a new line. +// DetailedError represents an error that provides additional details +// beyond the error message. type DetailedError interface { error + + // ErrorDetails returns additional details about the error. It should not + // repeat the error message and should end with a newline. + // + // An empty string is returned if the error does not provide + // additional details. ErrorDetails() string } -// messageError is the simplest possible error that contains only -// a string message. -type messageError struct { - msg string +// Message creates a simple error with the given message, without +// recording a stack trace. Each call returns a distinct error +// instance, even if the message is identical. +// +// This function is useful for creating sentinel errors, often +// referred to as "constant errors." +// +// To create an error with a stack trace, use [New] or [Newf] +// instead. +func Message(msg string) error { + return &messageError{msg: msg} } -// Error implements the error interface. -func (e *messageError) Error() string { - return e.msg +// Messagef creates a simple error with a formatted message, +// without recording a stack trace. The format string follows the +// conventions of [fmt.Sprintf]. Each call returns a distinct error +// instance, even if the message is identical. +// +// This function is useful for creating sentinel errors, often +// referred to as "constant errors." +// +// To create an error with a stack trace, use [New] or [Newf] +// instead. +func Messagef(format string, args ...any) error { + return &messageError{msg: fmt.Sprintf(format, args...)} } -// Message creates a simple error with the given message. It does not record -// a stack trace. Each call returns a distinct error value even if the -// message is identical. -// -// This function is intended to create sentinel errors, sometimes referred -// to as "constant errors". -func Message(msg string) error { - return &messageError{msg: msg} +// New creates a new error from the provided values and records a +// stack trace at the point of the call. If multiple values are +// provided, each value is wrapped by the previous one, forming a +// chain of errors. +// +// Usage examples: +// - Add a stack trace to an existing error: New(err) +// - Create an error with a message and a stack trace: New("access denied") +// - Wrap an error with a message: New("access denied", io.EOF) +// - Add context to a sentinel error: New(ErrReadError, "access denied") +// +// Conversion rules for arguments: +// - If the value is an error, it is used as is. +// - If the value is a string, a new error with that message is +// created. +// - If the value implements [fmt.Stringer], the result of +// String() is used to create an error. +// - If the value is nil, it is ignored. +// - Otherwise, the result of [fmt.Sprint] is used to create an +// error. +// +// If called with no arguments or only nil values, New returns nil. +// +// To create a sentinel error, use [Message] or [Messagef] instead. +func New(vals ...any) error { + err := Join(vals...) + if err == nil { + return nil + } + return &withStackTrace{ + err: err, + stack: callers(1), + } } -// New creates a new error from the given value and records a stack trace at -// the point it was called. If multiple values are provided, then each error -// is wrapped by the previous error. Calling New(a, b, c), where a, b, and c -// are errors, is equivalent to calling New(WithWrapper(WithWrapper(a, b), c)). -// -// This function may be used to: -// -// - Add a stack trace to an error: New(err) -// -// - Create a message error with a stack trace: New("access denied") -// -// - Wrap an error with a message: New("access denied", io.EOF) -// -// - Wrap one error in another: New(ErrAccessDenied, io.EOF) -// -// - Add a message to a sentinel error: New(ErrReadError, "access denied") -// -// Values are converted to errors according to the following rules: -// -// - If a value is an error, it will be used as is. -// -// - If a value is a string, then new error with a given string as a message -// will be created. +// Newf creates a new error with a formatted message and records a +// stack trace at the point of the call. The format string follows +// the conventions of [fmt.Errorf]. // -// - If a value is nil, it will be ignored. +// Unlike errors created by [fmt.Errorf], the Unwrap method on the +// returned error yields the next wrapped error, not a slice of errors, +// since this function is intended for creating linear error chains. // -// - If a value implements the fmt.Stringer interface, then a String() method -// will be used to create an error. -// -// - For other types the result of fmt.Sprint will be used to create a message -// error. -// -// It is possible to use errors.Is function on returned error to check whether -// an error has been used in the New function. -// -// If the function is called with no arguments or all arguments are nil, it -// returns nil. -// -// To create a simple message error without a stack trace to be used as a -// sentinel error, use the Message function instead. -func New(vals ...interface{}) error { - var errs error - for _, val := range vals { - if val == nil { +// To create a sentinel error, use [Message] or [Messagef] instead. +func Newf(format string, args ...any) error { + return &withStackTrace{ + err: Joinf(format, args...), + stack: callers(1), + } +} + +// Join joins multiple values into a single error, forming a chain +// of errors. +// +// Conversion rules for arguments: +// - If the value is an error, it is used as is. +// - If the value is a string, a new error with that message is +// created. +// - If the value implements [fmt.Stringer], the result of +// String() is used to create an error. +// - If the value is nil, it is ignored. +// - Otherwise, the result of [fmt.Sprint] is used to create an +// error. +// +// If called with no arguments or only nil values, Join returns nil. +// +// To create a multi-error instead of an error chain, use [Append]. +func Join(vals ...any) error { + var wErr error + for i := len(vals) - 1; i >= 0; i-- { + if vals[i] == nil { continue } - err := toError(val) - if errs == nil { - errs = err - } else { - errs = &withWrapper{ - wrapper: errs, - err: err, - } + err := toError(vals[i]) + if wErr == nil { + wErr = err + continue + } + wErr = &withWrapper{ + wrapper: err, + err: wErr, } } - if errs == nil { - return nil - } - return &withStackTrace{ - err: errs, - stack: callers(1), + return wErr +} + +// Joinf joins multiple values into a single error with a formatted +// message, forming an error chain. The format string follows the +// conventions of [fmt.Errorf]. +// +// Unlike errors created by [fmt.Errorf], the Unwrap method on the +// returned error yields the next wrapped error, not a slice of errors, +// since this function is intended for creating linear error chains. +// +// To create a multi-error instead of an error chain, use [Append]. +func Joinf(format string, args ...any) error { + err := fmt.Errorf(format, args...) + switch u := err.(type) { + case interface { + Unwrap() error + }: + return &withWrapper{ + err: u.Unwrap(), + msg: err.Error(), + } + case interface { + Unwrap() []error + }: + var wErr error + errs := u.Unwrap() + for i := len(errs) - 1; i >= 0; i-- { + if errs[i] == nil { + continue + } + if wErr == nil { + wErr = errs[i] + continue + } + wErr = &withWrapper{ + wrapper: errs[i], + err: wErr, + } + } + // Because the formatted message may not follow the "err1: err2: err3" + // pattern, we set the msg field to overwrite the wrapper's message. + if wErr, ok := wErr.(*withWrapper); ok { + wErr.msg = err.Error() + return wErr + } + // Edge case: if multiple %w verbs are used, and all of them are nil. + if wErr == nil { + return err + } + // Edge case: if multiple %w verbs are used, and only one of them is + // not nil. + return &withWrapper{ + err: wErr, + msg: err.Error(), + } + default: + return &messageError{msg: err.Error()} } } -func toError(val interface{}) error { +// messageError represents a simple error that contains only a string +// message. +type messageError struct { + msg string +} + +// Error implements the [error] interface. +func (e *messageError) Error() string { + return e.msg +} + +func toError(val any) error { var err error switch typ := val.(type) { case error: diff --git a/xerrors_go120_test.go b/xerrors_go120_test.go new file mode 100644 index 0000000..eb35dbc --- /dev/null +++ b/xerrors_go120_test.go @@ -0,0 +1,64 @@ +//go:build go1.20 +// +build go1.20 + +package xerrors + +import ( + "errors" + "fmt" + "testing" +) + +func TestJoinf_Go120(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + tests := []struct { + format string + args []any + want string + }{ + {format: "multiple errors: %w: %w", args: []any{err1, err2}, want: "multiple errors: first error: second error"}, + {format: "wrapped multiple nil errors: %w %w", args: []any{nil, nil}, want: "wrapped multiple nil errors: %!w() %!w()"}, + {format: "first error nil: %w %w", args: []any{nil, err2}, want: "first error nil: %!w() second error"}, + {format: "second error nil: %w %w", args: []any{err1, nil}, want: "second error nil: first error %!w()"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Joinf(tt.format, tt.args...) + if got == nil { + t.Errorf("Joinf(%q, %#v): expected non-nil error", tt.format, tt.args) + return + } + if got.Error() != tt.want { + t.Errorf("Joinf(%q, %#v): got: %q, want %q", tt.format, tt.args, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("Joinf(%q, %#v): returned error must not contain a stack trace", tt.format, tt.args) + } + for _, v := range tt.args { + if err, ok := v.(error); ok { + if !errors.Is(got, err) { + t.Errorf("errors.Is(Joinf(errs...), err): must return true") + } + } + } + }) + } +} + +func TestJoinf_Unwrap_Go120(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + got := Joinf("%w: %w", err1, err2) + unwrapper, ok := got.(interface{ Unwrap() error }) + if !ok { + t.Fatalf("Join(err1, err2) must implement Unwrap()") + } + unwrapped := unwrapper.Unwrap() + if unwrapped == nil { + t.Fatalf("Join(err1, err2).Unwrap() must not return nil") + } + if errors.Is(unwrapped, err1) || !errors.Is(unwrapped, err2) { + t.Fatalf("Join(err1, err2).Unwrap() must return the second error") + } +} diff --git a/xerrors_test.go b/xerrors_test.go index b78dfa7..8b9c6d1 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -3,7 +3,7 @@ package xerrors import ( "errors" "fmt" - "io" + "strings" "testing" ) @@ -23,34 +23,58 @@ func TestMessage(t *testing.T) { } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { - err := Message(tt.val) - if got := err.Error(); got != tt.want { - t.Errorf("Message(%#v): got: %q, want %q", tt.val, got, tt.want) + got1 := Message(tt.val) + got2 := Message(tt.val) + if msg := got1.Error(); msg != tt.want { + t.Errorf("Message(%#v): got: %q, want %q", tt.val, msg, tt.want) } - if len(StackTrace(err)) != 0 { + if len(StackTrace(got1)) != 0 { t.Errorf("Message(%#v): returned error must not contain a stack trace", tt.val) } + if got1 == got2 { + t.Errorf("Message(%#v): returned error must not be the same instance", tt.val) + } + }) + } +} + +func TestMessagef(t *testing.T) { + tests := []struct { + format string + args []any + want string + }{ + {format: "", args: nil, want: ""}, + {format: "foo", args: nil, want: "foo"}, + {format: "foo %d", args: []any{42}, want: "foo 42"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got1 := Messagef(tt.format, tt.args...) + got2 := Messagef(tt.format, tt.args...) + if msg := got1.Error(); msg != tt.want { + t.Errorf("Messagef(%q, %#v): got: %q, want %q", tt.format, tt.args, msg, tt.want) + } + if len(StackTrace(got1)) != 0 { + t.Errorf("Messagef(%q, %#v): returned error must not contain a stack trace", tt.format, tt.args) + } + if got1 == got2 { + t.Errorf("Messagef(%q, %#v): returned error must not be the same instance", tt.format, tt.args) + } }) } } func TestNew(t *testing.T) { + // Since New is mostly a wrapper around Join, we only test + // the error message and stack trace. tests := []struct { - vals []interface{} + vals []any want string wantNil bool }{ - {vals: []interface{}{""}, want: ""}, - {vals: []interface{}{"foo", "bar"}, want: "foo: bar"}, - {vals: []interface{}{nil, "foo", "bar"}, want: "foo: bar"}, - {vals: []interface{}{"foo", nil, "bar"}, want: "foo: bar"}, - {vals: []interface{}{Message("foo"), Message("bar")}, want: "foo: bar"}, - {vals: []interface{}{io.EOF, io.EOF}, want: "EOF: EOF"}, - {vals: []interface{}{stringer{s: "foo"}, stringer{s: "bar"}}, want: "foo: bar"}, - {vals: []interface{}{42, 314}, want: "42: 314"}, - {vals: []interface{}{}, wantNil: true}, - {vals: []interface{}{nil}, wantNil: true}, - {vals: []interface{}{nil, nil}, wantNil: true}, + {vals: []any{"foo", "bar"}, want: "foo: bar"}, + {vals: []any{nil}, wantNil: true}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { @@ -64,13 +88,92 @@ func TestNew(t *testing.T) { if got.Error() != tt.want { t.Errorf("New(%#v): got: %q, want %q", tt.vals, got, tt.want) } - if len(StackTrace(got)) == 0 { + st := StackTrace(got) + if len(st) == 0 { t.Errorf("New(%#v): returned error must contain a stack trace", tt.vals) + return + } + if !strings.Contains(st.Frames()[0].Function, "TestNew") { + t.Errorf("New(%#v): first frame must point to TestNew", tt.vals) + } + } + }) + } +} + +func TestNewf(t *testing.T) { + // Since Newf is mostly a wrapper around Joinf, we only test + // the error message and stack trace. + tests := []struct { + format string + args []any + want string + }{ + {format: "foo", args: nil, want: "foo"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Newf(tt.format, tt.args...) + if got.Error() != tt.want { + t.Errorf("Newf(%q, %#v): got: %q, want %q", tt.format, tt.args, got, tt.want) + } + st := StackTrace(got) + if len(st) == 0 { + t.Errorf("Newf(%q, %#v): returned error must contain a stack trace", tt.format, tt.args) + return + } + if !strings.Contains(st.Frames()[0].Function, "TestNewf") { + t.Errorf("Newf(%q, %#v): first frame must point to TestNewf", tt.format, tt.args) + } + }) + } +} + +func TestJoin(t *testing.T) { + tests := []struct { + vals []any + want string + wantNil bool + }{ + // String + {vals: []any{""}, want: ""}, + {vals: []any{"foo", "bar"}, want: "foo: bar"}, + + // Error + {vals: []any{Message("foo"), Message("bar")}, want: "foo: bar"}, + + // Stringer + {vals: []any{stringer{s: "foo"}, stringer{s: "bar"}}, want: "foo: bar"}, + + // Sprintf + {vals: []any{42, 314}, want: "42: 314"}, + + // Nil cases + {vals: []any{}, wantNil: true}, + {vals: []any{nil}, wantNil: true}, + {vals: []any{nil, nil}, wantNil: true}, + {vals: []any{nil, "foo", "bar"}, want: "foo: bar"}, + {vals: []any{"foo", nil, "bar"}, want: "foo: bar"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Join(tt.vals...) + switch { + case tt.wantNil: + if got != nil { + t.Errorf("Join(%#v): expected nil", tt.vals) + } + default: + if got.Error() != tt.want { + t.Errorf("Join(%#v): got: %q, want %q", tt.vals, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("Join(%#v): returned error must not contain a stack trace", tt.vals) } for _, v := range tt.vals { if err, ok := v.(error); ok { if !errors.Is(got, err) { - t.Errorf("errors.Is(New(errs...), err): must return true") + t.Errorf("errors.Is(Join(errs...), err): must return true") } } } @@ -78,3 +181,56 @@ func TestNew(t *testing.T) { }) } } + +func TestJoinf(t *testing.T) { + err := Message("first error") + tests := []struct { + format string + args []any + want string + }{ + {format: "simple error", args: nil, want: "simple error"}, + {format: "error with value %d", args: []any{42}, want: "error with value 42"}, + {format: "wrapped error: %w", args: []any{err}, want: "wrapped error: first error"}, + {format: "wrapped nil error: %w", args: []any{nil}, want: "wrapped nil error: %!w()"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Joinf(tt.format, tt.args...) + if got == nil { + t.Errorf("Joinf(%q, %#v): expected non-nil error", tt.format, tt.args) + return + } + if got.Error() != tt.want { + t.Errorf("Joinf(%q, %#v): got: %q, want %q", tt.format, tt.args, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("Joinf(%q, %#v): returned error must not contain a stack trace", tt.format, tt.args) + } + for _, v := range tt.args { + if err, ok := v.(error); ok { + if !errors.Is(got, err) { + t.Errorf("errors.Is(Joinf(errs...), err): must return true") + } + } + } + }) + } +} + +func TestJoin_Unwrap(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + got := Join(err1, err2) + unwrapper, ok := got.(interface{ Unwrap() error }) + if !ok { + t.Fatalf("Join(err1, err2) must implement Unwrap()") + } + unwrapped := unwrapper.Unwrap() + if unwrapped == nil { + t.Fatalf("Join(err1, err2).Unwrap() must not return nil") + } + if errors.Is(unwrapped, err1) || !errors.Is(unwrapped, err2) { + t.Fatalf("Join(err1, err2).Unwrap() must return the second error") + } +}