Skip to content

Commit fc8005e

Browse files
author
Tom Fleet
authored
Copy over the initial functionality from FollowTheProcess/test (#2)
## Summary <!-- Describe your changes in detail here, if it closes an open issue, include "Closes #<issue>" --> I had a branch with the very basics of this in `FollowTheProcess/test` before I decided it's actually better as it's own package
1 parent 518717a commit fc8005e

23 files changed

Lines changed: 982 additions & 15 deletions

Taskfile.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ tasks:
99
default:
1010
desc: List all available tasks
1111
silent: true
12-
cmds:
12+
cmds:
1313
- task --list
1414

1515
tidy:
@@ -36,22 +36,28 @@ tasks:
3636
desc: Run the test suite
3737
sources:
3838
- "**/*.go"
39-
cmds:
39+
- testdata/**/*
40+
- go.mod
41+
- go.sum
42+
cmds:
4043
- go test -race ./... {{ .CLI_ARGS }}
4144

4245
bench:
4346
desc: Run all project benchmarks
4447
sources:
4548
- "**/*.go"
46-
cmds:
49+
- testdata/**/*
50+
- go.mod
51+
- go.sum
52+
cmds:
4753
- go test ./... -run None -benchmem -bench . {{ .CLI_ARGS }}
4854

4955
lint:
5056
desc: Run the linters and auto-fix if possible
5157
sources:
5258
- "**/*.go"
5359
- .golangci.yml
54-
cmds:
60+
cmds:
5561
- golangci-lint run --fix
5662
preconditions:
5763
- sh: command -v golangci-lint
@@ -81,7 +87,7 @@ tasks:
8187

8288
sloc:
8389
desc: Print lines of code
84-
cmds:
90+
cmds:
8591
- fd . -e go | xargs wc -l | sort -nr | head
8692

8793
clean:

go.mod

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
module github.com/FollowTheProcess/snapshot
22

3-
go 1.23
3+
go 1.23
4+
5+
require (
6+
github.com/FollowTheProcess/test v0.17.1
7+
golang.org/x/tools v0.27.0
8+
)
9+
10+
require github.com/google/go-cmp v0.6.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
github.com/FollowTheProcess/test v0.17.1 h1:j4TkMqzxvYoyAP9alaTNPgKOPUJHOBCs0z4fNJb7Kr0=
2+
github.com/FollowTheProcess/test v0.17.1/go.mod h1:LlRdAk8bwBZ5kP10xHOcOTknNUrHU347IH7RgAm2Dgs=
3+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5+
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
6+
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=

internal/colour/colour.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Package colour implements basic text colouring for showing text diffs.
2+
//
3+
// I'm really putting "a little copying is better than a little dependency" into action here
4+
// this is from FollowTheProcess/test.
5+
package colour
6+
7+
// ANSI codes for coloured output, they are all the same length so as not to throw off
8+
// alignment of [text/tabwriter].
9+
const (
10+
codeRed = "\x1b[0;0031m" // Red, used for diff lines starting with '-'
11+
codeHeader = "\x1b[1;0036m" // Bold cyan, used for diff headers starting with '@@'
12+
codeGreen = "\x1b[0;0032m" // Green, used for diff lines starting with '+'
13+
codeReset = "\x1b[000000m" // Reset all attributes
14+
)
15+
16+
// Header returns a diff header styled string.
17+
func Header(text string) string {
18+
return sprint(codeHeader, text)
19+
}
20+
21+
// Green returns a green styled string.
22+
func Green(text string) string {
23+
return sprint(codeGreen, text)
24+
}
25+
26+
// Red returns a red styled string.
27+
func Red(text string) string {
28+
return sprint(codeRed, text)
29+
}
30+
31+
// sprint returns a string with a given colour and the reset code.
32+
//
33+
// It handles checking for NO_COLOR and FORCE_COLOR.
34+
func sprint(code, text string) string {
35+
return code + text + codeReset
36+
}

internal/diff/diff.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Taken from Go's internal/diff with only very minor tweaks; those being:
2+
// - Adding an extra space between the diff character (+/-) and the line so we can easily colour it
3+
// - Lint ignores
4+
// - Renaming diff test package to diff_test
5+
//
6+
// Copyright 2022 The Go Authors. All rights reserved.
7+
// Use of this source code is governed by a BSD-style
8+
// license that can be found in the LICENSE file.
9+
10+
package diff
11+
12+
import (
13+
"bytes"
14+
"fmt"
15+
"sort"
16+
"strings"
17+
)
18+
19+
// A pair is a pair of values tracked for both the x and y side of a diff.
20+
// It is typically a pair of line indexes.
21+
type pair struct{ x, y int }
22+
23+
// Diff returns an anchored diff of the two texts old and new
24+
// in the “unified diff” format. If old and new are identical,
25+
// Diff returns a nil slice (no output).
26+
//
27+
// Unix diff implementations typically look for a diff with
28+
// the smallest number of lines inserted and removed,
29+
// which can in the worst case take time quadratic in the
30+
// number of lines in the texts. As a result, many implementations
31+
// either can be made to run for a long time or cut off the search
32+
// after a predetermined amount of work.
33+
//
34+
// In contrast, this implementation looks for a diff with the
35+
// smallest number of “unique” lines inserted and removed,
36+
// where unique means a line that appears just once in both old and new.
37+
// We call this an “anchored diff” because the unique lines anchor
38+
// the chosen matching regions. An anchored diff is usually clearer
39+
// than a standard diff, because the algorithm does not try to
40+
// reuse unrelated blank lines or closing braces.
41+
// The algorithm also guarantees to run in O(n log n) time
42+
// instead of the standard O(n²) time.
43+
//
44+
// Some systems call this approach a “patience diff,” named for
45+
// the “patience sorting” algorithm, itself named for a solitaire card game.
46+
// We avoid that name for two reasons. First, the name has been used
47+
// for a few different variants of the algorithm, so it is imprecise.
48+
// Second, the name is frequently interpreted as meaning that you have
49+
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
50+
// when in fact the algorithm is faster than the standard one.
51+
func Diff( //nolint: gocyclo
52+
oldName string,
53+
old []byte,
54+
newName string,
55+
new []byte, //nolint: predeclared
56+
) []byte {
57+
if bytes.Equal(old, new) {
58+
return nil
59+
}
60+
x := lines(old)
61+
y := lines(new)
62+
63+
// Print diff header.
64+
var out bytes.Buffer
65+
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
66+
fmt.Fprintf(&out, "--- %s\n", oldName)
67+
fmt.Fprintf(&out, "+++ %s\n", newName)
68+
69+
// Loop over matches to consider,
70+
// expanding each match to include surrounding lines,
71+
// and then printing diff chunks.
72+
// To avoid setup/teardown cases outside the loop,
73+
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
74+
// in the sequence of matches.
75+
var (
76+
done pair // printed up to x[:done.x] and y[:done.y]
77+
chunk pair // start lines of current chunk
78+
count pair // number of lines from each side in current chunk
79+
ctext []string // lines for current chunk
80+
)
81+
for _, m := range tgs(x, y) {
82+
if m.x < done.x {
83+
// Already handled scanning forward from earlier match.
84+
continue
85+
}
86+
87+
// Expand matching lines as far possible,
88+
// establishing that x[start.x:end.x] == y[start.y:end.y].
89+
// Note that on the first (or last) iteration we may (or definitey do)
90+
// have an empty match: start.x==end.x and start.y==end.y.
91+
start := m
92+
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
93+
start.x--
94+
start.y--
95+
}
96+
end := m
97+
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
98+
end.x++
99+
end.y++
100+
}
101+
102+
// Emit the mismatched lines before start into this chunk.
103+
// (No effect on first sentinel iteration, when start = {0,0}.)
104+
for _, s := range x[done.x:start.x] {
105+
ctext = append(ctext, "- "+s)
106+
count.x++
107+
}
108+
for _, s := range y[done.y:start.y] {
109+
ctext = append(ctext, "+ "+s)
110+
count.y++
111+
}
112+
113+
// If we're not at EOF and have too few common lines,
114+
// the chunk includes all the common lines and continues.
115+
const C = 3 // number of context lines
116+
if (end.x < len(x) || end.y < len(y)) &&
117+
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
118+
for _, s := range x[start.x:end.x] {
119+
ctext = append(ctext, " "+s)
120+
count.x++
121+
count.y++
122+
}
123+
done = end
124+
continue
125+
}
126+
127+
// End chunk with common lines for context.
128+
if len(ctext) > 0 {
129+
n := end.x - start.x
130+
if n > C {
131+
n = C
132+
}
133+
for _, s := range x[start.x : start.x+n] {
134+
ctext = append(ctext, " "+s)
135+
count.x++
136+
count.y++
137+
}
138+
done = pair{start.x + n, start.y + n}
139+
140+
// Format and emit chunk.
141+
// Convert line numbers to 1-indexed.
142+
// Special case: empty file shows up as 0,0 not 1,0.
143+
if count.x > 0 {
144+
chunk.x++
145+
}
146+
if count.y > 0 {
147+
chunk.y++
148+
}
149+
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
150+
for _, s := range ctext {
151+
out.WriteString(s)
152+
}
153+
count.x = 0
154+
count.y = 0
155+
ctext = ctext[:0]
156+
}
157+
158+
// If we reached EOF, we're done.
159+
if end.x >= len(x) && end.y >= len(y) {
160+
break
161+
}
162+
163+
// Otherwise start a new chunk.
164+
chunk = pair{end.x - C, end.y - C}
165+
for _, s := range x[chunk.x:end.x] {
166+
ctext = append(ctext, " "+s)
167+
count.x++
168+
count.y++
169+
}
170+
done = end
171+
}
172+
173+
return out.Bytes()
174+
}
175+
176+
// lines returns the lines in the file x, including newlines.
177+
// If the file does not end in a newline, one is supplied
178+
// along with a warning about the missing newline.
179+
func lines(x []byte) []string {
180+
l := strings.SplitAfter(string(x), "\n")
181+
if l[len(l)-1] == "" {
182+
l = l[:len(l)-1]
183+
} else {
184+
// Treat last line as having a message about the missing newline attached,
185+
// using the same text as BSD/GNU diff (including the leading backslash).
186+
l[len(l)-1] += "\n\\ No newline at end of file\n"
187+
}
188+
return l
189+
}
190+
191+
// tgs returns the pairs of indexes of the longest common subsequence
192+
// of unique lines in x and y, where a unique line is one that appears
193+
// once in x and once in y.
194+
//
195+
// The longest common subsequence algorithm is as described in
196+
// Thomas G. Szymanski, “A Special Case of the Maximal Common
197+
// Subsequence Problem,” Princeton TR #170 (January 1975),
198+
// available at https://research.swtch.com/tgs170.pdf.
199+
func tgs(x, y []string) []pair {
200+
// Count the number of times each string appears in a and b.
201+
// We only care about 0, 1, many, counted as 0, -1, -2
202+
// for the x side and 0, -4, -8 for the y side.
203+
// Using negative numbers now lets us distinguish positive line numbers later.
204+
m := make(map[string]int)
205+
for _, s := range x {
206+
if c := m[s]; c > -2 {
207+
m[s] = c - 1
208+
}
209+
}
210+
for _, s := range y {
211+
if c := m[s]; c > -8 {
212+
m[s] = c - 4 //nolint: mnd
213+
}
214+
}
215+
216+
// Now unique strings can be identified by m[s] = -1+-4.
217+
//
218+
// Gather the indexes of those strings in x and y, building:
219+
// xi[i] = increasing indexes of unique strings in x.
220+
// yi[i] = increasing indexes of unique strings in y.
221+
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
222+
var xi, yi, inv []int
223+
for i, s := range y {
224+
if m[s] == -1+-4 {
225+
m[s] = len(yi)
226+
yi = append(yi, i)
227+
}
228+
}
229+
for i, s := range x {
230+
if j, ok := m[s]; ok && j >= 0 {
231+
xi = append(xi, i)
232+
inv = append(inv, j)
233+
}
234+
}
235+
236+
// Apply Algorithm A from Szymanski's paper.
237+
// In those terms, A = J = inv and B = [0, n).
238+
// We add sentinel pairs {0,0}, and {len(x),len(y)}
239+
// to the returned sequence, to help the processing loop.
240+
J := inv
241+
n := len(xi)
242+
T := make([]int, n)
243+
L := make([]int, n)
244+
for i := range T {
245+
T[i] = n + 1
246+
}
247+
for i := 0; i < n; i++ {
248+
k := sort.Search(n, func(k int) bool {
249+
return T[k] >= J[i]
250+
})
251+
T[k] = J[i]
252+
L[i] = k + 1
253+
}
254+
k := 0
255+
for _, v := range L {
256+
if k < v {
257+
k = v
258+
}
259+
}
260+
seq := make([]pair, 2+k) //nolint:mnd
261+
seq[1+k] = pair{len(x), len(y)} // sentinel at end
262+
lastj := n
263+
for i := n - 1; i >= 0; i-- {
264+
if L[i] == k && J[i] < lastj {
265+
seq[k] = pair{xi[i], yi[J[i]]}
266+
k--
267+
}
268+
}
269+
seq[0] = pair{0, 0} // sentinel at start
270+
return seq
271+
}

0 commit comments

Comments
 (0)