Skip to content

Commit 67e89b1

Browse files
authored
feat: truncate string values and add ellipsis (#24)
* feat: truncate string values and add ellipsis Signed-off-by: Ayush Sharma <kshitij3160@gmail.com> * fix: added truncation with plain text responses also Signed-off-by: Ayush Sharma <kshitij3160@gmail.com> * chore: addressed copilot comments Signed-off-by: Ayush Sharma <kshitij3160@gmail.com> * fix: unit tests Signed-off-by: Ayush Sharma <kshitij3160@gmail.com> --------- Signed-off-by: Ayush Sharma <kshitij3160@gmail.com>
1 parent 7b7a28f commit 67e89b1

File tree

2 files changed

+158
-21
lines changed

2 files changed

+158
-21
lines changed

jsonDiff_test.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -428,11 +428,13 @@ func TestSprintJSONDiff(t *testing.T) {
428428
"49bec237abb42a872e82edee006cb72e8270b4c14179140ce03ebc47ad36fa2d",
429429
"3cd84203cf23bffc56c12344c2b2fbf313c1e4ed34f125ea813a50b42adca1d9",
430430
"825c067292a1b5ce6ce1724c52fa2068bfb651a35273a65351be3e63d8614df1",
431+
"3bf543c95d44ce58e0005887bd44c4ab27d00a0bc1355fd23db30f5ba659a0d1",
431432
},
432433
expectedStringB: []string{
433434
"f3603f2c454c9d81d8cc19296af4e4aff906d102263beea5af3892c223d0ef29",
434435
"c25b5b827481d888a7a5551ee05d6ea4590d59d2674fb5182394f13c3adca29a",
435436
"d2f1d7f7dcea6764caeab964e34a99e936715959cc066ebd77822bb5daa80316",
437+
"beaa58fc7489cf3e180eca7d4ddd2fb5cacaf740df8ecc0b5f822b8a4b3d0c9d",
436438
},
437439
json1: "{\"longKey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"nested\":{\"key1\":{\"subkey1\":\"value1\"},\"key2\":{\"subkey2\":\"value2\"}}}",
438440
json2: "{\"longKey\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"nested\":{\"key1\":{\"subkey1\":\"value1\"},\"key2\":{\"subkey2\":\"value3\"}}}",
@@ -462,10 +464,10 @@ func TestSprintJSONDiff(t *testing.T) {
462464
},
463465
{
464466
expectedStringA: []string{
465-
"c9a069dd6d3aaf5052219992d43c57f00d66881849a0004df66ca44d4bcab74d",
467+
"9cac2220ac73f8abd3a13d9fbc4249bd481f0188590a26e87f87d354078c9dfa",
466468
},
467469
expectedStringB: []string{
468-
"b6a5cc8d3ef65269d1c729f769d2e64fb501e545b2d2f3306d0359211220b242",
470+
"ea18ec7619618dd08777bf00afd74100cf0ae6ea4f914bd26417621b9419a3b8",
469471
},
470472
json1: "{\"paragraph\":\"This is a long paragraph with many words. The quick brown fox jumps over the lazy dog. A random word will change in the middle of this sentence.\"}",
471473
json2: "{\"paragraph\":\"This is a long paragraph with many words. The quick brown fox jumps over the lazy dog. A random word will change in the middle of this phrase.\"}",
@@ -484,10 +486,10 @@ func TestSprintJSONDiff(t *testing.T) {
484486
},
485487
{
486488
expectedStringA: []string{
487-
"2882d548db56674bf1d45dc423178c66c7dbcc3f7e72d5c2edca479cda04180c",
489+
"56a9a0b500759fdb334d3977aac390d18c11fb8e5e10d269ff8f65e034604ea7",
488490
},
489491
expectedStringB: []string{
490-
"9fe5ef3cbbc2103ec21ddf9497a5edba97e546d27f08cad456f208352bf5bc8d",
492+
"546b08086b296dc7df590f37e5afb965ed58c129fcc09800bca7416a41515e34",
491493
},
492494
json1: "{\"longKey\":\"This is a long key with many words and a subtle change at the end of this sentence.\"}",
493495
json2: "{\"longKey\":\"This is a long key with many words and a subtle change at the end of this phrase.\"}",
@@ -506,10 +508,10 @@ func TestSprintJSONDiff(t *testing.T) {
506508
},
507509
{
508510
expectedStringA: []string{
509-
"563c5a6b903195cf1e4d408c265edd57ca2f97d818db6ea0b688f30d7b642128",
511+
"d190663b3f566e3fc02a1fb599851f1debda96ebf0fdc14e111e52149a99cf25",
510512
},
511513
expectedStringB: []string{
512-
"2e340d7201d7bfbcf9dd8181407fbcbccf993b9b9150bc91c824a17421fb5087",
514+
"b0722e878e6f8d4ba5b7bd6ccb1cca44e38d6f9f2e102d23badb4f4e1cae5852",
513515
},
514516
json1: "{\"level1\":{\"level2\":{\"level3\":{\"longKey\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoxLCJmaXJzdE5hbWUiOiJTdGVybGluZyIsImxhc3ROYW1lIjoiU2F1ZXIiLCJlbWFpbCI6Ik1hc29uLkdvbGRuZXI0OUBob3RtYWlsLmNvbSIsInBhc3N3b3JkIjoiZGFhOTMyMGY1YzU4NDRiODRiMjhlMDE2YjRiOGM0MGIiLCJjcmVhdGVkQXQiOiIyMDIzLTEyLTA4VDE4OjE2OjQxLjYzOFoiLCJ1cGRhdGVkQXQiOm51bGwsImRlbGV0ZWRBdCI6bnVsbH0sImlhdCI6MTcxOTM0MzYzOCwiZXhwIjoxNzE5NDMwMDM4fQ.Kgm3Lmbg97M_QQP5Gn9q4suRYEF7_n4ITqehV4i7t_s is a very long value with many descriptive words and phrases to make it lengthy.\"}}}}",
515517
json2: "{\"level1\":{\"level2\":{\"level3\":{\"longKey\":\"This is a very long value with many descriptive words and phrases to make it extensive.\"}}}}",
@@ -528,9 +530,9 @@ func TestSprintJSONDiff(t *testing.T) {
528530
},
529531
{
530532
expectedStringA: []string{
531-
"b0d3af312d652a356588bdcddd6f8560cd7700e3532f69405df5dd555b9b1516",
533+
"9776cf81505093952b85a8885cd84a713dfdbfc1367ab77de74c066c2d767678",
532534
},
533-
expectedStringB: []string{"7bac2737756e4613e655b7654087d8467cce2eb039f549c66bd5e1572d9c3a46"},
535+
expectedStringB: []string{"c652c8abb9793fc7073a55f9c5126b92a486afd7aadb194397ea88ff09582cfc"},
534536
json1: "{\"level1\":{\"level2\":{\"level3\":{\"longKeyWithMinorChangeA\":\"This is a very long value that remains mostly the same.\"}}}}",
535537
json2: "{\"level1\":{\"level2\":{\"level3\":{\"longKeyWithMinorChangeB\":\"This is a very long value that remains mostly the same.\"}}}}",
536538
name: "long nested structures with slight key changes",
@@ -548,9 +550,9 @@ func TestSprintJSONDiff(t *testing.T) {
548550
},
549551
{
550552
expectedStringA: []string{
551-
"67e2232cc42a9f92d7c6871d33a768ed9f7858eb22a00a52f593c1b163eced21",
553+
"2cf4331f583b813f6d368fd604c582f860eff645fe98541396095a52d08bf635",
552554
},
553-
expectedStringB: []string{"59c1bfd3866bc77553c17703b6f5bc69a13c46fba093f6dd09c1d14c15b5a4e7"},
555+
expectedStringB: []string{"e8916e02d1a0f3b95983c52e67b122d18f8f7c12f36877bc08e66fdbc841605e"},
554556
json1: "{\"nested\":{\"longParagraph\":\"This is a long paragraph. It contains multiple sentences. Each sentence has many words. One sentence will be different in the second JSON.\"}}",
555557
json2: "{\"nested\":{\"longParagraph\":\"This is a long paragraph. It contains multiple sentences. Each sentence has many words. One phrase will be different in the second JSON.\"}}",
556558
name: "long paragraphs with nested arrays and maps",

jsondiff.go

Lines changed: 146 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"regexp"
99
"sort"
1010
"strings"
11+
"unicode/utf8"
1112

1213
"github.com/fatih/color"
1314
"github.com/tidwall/gjson"
@@ -19,6 +20,8 @@ type colorRange struct {
1920
End int // End is the ending index of the range.
2021
}
2122

23+
const maxCharactersLength = 30
24+
2225
// Diff holds the colorized differences between the expected and actual JSON responses.
2326
// Expected: The colorized string representing the differences in the expected JSON response.
2427
// Actual: The colorized string representing the differences in the actual JSON response.
@@ -61,8 +64,8 @@ func CompareJSON(expectedJSON []byte, actualJSON []byte, noise map[string][]stri
6164
highlightActual := color.FgHiGreen
6265

6366
return Diff{
64-
Expected: breakSliceWithColor(expectedJSONString, &highlightExpected, offset),
65-
Actual: breakSliceWithColor(actualJSONString, &highlightActual, offset),
67+
Expected: truncateStringWithEllipsis(breakSliceWithColor(expectedJSONString, &highlightExpected, offset), highlightExpected),
68+
Actual: truncateStringWithEllipsis(breakSliceWithColor(actualJSONString, &highlightActual, offset), highlightActual),
6669
}, nil
6770
}
6871

@@ -111,8 +114,8 @@ func Compare(expectedJSON, actualJSON string) Diff {
111114
highlightActual := color.FgHiGreen
112115

113116
// Colorize the differences in the expected and actual JSON strings.
114-
colorizedExpected := breakSliceWithColor(expectedJSON, &highlightExpected, offsetExpected)
115-
colorizedActual := breakSliceWithColor(actualJSON, &highlightActual, offsetActual)
117+
colorizedExpected := truncateStringWithEllipsis(breakSliceWithColor(expectedJSON, &highlightExpected, offsetExpected), highlightExpected)
118+
colorizedActual := truncateStringWithEllipsis(breakSliceWithColor(actualJSON, &highlightActual, offsetActual), highlightActual)
116119

117120
// Return the colorized differences in a Diff struct.
118121
return Diff{
@@ -247,10 +250,13 @@ func writeKeyValuePair(builder *strings.Builder, key string, value interface{},
247250

248251
builder.WriteString(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, formattedValue))
249252
default:
250-
251253
serializedValue, _ := json.MarshalIndent(value, "", " ")
252254
formattedValue := string(serializedValue)
253255

256+
if len(string(serializedValue)) > maxCharactersLength {
257+
formattedValue = string(serializedValue[:maxCharactersLength]) + "..."
258+
}
259+
254260
// Check if a color function is provided and the value is not empty.
255261
if applyColor != nil && value != "" {
256262
formattedValue = applyColor(formattedValue)
@@ -425,13 +431,13 @@ func compare(key string, val1, val2 interface{}, indent string, expect, actual *
425431
return
426432
}
427433
// Colorize the differences in the values
428-
c := color.FgRed
434+
redColor := color.FgRed
429435
offsetsStr1, offsetsStr2, _ := diffArrayRange(string(val1Str), string(val2Str))
430-
expectDiff := breakSliceWithColor(string(val1Str), &c, offsetsStr1)
431-
c = color.FgGreen
432-
actualDiff := breakSliceWithColor(string(val2Str), &c, offsetsStr2)
433-
expect.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, string(expectDiff))))
434-
actual.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, string(actualDiff))))
436+
expectDiff := breakSliceWithColor(string(val1Str), &redColor, offsetsStr1)
437+
greenColor := color.FgGreen
438+
actualDiff := breakSliceWithColor(string(val2Str), &greenColor, offsetsStr2)
439+
expect.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, truncateStringWithEllipsis(string(expectDiff), redColor))))
440+
actual.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, truncateStringWithEllipsis(string(actualDiff), greenColor))))
435441
return
436442
}
437443
// If values are equal, write the value without color
@@ -813,6 +819,135 @@ func truncateToMatchWithEllipsis(expectedText, actualText string) (string, strin
813819
return truncatedExpected, truncatedActual
814820
}
815821

