Skip to content

Commit a907909

Browse files
roberthoenigjosephburnett
authored andcommitted
Color string diffs on a character level.
1 parent 35fea25 commit a907909

2 files changed

Lines changed: 198 additions & 15 deletions

File tree

v2/diff_write.go

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77

8+
lcs "github.com/yudai/golcs"
89
"golang.org/x/exp/slices"
910
)
1011

@@ -14,6 +15,25 @@ const (
1415
colorGreen = "\033[32m"
1516
)
1617

18+
// colorStringDiff returns a colored string diff where characters not in the common sequence
19+
// are colored with the provided color code
20+
func colorStringDiff(str string, commonSequence []interface{}, colorCode string) string {
21+
var b bytes.Buffer
22+
runes := []rune(str)
23+
lcsIndex := 0
24+
for i := 0; i < len(runes); i++ {
25+
if lcsIndex < len(commonSequence) && runes[i] == commonSequence[lcsIndex].(rune) {
26+
b.WriteRune(runes[i])
27+
lcsIndex++
28+
} else {
29+
b.WriteString(colorCode)
30+
b.WriteRune(runes[i])
31+
b.WriteString(colorDefault)
32+
}
33+
}
34+
return b.String()
35+
}
36+
1737
func (d DiffElement) Render(opts ...Option) string {
1838
isColor := checkOption[colorOption](opts)
1939
isMerge := checkOption[mergeOption](opts) || d.Metadata.Merge
@@ -22,6 +42,28 @@ func (d DiffElement) Render(opts ...Option) string {
2242
b.WriteString("@ ")
2343
b.Write([]byte(d.Path.JsonNode().Json()))
2444
b.WriteString("\n")
45+
46+
// Check if this is a single string diff. If so, compute the common sequence for a character
47+
// level diff.
48+
var commonSequence []interface{}
49+
isSingleStringDiff := false
50+
if len(d.Remove) == 1 && len(d.Add) == 1 {
51+
oldStr, oldOk := d.Remove[0].(jsonString)
52+
newStr, newOk := d.Add[0].(jsonString)
53+
if oldOk && newOk {
54+
oldChars := make([]interface{}, len(string(oldStr)))
55+
for i, c := range string(oldStr) {
56+
oldChars[i] = c
57+
}
58+
newChars := make([]interface{}, len(string(newStr)))
59+
for i, c := range string(newStr) {
60+
newChars[i] = c
61+
}
62+
commonSequence = lcs.New(oldChars, newChars).Values()
63+
isSingleStringDiff = true
64+
}
65+
}
66+
2567
for _, before := range d.Before {
2668
if isVoid(before) {
2769
b.WriteString("[\n")
@@ -36,40 +78,63 @@ func (d DiffElement) Render(opts ...Option) string {
3678
}
3779
}
3880
for _, oldValue := range d.Remove {
39-
if isColor {
40-
b.WriteString(colorRed)
81+
if isVoid(oldValue) {
82+
continue
4183
}
42-
if !isVoid(oldValue) {
84+
if isSingleStringDiff && isColor {
85+
oldStr := string(oldValue.(jsonString))
86+
b.WriteString("- \"")
87+
b.WriteString(colorStringDiff(oldStr, commonSequence, colorRed))
88+
b.WriteString("\"\n")
89+
} else {
90+
if isColor {
91+
b.WriteString(colorRed)
92+
}
4393
oldValueJson, err := json.Marshal(oldValue)
4494
if err != nil {
4595
panic(err)
4696
}
4797
b.WriteString("- ")
4898
b.Write(oldValueJson)
4999
b.WriteString("\n")
50-
}
51-
if isColor {
52-
b.WriteString(colorDefault)
100+
if isColor {
101+
b.WriteString(colorDefault)
102+
}
53103
}
54104
}
55105
for _, newValue := range d.Add {
56-
if isColor {
57-
b.WriteString(colorGreen)
106+
if isVoid(newValue) {
107+
if isMerge {
108+
// Merge deletion is writing void to a node.
109+
if isColor {
110+
b.WriteString(colorGreen)
111+
}
112+
b.WriteString("+\n")
113+
if isColor {
114+
b.WriteString(colorDefault)
115+
}
116+
}
117+
continue
58118
}
59-
if !isVoid(newValue) {
119+
if isSingleStringDiff && isColor {
120+
newStr := string(newValue.(jsonString))
121+
b.WriteString("+ \"")
122+
b.WriteString(colorStringDiff(newStr, commonSequence, colorGreen))
123+
b.WriteString("\"\n")
124+
} else {
125+
if isColor {
126+
b.WriteString(colorGreen)
127+
}
60128
newValueJson, err := json.Marshal(newValue)
61129
if err != nil {
62130
panic(err)
63131
}
64132
b.WriteString("+ ")
65133
b.Write(newValueJson)
66134
b.WriteString("\n")
67-
} else if isMerge {
68-
// Merge deletion is writing void to a node.
69-
b.WriteString("+\n")
70-
}
71-
if isColor {
72-
b.WriteString(colorDefault)
135+
if isColor {
136+
b.WriteString(colorDefault)
137+
}
73138
}
74139
}
75140
for _, after := range d.After {

v2/diff_write_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package jd
22

33
import (
4+
"strings"
45
"testing"
56
)
67

@@ -23,6 +24,48 @@ func TestDiffRender(t *testing.T) {
2324
`- {"b":1}`,
2425
`@ ["c"]`,
2526
`+ {"b":1}`)
27+
// String changes
28+
checkDiffRender(t, `{"a":"bar"}`, `{"a":"baz"}`,
29+
`@ ["a"]`,
30+
`- "bar"`,
31+
`+ "baz"`)
32+
// Array of strings
33+
checkDiffRender(t, `{"qux":["foobar","foobaz"]}`, `{"qux":["fooarrr","foobaz"]}`,
34+
`@ ["qux",0]`,
35+
`[`,
36+
`- "foobar"`,
37+
`+ "fooarrr"`,
38+
` "foobaz"`,
39+
)
40+
// Addition only
41+
checkDiffRender(t, `{"str":""}`, `{"str":"abc"}`,
42+
`@ ["str"]`,
43+
`- ""`,
44+
`+ "abc"`)
45+
// Removal only
46+
checkDiffRender(t, `{"str":"abc"}`, `{"str":""}`,
47+
`@ ["str"]`,
48+
`- "abc"`,
49+
`+ ""`)
50+
// Nested strings
51+
checkDiffRender(t, `{"a":{"b":"hello"}}`, `{"a":{"b":"world"}}`,
52+
`@ ["a","b"]`,
53+
`- "hello"`,
54+
`+ "world"`)
55+
// Multiple string changes
56+
checkDiffRender(t, `{"a":"foo","b":"bar"}`, `{"a":"baz","b":"qux"}`,
57+
`@ ["a"]`,
58+
`- "foo"`,
59+
`+ "baz"`,
60+
`@ ["b"]`,
61+
`- "bar"`,
62+
`+ "qux"`)
63+
// Key change
64+
checkDiffRender(t, `{"a":"foo"}`, `{"b":"foo"}`,
65+
`@ ["a"]`,
66+
`- "foo"`,
67+
`@ ["b"]`,
68+
`+ "foo"`)
2669
}
2770

2871
func checkDiffRender(t *testing.T, a, b string, diffLines ...string) {
@@ -38,10 +81,85 @@ func checkDiffRender(t *testing.T, a, b string, diffLines ...string) {
3881
if err != nil {
3982
t.Errorf("%v", err.Error())
4083
}
84+
85+
// Test without color
4186
d := aJson.diff(bJson, nil, []Option{}, strictPatchStrategy).Render()
4287
if d != diff {
4388
t.Errorf("%v.diff(%v) = %v. Want %v.", a, b, d, diff)
4489
}
90+
91+
// Test with color
92+
coloredDiff := aJson.diff(bJson, nil, []Option{}, strictPatchStrategy).Render(COLOR)
93+
strippedDiff := stripAnsiCodes(coloredDiff)
94+
if strippedDiff != diff {
95+
t.Errorf("%v.diff(%v) with color (stripped) = %v. Want %v.", a, b, strippedDiff, diff)
96+
}
97+
98+
// Verify that uncolored parts in string diffs match between + and - lines
99+
lines := strings.Split(coloredDiff, "\n")
100+
var minusLine, plusLine string
101+
for i, line := range lines {
102+
if len(line) == 0 {
103+
continue
104+
}
105+
if line[0] == '-' && strings.Contains(line, "\"") { // Only check string diffs
106+
minusLine = line
107+
if i+1 < len(lines) && len(lines[i+1]) > 0 && lines[i+1][0] == '+' {
108+
plusLine = lines[i+1]
109+
minusUncolored := removeColoredParts(minusLine[1:]) // Skip the "- " prefix
110+
plusUncolored := removeColoredParts(plusLine[1:]) // Skip the "+ " prefix
111+
if minusUncolored != plusUncolored {
112+
t.Errorf("Uncolored parts don't match:\n- %s\n+ %s", minusUncolored, plusUncolored)
113+
}
114+
}
115+
}
116+
}
117+
}
118+
119+
// removeColoredParts returns the string with the colored parts (including the text between color codes) removed
120+
func removeColoredParts(s string) string {
121+
result := ""
122+
inColor := false
123+
for i := 0; i < len(s); i++ {
124+
// detect a color code (starts coloring)
125+
if !inColor && i+1 < len(s) && s[i] == '\033' && s[i+1] == '[' {
126+
inColor = true
127+
i++ // skip '['
128+
continue
129+
}
130+
// if not colored, add the character to the result
131+
if !inColor {
132+
result += string(s[i])
133+
}
134+
// detect the reset color code (ends coloring)
135+
if inColor && i+1 < len(s) && s[i] == '[' && s[i+1] == '0' && i+2 < len(s) && s[i+2] == 'm' {
136+
inColor = false
137+
i += 2
138+
}
139+
}
140+
return result
141+
}
142+
143+
// stripAnsiCodes removes ANSI color escape sequences from a string
144+
func stripAnsiCodes(s string) string {
145+
result := ""
146+
inEscape := false
147+
148+
for i := 0; i < len(s); i++ {
149+
if !inEscape && i+1 < len(s) && s[i] == '\033' && s[i+1] == '[' {
150+
inEscape = true
151+
i++ // skip the '['
152+
continue
153+
}
154+
if inEscape {
155+
if s[i] == 'm' {
156+
inEscape = false
157+
}
158+
continue
159+
}
160+
result += string(s[i])
161+
}
162+
return result
45163
}
46164

47165
func TestDiffRenderPatch(t *testing.T) {

0 commit comments

Comments
 (0)