Skip to content

Commit 9940e4d

Browse files
Implement a much nicer diff view (#100)
* Implement a much nicer diff view * Tweak colours * Update tests * Tweak TestRender to be less fragile
1 parent b100476 commit 9940e4d

25 files changed

Lines changed: 1180 additions & 134 deletions

.golangci.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ linters:
4343
- varnamelen # Lots of false positives of things that are fine
4444
- wrapcheck # Not every error must be wrapped
4545
- wsl # Very aggressive, some of this I like but tend to do anyway
46-
- wsl_v5 # As above, just newer version
46+
- mnd # Magic numbers are fine, context makes them clear
4747

4848
exclusions:
4949
presets:
@@ -58,9 +58,6 @@ linters:
5858
- gosec # Tests don't need security stuff
5959
- goconst # Nah
6060

61-
paths:
62-
- internal/diff
63-
6461
settings:
6562
cyclop:
6663
max-complexity: 20

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
module go.followtheprocess.codes/test
22

3-
go 1.25.0
3+
go 1.26
44

55
require (
6-
go.followtheprocess.codes/hue v1.0.0
6+
go.followtheprocess.codes/hue v1.1.0
77
go.followtheprocess.codes/snapshot v0.9.1
88
golang.org/x/tools v0.43.0
99
)
1010

1111
require (
1212
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
13-
golang.org/x/sys v0.42.0 // indirect
14-
golang.org/x/term v0.41.0 // indirect
13+
golang.org/x/sys v0.43.0 // indirect
14+
golang.org/x/term v0.42.0 // indirect
1515
)

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
go.followtheprocess.codes/hue v1.0.0 h1:0fYXAGR1o+w7Vja+Q+iVtqeEP3/CE6ET/pniyl8e9yo=
2-
go.followtheprocess.codes/hue v1.0.0/go.mod h1:gSn5xK6KJapih+eFgQk3woo1qg3/rx9XSrAanUIuDr8=
1+
go.followtheprocess.codes/hue v1.1.0 h1:bPq21YLdWxQ0ki4lIvXCYtgutaGaDUYaSIENDdrrlNQ=
2+
go.followtheprocess.codes/hue v1.1.0/go.mod h1:VnCeVmYESGmX7fZJSFs59u8G+5zseCwGdFiJGHCFg4o=
33
go.followtheprocess.codes/snapshot v0.9.1 h1:q90k4ZsV4WNrJkAXo6gLqYLgE3RipnzSOXU5o5Moyts=
44
go.followtheprocess.codes/snapshot v0.9.1/go.mod h1:IKWetABJnYaG6rZAsINXAc6rB0TxoxdysBC3R2HvwWo=
55
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
66
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
7-
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
8-
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
9-
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
10-
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
7+
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
8+
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
9+
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
10+
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
1111
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
1212
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=

internal/diff/chardiff_test.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package diff_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"go.followtheprocess.codes/test/internal/diff"
8+
)
9+
10+
func TestCharDiff(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
removed []byte
14+
added []byte
15+
wantAllUnchanged bool // if true, expect all segments Changed:false (identical inputs)
16+
wantHasChanged bool // if true, expect at least one Changed:true segment on either side
17+
}{
18+
{
19+
name: "identical lines - all segments unchanged",
20+
removed: []byte("hello world\n"),
21+
added: []byte("hello world\n"),
22+
wantAllUnchanged: true,
23+
},
24+
{
25+
name: "completely different",
26+
removed: []byte("abc\n"),
27+
added: []byte("xyz\n"),
28+
wantHasChanged: true,
29+
},
30+
{
31+
name: "prefix change",
32+
removed: []byte("foobar\n"),
33+
added: []byte("bazbar\n"),
34+
wantHasChanged: true,
35+
},
36+
{
37+
name: "suffix change",
38+
removed: []byte("hello world\n"),
39+
added: []byte("hello earth\n"),
40+
wantHasChanged: true,
41+
},
42+
{
43+
name: "middle change",
44+
removed: []byte("hello world bye\n"),
45+
added: []byte("hello earth bye\n"),
46+
wantHasChanged: true,
47+
},
48+
{
49+
name: "unicode",
50+
removed: []byte("héllo wörld\n"),
51+
added: []byte("héllo earth\n"),
52+
wantHasChanged: true,
53+
},
54+
{
55+
name: "empty removed",
56+
removed: []byte(""),
57+
added: []byte("new content\n"),
58+
wantHasChanged: true,
59+
},
60+
{
61+
name: "empty added",
62+
removed: []byte("old content\n"),
63+
added: []byte(""),
64+
wantHasChanged: true,
65+
},
66+
{
67+
name: "both empty",
68+
removed: []byte(""),
69+
added: []byte(""),
70+
wantAllUnchanged: true,
71+
},
72+
{
73+
name: "trailing newline preserved",
74+
removed: []byte("line\n"),
75+
added: []byte("changed\n"),
76+
wantHasChanged: true,
77+
},
78+
}
79+
80+
for _, tt := range tests {
81+
t.Run(tt.name, func(t *testing.T) {
82+
result := diff.CharDiff(tt.removed, tt.added)
83+
84+
assertJoinInvariant(t, result, tt.removed, tt.added)
85+
86+
if tt.wantAllUnchanged {
87+
assertNoneChanged(t, result.Removed, "removed")
88+
assertNoneChanged(t, result.Added, "added")
89+
}
90+
91+
if tt.wantHasChanged {
92+
if !anyChanged(result.Removed) && !anyChanged(result.Added) {
93+
t.Error("CharDiff on differing inputs should produce at least one Changed segment")
94+
}
95+
}
96+
})
97+
}
98+
}
99+
100+
// TestCharDiffCapTriggers verifies the > 500 rune safety cap produces a single-segment fallback.
101+
func TestCharDiffCapTriggers(t *testing.T) {
102+
long := []byte(strings.Repeat("a", 501) + "\n")
103+
other := []byte(strings.Repeat("b", 501) + "\n")
104+
105+
result := diff.CharDiff(long, other)
106+
107+
if len(result.Removed) != 1 {
108+
t.Errorf("expected 1 removed segment for >500 rune input, got %d", len(result.Removed))
109+
}
110+
111+
if len(result.Added) != 1 {
112+
t.Errorf("expected 1 added segment for >500 rune input, got %d", len(result.Added))
113+
}
114+
115+
if !result.Removed[0].Changed {
116+
t.Error("expected removed fallback segment to be Changed:true")
117+
}
118+
119+
if !result.Added[0].Changed {
120+
t.Error("expected added fallback segment to be Changed:true")
121+
}
122+
}
123+
124+
// TestCharDiffNewlineNotHighlighted asserts that the trailing newline is never included in a
125+
// Changed segment. A highlighted \n causes the ANSI background colour to bleed onto the next
126+
// terminal line when rendered.
127+
func TestCharDiffNewlineNotHighlighted(t *testing.T) {
128+
tests := []struct {
129+
name string
130+
removed []byte
131+
added []byte
132+
}{
133+
{
134+
name: "suffix added",
135+
removed: []byte("hello\n"),
136+
added: []byte("hello world\n"),
137+
},
138+
{
139+
name: "inline change with trailing newline",
140+
removed: []byte("\treturn \"Hello, \" + name\n"),
141+
added: []byte("\treturn \"Hello, \" + name + \"!\"\n"),
142+
},
143+
{
144+
name: "completely different lines",
145+
removed: []byte("abc\n"),
146+
added: []byte("xyz\n"),
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
result := diff.CharDiff(tt.removed, tt.added)
153+
154+
for i, seg := range result.Removed {
155+
if seg.Changed && len(seg.Text) > 0 && seg.Text[len(seg.Text)-1] == '\n' {
156+
t.Errorf("removed segment[%d] is Changed=true but ends with \\n; highlight would bleed onto next terminal line", i)
157+
}
158+
}
159+
160+
for i, seg := range result.Added {
161+
if seg.Changed && len(seg.Text) > 0 && seg.Text[len(seg.Text)-1] == '\n' {
162+
t.Errorf("added segment[%d] is Changed=true but ends with \\n; highlight would bleed onto next terminal line", i)
163+
}
164+
}
165+
})
166+
}
167+
}
168+
169+
// TestCharDiffNotSameForDifferent verifies that differing inputs produce at least one Changed segment.
170+
func TestCharDiffNotSameForDifferent(t *testing.T) {
171+
result := diff.CharDiff([]byte("hello\n"), []byte("world\n"))
172+
173+
hasChanged := false
174+
175+
for _, seg := range result.Removed {
176+
if seg.Changed {
177+
hasChanged = true
178+
break
179+
}
180+
}
181+
182+
if !hasChanged {
183+
for _, seg := range result.Added {
184+
if seg.Changed {
185+
hasChanged = true
186+
break
187+
}
188+
}
189+
}
190+
191+
if !hasChanged {
192+
t.Error("CharDiff on different inputs should produce at least one Changed segment")
193+
}
194+
}
195+
196+
// BenchmarkCharDiff benchmarks the CharDiff function.
197+
func BenchmarkCharDiff(b *testing.B) {
198+
removed := []byte("the quick brown fox jumps over the lazy dog\n")
199+
added := []byte("the quick brown cat jumps over the lazy frog\n")
200+
201+
b.ResetTimer()
202+
203+
for b.Loop() {
204+
diff.CharDiff(removed, added)
205+
}
206+
}
207+
208+
// FuzzCharDiff verifies CharDiff never panics, terminates, and produces segments
209+
// whose concatenation equals the original input on each side.
210+
func FuzzCharDiff(f *testing.F) {
211+
f.Add([]byte("hello\n"), []byte("hello\n"))
212+
f.Add([]byte("a\n"), []byte("b\n"))
213+
f.Add([]byte("the quick brown fox\n"), []byte("the quick brown cat\n"))
214+
f.Add([]byte(strings.Repeat("a", 10)+"\n"), []byte(strings.Repeat("b", 10)+"\n"))
215+
f.Add([]byte("héllo\n"), []byte("wörld\n"))
216+
f.Add([]byte(""), []byte("content\n"))
217+
f.Add([]byte("content\n"), []byte(""))
218+
219+
f.Fuzz(func(t *testing.T, removed, added []byte) {
220+
result := diff.CharDiff(removed, added)
221+
222+
// Invariant: segments must join back to original input.
223+
if joinSegments(result.Removed) != string(removed) {
224+
t.Fatalf("removed segments join = %q, want %q", joinSegments(result.Removed), string(removed))
225+
}
226+
227+
if joinSegments(result.Added) != string(added) {
228+
t.Fatalf("added segments join = %q, want %q", joinSegments(result.Added), string(added))
229+
}
230+
231+
// Invariant: identical inputs → all segments Changed:false.
232+
if string(removed) == string(added) {
233+
for i, seg := range result.Removed {
234+
if seg.Changed {
235+
t.Fatalf("removed segment[%d] Changed=true for identical inputs", i)
236+
}
237+
}
238+
239+
for i, seg := range result.Added {
240+
if seg.Changed {
241+
t.Fatalf("added segment[%d] Changed=true for identical inputs", i)
242+
}
243+
}
244+
}
245+
})
246+
}
247+
248+
func assertJoinInvariant(t *testing.T, result diff.InlineChange, removed, added []byte) {
249+
t.Helper()
250+
251+
if got := joinSegments(result.Removed); got != string(removed) {
252+
t.Errorf("removed segments join = %q, want %q", got, string(removed))
253+
}
254+
255+
if got := joinSegments(result.Added); got != string(added) {
256+
t.Errorf("added segments join = %q, want %q", got, string(added))
257+
}
258+
}
259+
260+
func assertNoneChanged(t *testing.T, segs []diff.Segment, side string) {
261+
t.Helper()
262+
263+
for i, seg := range segs {
264+
if seg.Changed {
265+
t.Errorf("%s segment[%d] Changed=true, want false for identical inputs", side, i)
266+
}
267+
}
268+
}
269+
270+
func anyChanged(segs []diff.Segment) bool {
271+
for _, s := range segs {
272+
if s.Changed {
273+
return true
274+
}
275+
}
276+
277+
return false
278+
}
279+
280+
func joinSegments(segs []diff.Segment) string {
281+
var sb strings.Builder
282+
for _, s := range segs {
283+
sb.Write(s.Text)
284+
}
285+
286+
return sb.String()
287+
}

0 commit comments

Comments
 (0)