Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,26 @@ permissions:
contents: read

jobs:
test:
name: Tests
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.22'
cache: false
- name: Run tests
run: go test -v ./...
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.22'
cache: true
- name: Run unit tests
run: go test -v ./...

fuzz-tests:
name: Fuzz Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.22'
cache: true
- name: Run fuzz tests
run: go test -fuzz=FuzzCompareJSON -fuzztime=240s ./...
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
# Dependency directories (remove the comment below to include it)
# vendor/

# Fuzz test data
testdata

# Go workspace file
go.work
go.work.sum
89 changes: 89 additions & 0 deletions jsonDiff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -868,3 +868,92 @@ func wrapTextWithAnsi(input string) string {
// Return the processed string with properly wrapped ANSI escape sequences.
return wrappedBuilder.String()
}

func TestExtractKey(t *testing.T) {
tests := []struct {
name string
inputDiff string
expectedKeys string
}{
{
name: "Standard case with plus and minus",
inputDiff: "- \"name\": \"Cat\"\n+ \"name\": \"Dog\"",
expectedKeys: "name|name",
},
{
name: "Keys with single and double quotes",
inputDiff: `- 'id': 123` + "\n" + `+ "id": 456`,
expectedKeys: "id|id",
},
{
name: "Handles empty lines between diffs",
inputDiff: "- \"key1\": \"val1\"\n\n+ \"key1\": \"val2\"",
expectedKeys: "key1|key1",
},
{
name: "Handles leading and trailing empty lines",
inputDiff: "\n- \"key1\": \"val1\"\n+ \"key1\": \"val2\"\n",
expectedKeys: "key1|key1",
},
{
name: "Empty input string",
inputDiff: "",
expectedKeys: "",
},
{
name: "Input with only whitespace and newlines",
inputDiff: " \n \n ",
expectedKeys: "",
},
{
name: "Malformed line without a colon",
inputDiff: "- \"key1\" \"val1\"",
expectedKeys: "",
},
{
name: "Line with only a plus or minus sign",
inputDiff: "-\n+",
expectedKeys: "",
},
{
name: "Mixed valid and invalid lines",
inputDiff: "- \"validKey1\": 1\n- malformed\n+ \"validKey2\": 2",
expectedKeys: "validKey1|validKey2",
},
{
name: "Key with extra whitespace",
inputDiff: `- " spaced key " : "value"`,
expectedKeys: "spaced key",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractKey(tt.inputDiff)
if got != tt.expectedKeys {
t.Errorf("extractKey() = %v, want %v", got, tt.expectedKeys)
}
})
}
}

func FuzzCompareJSON(f *testing.F) {
// Add some seed inputs to give the fuzzer a starting point.
f.Add(`{"id": 1}`, `{"id": 2}`)
f.Add(`{"a": "b"}`, `{}`)
f.Add(`[]`, `{}`)
f.Add(`"a"`, `"b"`)
f.Add(`- "key": val`, `+ "key": val`) // Seed for extractKey

// The fuzzer will now generate random strings and run them through your functions.
f.Fuzz(func(t *testing.T, json1 string, json2 string) {
// We only care if these functions panic. We don't need to check the output.
// A panic will automatically fail the test.

// Fuzz the main function
_, _ = CompareJSON([]byte(json1), []byte(json2), nil, false)

// Fuzz the function that was originally panicking
_ = extractKey(json1)
})
}
76 changes: 53 additions & 23 deletions jsondiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,27 +204,50 @@ func calculateJSONDiffs(expectedJSON, actualJSON []byte) (string, error) {
return strings.Join(diffs, "\n"), nil
}

// unquoteKey trims surrounding quotes and spaces from a JSON key safely.
func unquoteKey(k string) string {
k = strings.TrimSpace(k)
// Trim both double/single quotes if present first.
k = strings.Trim(k, `"'`)
// Then, trim any remaining whitespace that was inside the quotes.
k = strings.TrimSpace(k)
return k
}

