diff --git a/.github/FUNDING.md b/.github/FUNDING.md new file mode 100644 index 0000000..1086c3d --- /dev/null +++ b/.github/FUNDING.md @@ -0,0 +1 @@ +github: jub0bs \ No newline at end of file diff --git a/.github/workflows/errutil.yml b/.github/workflows/errutil.yml new file mode 100644 index 0000000..4155225 --- /dev/null +++ b/.github/workflows/errutil.yml @@ -0,0 +1,73 @@ +name: build + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [1.23] + steps: + - name: Check out Source + uses: actions/checkout@v4 + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + - name: Display Go version + run: go version + - name: Test + run: go test -v -coverprofile=cover.out ./... + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: jub0bs/errutil + benchmark: + needs: test + strategy: + matrix: + os: [ubuntu-latest] + go-version: [1.23] + name: Benchmark comparison ${{ matrix.os }} @ Go ${{ matrix.go-version }} + runs-on: ${{ matrix.os }} + steps: + - name: Check out Code (previous) + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + path: previous + - name: Check out Code (new) + uses: actions/checkout@v4 + with: + path: new + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Install benchstat + run: go install golang.org/x/perf/cmd/benchstat@latest + - name: Run Benchmark (previous) + run: | + cd previous + go test -run '^$' -bench '^(BenchmarkAs|BenchmarkFind)$' -count 10 . > benchmark.txt + - name: Run Benchmark (new) + run: | + cd new + go test -run '^$' -bench '^(BenchmarkAs|BenchmarkFind)$' -count 10 . > benchmark.txt + - name: Run benchstat + # Mostly to compare allocations; + # measurements of execution speed in GitHub Actions are unreliable. + run: | + benchstat previous/benchmark.txt new/benchmark.txt + - name: Run Benchmark (against standard library) + run: | + cd new + go test -run '^$' -bench '^(BenchmarkAsAgainstErrorsPkg|BenchmarkFindAgainstErrorsPkg)$' -count 10 . > benchmark.txt + - name: Run benchstat (against standard library) + # Mostly to compare allocations; + # measurements of execution speed in GitHub Actions are unreliable. + run: | + benchstat -col "/v@(errors errutil)" new/benchmark.txt \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6243378 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] (2024-10-02) + +[0.1.0]: https://github.com/jub0bs/errutil/releases/tag/v0.1.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7ca827c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing to jub0bs/errutil + +jub0bs/errutil is an open-source project +but currently does not accept external contributions. + +However, if you want to report a problem (a bug, a missing feature, +a misfeature, an idea for performance improvement, etc.), +feel free to [open an issue](https://github.com/jub0bs/errutil/issues/new). \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4740794 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 jub0bs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f1a629 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# jub0bs/errutil + +[![tag](https://img.shields.io/github/tag/jub0bs/errutil.svg)](https://github.com/jub0bs/errutil/releases) +![Go Version](https://img.shields.io/badge/Go-%3E%3D%201.23.2-%23007d9c) +[![Go Reference](https://pkg.go.dev/badge/github.com/jub0bs/errutil.svg)](https://pkg.go.dev/github.com/jub0bs/errutil) +[![license](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat)](https://github.com/jub0bs/errutil/raw/main/LICENSE) +[![build](https://github.com/jub0bs/errutil/actions/workflows/errutil.yml/badge.svg)](https://github.com/jub0bs/errutil/actions/workflows/errutil.yml) +[![codecov](https://codecov.io/gh/jub0bs/errutil/branch/main/graph/badge.svg?token=N208BHWQTM)](https://app.codecov.io/gh/jub0bs/errutil/tree/main) +[![goreport](https://goreportcard.com/badge/jub0bs/errutil)](https://goreportcard.com/report/jub0bs/errutil) + +A collection of utility functions for working with [Go][golang] errors. + +## Installation + +```shell +go get github.com/jub0bs/errutil +``` + +jub0bs/errutil requires Go 1.23.2 or above. + +## Documentation + +The documentation is available on [pkg.go.dev][pkgsite]. + +## Code coverage + +![coverage](https://codecov.io/gh/jub0bs/errutil/branch/main/graphs/sunburst.svg?token=N208BHWQTM) + +## License + +All source code is covered by the [MIT License][license]. + +[golang]: https://go.dev/ +[license]: https://github.com/jub0bs/errutil/blob/main/LICENSE +[pkgsite]: https://pkg.go.dev/github.com/jub0bs/errutil \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dba3809 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +## Reporting security issue + +Please do **not** open an issue on GitHub. +Instead, contact jub0bs privately on [Mastodon]. + +[Mastodon]: https://infosec.exchange/@jub0bs \ No newline at end of file diff --git a/errutil.go b/errutil.go new file mode 100644 index 0000000..150fefe --- /dev/null +++ b/errutil.go @@ -0,0 +1,147 @@ +/* +Package errutil provides utility functions for working with errors. + +The advent of [errors.As] in the standard library predates that of parametric +polymorphism (generics) in the language. +As a result, [errors.As] is not as ergonomic, type-safe, or efficient as it +ideally could be. +Functions [As] and [Find] are inspired by several unaccepted proposals +(see issues [51945], [56949], and [64771]) and aim to address those limitations. + +In most cases, [As] can be used as a drop-in replacement for [errors.As]. + +[Find] is a more efficient and arguably more ergonomic alternative to [As]. +Incidentally, [the error-inspection draft design proposal] suggests that [errors.As] +would have been very similar to [Find] if the Go team had cracked +the parametric-polymorphism nut in time for [errors.As]'s inception in the +standard library. +In many cases, a call to [errors.As] can advantageously be refactored into a +call to [Find]. + +[51945]: https://github.com/golang/go/issues/51945 +[56949]: https://github.com/golang/go/issues/56949 +[64771]: https://github.com/golang/go/issues/64771 +[the error-inspection draft design proposal]: https://go.googlesource.com/proposal/+/master/design/go2draft-error-inspection.md#the-is-and-as-functions +*/ +package errutil + +// As finds the first error in err's tree that matches target, +// and if one is found, sets target to that error value and returns true. +// Otherwise, it returns false. +// +// The tree consists of err itself, followed by the errors obtained by repeatedly +// calling its Unwrap() error or Unwrap() []error method. When err wraps multiple +// errors, As examines err followed by a depth-first traversal of its children. +// +// An error matches target if the error's concrete value is assignable to the value +// pointed to by target +// or if the error has a method As(any) bool such that As(target) returns true. +// In the latter case, the As method is responsible for setting target. +// +// An error type might provide an As method so it can be treated as if it were a +// different error type. +// +// As panics if err is not nil and target is nil. +func As[T error](err error, target *T) bool { + if err == nil { + return false + } + if target == nil { + panic("errutil: target cannot be nil") + } + return as(err, target) +} + +func as[T error](err error, target *T) bool { + for { + if x, ok := err.(T); ok { + *target = x + return true + } + if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) { + return true + } + switch x := err.(type) { + case interface{ Unwrap() error }: + err = x.Unwrap() + if err == nil { + return false + } + case interface{ Unwrap() []error }: + for _, err := range x.Unwrap() { + if err == nil { + continue + } + if as(err, target) { + return true + } + } + return false + default: + return false + } + } +} + +// Find finds the first error in err's tree that matches type T, +// and if so, returns the corresponding value and true. +// Otherwise, it returns the zero value and false. +// +// The tree consists of err itself, followed by the errors obtained by repeatedly +// calling its Unwrap() error or Unwrap() []error method. When err wraps multiple +// errors, Find examines err followed by a depth-first traversal of its children. +// +// An error matches type T if type-asserting it to T succeeds, +// or if the error has a method As(any) bool such that As(target), +// where target is any non-nil value of type *T, returns true. +// In the latter case, the As method is responsible for setting target. +// +// An error type might provide an As method so it can be treated as if it were a +// different error type. +func Find[T error](err error) (T, bool) { + if err == nil { + var zero T + return zero, false + } + var ptr *T + return find[T](err, &ptr) +} + +func find[T error](err error, ptr2 **T) (T, bool) { + for { + x, ok := err.(T) + if ok { + return x, true + } + if x, ok := err.(interface{ As(any) bool }); ok { + if *ptr2 == nil { + *ptr2 = new(T) + } + if x.As(*ptr2) { + return **ptr2, true + } + } + switch x := err.(type) { + case interface{ Unwrap() error }: + err = x.Unwrap() + if err == nil { + var zero T + return zero, false + } + case interface{ Unwrap() []error }: + for _, err := range x.Unwrap() { + if err == nil { + continue + } + if x, ok := find[T](err, ptr2); ok { + return x, true + } + } + var zero T + return zero, false + default: + var zero T + return zero, false + } + } +} diff --git a/errutil_test.go b/errutil_test.go new file mode 100644 index 0000000..0cf4171 --- /dev/null +++ b/errutil_test.go @@ -0,0 +1,501 @@ +package errutil_test + +import ( + "errors" + "fmt" + "io/fs" + "net" + "os" + "slices" + "testing" + + "github.com/jub0bs/errutil" +) + +// cases := []struct { +// desc string +// target *simpleError +// }{ +// { +// desc: "nil target", +// target: nil, +// }, { +// desc: "non-nil target", +// target: new(simpleError), +// }, +// } +// for _, tc := range cases { +// f := func(t *testing.T) { +// var err error +// got := errutil.As(err, tc.target) +// if got { +// const tmpl = "As(%v, %T(%v)): got true; want false" +// t.Errorf(tmpl, err, tc.target, tc.target) +// } +// } +// t.Run(tc.desc, f) +// } +// } + +func TestAsPanicsForNonNilErrAndNilTarget(t *testing.T) { + err := errors.New("oh no!") + var target *simpleError + defer func() { + if r := recover(); r == nil { + const tmpl = "As(%v, %T(%v)) did not panic" + t.Errorf(tmpl, err, target, target) + } + }() + errutil.As(err, target) +} + +func TestAs(t *testing.T) { + for _, tc := range cases { + f := func(t *testing.T) { + match := errutil.As(tc.err, tc.target) + if match != tc.match { + const tmpl = "errutil.As(err, %[1]T(%[1]v)): got %t; want %t" + t.Fatalf(tmpl, tc.target, match, tc.match) + } + if !match { + return + } + if got := *tc.target; got != tc.want { + t.Fatalf("*target: got %#v; want %#v", got, tc.want) + } + if match != errors.As(tc.err, tc.target) { // sanity check + const tmpl = "errutil.As(err, %[1]T(%[1]v)) != errors.As(err, %[1]T(%[1]v))" + t.Fatalf(tmpl, tc.target) + } + } + t.Run(tc.desc, f) + } +} + +// see https://github.com/golang/go/issues/66455#issuecomment-2018372473 +func TestAsTargetWiderThanError(t *testing.T) { + err := new(net.DNSError) + type timeouter interface { + Timeout() bool + error + } + var _ timeouter = err + var target = new(timeouter) + match := errutil.As(err, target) + if !match { + const tmpl = "errutil.As(err, %[1]T(%[1]v)): got false; want true" + t.Fatalf(tmpl, target) + } + if got := *target; got != err { + t.Fatalf("*target: got %#v; want %#v", got, err) + } + if match != errors.As(err, target) { // sanity check + const tmpl = "errutil.As(err, %[1]T(%[1]v)) != errors.As(err, %[1]T(%[1]v))" + t.Fatalf(tmpl, target) + } +} + +func ExampleAs() { + if _, err := os.Open("non-existing"); err != nil { + var pathError *fs.PathError + if errutil.As(err, &pathError) { + fmt.Println("Failed at path:", pathError.Path) + } else { + fmt.Println(err) + } + } + // Output: + // Failed at path: non-existing +} + +// In this example, the target's desired type is an interface type other than +// error: +// +// interface { Timeout() bool } +// +// A simple workaround for coaxing [As] into accepting such a target simply +// consists in [embedding] error in the target's desired type. +// +// [embedding]: https://go.dev/ref/spec#Embedded_interfaces +func ExampleAs_interface() { + fakeLookupIP := func(_ string) ([]net.IP, error) { + return nil, &net.DNSError{IsTimeout: true} + } + if _, err := fakeLookupIP("invalid-TLD.123"); err != nil { + var to interface { + Timeout() bool + error // for errutil.As to accept &to as its second argument + } + if errutil.As(err, &to) { + fmt.Printf("Timed out: %t\n", to.Timeout()) + } else { + fmt.Println(err) + } + } + // Output: + // Timed out: true +} + +func BenchmarkAs(b *testing.B) { + for _, bc := range cases { + f := func(b *testing.B) { + b.ReportAllocs() + for range b.N { + errutil.As(bc.err, bc.target) + } + } + b.Run(bc.desc, f) + } +} + +func BenchmarkAsAgainstErrorsPkg(b *testing.B) { + for _, bc := range cases { + f := func(b *testing.B) { + b.ReportAllocs() + for range b.N { + errors.As(bc.err, bc.target) + } + } + b.Run("v=errors/"+bc.desc, f) + + f = func(b *testing.B) { + b.ReportAllocs() + for range b.N { + errutil.As(bc.err, bc.target) + } + } + b.Run("v=errutil/"+bc.desc, f) + } +} + +func TestFind(t *testing.T) { + for _, tc := range cases { + f := func(t *testing.T) { + got, match := errutil.Find[simpleError](tc.err) + if match != tc.match || got != tc.want { + const tmpl = "errutil.Find(err): got %#v, %t; want %#v, %t" + t.Fatalf(tmpl, got, match, tc.want, tc.match) + } + } + t.Run(tc.desc, f) + } +} + +// see https://github.com/golang/go/issues/66455#issuecomment-2018372473 +func TestFindTargetWiderThanError(t *testing.T) { + err := new(net.DNSError) + type timeouter interface { + Timeout() bool + error + } + var _ timeouter = err + got, match := errutil.Find[timeouter](err) + want := timeouter(err) + if !match || got != want { + const tmpl = "errutil.Find(err): got %#v, %t; want %#v, true" + t.Fatalf(tmpl, got, match, want) + } +} + +func ExampleFind() { + if _, err := os.Open("non-existing"); err != nil { + if pathError, ok := errutil.Find[*fs.PathError](err); ok { + fmt.Println("Failed at path:", pathError.Path) + } else { + fmt.Println(err) + } + } + // Output: + // Failed at path: non-existing +} + +// In this example, the result's desired type is an interface type other than +// error: +// +// interface { Timeout() bool } +// +// A simple workaround for coaxing [Find] into accepting such a type argument +// simply consists in [embedding] error in the result's desired type. +// +// [embedding]: https://go.dev/ref/spec#Embedded_interfaces +func ExampleFind_interface() { + fakeLookupIP := func(_ string) ([]net.IP, error) { + return nil, &net.DNSError{IsTimeout: true} + } + if _, err := fakeLookupIP("invalid-TLD.123"); err != nil { + type timeouter interface { + Timeout() bool + error // for errutil.Find to accept timeouter as its type argument + } + if to, ok := errutil.Find[timeouter](err); ok { + fmt.Printf("Timed out: %t\n", to.Timeout()) + } else { + fmt.Println(err) + } + } + // Output: + // Timed out: true +} + +func BenchmarkFind(b *testing.B) { + for _, bc := range cases { + f := func(b *testing.B) { + b.ReportAllocs() + for range b.N { + errutil.Find[simpleError](bc.err) + } + } + b.Run(bc.desc, f) + } +} + +func BenchmarkFindAgainstErrorsPkg(b *testing.B) { + for _, bc := range cases { + f := func(b *testing.B) { + b.ReportAllocs() + for range b.N { + findErrorsPkg[simpleError](bc.err) + } + } + b.Run("v=errors/"+bc.desc, f) + + f = func(b *testing.B) { + b.ReportAllocs() + for range b.N { + errutil.Find[simpleError](bc.err) + } + } + b.Run("v=errutil/"+bc.desc, f) + } +} + +// A version of errors.Find implemented in terms of errors.As; +// useful for benchmarks. +func findErrorsPkg[T error](err error) (T, bool) { + if err == nil { + var zero T + return zero, false + } + target := new(T) + ok := errors.As(err, target) + return *target, ok +} + +type TestCase[T error] struct { + desc string + err error + target *T + match bool + want T +} + +var cases = []TestCase[simpleError]{ + { + desc: "nil error, nil target", + err: nil, + target: nil, + match: false, + }, { + desc: "nil error, non-nil target", + err: nil, + target: new(simpleError), + match: false, + }, { + desc: "no match", + err: errors.New("oh no!"), + target: new(simpleError), + }, { + desc: "simple match", + err: simpleError{msg: "foo"}, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "aser", + err: aser{msg: "foo", f: masqueradeAsSimpleError}, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "wrapper that wraps nil error", + err: wrapper{}, + target: new(simpleError), + match: false, + }, { + desc: "wrapper that contains match", + err: wrapper{ + simpleError{msg: "foo"}, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "deeply nested wrapper that contains match", + err: wrapper{ + wrapper{ + wrapper{simpleError{msg: "foo"}}, + }, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "wrapper that contains aser", + err: wrapper{ + aser{msg: "foo", f: masqueradeAsSimpleError}, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "empty joiner", + err: joiner{}, + target: new(simpleError), + match: false, + }, { + desc: "joiner that contains nil", + err: joiner{nil}, + target: new(simpleError), + match: false, + }, { + desc: "joiner that contains nil and match", + err: joiner{ + nil, + simpleError{msg: "foo"}, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "joiner that contains non-nil and match", + err: joiner{ + errors.New("oh no!"), + simpleError{msg: "foo"}, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "joiner that contains match and non-nil", + err: joiner{ + simpleError{msg: "foo"}, + errors.New("oh no!"), + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "joiner that contains two matches", + err: joiner{ + simpleError{msg: "foo"}, + simpleError{msg: "bar"}, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "deeply nested joiner that contains non-nil and three matches", + err: joiner{ + simpleError{msg: "foo"}, + joiner{ + errors.New("oh no!"), + simpleError{msg: "bar"}, + simpleError{msg: "baz"}, + }, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "mix of wrappers and joiners", + err: joiner{ + wrapper{ + simpleError{msg: "foo"}, + }, + joiner{ + errors.New("oh no!"), + wrapper{simpleError{msg: "bar"}}, + simpleError{msg: "baz"}, + }, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "mix of wrappers and joiners that contains asers", + err: joiner{ + wrapper{ + aser{msg: "foo", f: masqueradeAsSimpleError}, + }, + joiner{ + errors.New("oh no!"), + wrapper{aser{msg: "bar", f: masqueradeAsSimpleError}}, + aser{msg: "baz", f: masqueradeAsSimpleError}, + }, + }, + target: new(simpleError), + match: true, + want: simpleError{msg: "foo"}, + }, { + desc: "joiner that contains many false asers", + err: joiner(slices.Repeat([]error{aser{msg: "foo"}}, 16)), + target: new(simpleError), + match: false, + }, +} + +type simpleError struct { + msg string +} + +func (s simpleError) Error() string { + return s.msg +} + +type wrapper struct { + err error +} + +func (w wrapper) Error() string { + return "" +} + +func (w wrapper) Unwrap() error { + return w.err +} + +type joiner []error + +func (j joiner) Error() string { + return "" +} + +func (j joiner) Unwrap() []error { + return j +} + +type aser struct { + msg string + f func(aser, any) bool +} + +func (a aser) Error() string { + return a.msg +} + +func (a aser) As(target any) bool { + if a.f == nil { + return false + } + return a.f(a, target) +} + +func masqueradeAsSimpleError(a aser, target any) bool { + switch x := target.(type) { + case *simpleError: + *x = simpleError{msg: a.msg} + return true + default: + return false + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c3ee81 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/jub0bs/errutil + +go 1.23.2