822+
// truncateStringWithEllipsis truncates a string to a specified length, adding an ellipsis if necessary.
823+
func truncateStringWithEllipsis(val string, c color.Attribute) string {
824+
if !ansiRegex.MatchString(val) {
825+
return truncatePlain(val, maxCharactersLength, "...")
826+
}
827+
828+
colorEllipsis := color.New(c).Sprint("...")
829+
830+
type ansiRange struct{ Start, End int }
831+
ranges := collectANSISegments(val)
832+
833+
// NEW: coalesce adjacent runs (or runs separated only by whitespace)
834+
coalesced := make([]ansiRange, 0, len(ranges))
835+
for _, r := range ranges {
836+
if len(coalesced) == 0 {
837+
coalesced = append(coalesced, r)
838+
continue
839+
}
840+
last := &coalesced[len(coalesced)-1]
841+
gap := val[last.End:r.Start]
842+
if strings.TrimSpace(gap) == "" {
843+
// merge into one big block (includes the whitespace gap)
844+
last.End = r.End
845+
} else {
846+
coalesced = append(coalesced, r)
847+
}
848+
}
849+
850+
var out strings.Builder
851+
prev := 0
852+
for _, r := range coalesced {
853+
out.WriteString(truncatePlain(val[prev:r.Start], maxCharactersLength, "..."))
854+
out.WriteString(truncateANSISegment(val[r.Start:r.End], maxCharactersLength, colorEllipsis))
855+
prev = r.End
856+
}
857+
out.WriteString(truncatePlain(val[prev:], maxCharactersLength, "..."))
858+
return out.String()
859+
}
860+
861+
// collectANSISegments collects segments of ANSI escape codes from the input string.
862+
func collectANSISegments(s string) []struct{ Start, End int } {
863+
var res []struct{ Start, End int }
864+
865+
in := false
866+
segStart := 0
867+
ms := ansiRegex.FindAllStringIndex(s, -1)
868+
for _, m := range ms {
869+
code := s[m[0]:m[1]]
870+
if !in {
871+
if code != ansiResetCode {
872+
in = true
873+
segStart = m[0]
874+
}
875+
} else {
876+
if code == ansiResetCode {
877+
res = append(res, struct{ Start, End int }{Start: segStart, End: m[1]})
878+
in = false
879+
}
880+
}
881+
}
882+
if in { // unclosed color
883+
res = append(res, struct{ Start, End int }{Start: segStart, End: len(s)})
884+
}
885+
return res
886+
}
887+
888+
func truncatePlain(s string, limit int, ellipsis string) string {
889+
if utf8.RuneCountInString(s) <= limit {
890+
return s
891+
}
892+
cut := byteIndexAfterNRunes(s, limit)
893+
return s[:cut] + ellipsis
894+
}
895+
896+
func truncateANSISegment(seg string, limit int, coloredEllipsis string) string {
897+
hasReset := strings.Contains(seg, ansiResetCode)
898+
ms := ansiRegex.FindAllStringIndex(seg, -1)
899+
900+
var b strings.Builder
901+
pos, mi, visible := 0, 0, 0
902+
903+
for pos < len(seg) && visible < limit {
904+
if mi < len(ms) && pos == ms[mi][0] {
905+
b.WriteString(seg[ms[mi][0]:ms[mi][1]])
906+
pos = ms[mi][1]
907+
mi++
908+
continue
909+
}
910+
_, sz := utf8.DecodeRuneInString(seg[pos:])
911+
b.WriteString(seg[pos : pos+sz])
912+
pos += sz
913+
visible++
914+
}
915+
916+
if visible >= limit && moreVisibleAhead(seg, pos, ms, mi) {
917+
b.WriteString(coloredEllipsis)
918+
}
919+
if hasReset && !strings.HasSuffix(b.String(), ansiResetCode) {
920+
b.WriteString(ansiResetCode)
921+
}
922+
return b.String()
923+
}
924+
925+
func moreVisibleAhead(s string, pos int, ms [][]int, mi int) bool {
926+
for pos < len(s) {
927+
if mi < len(ms) && pos == ms[mi][0] {
928+
pos = ms[mi][1]
929+
mi++
930+
continue
931+
}
932+
return true
933+
}
934+
return false
935+
}
936+
937+
func byteIndexAfterNRunes(s string, n int) int {
938+
if n <= 0 {
939+
return 0
940+
}
941+
count := 0
942+
for i := range s {
943+
if count == n {
944+
return i
945+
}
946+
count++
947+
}
948+
return len(s)
949+
}
950+
816951
// compareAndColorizeMaps compares two maps and returns the differences as colorized strings.
817952
// a: The first map to compare.
818953
// b: The second map to compare.

0 commit comments

Comments
 (0)