// extractKey extracts the keys from the diff string.
// diffString: The input string representing the differences.
// Returns a string containing all the keys separated by a pipe character.
// Handles empty lines and lines that don't start with +/- safely.
func extractKey(diffString string) string {
diffLines := strings.Split(diffString, "\n") // Split the diff string into lines.
diffLines := strings.Split(diffString, "\n")
var keys []string

// Iterate over each line in the diff string.
for _, line := range diffLines {
// Remove the leading '-' or '+' and any surrounding spaces
line = strings.TrimSpace(line[1:])
for _, raw := range diffLines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}

// Remove a single leading +/- if present.
if line[0] == '-' || line[0] == '+' {
Copy link

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line can still cause a panic if line is an empty string after trimming. The empty string check should be performed before accessing line[0].

Suggested change
if line[0] == '-' || line[0] == '+' {
if len(line) > 0 && (line[0] == '-' || line[0] == '+') {

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good to have but this will never happen since line 223 handles that

// If it's exactly "-" or "+", skip.
if len(line) == 1 {
continue
}
line = strings.TrimSpace(line[1:])
if line == "" {
continue
}
}

if colonIndex := strings.Index(line, ":"); colonIndex != -1 {
// Extract and clean up the key
key := strings.Trim(line[:colonIndex], `"'`)
// Expect `<key>: <value>`; split once.
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := unquoteKey(parts[0])
if key != "" {
keys = append(keys, key)
}
// Add the key to the list of keys.
}

// Join the keys into a single string separated by a pipe character.
return strings.Join(keys, "|")
}

Expand Down Expand Up @@ -484,30 +507,33 @@ func separateAndColorize(diffStr string, noise map[string][]string) (string, str
actualTrimmedLine := nextLine[3:] // Trim the '+ ' prefix from the next line.
actualKeyValue := strings.SplitN(actualTrimmedLine, ":", 2)
actualKey = strings.TrimSpace(actualKeyValue[0])
cleanActualKey := unquoteKey(actualKey)
// Process the value
value := strings.TrimSpace(actualKeyValue[1])
var jsonObj map[string]interface{}
switch {
case json.Unmarshal([]byte(value), &jsonObj) == nil:
isActualMap = true
actualMap = map[string]interface{}{actualKey[:len(actualKey)-1]: jsonObj}
actualMap = map[string]interface{}{cleanActualKey: jsonObj}
case json.Unmarshal([]byte(value), &actualsArray) == nil:
default:
actualValue = value
}
}

if len(strings.SplitN(line[3:], ":", 2)) == 2 {
if len(line) > 3 && len(strings.SplitN(line[3:], ":", 2)) == 2 {
expectTrimmedLine := line[3:] // Trim the '- ' prefix from the current line.
expectkeyValue := strings.SplitN(expectTrimmedLine, ":", 2)
expectKey = strings.TrimSpace(expectkeyValue[0])
cleanExpectKey := unquoteKey(expectKey)
// Process the value
value := strings.TrimSpace(expectkeyValue[1])
var jsonObj map[string]interface{}
switch {
case json.Unmarshal([]byte(value), &jsonObj) == nil:
isExpectMap = true
expectMap = map[string]interface{}{expectKey[:len(expectKey)-1]: jsonObj}
expectMap = map[string]interface{}{cleanExpectKey: jsonObj}

Comment on lines +535 to +536
Copy link

Copilot AI Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] There's an unnecessary empty line that breaks the consistency of the switch statement formatting.

Suggested change
expectMap = map[string]interface{}{cleanExpectKey: jsonObj}

Copilot uses AI. Check for mistakes.
case json.Unmarshal([]byte(value), &expectsArray) == nil:
default:
expectValue = value
Expand All @@ -523,19 +549,23 @@ func separateAndColorize(diffStr string, noise map[string][]string) (string, str

if expectValue != nil && actualValue != nil {
var expectBuilder, actualBuilder strings.Builder
if expectKey != actualKey {
actualBuilder.WriteString(fmt.Sprintf("%s: %s\n", green(serialize(actualKey[:len(actualKey)-1])), actualValue))
expectBuilder.WriteString(fmt.Sprintf("%s: %s\n", red(serialize(expectKey[:len(expectKey)-1])), expectValue))
eKey := unquoteKey(expectKey)
aKey := unquoteKey(actualKey)
if eKey != aKey {
actualBuilder.WriteString(fmt.Sprintf("%s: %s\n", green(serialize(aKey)), actualValue))
expectBuilder.WriteString(fmt.Sprintf("%s: %s\n", red(serialize(eKey)), expectValue))
} else {
compare(expectKey[:len(expectKey)-1], expectValue, actualValue, " ", &expectBuilder, &actualBuilder, red, green, intialJsonPath, noise)
compare(eKey, expectValue, actualValue, " ", &expectBuilder, &actualBuilder, red, green, intialJsonPath, noise)
}
expectedText = expectBuilder.String()
actualText = actualBuilder.String()
} else if !isExpectMap || !isActualMap {
if actualKey != expectKey {
eKey := unquoteKey(expectKey)
aKey := unquoteKey(actualKey)
if aKey != eKey {
continue
}
isNoised := checkNoise(actualKey, noise)
isNoised := checkNoise(aKey, noise)
if isNoised {
continue
}
Expand Down Expand Up @@ -750,7 +780,7 @@ func insertEmptyLines(lines []string) []string {
result = append(result, lines[i]) // Append the current line to the result slice.

// Check if the current line and the next line start with the same symbol.
if i < len(lines)-1 && lines[i] != "" && lines[i][0] == lines[i+1][0] {
if i < len(lines)-1 && lines[i] != "" && lines[i+1] != "" && lines[i][0] == lines[i+1][0] {
result = append(result, "") // Insert an empty line between consecutive elements with the same symbol.
}
}
Expand Down
Loading