From 7fb0b16914ce0401890b42346bba054025b42bef Mon Sep 17 00:00:00 2001 From: am Date: Fri, 18 Apr 2025 13:17:03 +0100 Subject: [PATCH 1/7] Make concurrency-safe while preserving V1 API --- deep.go | 85 ++++++++++++------------- differ.go | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 45 deletions(-) create mode 100644 differ.go diff --git a/deep.go b/deep.go index 4be3e1f..936d8ee 100644 --- a/deep.go +++ b/deep.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "reflect" + "slices" "strings" ) @@ -76,7 +77,7 @@ type cmp struct { diff []string buff []string floatFormat string - flag map[byte]bool + conf Differ } var errorType = reflect.TypeOf((*error)(nil)).Elem() @@ -92,38 +93,32 @@ var errorType = reflect.TypeOf((*error)(nil)).Elem() // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. func Equal(a, b interface{}, flags ...interface{}) []string { - aVal := reflect.ValueOf(a) - bVal := reflect.ValueOf(b) - c := &cmp{ - diff: []string{}, - buff: []string{}, - floatFormat: fmt.Sprintf("%%.%df", FloatPrecision), - flag: map[byte]bool{}, - } - for i := range flags { - c.flag[flags[i].(byte)] = true - } - if a == nil && b == nil { - return nil - } else if a == nil && b != nil { - c.saveDiff("", b) - } else if a != nil && b == nil { - c.saveDiff(a, "") - } - if len(c.diff) > 0 { - return c.diff - } + // error ignored to preserve API + differ, _ := New( + WithCompareFunctions(CompareFunctions), + WithCompareUnexportedFields(CompareUnexportedFields), + WithFloatPrecision(FloatPrecision), + WithIgnoreSliceOrder(hasFlag(flags, FLAG_IGNORE_SLICE_ORDER)), + WithLogErrors(LogErrors), + WithMaxDepth(MaxDepth), + WithMaxDiff(MaxDiff), + WithNilMapsAreEmpty(NilMapsAreEmpty), + WithNilPointersAreZero(NilPointersAreZero), + WithNilSlicesAreEmpty(NilSlicesAreEmpty), + ) + return differ.Compare(a, b) +} - c.equals(aVal, bVal, 0) - if len(c.diff) > 0 { - return c.diff // diffs - } - return nil // no diffs +func hasFlag(flags []interface{}, flag byte) bool { + return slices.ContainsFunc(flags, func(i interface{}) bool { + v, ok := i.(byte) + return ok && v == flag + }) } func (c *cmp) equals(a, b reflect.Value, level int) { - if MaxDepth > 0 && level > MaxDepth { - logError(ErrMaxRecursion) + if c.conf.maxDepth > 0 && level > c.conf.maxDepth { + c.logError(ErrMaxRecursion) return } @@ -153,7 +148,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { bFullType := bType.PkgPath() + "." + bType.Name() c.saveDiff(aFullType, bFullType) } - logError(ErrTypeMismatch) + c.logError(ErrTypeMismatch) return } @@ -193,10 +188,10 @@ func (c *cmp) equals(a, b reflect.Value, level int) { if bElem { b = b.Elem() } - if aElem && NilPointersAreZero && !a.IsValid() && b.IsValid() { + if aElem && c.conf.nilPointersAreZero && !a.IsValid() && b.IsValid() { a = reflect.Zero(b.Type()) } - if bElem && NilPointersAreZero && !b.IsValid() && a.IsValid() { + if bElem && c.conf.nilPointersAreZero && !b.IsValid() && a.IsValid() { b = reflect.Zero(a.Type()) } c.equals(a, b, level+1) @@ -244,7 +239,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } for i := 0; i < a.NumField(); i++ { - if aType.Field(i).PkgPath != "" && !CompareUnexportedFields { + if aType.Field(i).PkgPath != "" && !c.conf.compareUnexportedFields { continue // skip unexported field, e.g. s in type T struct {s string} } @@ -264,7 +259,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() // pop field name from buff - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { break } } @@ -285,7 +280,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { */ if a.IsNil() || b.IsNil() { - if NilMapsAreEmpty { + if c.conf.nilMapsAreEmpty { if a.IsNil() && b.Len() != 0 { c.saveDiff("", b) return @@ -320,7 +315,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { return } } @@ -333,7 +328,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("map[%v]", key)) c.saveDiff("", b.MapIndex(key)) c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { return } } @@ -343,12 +338,12 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { break } } case reflect.Slice: - if NilSlicesAreEmpty { + if c.conf.nilSlicesAreEmpty { if a.IsNil() && b.Len() != 0 { c.saveDiff("", b) return @@ -378,7 +373,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { return } - if c.flag[FLAG_IGNORE_SLICE_ORDER] { + if c.conf.ignoreSliceOrder { // Compare slices by value and value count; ignore order. // Value equality is impliclity established by the maps: // any value v1 will hash to the same map value if it's equal @@ -411,7 +406,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff("", b.Index(i)) } c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { break } } @@ -451,7 +446,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff(a.String(), b.String()) } case reflect.Func: - if CompareFunctions { + if c.conf.compareFunctions { if !a.IsNil() || !b.IsNil() { aVal, bVal := "nil func", "nil func" if !a.IsNil() { @@ -464,7 +459,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } } default: - logError(ErrNotHandled) + c.logError(ErrNotHandled) } } @@ -506,8 +501,8 @@ func (c *cmp) cmpMapValueCounts(a, b reflect.Value, am, bm map[interface{}]int, } } -func logError(err error) { - if LogErrors { +func (c *cmp) logError(err error) { + if c.conf.logErrors { log.Println(err) } } diff --git a/differ.go b/differ.go new file mode 100644 index 0000000..8060577 --- /dev/null +++ b/differ.go @@ -0,0 +1,186 @@ +// Package deep provides function deep.Equal which is like reflect.DeepEqual but +// returns a list of differences. This is helpful when comparing complex types +// like structures and maps. +package deep + +import ( + "fmt" + "reflect" +) + +type Opt func(*Differ) error + +// WithFloatPrecision is the number of decimal places to round float values +// to when comparing. +func WithFloatPrecision(p int) Opt { + return func(d *Differ) error { + d.floatPrecision = p + return nil + } +} + +// WithMaxDiff specifies the maximum number of differences to return. +func WithMaxDiff(m int) Opt { + return func(d *Differ) error { + d.maxDiff = m + return nil + } +} + +// WithMaxDepth specifies the maximum levels of a struct to recurse into, +// if greater than zero. If zero, there is no limit. +func WithMaxDepth(m int) Opt { + return func(d *Differ) error { + d.maxDepth = m + return nil + } +} + +// WithLogErrors causes errors to be logged to STDERR when true. +func WithLogErrors(b bool) Opt { + return func(differ *Differ) error { + differ.logErrors = b + return nil + } +} + +// WithCompareUnexportedFields causes unexported struct fields, like s in +// T{s int}, to be compared when true. This does not work for comparing +// error or Time types on unexported fields because methods on unexported +// fields cannot be called. +func WithCompareUnexportedFields(b bool) Opt { + return func(differ *Differ) error { + differ.compareUnexportedFields = b + return nil + } +} + +// WithCompareFunctions compares functions the same as reflect.DeepEqual: +// only two nil functions are equal. Every other combination is not equal. +// This is disabled by default because previous versions of this package +// ignored functions. Enabling it can possibly report new diffs. +func WithCompareFunctions(b bool) Opt { + return func(differ *Differ) error { + differ.compareFunctions = b + return nil + } +} + +// WithNilSlicesAreEmpty causes a nil slice to be equal to an empty slice. +func WithNilSlicesAreEmpty(b bool) Opt { + return func(differ *Differ) error { + differ.nilSlicesAreEmpty = b + return nil + } +} + +// WithNilMapsAreEmpty causes a nil map to be equal to an empty map. +func WithNilMapsAreEmpty(b bool) Opt { + return func(differ *Differ) error { + differ.nilMapsAreEmpty = b + return nil + } +} + +// WithNilPointersAreZero causes a nil pointer to be equal to a zero value. +func WithNilPointersAreZero(b bool) Opt { + return func(differ *Differ) error { + differ.nilPointersAreZero = b + return nil + } +} + +// WithIgnoreSliceOrder causes Equal to ignore slice order so that +// []int{1, 2} and []int{2, 1} are equal. Only slices of primitive scalars +// like numbers and strings are supported. Slices of complex types, +// like []T where T is a struct, are undefined because Equal does not +// recurse into the slice value when this flag is enabled. +func WithIgnoreSliceOrder(b bool) Opt { + return func(differ *Differ) error { + differ.ignoreSliceOrder = b + return nil + } +} + +type Differ struct { + compareFunctions bool + compareUnexportedFields bool + floatPrecision int + ignoreSliceOrder bool + logErrors bool + maxDepth int + maxDiff int + nilMapsAreEmpty bool + nilPointersAreZero bool + nilSlicesAreEmpty bool +} + +func New(opts ...Opt) (d Differ, err error) { + d = Differ{ + // options where zero-value equals default value are omitted + floatPrecision: 10, + maxDiff: 10, + } + for opt := range opts { + err = opts[opt](&d) + if err != nil { + return d, fmt.Errorf("invalid option: %w", err) + } + } + return d, nil +} + +type Delta []string + +func (d Delta) Equal(other Delta) bool { + if len(d) != len(other) { + return false + } + for i := range d { + if d[i] != other[i] { + return false + } + } + return true +} + +func (d Delta) ToSlice() []string { + return d +} + +// Compare compares variables a and b, recursing into their structure up to +// MaxDepth levels deep (if greater than zero), and returns a list of differences, +// or nil if there are none. Some differences may not be found if an error is +// also returned. +// +// If a type has an Equal method, like time.Equal, it is called to check for +// equality. +// +// When comparing a struct, if a field has the tag `deep:"-"` then it will be +// ignored. +func (d Differ) Compare(a, b any) Delta { + aVal := reflect.ValueOf(a) + bVal := reflect.ValueOf(b) + c := &cmp{ + conf: d, + diff: []string{}, + buff: []string{}, + floatFormat: fmt.Sprintf("%%.%df", d.floatPrecision), + } + if a == nil && b == nil { + return nil + } else if a == nil && b != nil { + c.saveDiff("", b) + } else if a != nil && b == nil { + c.saveDiff(a, "") + } + if len(c.diff) > 0 { + return c.diff + } + + c.equals(aVal, bVal, 0) + if len(c.diff) > 0 { + return c.diff // diffs + } + return nil // no diffs +} From aeb4df5cd0177ff19ee9a34a26f547eb2f446f99 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 15:06:13 -0400 Subject: [PATCH 2/7] Update to modern Go syntax --- deep.go | 32 ++++++++++++++------------------ deep_test.go | 19 +++++++++---------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/deep.go b/deep.go index 83a2973..a3f9599 100644 --- a/deep.go +++ b/deep.go @@ -80,7 +80,7 @@ type cmp struct { conf Differ } -var errorType = reflect.TypeOf((*error)(nil)).Elem() +var errorType = reflect.TypeFor[error]() // Equal compares variables a and b, recursing into their structure up to // MaxDepth levels deep (if greater than zero), and returns a list of differences, @@ -92,7 +92,7 @@ var errorType = reflect.TypeOf((*error)(nil)).Elem() // // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. -func Equal(a, b interface{}, flags ...interface{}) []string { +func Equal(a, b any, flags ...any) []string { // error ignored to preserve API differ, _ := New( WithCompareFunctions(CompareFunctions), @@ -109,8 +109,8 @@ func Equal(a, b interface{}, flags ...interface{}) []string { return differ.Compare(a, b) } -func hasFlag(flags []interface{}, flag byte) bool { - return slices.ContainsFunc(flags, func(i interface{}) bool { +func hasFlag(flags []any, flag byte) bool { + return slices.ContainsFunc(flags, func(i any) bool { v, ok := i.(byte) return ok && v == flag }) @@ -157,8 +157,8 @@ func (c *cmp) equals(a, b reflect.Value, level int) { bKind := b.Kind() // Do a and b have underlying elements? Yes if they're ptr or interface. - aElem := aKind == reflect.Ptr || aKind == reflect.Interface - bElem := bKind == reflect.Ptr || bKind == reflect.Interface + aElem := aKind == reflect.Pointer || aKind == reflect.Interface + bElem := bKind == reflect.Pointer || bKind == reflect.Interface // If both types implement the error interface, compare the error strings. // This must be done before dereferencing because errors.New() returns a @@ -334,7 +334,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } case reflect.Array: n := a.Len() - for i := 0; i < n; i++ { + for i := range n { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() @@ -380,23 +380,19 @@ func (c *cmp) equals(a, b reflect.Value, level int) { // to another value v2. Then equality is determined by value // count: presuming v1==v2, then the slices are equal if there // are equal numbers of v1 in each slice. - am := map[interface{}]int{} + am := map[any]int{} for i := 0; i < a.Len(); i++ { am[a.Index(i).Interface()] += 1 } - bm := map[interface{}]int{} + bm := map[any]int{} for i := 0; i < b.Len(); i++ { bm[b.Index(i).Interface()] += 1 } - c.cmpMapValueCounts(a, b, am, bm, true) // a cmp b - c.cmpMapValueCounts(b, a, bm, am, false) // b cmp a + c.cmpMapValueCounts(am, bm, true) // a cmp b + c.cmpMapValueCounts(bm, am, false) // b cmp a } else { // Compare slices by order - n := aLen - if bLen > aLen { - n = bLen - } - for i := 0; i < n; i++ { + for i := range max(aLen, bLen) { c.push(fmt.Sprintf("slice[%d]", i)) if i < aLen && i < bLen { c.equals(a.Index(i), b.Index(i), level+1) @@ -473,7 +469,7 @@ func (c *cmp) pop() { } } -func (c *cmp) saveDiff(aval, bval interface{}) { +func (c *cmp) saveDiff(aval, bval any) { if len(c.buff) > 0 { varName := strings.Join(c.buff, ".") c.diff = append(c.diff, fmt.Sprintf("%s: %v != %v", varName, aval, bval)) @@ -482,7 +478,7 @@ func (c *cmp) saveDiff(aval, bval interface{}) { } } -func (c *cmp) cmpMapValueCounts(a, b reflect.Value, am, bm map[interface{}]int, a2b bool) { +func (c *cmp) cmpMapValueCounts(am, bm map[any]int, a2b bool) { for v := range am { aCount, _ := am[v] bCount, _ := bm[v] diff --git a/deep_test.go b/deep_test.go index e743ae8..9a7fb77 100644 --- a/deep_test.go +++ b/deep_test.go @@ -62,7 +62,6 @@ func TestFloat(t *testing.T) { if diff[0] != "1.123456 != 1.123457" { t.Error("wrong diff:", diff[0]) } - } func TestInt(t *testing.T) { @@ -319,8 +318,8 @@ func TestNotHandled(t *testing.T) { b := unsafe.Pointer(&v) // UnsafePointer added in Go 1.88. Use these lines once this pkg // no longer supports Go 1.17. - //a := reflect.ValueOf(v).UnsafePointer() - //b := reflect.ValueOf(v).UnsafePointer() + // a := reflect.ValueOf(v).UnsafePointer() + // b := reflect.ValueOf(v).UnsafePointer() diff := deep.Equal(a, b) if len(diff) > 0 { t.Error("got diffs:", diff) @@ -1185,12 +1184,12 @@ func TestTimeUnexported(t *testing.T) { } func TestInterface(t *testing.T) { - a := map[string]interface{}{ + a := map[string]any{ "foo": map[string]string{ "bar": "a", }, } - b := map[string]interface{}{ + b := map[string]any{ "foo": map[string]string{ "bar": "b", }, @@ -1211,10 +1210,10 @@ func TestInterface2(t *testing.T) { } }() - a := map[string]interface{}{ + a := map[string]any{ "bar": 1, } - b := map[string]interface{}{ + b := map[string]any{ "bar": 1.23, } diff := deep.Equal(a, b) @@ -1228,10 +1227,10 @@ func TestInterface2(t *testing.T) { func TestInterface3(t *testing.T) { type Value struct{ int } - a := map[string]interface{}{ + a := map[string]any{ "foo": &Value{}, } - b := map[string]interface{}{ + b := map[string]any{ "foo": 1.23, } diff := deep.Equal(a, b) @@ -1431,7 +1430,7 @@ func TestNil(t *testing.T) { } mark := student{"mark", 10} - var someNilThing interface{} = nil + var someNilThing any = nil diff := deep.Equal(someNilThing, mark) if diff == nil { t.Error("Nil value to comparison should not be equal") From d057959b52ee82f0557a21b58a8a77e3ec58b23a Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 15:17:02 -0400 Subject: [PATCH 3/7] Rename Go workflow --- .github/workflows/{go.yml => go-test-matrix.yml} | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) rename .github/workflows/{go.yml => go-test-matrix.yml} (83%) diff --git a/.github/workflows/go.yml b/.github/workflows/go-test-matrix.yml similarity index 83% rename from .github/workflows/go.yml rename to .github/workflows/go-test-matrix.yml index 5177bca..8c490ed 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go-test-matrix.yml @@ -1,7 +1,4 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -name: Go +name: "Go Test Matrix" on: push: From 3ca8822642353815111a20bbfb50cd063ef972a1 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 16:21:06 -0400 Subject: [PATCH 4/7] Test Differ, rename New to NewDiffer and remove error retval, rename Delta to Diff --- .gitignore | 1 + deep.go | 23 ++++++++------- differ.go | 77 +++++++++++++++++++++----------------------------- differ_test.go | 52 ++++++++++++++++++++++++++++++++++ test/coverage | 4 ++- 5 files changed, 99 insertions(+), 58 deletions(-) create mode 100644 differ_test.go diff --git a/.gitignore b/.gitignore index 53f12f0..5f18ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.swp *.out +coverage.* diff --git a/deep.go b/deep.go index a3f9599..1f4efdc 100644 --- a/deep.go +++ b/deep.go @@ -94,19 +94,18 @@ var errorType = reflect.TypeFor[error]() // ignored. func Equal(a, b any, flags ...any) []string { // error ignored to preserve API - differ, _ := New( + return NewDiffer( WithCompareFunctions(CompareFunctions), WithCompareUnexportedFields(CompareUnexportedFields), - WithFloatPrecision(FloatPrecision), + WithFloatPrecision(uint(FloatPrecision)), WithIgnoreSliceOrder(hasFlag(flags, FLAG_IGNORE_SLICE_ORDER)), WithLogErrors(LogErrors), - WithMaxDepth(MaxDepth), - WithMaxDiff(MaxDiff), + WithMaxDepth(uint(MaxDepth)), + WithMaxDiff(uint(MaxDiff)), WithNilMapsAreEmpty(NilMapsAreEmpty), WithNilPointersAreZero(NilPointersAreZero), WithNilSlicesAreEmpty(NilSlicesAreEmpty), - ) - return differ.Compare(a, b) + ).Diff(a, b) } func hasFlag(flags []any, flag byte) bool { @@ -117,7 +116,7 @@ func hasFlag(flags []any, flag byte) bool { } func (c *cmp) equals(a, b reflect.Value, level int) { - if c.conf.maxDepth > 0 && level > c.conf.maxDepth { + if c.conf.maxDepth > 0 && uint(level) > c.conf.maxDepth { c.logError(ErrMaxRecursion) return } @@ -259,7 +258,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() // pop field name from buff - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } @@ -315,7 +314,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { return } } @@ -328,7 +327,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("map[%v]", key)) c.saveDiff("", b.MapIndex(key)) c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { return } } @@ -338,7 +337,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } @@ -402,7 +401,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff("", b.Index(i)) } c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } diff --git a/differ.go b/differ.go index 8060577..d4dcffa 100644 --- a/differ.go +++ b/differ.go @@ -8,39 +8,35 @@ import ( "reflect" ) -type Opt func(*Differ) error +type Opt func(*Differ) // WithFloatPrecision is the number of decimal places to round float values // to when comparing. -func WithFloatPrecision(p int) Opt { - return func(d *Differ) error { +func WithFloatPrecision(p uint) Opt { + return func(d *Differ) { d.floatPrecision = p - return nil } } // WithMaxDiff specifies the maximum number of differences to return. -func WithMaxDiff(m int) Opt { - return func(d *Differ) error { +func WithMaxDiff(m uint) Opt { + return func(d *Differ) { d.maxDiff = m - return nil } } // WithMaxDepth specifies the maximum levels of a struct to recurse into, // if greater than zero. If zero, there is no limit. -func WithMaxDepth(m int) Opt { - return func(d *Differ) error { +func WithMaxDepth(m uint) Opt { + return func(d *Differ) { d.maxDepth = m - return nil } } // WithLogErrors causes errors to be logged to STDERR when true. func WithLogErrors(b bool) Opt { - return func(differ *Differ) error { - differ.logErrors = b - return nil + return func(d *Differ) { + d.logErrors = b } } @@ -49,9 +45,8 @@ func WithLogErrors(b bool) Opt { // error or Time types on unexported fields because methods on unexported // fields cannot be called. func WithCompareUnexportedFields(b bool) Opt { - return func(differ *Differ) error { - differ.compareUnexportedFields = b - return nil + return func(d *Differ) { + d.compareUnexportedFields = b } } @@ -60,33 +55,29 @@ func WithCompareUnexportedFields(b bool) Opt { // This is disabled by default because previous versions of this package // ignored functions. Enabling it can possibly report new diffs. func WithCompareFunctions(b bool) Opt { - return func(differ *Differ) error { - differ.compareFunctions = b - return nil + return func(d *Differ) { + d.compareFunctions = b } } // WithNilSlicesAreEmpty causes a nil slice to be equal to an empty slice. func WithNilSlicesAreEmpty(b bool) Opt { - return func(differ *Differ) error { - differ.nilSlicesAreEmpty = b - return nil + return func(d *Differ) { + d.nilSlicesAreEmpty = b } } // WithNilMapsAreEmpty causes a nil map to be equal to an empty map. func WithNilMapsAreEmpty(b bool) Opt { - return func(differ *Differ) error { - differ.nilMapsAreEmpty = b - return nil + return func(d *Differ) { + d.nilMapsAreEmpty = b } } // WithNilPointersAreZero causes a nil pointer to be equal to a zero value. func WithNilPointersAreZero(b bool) Opt { - return func(differ *Differ) error { - differ.nilPointersAreZero = b - return nil + return func(d *Differ) { + d.nilPointersAreZero = b } } @@ -96,43 +87,39 @@ func WithNilPointersAreZero(b bool) Opt { // like []T where T is a struct, are undefined because Equal does not // recurse into the slice value when this flag is enabled. func WithIgnoreSliceOrder(b bool) Opt { - return func(differ *Differ) error { - differ.ignoreSliceOrder = b - return nil + return func(d *Differ) { + d.ignoreSliceOrder = b } } type Differ struct { compareFunctions bool compareUnexportedFields bool - floatPrecision int + floatPrecision uint ignoreSliceOrder bool logErrors bool - maxDepth int - maxDiff int + maxDepth uint + maxDiff uint nilMapsAreEmpty bool nilPointersAreZero bool nilSlicesAreEmpty bool } -func New(opts ...Opt) (d Differ, err error) { - d = Differ{ +func NewDiffer(opts ...Opt) Differ { + d := Differ{ // options where zero-value equals default value are omitted floatPrecision: 10, maxDiff: 10, } for opt := range opts { - err = opts[opt](&d) - if err != nil { - return d, fmt.Errorf("invalid option: %w", err) - } + opts[opt](&d) } - return d, nil + return d } -type Delta []string +type Diff []string -func (d Delta) Equal(other Delta) bool { +func (d Diff) Equal(other Diff) bool { if len(d) != len(other) { return false } @@ -144,7 +131,7 @@ func (d Delta) Equal(other Delta) bool { return true } -func (d Delta) ToSlice() []string { +func (d Diff) ToSlice() []string { return d } @@ -158,7 +145,7 @@ func (d Delta) ToSlice() []string { // // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. -func (d Differ) Compare(a, b any) Delta { +func (d Differ) Diff(a, b any) Diff { aVal := reflect.ValueOf(a) bVal := reflect.ValueOf(b) c := &cmp{ diff --git a/differ_test.go b/differ_test.go new file mode 100644 index 0000000..42a32ac --- /dev/null +++ b/differ_test.go @@ -0,0 +1,52 @@ +package deep_test + +import ( + "testing" + + "github.com/go-test/deep" +) + +func TestDiffer(t *testing.T) { + d := deep.NewDiffer() + + diff1 := d.Diff("foo", "bar") + if diff1 == nil { + t.Error("no diff") + } + + s := diff1.ToSlice() + if len(s) != 1 { + t.Errorf("got slice len %d, expected 1", len(s)) + } + + diff2 := d.Diff("foo", "bar") + if diff2 == nil { + t.Error("no diff") + } + + // diff1 and diff2 are equal (i.e. same diff: foo != bar) + eq := diff1.Equal(diff2) + if eq != true { + t.Error("diffs not equal") + } + + // diff1 and diff3 are NOT equal + diff3 := d.Diff("a", "b") + if diff3 == nil { + t.Error("should not be equal") + } + eq = diff1.Equal(diff3) + if eq == true { + t.Error("diffs should not be equal") + } + + // diff4 isn't a diff, so it's not equal to diff1 + diff4 := d.Diff("ok", "ok") + if diff4 != nil { + t.Error("should be equal") + } + eq = diff1.Equal(diff4) + if eq == true { + t.Error("should not be equal") + } +} diff --git a/test/coverage b/test/coverage index e328944..ae5376c 100755 --- a/test/coverage +++ b/test/coverage @@ -1,3 +1,5 @@ #!/bin/sh +export GTK_MODULES="" # avoid Ubuntu/Firefox warning go test -coverprofile=coverage.out "$@" -go tool cover -html=coverage.out +go tool cover -html=coverage.out -o coverage.html +open coverage.html From 7dc67712d19cc0025eea87bc4436705e69e636d0 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 18:16:55 -0400 Subject: [PATCH 5/7] Add inline testing with Differ --- differ.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++---- differ_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/differ.go b/differ.go index d4dcffa..793b6fe 100644 --- a/differ.go +++ b/differ.go @@ -92,6 +92,32 @@ func WithIgnoreSliceOrder(b bool) Opt { } } +// WithInlineTest causes Differ.Diff to call T.Error when diffs are found. +// It also calls T.Helper to make the error report where it's called in the +// unit test, not within Differ.Diff. This option replaces a common pattern: +// +// if diff := deep.Equal("foo", "bar"); diff != nil { +// t.Error(diff) +// } +// +// Instead, where d is a Differ with inline testing: +// +// d.Diff("foo", "bar") +// +// With inline testing, Differ.Diff still returns a Diff (nil or non-nil). +func WithInlineTest(i InlineTest) Opt { + return func(d *Differ) { d.inline = i } +} + +// InlineTest is the value and options passed to WithInlineTest. Only T is +// required, and notice: it's the deep.T interface, and standard library +// testing.T satisfies this interface. (The deep.T interface is necessary +// to test the testing.) +type InlineTest struct { + T T + Fatal bool // call T.Error by default +} + type Differ struct { compareFunctions bool compareUnexportedFields bool @@ -103,6 +129,7 @@ type Differ struct { nilMapsAreEmpty bool nilPointersAreZero bool nilSlicesAreEmpty bool + inline InlineTest } func NewDiffer(opts ...Opt) Differ { @@ -146,6 +173,11 @@ func (d Diff) ToSlice() []string { // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. func (d Differ) Diff(a, b any) Diff { + t := d.inline.T + if t != nil { + t.Helper() + } + aVal := reflect.ValueOf(a) bVal := reflect.ValueOf(b) c := &cmp{ @@ -154,20 +186,38 @@ func (d Differ) Diff(a, b any) Diff { buff: []string{}, floatFormat: fmt.Sprintf("%%.%df", d.floatPrecision), } + + // Can bypass full diff if both or only one operand is nil if a == nil && b == nil { return nil } else if a == nil && b != nil { c.saveDiff("", b) + goto ret } else if a != nil && b == nil { c.saveDiff(a, "") + goto ret } - if len(c.diff) > 0 { - return c.diff - } - c.equals(aVal, bVal, 0) + c.equals(aVal, bVal, 0) // full diff + +ret: if len(c.diff) > 0 { + if t != nil { + if d.inline.Fatal { + t.Fatal(c.diff) + } else { + t.Error(c.diff) + } + } return c.diff // diffs } + return nil // no diffs } + +// T is the subset of standard library testing.T that option WithInlineTest needs. +type T interface { + Error(args ...any) + Fatal(args ...any) + Helper() +} diff --git a/differ_test.go b/differ_test.go index 42a32ac..ba3fb46 100644 --- a/differ_test.go +++ b/differ_test.go @@ -50,3 +50,57 @@ func TestDiffer(t *testing.T) { t.Error("should not be equal") } } + +type mockT struct { + calledError bool + calledFatal bool + calledHelper bool +} + +func (t *mockT) Error(args ...any) { t.calledError = true } +func (t *mockT) Fatal(args ...any) { t.calledFatal = true } +func (t *mockT) Helper() { t.calledHelper = true } + +func TestDiffer_WithInlineTest(t *testing.T) { + // No diff, no calls to T except Helper + m := &mockT{} + d := deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) + d.Diff("foo", "foo") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError == true { + t.Errorf("called T.Error") + } + if m.calledFatal == true { + t.Errorf("called T.Fatal") + } + + // Diff and by default Error, not Fatal, is called + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) + d.Diff("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError != true { + t.Errorf("did not call T.Error") + } + if m.calledFatal == true { + t.Errorf("called T.Fatal") + } + + // Diff and Fatal + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m, Fatal: true})) + d.Diff("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError == true { + t.Errorf("called T.Error") + } + if m.calledFatal != true { + t.Errorf("did not call T.Fatal") + } +} From 4e599ffb6e847121102dd8b42cf2adbb131be55d Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 18:36:31 -0400 Subject: [PATCH 6/7] Add blog link about reflect.TypeFor --- deep.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deep.go b/deep.go index 1f4efdc..ccd6baa 100644 --- a/deep.go +++ b/deep.go @@ -80,7 +80,7 @@ type cmp struct { conf Differ } -var errorType = reflect.TypeFor[error]() +var errorType = reflect.TypeFor[error]() // https://blog.carlana.net/post/2024/golang-reflect-type-for/ // Equal compares variables a and b, recursing into their structure up to // MaxDepth levels deep (if greater than zero), and returns a list of differences, From 60f6defea3c01af4ddbb91dd83a7a4f4555aa70f Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Tue, 13 Oct 2026 12:19:03 -0400 Subject: [PATCH 7/7] Fix branch name in test matrix --- .github/workflows/go-test-matrix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go-test-matrix.yml b/.github/workflows/go-test-matrix.yml index 8c490ed..3ed7800 100644 --- a/.github/workflows/go-test-matrix.yml +++ b/.github/workflows/go-test-matrix.yml @@ -2,9 +2,9 @@ name: "Go Test Matrix" on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: test: