Skip to content

Commit 5fa5974

Browse files
committed
Add color support for multi-line diffs
1 parent f644bac commit 5fa5974

File tree

6 files changed

+160
-13
lines changed

6 files changed

+160
-13
lines changed

be/assertions.go

+5-13
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import (
66
"strconv"
77
"strings"
88

9-
"github.com/google/go-cmp/cmp"
10-
119
"github.com/rliebz/ghost"
1210
"github.com/rliebz/ghost/ghostlib"
1311
"github.com/rliebz/ghost/internal/constraints"
@@ -114,14 +112,10 @@ func DeepEqual[T any](got, want T) ghost.Result {
114112
args := ghostlib.ArgsFromAST(got, want)
115113
argGot, argWant := args[0], args[1]
116114

117-
if diff := cmp.Diff(
118-
want, got,
119-
cmp.Exporter(func(reflect.Type) bool { return true }),
120-
); diff != "" {
115+
if diff := colorDiff(want, got); diff != "" {
121116
return ghost.Result{
122117
Ok: false,
123118
Message: fmt.Sprintf(`%v != %v
124-
diff (-want +got):
125119
%v`, argGot, argWant, diff),
126120
}
127121
}
@@ -169,8 +163,7 @@ value: %v
169163
return ghost.Result{
170164
Ok: false,
171165
Message: fmt.Sprintf(`%v != %v
172-
diff (-want +got):
173-
%v`, argGot, argWant, cmp.Diff(want, got)),
166+
%v`, argGot, argWant, colorDiff(want, got)),
174167
}
175168
case reflect.String:
176169
if strings.ContainsAny(v.String(), "\n\r") ||
@@ -179,8 +172,7 @@ diff (-want +got):
179172
return ghost.Result{
180173
Ok: false,
181174
Message: fmt.Sprintf(`%v != %v
182-
diff (-want +got):
183-
%v`, argGot, argWant, cmp.Diff(want, got)),
175+
%v`, argGot, argWant, colorDiff(want, got)),
184176
}
185177
}
186178

@@ -235,7 +227,7 @@ func JSONEqual[T ~string | ~[]byte](got, want T) ghost.Result {
235227
args := ghostlib.ArgsFromAST(got, want)
236228
argGot, argWant := args[0], args[1]
237229

238-
diff, kind := jsondiff.Diff(got, want)
230+
diff, kind := colorJSONDiff(got, want)
239231

240232
switch kind {
241233
case jsondiff.Match:
@@ -270,7 +262,7 @@ want:
270262
return ghost.Result{
271263
Ok: false,
272264
Message: fmt.Sprintf(`%v and %v are not JSON equal
273-
diff: %s`, argGot, argWant, diff),
265+
%s`, argGot, argWant, diff),
274266
}
275267
}
276268

be/diff.go

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package be
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
8+
"github.com/google/go-cmp/cmp"
9+
10+
"github.com/rliebz/ghost/internal/color"
11+
"github.com/rliebz/ghost/internal/jsondiff"
12+
)
13+
14+
var exportTypes = cmp.Exporter(func(reflect.Type) bool { return true })
15+
16+
func colorDiff[T any](x, y T, opts ...cmp.Option) string {
17+
diff := cmp.Diff(x, y, append(opts, exportTypes)...)
18+
return applyColors(diff)
19+
}
20+
21+
func colorJSONDiff[T ~string | ~[]byte](got, want T) (string, jsondiff.Kind) {
22+
diff, kind := jsondiff.Diff(got, want)
23+
return applyColors(diff), kind
24+
}
25+
26+
func applyColors(diff string) string {
27+
if diff == "" {
28+
return ""
29+
}
30+
31+
ss := strings.Split(diff, "\n")
32+
for i, s := range ss {
33+
switch {
34+
case strings.HasPrefix(s, "-"):
35+
ss[i] = color.Red(s)
36+
case strings.HasPrefix(s, "+"):
37+
ss[i] = color.Green(s)
38+
// Only color the first character, since we expect inline red/green
39+
case strings.HasPrefix(s, "~"):
40+
ss[i] = color.Yellow("~") + s[1:]
41+
}
42+
}
43+
44+
return fmt.Sprintf(
45+
`diff (%s %s):
46+
%v`,
47+
color.Red("-want"),
48+
color.Green("+got"),
49+
strings.Join(ss, "\n"),
50+
)
51+
}

be/main_test.go

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package be
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
// Avoid dealing with ANSI escape sequences.
9+
func TestMain(m *testing.M) {
10+
if err := os.Setenv("NO_COLOR", "1"); err != nil {
11+
panic(err)
12+
}
13+
os.Exit(m.Run())
14+
}

internal/color/color.go

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Package color exports utilities for applying color to terminal outputs.
2+
package color
3+
4+
import (
5+
"os"
6+
"regexp"
7+
"runtime"
8+
"sync"
9+
)
10+
11+
const (
12+
// ANSIReset is the ANSI escape sequence for reset.
13+
ANSIReset = "\033[0m"
14+
// ANSIRed is the ANSI escape sequence for red.
15+
ANSIRed = "\033[31m"
16+
// ANSIGreen is the ANSI escape sequence for green.
17+
ANSIGreen = "\033[32m"
18+
// ANSIYellow is the ANSI escape sequence for yellow.
19+
ANSIYellow = "\033[33m"
20+
)
21+
22+
// Red colors the string red.
23+
func Red(s string) string {
24+
return apply(ANSIRed, s)
25+
}
26+
27+
// Green colors the string green.
28+
func Green(s string) string {
29+
return apply(ANSIGreen, s)
30+
}
31+
32+
// Yellow colors the string yellow
33+
func Yellow(s string) string {
34+
return apply(ANSIYellow, s)
35+
}
36+
37+
var (
38+
// reReset captures any reset sequence that is followed by at least one
39+
// character. The extra character check prevents us from writing escape
40+
// squences at the end of lines.
41+
reReset = regexp.MustCompile(`(\033\[0m)(.)`)
42+
// reANSI identifies any non-reset ANSI escape sequence.
43+
reANSI = regexp.MustCompile(`\033\[[1-9][\d;]*m`)
44+
)
45+
46+
func apply(color string, s string) string {
47+
if !Enabled() {
48+
return s
49+
}
50+
51+
// Preserve existing colors by resetting before ANSI escape sequences
52+
// and re-applying after the reset sequence.
53+
s = reReset.ReplaceAllString(s, "$1"+color+"$2")
54+
s = reANSI.ReplaceAllString(s, ANSIReset+"$0")
55+
return color + s + ANSIReset
56+
}
57+
58+
// Enabled returns whether colors are enabled.
59+
var Enabled = sync.OnceValue(func() bool {
60+
if runtime.GOOS == "windows" {
61+
return false
62+
}
63+
64+
_, ok := os.LookupEnv("NO_COLOR")
65+
return !ok
66+
})

internal/jsondiff/jsondiff.go

+15
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"sort"
1010
"strconv"
1111
"strings"
12+
13+
"github.com/rliebz/ghost/internal/color"
1214
)
1315

1416
// Kind describes the result of the diff categorically.
@@ -327,12 +329,25 @@ func (d *differ) writeMismatch(got, want any) {
327329
d.buf.Write(data)
328330
}
329331

332+
d.writeANSI(color.ANSIRed)
330333
d.writeValueInline(want)
334+
d.writeANSI(color.ANSIReset)
335+
331336
d.buf.WriteString(" => ")
337+
338+
d.writeANSI(color.ANSIGreen)
332339
d.writeValueInline(got)
340+
d.writeANSI(color.ANSIReset)
341+
333342
d.failMatch()
334343
}
335344

345+
func (d *differ) writeANSI(sequence string) {
346+
if color.Enabled() {
347+
d.buf.WriteString(sequence)
348+
}
349+
}
350+
336351
// TODO: I would rather have the last element of EACH collection handle the
337352
// middle commas correctly. Probably a hefty refactor, can save it for later.
338353
func (d *differ) writeElementSeparator(first bool) {

internal/jsondiff/jsondiff_test.go

+9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
package jsondiff_test
22

33
import (
4+
"os"
45
"testing"
56

67
"github.com/rliebz/ghost"
78
"github.com/rliebz/ghost/be"
89
"github.com/rliebz/ghost/internal/jsondiff"
910
)
1011

12+
// Avoid dealing with ANSI escape sequences in tests.
13+
func TestMain(m *testing.M) {
14+
if err := os.Setenv("NO_COLOR", "1"); err != nil {
15+
panic(err)
16+
}
17+
os.Exit(m.Run())
18+
}
19+
1120
func TestDiff(t *testing.T) {
1221
tests := []struct {
1322
name string

0 commit comments

Comments
 (0)