From 0b12171be06fcf8953328611e1e608641fcb74e2 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Mon, 11 Aug 2025 08:04:25 +0530 Subject: [PATCH 1/4] feat: truncate string values and add ellipsis Signed-off-by: Ayush Sharma --- jsondiff.go | 147 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/jsondiff.go b/jsondiff.go index 1ae560d..bc3effc 100644 --- a/jsondiff.go +++ b/jsondiff.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" "strings" + "unicode/utf8" "github.com/fatih/color" "github.com/tidwall/gjson" @@ -247,10 +248,13 @@ func writeKeyValuePair(builder *strings.Builder, key string, value interface{}, builder.WriteString(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, formattedValue)) default: - serializedValue, _ := json.MarshalIndent(value, "", " ") formattedValue := string(serializedValue) + if len(string(serializedValue)) > 30 { + formattedValue = string(serializedValue[:30]) + "..." + } + // Check if a color function is provided and the value is not empty. if applyColor != nil && value != "" { formattedValue = applyColor(formattedValue) @@ -425,13 +429,13 @@ func compare(key string, val1, val2 interface{}, indent string, expect, actual * return } // Colorize the differences in the values - c := color.FgRed + redColor := color.FgRed offsetsStr1, offsetsStr2, _ := diffArrayRange(string(val1Str), string(val2Str)) - expectDiff := breakSliceWithColor(string(val1Str), &c, offsetsStr1) - c = color.FgGreen - actualDiff := breakSliceWithColor(string(val2Str), &c, offsetsStr2) - expect.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, string(expectDiff)))) - actual.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, string(actualDiff)))) + expectDiff := breakSliceWithColor(string(val1Str), &redColor, offsetsStr1) + greenColor := color.FgGreen + actualDiff := breakSliceWithColor(string(val2Str), &greenColor, offsetsStr2) + expect.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, truncateStringWithEllipsis(string(expectDiff), redColor)))) + actual.WriteString(breakLines(fmt.Sprintf("%s\"%s\": %s,\n", indent, key, truncateStringWithEllipsis(string(actualDiff), greenColor)))) return } // If values are equal, write the value without color @@ -813,6 +817,135 @@ func truncateToMatchWithEllipsis(expectedText, actualText string) (string, strin return truncatedExpected, truncatedActual } +// truncateStringWithEllipsis truncates a string to a specified length, adding an ellipsis if necessary. +func truncateStringWithEllipsis(val string, c color.Attribute) string { + if !ansiRegex.MatchString(val) { + return truncatePlain(val, 20, "...") + } + + colorEllipsis := color.New(c).Sprint("...") + + type ansiRange struct{ Start, End int } + ranges := collectANSISegments(val) + + // NEW: coalesce adjacent runs (or runs separated only by whitespace) + coalesced := make([]ansiRange, 0, len(ranges)) + for _, r := range ranges { + if len(coalesced) == 0 { + coalesced = append(coalesced, r) + continue + } + last := &coalesced[len(coalesced)-1] + gap := val[last.End:r.Start] + if strings.TrimSpace(gap) == "" { + // merge into one big block (includes the whitespace gap) + last.End = r.End + } else { + coalesced = append(coalesced, r) + } + } + + var out strings.Builder + prev := 0 + for _, r := range coalesced { + out.WriteString(truncatePlain(val[prev:r.Start], 20, "...")) + out.WriteString(truncateANSISegment(val[r.Start:r.End], 20, colorEllipsis)) + prev = r.End + } + out.WriteString(truncatePlain(val[prev:], 20, "...")) + return out.String() +} + +// collectANSISegments collects segments of ANSI escape codes from the input string. +func collectANSISegments(s string) []struct{ Start, End int } { + var res []struct{ Start, End int } + + in := false + segStart := 0 + ms := ansiRegex.FindAllStringIndex(s, -1) + for _, m := range ms { + code := s[m[0]:m[1]] + if !in { + if code != ansiResetCode { + in = true + segStart = m[0] + } + } else { + if code == ansiResetCode { + res = append(res, struct{ Start, End int }{Start: segStart, End: m[1]}) + in = false + } + } + } + if in { // unclosed color + res = append(res, struct{ Start, End int }{Start: segStart, End: len(s)}) + } + return res +} + +func truncatePlain(s string, limit int, ellipsis string) string { + if utf8.RuneCountInString(s) <= limit { + return s + } + cut := byteIndexAfterNRunes(s, limit) + return s[:cut] + ellipsis +} + +func truncateANSISegment(seg string, limit int, coloredEllipsis string) string { + hasReset := strings.Contains(seg, ansiResetCode) + ms := ansiRegex.FindAllStringIndex(seg, -1) + + var b strings.Builder + pos, mi, visible := 0, 0, 0 + + for pos < len(seg) && visible < limit { + if mi < len(ms) && pos == ms[mi][0] { + b.WriteString(seg[ms[mi][0]:ms[mi][1]]) + pos = ms[mi][1] + mi++ + continue + } + _, sz := utf8.DecodeRuneInString(seg[pos:]) + b.WriteString(seg[pos : pos+sz]) + pos += sz + visible++ + } + + if visible >= limit && moreVisibleAhead(seg, pos, ms, mi) { + b.WriteString(coloredEllipsis) + } + if hasReset && !strings.HasSuffix(b.String(), ansiResetCode) { + b.WriteString(ansiResetCode) + } + return b.String() +} + +func moreVisibleAhead(s string, pos int, ms [][]int, mi int) bool { + for pos < len(s) { + if mi < len(ms) && pos == ms[mi][0] { + pos = ms[mi][1] + mi++ + continue + } + return true + } + return false +} + +func byteIndexAfterNRunes(s string, n int) int { + if n <= 0 { + return 0 + } + count := 0 + for i := range s { + if count == n { + return i + } + count++ + } + return len(s) +} + // compareAndColorizeMaps compares two maps and returns the differences as colorized strings. // a: The first map to compare. // b: The second map to compare. From 1fa8e403112580310d24baa3bb700318fe55a73c Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Mon, 11 Aug 2025 19:22:38 +0530 Subject: [PATCH 2/4] fix: added truncation with plain text responses also Signed-off-by: Ayush Sharma --- jsondiff.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jsondiff.go b/jsondiff.go index bc3effc..f5cc665 100644 --- a/jsondiff.go +++ b/jsondiff.go @@ -62,8 +62,8 @@ func CompareJSON(expectedJSON []byte, actualJSON []byte, noise map[string][]stri highlightActual := color.FgHiGreen return Diff{ - Expected: breakSliceWithColor(expectedJSONString, &highlightExpected, offset), - Actual: breakSliceWithColor(actualJSONString, &highlightActual, offset), + Expected: truncateStringWithEllipsis(breakSliceWithColor(expectedJSONString, &highlightExpected, offset), highlightExpected), + Actual: truncateStringWithEllipsis(breakSliceWithColor(actualJSONString, &highlightActual, offset), highlightActual), }, nil } @@ -112,8 +112,8 @@ func Compare(expectedJSON, actualJSON string) Diff { highlightActual := color.FgHiGreen // Colorize the differences in the expected and actual JSON strings. - colorizedExpected := breakSliceWithColor(expectedJSON, &highlightExpected, offsetExpected) - colorizedActual := breakSliceWithColor(actualJSON, &highlightActual, offsetActual) + colorizedExpected := truncateStringWithEllipsis(breakSliceWithColor(expectedJSON, &highlightExpected, offsetExpected), highlightExpected) + colorizedActual := truncateStringWithEllipsis(breakSliceWithColor(actualJSON, &highlightActual, offsetActual), highlightActual) // Return the colorized differences in a Diff struct. return Diff{ From 4386ef22d9d350d6ba8514b40c7466ab0a9bd90e Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Mon, 11 Aug 2025 19:27:35 +0530 Subject: [PATCH 3/4] chore: addressed copilot comments Signed-off-by: Ayush Sharma --- jsondiff.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/jsondiff.go b/jsondiff.go index f5cc665..9a62385 100644 --- a/jsondiff.go +++ b/jsondiff.go @@ -20,6 +20,8 @@ type colorRange struct { End int // End is the ending index of the range. } +const maxCharactersLength = 30 + // Diff holds the colorized differences between the expected and actual JSON responses. // Expected: The colorized string representing the differences in the expected JSON response. // Actual: The colorized string representing the differences in the actual JSON response. @@ -251,8 +253,8 @@ func writeKeyValuePair(builder *strings.Builder, key string, value interface{}, serializedValue, _ := json.MarshalIndent(value, "", " ") formattedValue := string(serializedValue) - if len(string(serializedValue)) > 30 { - formattedValue = string(serializedValue[:30]) + "..." + if len(string(serializedValue)) > maxCharactersLength { + formattedValue = string(serializedValue[:maxCharactersLength]) + "..." } // Check if a color function is provided and the value is not empty. @@ -820,7 +822,7 @@ func truncateToMatchWithEllipsis(expectedText, actualText string) (string, strin // truncateStringWithEllipsis truncates a string to a specified length, adding an ellipsis if necessary. func truncateStringWithEllipsis(val string, c color.Attribute) string { if !ansiRegex.MatchString(val) { - return truncatePlain(val, 20, "...") + return truncatePlain(val, maxCharactersLength, "...") } colorEllipsis := color.New(c).Sprint("...") @@ -848,11 +850,11 @@ func truncateStringWithEllipsis(val string, c color.Attribute) string { var out strings.Builder prev := 0 for _, r := range coalesced { - out.WriteString(truncatePlain(val[prev:r.Start], 20, "...")) - out.WriteString(truncateANSISegment(val[r.Start:r.End], 20, colorEllipsis)) + out.WriteString(truncatePlain(val[prev:r.Start], maxCharactersLength, "...")) + out.WriteString(truncateANSISegment(val[r.Start:r.End], maxCharactersLength, colorEllipsis)) prev = r.End } - out.WriteString(truncatePlain(val[prev:], 20, "...")) + out.WriteString(truncatePlain(val[prev:], maxCharactersLength, "...")) return out.String() } From 8ecf85dcde3b3781bdb8e9168e8f7f2089ae931e Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Mon, 18 Aug 2025 19:32:37 +0530 Subject: [PATCH 4/4] fix: unit tests Signed-off-by: Ayush Sharma --- jsonDiff_test.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/jsonDiff_test.go b/jsonDiff_test.go index 1db0c6a..ad78021 100644 --- a/jsonDiff_test.go +++ b/jsonDiff_test.go @@ -428,11 +428,13 @@ func TestSprintJSONDiff(t *testing.T) { "49bec237abb42a872e82edee006cb72e8270b4c14179140ce03ebc47ad36fa2d", "3cd84203cf23bffc56c12344c2b2fbf313c1e4ed34f125ea813a50b42adca1d9", "825c067292a1b5ce6ce1724c52fa2068bfb651a35273a65351be3e63d8614df1", + "3bf543c95d44ce58e0005887bd44c4ab27d00a0bc1355fd23db30f5ba659a0d1", }, expectedStringB: []string{ "f3603f2c454c9d81d8cc19296af4e4aff906d102263beea5af3892c223d0ef29", "c25b5b827481d888a7a5551ee05d6ea4590d59d2674fb5182394f13c3adca29a", "d2f1d7f7dcea6764caeab964e34a99e936715959cc066ebd77822bb5daa80316", + "beaa58fc7489cf3e180eca7d4ddd2fb5cacaf740df8ecc0b5f822b8a4b3d0c9d", }, json1: "{\"longKey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"nested\":{\"key1\":{\"subkey1\":\"value1\"},\"key2\":{\"subkey2\":\"value2\"}}}", json2: "{\"longKey\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"nested\":{\"key1\":{\"subkey1\":\"value1\"},\"key2\":{\"subkey2\":\"value3\"}}}", @@ -462,10 +464,10 @@ func TestSprintJSONDiff(t *testing.T) { }, { expectedStringA: []string{ - "c9a069dd6d3aaf5052219992d43c57f00d66881849a0004df66ca44d4bcab74d", + "9cac2220ac73f8abd3a13d9fbc4249bd481f0188590a26e87f87d354078c9dfa", }, expectedStringB: []string{ - "b6a5cc8d3ef65269d1c729f769d2e64fb501e545b2d2f3306d0359211220b242", + "ea18ec7619618dd08777bf00afd74100cf0ae6ea4f914bd26417621b9419a3b8", }, 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.\"}", 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) { }, { expectedStringA: []string{ - "2882d548db56674bf1d45dc423178c66c7dbcc3f7e72d5c2edca479cda04180c", + "56a9a0b500759fdb334d3977aac390d18c11fb8e5e10d269ff8f65e034604ea7", }, expectedStringB: []string{ - "9fe5ef3cbbc2103ec21ddf9497a5edba97e546d27f08cad456f208352bf5bc8d", + "546b08086b296dc7df590f37e5afb965ed58c129fcc09800bca7416a41515e34", }, json1: "{\"longKey\":\"This is a long key with many words and a subtle change at the end of this sentence.\"}", 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) { }, { expectedStringA: []string{ - "563c5a6b903195cf1e4d408c265edd57ca2f97d818db6ea0b688f30d7b642128", + "d190663b3f566e3fc02a1fb599851f1debda96ebf0fdc14e111e52149a99cf25", }, expectedStringB: []string{ - "2e340d7201d7bfbcf9dd8181407fbcbccf993b9b9150bc91c824a17421fb5087", + "b0722e878e6f8d4ba5b7bd6ccb1cca44e38d6f9f2e102d23badb4f4e1cae5852", }, 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.\"}}}}", 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) { }, { expectedStringA: []string{ - "b0d3af312d652a356588bdcddd6f8560cd7700e3532f69405df5dd555b9b1516", + "9776cf81505093952b85a8885cd84a713dfdbfc1367ab77de74c066c2d767678", }, - expectedStringB: []string{"7bac2737756e4613e655b7654087d8467cce2eb039f549c66bd5e1572d9c3a46"}, + expectedStringB: []string{"c652c8abb9793fc7073a55f9c5126b92a486afd7aadb194397ea88ff09582cfc"}, json1: "{\"level1\":{\"level2\":{\"level3\":{\"longKeyWithMinorChangeA\":\"This is a very long value that remains mostly the same.\"}}}}", json2: "{\"level1\":{\"level2\":{\"level3\":{\"longKeyWithMinorChangeB\":\"This is a very long value that remains mostly the same.\"}}}}", name: "long nested structures with slight key changes", @@ -548,9 +550,9 @@ func TestSprintJSONDiff(t *testing.T) { }, { expectedStringA: []string{ - "67e2232cc42a9f92d7c6871d33a768ed9f7858eb22a00a52f593c1b163eced21", + "2cf4331f583b813f6d368fd604c582f860eff645fe98541396095a52d08bf635", }, - expectedStringB: []string{"59c1bfd3866bc77553c17703b6f5bc69a13c46fba093f6dd09c1d14c15b5a4e7"}, + expectedStringB: []string{"e8916e02d1a0f3b95983c52e67b122d18f8f7c12f36877bc08e66fdbc841605e"}, 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.\"}}", 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.\"}}", name: "long paragraphs with nested arrays and maps",