diff --git a/.golangci.yml b/.golangci.yml index f13b2c9..186a57f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,10 +5,10 @@ run: linters: default: all disable: - - nilnil - - tparallel + - gocyclo + - errcheck - err113 - - errorlint + - nilnil - noinlineerr - wsl_v5 - funcorder @@ -34,6 +34,11 @@ linters: - varnamelen - wrapcheck settings: + gocognit: + min-complexity: 50 + funlen: + statements: 65 + lines: 120 dupl: threshold: 100 errcheck: @@ -41,16 +46,17 @@ linters: check-blank: true gocyclo: min-complexity: 20 - cyclop: - max-complexity: 15 misspell: locale: US unparam: check-exported: true + cyclop: + max-complexity: 25 exclusions: generated: lax rules: - linters: + - tparallel - gosec - dupl - funlen diff --git a/cmd/jsoncompact/main.go b/cmd/jsoncompact/main.go index 509f099..13900ba 100644 --- a/cmd/jsoncompact/main.go +++ b/cmd/jsoncompact/main.go @@ -16,7 +16,7 @@ import ( ) // The function is a bit lengthy, but I'm not sure if it would be more approachable divided in several functions. -func main() { //nolint +func main() { var ( input, output string length int @@ -40,7 +40,7 @@ func main() { //nolint input = flag.Arg(0) if input == "" { - _, _ = fmt.Fprintln(flag.CommandLine.Output(), "Missing input path argument, use `-` for stdin.") //nolint + _, _ = fmt.Fprintln(flag.CommandLine.Output(), "Missing input path argument, use `-` for stdin.") flag.Usage() return diff --git a/compare.go b/compare.go new file mode 100644 index 0000000..71323db --- /dev/null +++ b/compare.go @@ -0,0 +1,247 @@ +package assertjson + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/bool64/shared" + "github.com/swaggest/assertjson/diff" +) + +func (c Comparer) varCollected(s string, v interface{}) bool { + if c.Vars != nil && c.Vars.IsVar(s) { + if _, found := c.Vars.Get(s); !found { + if n, ok := v.(json.Number); ok { + v = shared.DecodeJSONNumber(n) + } else if f, ok := v.(float64); ok && f == float64(int64(f)) { + v = int64(f) + } + + c.Vars.Set(s, v) + + return true + } + } + + return false +} + +func (c Comparer) filterDeltas(deltas []diff.Delta, ignoreAdded bool) []diff.Delta { + result := make([]diff.Delta, 0, len(deltas)) + + for _, delta := range deltas { + switch v := delta.(type) { + case *diff.Modified: + if c.IgnoreDiff == "" && c.Vars == nil { + break + } + + if s, ok := v.OldValue.(string); ok { + if s == c.IgnoreDiff { // discarding ignored diff + continue + } + + if c.varCollected(s, v.NewValue) { + continue + } + } + case *diff.Object: + v.Deltas = c.filterDeltas(v.Deltas, ignoreAdded) + if len(v.Deltas) == 0 { + continue + } + + delta = v + case *diff.Array: + v.Deltas = c.filterDeltas(v.Deltas, ignoreAdded) + if len(v.Deltas) == 0 { + continue + } + + delta = v + + case *diff.Added: + if ignoreAdded { + continue + } + } + + result = append(result, delta) + } + + return result +} + +type df struct { + deltas []diff.Delta +} + +func (df *df) Deltas() []diff.Delta { + return df.deltas +} + +func (df *df) Modified() bool { + return len(df.deltas) > 0 +} + +func (c Comparer) filterExpected(expected []byte) ([]byte, error) { + if c.Vars != nil { + for k, v := range c.Vars.GetAll() { + j, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal var %s: %w", k, err) // Not wrapping to support go1.12. + } + + expected = bytes.ReplaceAll(expected, []byte(`"`+k+`"`), j) + } + } + + return expected, nil +} + +func (c Comparer) compare(expDecoded, actDecoded interface{}) (diff.Diff, error) { + switch v := expDecoded.(type) { + case []interface{}: + if actArray, ok := actDecoded.([]interface{}); ok { + return diff.New().CompareArrays(v, actArray), nil + } + + return nil, errors.New("types mismatch, array expected") + + case map[string]interface{}: + if actObject, ok := actDecoded.(map[string]interface{}); ok { + return diff.New().CompareObjects(v, actObject), nil + } + + return nil, errors.New("types mismatch, object expected") + + default: + if !reflect.DeepEqual(expDecoded, actDecoded) { // scalar value comparison + return nil, fmt.Errorf("values %v and %v are not equal", expDecoded, actDecoded) + } + } + + return nil, nil +} + +func unmarshal(data []byte, decoded interface{}) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + + return dec.Decode(decoded) +} + +func (c Comparer) fail(expected, actual []byte, ignoreAdded bool) error { + var expDecoded, actDecoded interface{} + + expected, err := c.filterExpected(expected) + if err != nil { + return err + } + + err = unmarshal(expected, &expDecoded) + if err != nil { + return fmt.Errorf("failed to unmarshal expected:\n%wv", err) + } + + err = unmarshal(actual, &actDecoded) + if err != nil { + return fmt.Errorf("failed to unmarshal actual:\n%wv", err) + } + + if s, ok := expDecoded.(string); ok && c.Vars != nil && c.Vars.IsVar(s) { + if c.varCollected(s, actDecoded) { + return nil + } + + if v, found := c.Vars.Get(s); found { + expDecoded = v + } + } + + diffValue, err := c.compare(expDecoded, actDecoded) + if err != nil { + return err + } + + if diffValue == nil { + return nil + } + + if !diffValue.Modified() { + return nil + } + + diffValue = &df{deltas: c.filterDeltas(diffValue.Deltas(), ignoreAdded)} + if !diffValue.Modified() { + return nil + } + + diffText, err := diff.NewASCIIFormatter(expDecoded, c.FormatterConfig).Format(diffValue) + if err != nil { + return fmt.Errorf("failed to format diff:\n%wv", err) + } + + diffText = c.reduceDiff(diffText) + + return errors.New("not equal:\n" + diffText) +} + +func (c Comparer) reduceDiff(diffText string) string { + if c.KeepFullDiff { + return diffText + } + + if c.FullDiffMaxLines == 0 { + c.FullDiffMaxLines = 50 + } + + if c.DiffSurroundingLines == 0 { + c.DiffSurroundingLines = 5 + } + + diffRows := strings.Split(diffText, "\n") + if len(diffRows) <= c.FullDiffMaxLines { + return diffText + } + + var result []string + + prev := 0 + + for i, r := range diffRows { + if len(r) == 0 { + continue + } + + if r[0] == '-' || r[0] == '+' { + start := i - c.DiffSurroundingLines + if start < prev { + start = prev + } else if start > prev { + result = append(result, "...") + } + + end := i + c.DiffSurroundingLines + if end >= len(diffRows) { + end = len(diffRows) - 1 + } + + prev = end + + for k := start; k < end; k++ { + result = append(result, diffRows[k]) + } + } + } + + if prev < len(diffRows)-1 { + result = append(result, "...") + } + + return strings.Join(result, "\n") +} diff --git a/diff/ascii.go b/diff/ascii.go new file mode 100644 index 0000000..84b5284 --- /dev/null +++ b/diff/ascii.go @@ -0,0 +1,397 @@ +package diff + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// NewASCIIFormatter creates a new ASCIIFormatter instance with the specified left data and configuration settings. +func NewASCIIFormatter(left interface{}, config ASCIIFormatterConfig) *ASCIIFormatter { + return &ASCIIFormatter{ + left: left, + config: config, + } +} + +// ASCIIFormatter is used to generate ASCII representations of differences between JSON-like data structures. +type ASCIIFormatter struct { + left interface{} + config ASCIIFormatterConfig + buffer *bytes.Buffer + path []string + size []int + inArray []bool + line *ASCIILine +} + +// ASCIIFormatterConfig specifies configuration options for formatting ASCII representations of data structures. +// ShowArrayIndex determines if array indices should be displayed in the formatted output. +// Coloring enables or disables colored output in the formatted result. +type ASCIIFormatterConfig struct { + ShowArrayIndex bool + Coloring bool +} + +// ASCIILine represents a line in an ASCII-formatted output with a marker, indentation, and a buffer containing content. +type ASCIILine struct { + marker string + indent int + buffer *bytes.Buffer +} + +// Format formats the differences between two JSON objects into an ASCII representation using the provided Diff. +func (f *ASCIIFormatter) Format(diff Diff) (result string, err error) { + f.buffer = bytes.NewBuffer([]byte{}) + f.path = []string{} + f.size = []int{} + f.inArray = []bool{} + + if v, ok := f.left.(map[string]interface{}); ok { + f.formatObject(v, diff) + } else if v, ok := f.left.([]interface{}); ok { + f.formatArray(v, diff) + } else { + return "", fmt.Errorf("expected map[string]interface{} or []interface{}, got %T", + f.left) + } + + return f.buffer.String(), nil +} + +func (f *ASCIIFormatter) formatObject(left map[string]interface{}, df Diff) { + f.addLineWith("{") + f.push("ROOT", len(left), false) + + if err := f.processObject(left, df.Deltas()); err != nil { + panic(err) + } + + f.pop() + f.addLineWith("}") +} + +func (f *ASCIIFormatter) formatArray(left []interface{}, df Diff) { + f.addLineWith("[") + f.push("ROOT", len(left), true) + + if err := f.processArray(left, df.Deltas()); err != nil { + panic(err) + } + + f.pop() + f.addLineWith("]") +} + +func (f *ASCIIFormatter) processArray(array []interface{}, deltas []Delta) error { + patchedIndex := 0 + + for index, value := range array { + if err := f.processItem(value, deltas, Index(index)); err != nil { + return err + } + + patchedIndex++ + } + + // additional Added + for _, delta := range deltas { + if d, ok := delta.(*Added); ok { + // skip items already processed + if int(d.Position.(Index)) < len(array) { + continue + } + + f.printRecursive(d.String(), d.Value, ASCIIAdded) + } + } + + return nil +} + +func (f *ASCIIFormatter) processObject(object map[string]interface{}, deltas []Delta) error { + names := sortedKeys(object) + for _, name := range names { + value := object[name] + if err := f.processItem(value, deltas, Name(name)); err != nil { + return err + } + } + + // Added + for _, delta := range deltas { + if dt, ok := delta.(*Added); ok { + d := dt + f.printRecursive(d.String(), d.Value, ASCIIAdded) + } + } + + return nil +} + +func (f *ASCIIFormatter) processItem(value interface{}, deltas []Delta, position Position) error { + matchedDeltas := f.searchDeltas(deltas, position) + positionStr := position.String() + + if len(matchedDeltas) > 0 { + for _, matchedDelta := range matchedDeltas { + switch d := matchedDelta.(type) { + case *Object: + switch value.(type) { + case map[string]interface{}: + // ok + default: + return errors.New("type mismatch") + } + + o := value.(map[string]interface{}) + + f.newLine(ASCIISame) + f.printKey(positionStr) + f.print("{") + f.closeLine() + f.push(positionStr, len(o), false) + + if err := f.processObject(o, d.Deltas); err != nil { + return err + } + + f.pop() + f.newLine(ASCIISame) + f.print("}") + f.printComma() + f.closeLine() + + case *Array: + switch value.(type) { + case []interface{}: + // ok + default: + return errors.New("type mismatch") + } + + a := value.([]interface{}) + + f.newLine(ASCIISame) + f.printKey(positionStr) + f.print("[") + f.closeLine() + f.push(positionStr, len(a), true) + + if err := f.processArray(a, d.Deltas); err != nil { + return err + } + + f.pop() + f.newLine(ASCIISame) + f.print("]") + f.printComma() + f.closeLine() + + case *Added: + f.printRecursive(positionStr, d.Value, ASCIIAdded) + + f.size[len(f.size)-1]++ + + case *Modified: + savedSize := f.size[len(f.size)-1] + f.printRecursive(positionStr, d.OldValue, ASCIIDeleted) + f.size[len(f.size)-1] = savedSize + f.printRecursive(positionStr, d.NewValue, ASCIIAdded) + + case *TextDiff: + savedSize := f.size[len(f.size)-1] + f.printRecursive(positionStr, d.OldValue, ASCIIDeleted) + f.size[len(f.size)-1] = savedSize + f.printRecursive(positionStr, d.NewValue, ASCIIAdded) + + case *Deleted: + f.printRecursive(positionStr, d.Value, ASCIIDeleted) + + default: + return errors.New("unknown Delta type detected") + } + } + } else { + f.printRecursive(positionStr, value, ASCIISame) + } + + return nil +} + +func (f *ASCIIFormatter) searchDeltas(deltas []Delta, position Position) (results []Delta) { + results = make([]Delta, 0) + + for _, delta := range deltas { + switch dt := delta.(type) { + case PostDelta: + if dt.PostPosition() == position { + results = append(results, delta) + } + case PreDelta: + if dt.PrePosition() == position { + results = append(results, delta) + } + default: + panic("heh") + } + } + + return results +} + +const ( + // ASCIISame represents the ASCII string " " used to indicate unchanged or identical elements in a comparison. + ASCIISame = " " + + // ASCIIAdded represents the ASCII string "+" used to indicate newly added items or elements in comparisons. + ASCIIAdded = "+" + + // ASCIIDeleted represents the ASCII string "-" used to indicate deleted items or removed elements. + ASCIIDeleted = "-" +) + +// ACSIIStyles is a map defining ANSI color styles for different ASCII markers used in formatting output. +var ACSIIStyles = map[string]string{ + ASCIIAdded: "30;42", + ASCIIDeleted: "30;41", +} + +func (f *ASCIIFormatter) push(name string, size int, array bool) { + f.path = append(f.path, name) + f.size = append(f.size, size) + f.inArray = append(f.inArray, array) +} + +func (f *ASCIIFormatter) pop() { + f.path = f.path[0 : len(f.path)-1] + f.size = f.size[0 : len(f.size)-1] + f.inArray = f.inArray[0 : len(f.inArray)-1] +} + +func (f *ASCIIFormatter) addLineWith(value string) { + f.line = &ASCIILine{ + marker: ASCIISame, + indent: len(f.path), + buffer: bytes.NewBufferString(value), + } + f.closeLine() +} + +func (f *ASCIIFormatter) newLine(marker string) { + f.line = &ASCIILine{ + marker: marker, + indent: len(f.path), + buffer: bytes.NewBuffer([]byte{}), + } +} + +func (f *ASCIIFormatter) closeLine() { + style, ok := ACSIIStyles[f.line.marker] + if f.config.Coloring && ok { + f.buffer.WriteString("\x1b[" + style + "m") + } + + f.buffer.WriteString(f.line.marker) + + for n := 0; n < f.line.indent; n++ { + f.buffer.WriteString(" ") + } + + f.buffer.Write(f.line.buffer.Bytes()) + + if f.config.Coloring && ok { + f.buffer.WriteString("\x1b[0m") + } + + f.buffer.WriteRune('\n') +} + +func (f *ASCIIFormatter) printKey(name string) { + if !f.inArray[len(f.inArray)-1] { + fmt.Fprintf(f.line.buffer, `"%s": `, name) + } else if f.config.ShowArrayIndex { + fmt.Fprintf(f.line.buffer, `%s: `, name) + } +} + +func (f *ASCIIFormatter) printComma() { + f.size[len(f.size)-1]-- + if f.size[len(f.size)-1] > 0 { + f.line.buffer.WriteRune(',') + } +} + +func (f *ASCIIFormatter) printValue(value interface{}) { + switch v := value.(type) { + case uint64: + fmt.Fprint(f.line.buffer, v) + case json.Number: + fmt.Fprint(f.line.buffer, v.String()) + case string: + fmt.Fprintf(f.line.buffer, `"%s"`, value) + case nil: + f.line.buffer.WriteString("null") + default: + fmt.Fprintf(f.line.buffer, `%#v`, value) + } +} + +func (f *ASCIIFormatter) print(a string) { + f.line.buffer.WriteString(a) +} + +func (f *ASCIIFormatter) printRecursive(name string, value interface{}, marker string) { + switch value := value.(type) { + case map[string]interface{}: + f.newLine(marker) + f.printKey(name) + f.print("{") + f.closeLine() + + m := value + size := len(m) + f.push(name, size, false) + + keys := sortedKeys(m) + for _, key := range keys { + f.printRecursive(key, m[key], marker) + } + + f.pop() + + f.newLine(marker) + f.print("}") + f.printComma() + f.closeLine() + + case []interface{}: + f.newLine(marker) + f.printKey(name) + f.print("[") + f.closeLine() + + s := value + size := len(s) + f.push("", size, true) + + for _, item := range s { + f.printRecursive("", item, marker) + } + + f.pop() + + f.newLine(marker) + f.print("]") + f.printComma() + f.closeLine() + + default: + f.newLine(marker) + f.printKey(name) + f.printValue(value) + f.printComma() + f.closeLine() + } +} diff --git a/diff/deltas.go b/diff/deltas.go new file mode 100644 index 0000000..bb4bcf2 --- /dev/null +++ b/diff/deltas.go @@ -0,0 +1,492 @@ +package diff + +import ( + "reflect" + "strconv" + + dmp "github.com/sergi/go-diff/diffmatchpatch" +) + +// A Delta represents an atomic difference between two JSON objects. +type Delta interface { + // Similarity calculates the similarity of the Delta values. + // The return value is normalized from 0 to 1, + // 0 is completely different and 1 is they are same + Similarity() (similarity float64) +} + +// To cache the calculated similarity, +// concrete Deltas can use similariter and similarityCache. +type similariter interface { + similarity() (similarity float64) +} + +type similarityCache struct { + similariter + + value float64 +} + +func newSimilarityCache(sim similariter) similarityCache { + cache := similarityCache{similariter: sim, value: -1} + + return cache +} + +func (cache similarityCache) Similarity() (similarity float64) { + if cache.value < 0 { + cache.value = cache.similarity() + } + + return cache.value +} + +// A Position represents the position of a Delta in an object or an array. +type Position interface { + // String returns the position as a string + String() (name string) + + // CompareTo returns a true if the Position is smaller than another Position. + // This function is used to sort Positions by the sort package. + CompareTo(another Position) bool +} + +// A Name is a Postition with a string, which means the delta is in an object. +type Name string + +// String returns the string representation of the Name. +func (n Name) String() (name string) { + return string(n) +} + +// CompareTo returns true if the Name is lexicographically less than the given Position, which must be of type Name. +func (n Name) CompareTo(another Position) bool { + return n < another.(Name) +} + +// Index is a Position with an int value, which means the Delta is in an Array. +type Index int + +// String converts the Index value to its string representation and returns it. +func (i Index) String() (name string) { + return strconv.Itoa(int(i)) +} + +// CompareTo compares the current Index with another Position and returns true if the current Index is smaller. +func (i Index) CompareTo(another Position) bool { + return i < another.(Index) +} + +// A PreDelta is a Delta that has a position of the left side JSON object. +// Deltas implements this interface should be applies before PostDeltas. +type PreDelta interface { + // PrePosition returns the Position. + PrePosition() Position + + // PreApply applies the delta to object. + PreApply(object interface{}) interface{} +} + +type preDelta struct{ Position } + +func (i preDelta) PrePosition() Position { + return i.Position +} + +type preDeltas []PreDelta + +// Len returns the number of elements in the preDeltas collection. +func (s preDeltas) Len() int { + return len(s) +} + +// Swap exchanges the elements at indices i and j in the preDeltas collection. +func (s preDeltas) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less reports whether the element with index i should sort before the element with index j in the preDeltas collection. +func (s preDeltas) Less(i, j int) bool { + return !s[i].PrePosition().CompareTo(s[j].PrePosition()) +} + +// PostDelta represents an interface for deltas applied post object modification. +// It requires methods to retrieve the position and apply the delta to an object. +type PostDelta interface { + // PostPosition returns the Position. + PostPosition() Position + + // PostApply applies the delta to object. + PostApply(object interface{}) interface{} +} + +type postDelta struct{ Position } + +func (i postDelta) PostPosition() Position { + return i.Position +} + +type postDeltas []PostDelta + +// Len returns the number of elements in the postDeltas slice. It is used for implementing the sort.Interface. +func (s postDeltas) Len() int { + return len(s) +} + +// Swap swaps the elements at indices i and j in the postDeltas slice. It is used to implement the sort.Interface. +func (s postDeltas) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less compares the PostPosition of elements at indices i and j and returns true if the i-th element is less than the j-th. +func (s postDeltas) Less(i, j int) bool { + return s[i].PostPosition().CompareTo(s[j].PostPosition()) +} + +// An Object is a Delta that represents an object of JSON. +type Object struct { + postDelta + similarityCache + + // Deltas holds internal Deltas + Deltas []Delta +} + +// NewObject returns an Object. +func NewObject(position Position, deltas []Delta) *Object { + d := Object{postDelta: postDelta{position}, Deltas: deltas} + d.similarityCache = newSimilarityCache(&d) + + return &d +} + +// PostApply processes the given object by applying deltas at positions determined by the object's type (map or slice). +func (d *Object) PostApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + n := string(d.PostPosition().(Name)) + o[n] = applyDeltas(d.Deltas, o[n]) + case []interface{}: + n := int(d.PostPosition().(Index)) + o[n] = applyDeltas(d.Deltas, o[n]) + } + + return object +} + +func (d *Object) similarity() (similarity float64) { + similarity = deltasSimilarity(d.Deltas) + + return similarity +} + +// An Array is a Delta that represents an array of JSON. +type Array struct { + postDelta + similarityCache + + // Deltas holds internal Deltas + Deltas []Delta +} + +// NewArray returns an Array. +func NewArray(position Position, deltas []Delta) *Array { + d := Array{postDelta: postDelta{position}, Deltas: deltas} + d.similarityCache = newSimilarityCache(&d) + + return &d +} + +// PostApply applies the stored deltas to the provided object based on their positions, modifying and returning the object. +func (d *Array) PostApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + n := string(d.PostPosition().(Name)) + o[n] = applyDeltas(d.Deltas, o[n]) + case []interface{}: + n := int(d.PostPosition().(Index)) + o[n] = applyDeltas(d.Deltas, o[n]) + } + + return object +} + +func (d *Array) similarity() (similarity float64) { + similarity = deltasSimilarity(d.Deltas) + + return similarity +} + +// An Added represents a new added field of an object or an array. +type Added struct { + postDelta + similarityCache + + // Values holds the added value + Value interface{} +} + +// NewAdded returns a new Added. +func NewAdded(position Position, value interface{}) *Added { + d := Added{postDelta: postDelta{position}, Value: value} + + return &d +} + +// PostApply applies the added value to the given object at the position specified by the PostPosition method. +func (d *Added) PostApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + object.(map[string]interface{})[string(d.PostPosition().(Name))] = d.Value + case []interface{}: + i := int(d.PostPosition().(Index)) + + if i < len(o) { + o = append(o, 0) // dummy + copy(o[i+1:], o[i:]) + o[i] = d.Value + + return o + } + + return append(o, d.Value) + } + + return object +} + +func (d *Added) similarity() (similarity float64) { + return 0 +} + +// A Modified represents a field whose value is changed. +type Modified struct { + postDelta + similarityCache + + // The value before modification + OldValue interface{} + + // The value after modification + NewValue interface{} +} + +// NewModified returns a Modified. +func NewModified(position Position, oldValue, newValue interface{}) *Modified { + d := Modified{ + postDelta: postDelta{position}, + OldValue: oldValue, + NewValue: newValue, + } + d.similarityCache = newSimilarityCache(&d) + + return &d +} + +// PostApply updates a map or slice at a specific position with a new value and returns the modified object. +func (d *Modified) PostApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + o[string(d.PostPosition().(Name))] = d.NewValue + case []interface{}: + o[(d.PostPosition().(Index))] = d.NewValue + } + + return object +} + +func (d *Modified) similarity() (similarity float64) { + similarity += 0.3 // at least, they are at the same position + if reflect.TypeOf(d.OldValue) == reflect.TypeOf(d.NewValue) { + similarity += 0.3 // types are same + + switch t := d.OldValue.(type) { + case string: + similarity += 0.4 * stringSimilarity(t, d.NewValue.(string)) + case float64: + ratio := t / d.NewValue.(float64) + if ratio > 1 { + ratio = 1 / ratio + } + + similarity += 0.4 * ratio + } + } + + return similarity +} + +// A TextDiff represents a Modified with TextDiff between the old and the new values. +type TextDiff struct { + Modified + + // Diff string + Diff []dmp.Patch +} + +// NewTextDiff creates a new TextDiff instance with the provided position, diff, oldValue, and newValue. +func NewTextDiff(position Position, diff []dmp.Patch, oldValue, newValue interface{}) *TextDiff { + d := TextDiff{ + Modified: *NewModified(position, oldValue, newValue), + Diff: diff, + } + + return &d +} + +// PostApply updates the provided object with the changes specified in the TextDiff and returns the modified object. +func (d *TextDiff) PostApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + i := string(d.PostPosition().(Name)) + d.OldValue = o[i] + d.patch() + o[i] = d.NewValue + case []interface{}: + i := d.PostPosition().(Index) + d.OldValue = o[i] + d.patch() + o[i] = d.NewValue + } + + return object +} + +func (d *TextDiff) patch() { + if d.OldValue == nil { + panic("old Value is not set") + } + + patcher := dmp.New() + + patched, successes := patcher.PatchApply(d.Diff, d.OldValue.(string)) + for _, success := range successes { + if !success { + panic("failed to apply a patch") + } + } + + d.NewValue = patched +} + +// DiffString returns the textual representation of the diff stored in the TextDiff instance. +func (d *TextDiff) DiffString() string { + dm := dmp.New() + + return dm.PatchToText(d.Diff) +} + +// Deleted represents a change where an element is removed from a map or slice at a specific position. +// It embeds preDelta to store positional metadata and includes the Value field to reference the deleted element. +type Deleted struct { + preDelta + + // The value deleted + Value interface{} +} + +// NewDeleted returns a Deleted. +func NewDeleted(position Position, value interface{}) *Deleted { + d := Deleted{ + preDelta: preDelta{position}, + Value: value, + } + + return &d +} + +// PreApply removes an element from a map or slice based on the position specified in the Deleted instance. +func (d Deleted) PreApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + delete(object.(map[string]interface{}), string(d.PrePosition().(Name))) + case []interface{}: + i := int(d.PrePosition().(Index)) + + return append(o[:i], o[i+1:]...) + } + + return object +} + +// Similarity calculates and returns the similarity for the Deleted delta type as a floating-point value. +func (d Deleted) Similarity() (similarity float64) { + return 0 +} + +// A Moved represents field that is moved, which means the index or name is +// changed. Note that, in this library, assigning a Moved and a Modified to +// a single position is not allowed. For the compatibility with jsondiffpatch, +// the Moved in this library can hold the old and new value in it. +type Moved struct { + preDelta + postDelta + similarityCache + + // The value before moving + Value interface{} + // The delta applied after moving (for compatibility) + Delta interface{} +} + +// NewMoved creates and returns a new Moved instance representing a field that has been relocated with old and new positions. +func NewMoved(oldPosition Position, newPosition Position, value interface{}, delta Delta) *Moved { + d := Moved{ + preDelta: preDelta{oldPosition}, + postDelta: postDelta{newPosition}, + Value: value, + Delta: delta, + } + d.similarityCache = newSimilarityCache(&d) + + return &d +} + +// PreApply modifies the given object by removing the element at the pre-move index and storing its value in the Moved instance. +func (d *Moved) PreApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + // not supported + case []interface{}: + i := int(d.PrePosition().(Index)) + d.Value = o[i] + + return append(o[:i], o[i+1:]...) + } + + return object +} + +// PostApply applies the stored delta after a move operation and adjusts the position of a value in the object. +func (d *Moved) PostApply(object interface{}) interface{} { + switch o := object.(type) { + case map[string]interface{}: + // not supported + case []interface{}: + i := int(d.PostPosition().(Index)) + + o = append(o, 0) // dummy + copy(o[i+1:], o[i:]) + o[i] = d.Value + object = o + } + + if d.Delta != nil { + d.Delta.(PostDelta).PostApply(object) + } + + return object +} + +func (d *Moved) similarity() (similarity float64) { + similarity = 0.6 // as type and contents are same + + ratio := float64(d.PrePosition().(Index)) / float64(d.PostPosition().(Index)) + if ratio > 1 { + ratio = 1 / ratio + } + + similarity += 0.4 * ratio + + return similarity +} diff --git a/diff/diff.go b/diff/diff.go new file mode 100644 index 0000000..b60f33c --- /dev/null +++ b/diff/diff.go @@ -0,0 +1,470 @@ +// Package diff implements "Diff" that compares two JSON objects and +// generates Deltas that describes differences between them. The package also +// provides "Patch" that apply Deltas to a JSON object. +// +// Updated copy from https://github.com/yudai/gojsondiff. +package diff + +import ( + "container/list" + "encoding/json" + "reflect" + "sort" + + "github.com/bool64/shared" + dmp "github.com/sergi/go-diff/diffmatchpatch" + lcs "github.com/yudai/golcs" +) + +// A Diff holds deltas generated by a Differ. +type Diff interface { + // Deltas returns Deltas that describe differences between two JSON objects + Deltas() []Delta + // Modified returns true if Diff has at least one Delta. + Modified() bool +} + +type diff struct { + deltas []Delta +} + +func (diff *diff) Deltas() []Delta { + return diff.deltas +} + +func (diff *diff) Modified() bool { + return len(diff.deltas) > 0 +} + +// A Differ compares JSON objects and apply patches. +type Differ struct { + textDiffMinimumLength int +} + +// New returns new Differ with default configuration. +func New() *Differ { + return &Differ{ + textDiffMinimumLength: 30, + } +} + +// Compare compares two JSON strings as []bytes and return a Diff object. +func (differ *Differ) Compare( + left []byte, + right []byte, +) (Diff, error) { + var leftMap, rightMap map[string]interface{} + + err := json.Unmarshal(left, &leftMap) + if err != nil { + return nil, err + } + + err = json.Unmarshal(right, &rightMap) + if err != nil { + return nil, err + } + + return differ.CompareObjects(leftMap, rightMap), nil +} + +// CompareObjects compares two JSON object as map[string]interface{} +// and return a Diff object. +func (differ *Differ) CompareObjects( + left map[string]interface{}, + right map[string]interface{}, +) Diff { + deltas := differ.compareMaps(left, right) + + return &diff{deltas: deltas} +} + +// CompareArrays compares two JSON arrays as []interface{} +// and return a Diff object. +func (differ *Differ) CompareArrays( + left []interface{}, + right []interface{}, +) Diff { + deltas := differ.compareArrays(left, right) + + return &diff{deltas: deltas} +} + +func (differ *Differ) compareMaps( + left map[string]interface{}, + right map[string]interface{}, +) (deltas []Delta) { + deltas = make([]Delta, 0) + + names := sortedKeys(left) // stabilize delta order + for _, name := range names { + if rightValue, ok := right[name]; ok { + same, delta := differ.compareValues(Name(name), left[name], rightValue) + if !same { + deltas = append(deltas, delta) + } + } else { + deltas = append(deltas, NewDeleted(Name(name), left[name])) + } + } + + names = sortedKeys(right) // stabilize delta order + for _, name := range names { + if _, ok := left[name]; !ok { + deltas = append(deltas, NewAdded(Name(name), right[name])) + } + } + + return deltas +} + +// ApplyPatch applies a Diff to an JSON object. This method is destructive. +func (differ *Differ) ApplyPatch(json map[string]interface{}, patch Diff) { + applyDeltas(patch.Deltas(), json) +} + +type maybe struct { + index int + lcsIndex int + item interface{} +} + +func (differ *Differ) compareArrays( + left []interface{}, + right []interface{}, +) (deltas []Delta) { + deltas = make([]Delta, 0) + // LCS index pairs + lcsPairs := lcs.New(left, right).IndexPairs() + + // list up items not in LCS, they are maybe deleted + maybeDeleted := list.New() // but maybe moved or modified + + lcsI := 0 + for i, leftValue := range left { + if lcsI < len(lcsPairs) && lcsPairs[lcsI].Left == i { + lcsI++ + } else { + maybeDeleted.PushBack(maybe{index: i, lcsIndex: lcsI, item: leftValue}) + } + } + + // list up items not in LCS, they are maybe Added + maybeAdded := list.New() // but maybe moved or modified + + lcsI = 0 + for i, rightValue := range right { + if lcsI < len(lcsPairs) && lcsPairs[lcsI].Right == i { + lcsI++ + } else { + maybeAdded.PushBack(maybe{index: i, lcsIndex: lcsI, item: rightValue}) + } + } + + // find moved items + var delNext *list.Element // for prefetch to remove item in iteration + + for delCandidate := maybeDeleted.Front(); delCandidate != nil; delCandidate = delNext { + delCan := delCandidate.Value.(maybe) + delNext = delCandidate.Next() + + for addCandidate := maybeAdded.Front(); addCandidate != nil; addCandidate = addCandidate.Next() { + addCan := addCandidate.Value.(maybe) + if reflect.DeepEqual(delCan.item, addCan.item) { + deltas = append(deltas, NewMoved(Index(delCan.index), Index(addCan.index), delCan.item, nil)) + + maybeAdded.Remove(addCandidate) + maybeDeleted.Remove(delCandidate) + + break + } + } + } + + // find modified or add+del + prevIndexDel := 0 + prevIndexAdd := 0 + delElement := maybeDeleted.Front() + addElement := maybeAdded.Front() + + for i := 0; i <= len(lcsPairs); i++ { // not "< len(lcsPairs)" + var lcsPair lcs.IndexPair + + var delSize, addSize int + + if i < len(lcsPairs) { + lcsPair = lcsPairs[i] + delSize = lcsPair.Left - prevIndexDel - 1 + addSize = lcsPair.Right - prevIndexAdd - 1 + prevIndexDel = lcsPair.Left + prevIndexAdd = lcsPair.Right + } + + var delSlice []maybe + if delSize > 0 { + delSlice = make([]maybe, 0, delSize) + } else { + delSlice = make([]maybe, 0, maybeDeleted.Len()) + } + + for ; delElement != nil; delElement = delElement.Next() { + d := delElement.Value.(maybe) + if d.lcsIndex != i { + break + } + + delSlice = append(delSlice, d) + } + + var addSlice []maybe + if addSize > 0 { + addSlice = make([]maybe, 0, addSize) + } else { + addSlice = make([]maybe, 0, maybeAdded.Len()) + } + + for ; addElement != nil; addElement = addElement.Next() { + a := addElement.Value.(maybe) + if a.lcsIndex != i { + break + } + + addSlice = append(addSlice, a) + } + + if len(delSlice) > 0 && len(addSlice) > 0 { + var bestDeltas []Delta + bestDeltas, delSlice, addSlice = differ.maximizeSimilarities(delSlice, addSlice) + + deltas = append(deltas, bestDeltas...) + } + + for _, del := range delSlice { + deltas = append(deltas, NewDeleted(Index(del.index), del.item)) + } + + for _, add := range addSlice { + deltas = append(deltas, NewAdded(Index(add.index), add.item)) + } + } + + return deltas +} + +func (differ *Differ) compareValues( + position Position, + left interface{}, + right interface{}, +) (same bool, delta Delta) { + if reflect.TypeOf(left) != reflect.TypeOf(right) { + return false, NewModified(position, left, right) + } + + switch l := left.(type) { + case map[string]interface{}: + childDeltas := differ.compareMaps(l, right.(map[string]interface{})) + if len(childDeltas) > 0 { + return false, NewObject(position, childDeltas) + } + + case []interface{}: + childDeltas := differ.compareArrays(l, right.([]interface{})) + + if len(childDeltas) > 0 { + return false, NewArray(position, childDeltas) + } + + default: + if !reflect.DeepEqual(left, right) { + if ln, ok := left.(json.Number); ok { + left = shared.DecodeJSONNumber(ln) + } + + if rn, ok := right.(json.Number); ok { + right = shared.DecodeJSONNumber(rn) + } + + if reflect.ValueOf(left).Kind() == reflect.String && + reflect.ValueOf(right).Kind() == reflect.String && + differ.textDiffMinimumLength <= len(reflect.ValueOf(left).String()) { + textDiff := dmp.New() + patches := textDiff.PatchMake(reflect.ValueOf(left).String(), reflect.ValueOf(right).String()) + + return false, NewTextDiff(position, patches, left, right) + } + + return false, NewModified(position, left, right) + } + } + + return true, nil +} + +func applyDeltas(deltas []Delta, object interface{}) interface{} { + preDeltas := make(preDeltas, 0) + + for _, delta := range deltas { + if dt, ok := delta.(PreDelta); ok { + preDeltas = append(preDeltas, dt) + } + } + + sort.Sort(preDeltas) + + for _, delta := range preDeltas { + object = delta.PreApply(object) + } + + postDeltas := make(postDeltas, 0, len(deltas)-len(preDeltas)) + + for _, delta := range deltas { + if dt, ok := delta.(PostDelta); ok { + postDeltas = append(postDeltas, dt) + } + } + + sort.Sort(postDeltas) + + for _, delta := range postDeltas { + object = delta.PostApply(object) + } + + return object +} + +func (differ *Differ) maximizeSimilarities(left []maybe, right []maybe) (resultDeltas []Delta, freeLeft, freeRight []maybe) { + deltaTable := make([][]Delta, len(left)) + + for i := 0; i < len(left); i++ { + deltaTable[i] = make([]Delta, len(right)) + } + + for i, leftValue := range left { + for j, rightValue := range right { + _, delta := differ.compareValues(Index(rightValue.index), leftValue.item, rightValue.item) + deltaTable[i][j] = delta + } + } + + sizeX := len(left) + 1 // margins for both sides + sizeY := len(right) + 1 + + // fill out with similarities + dpTable := make([][]float64, sizeX) + + for i := 0; i < sizeX; i++ { + dpTable[i] = make([]float64, sizeY) + } + + for x := sizeX - 2; x >= 0; x-- { + for y := sizeY - 2; y >= 0; y-- { + prevX := dpTable[x+1][y] + prevY := dpTable[x][y+1] + score := deltaTable[x][y].Similarity() + dpTable[x+1][y+1] + + dpTable[x][y] = maxFloat(prevX, prevY, score) + } + } + + minLength := len(left) + + if minLength > len(right) { + minLength = len(right) + } + + maxInvalidLength := minLength - 1 + + freeLeft = make([]maybe, 0, len(left)-minLength) + freeRight = make([]maybe, 0, len(right)-minLength) + + resultDeltas = make([]Delta, 0, minLength) + + var x, y int + + for x, y = 0, 0; x <= sizeX-2 && y <= sizeY-2; { + current := dpTable[x][y] + nextX := dpTable[x+1][y] + nextY := dpTable[x][y+1] + + xValidLength := len(left) - maxInvalidLength + y + yValidLength := len(right) - maxInvalidLength + x + + switch { + case x+1 < xValidLength && current == nextX: + freeLeft = append(freeLeft, left[x]) + x++ + case y+1 < yValidLength && current == nextY: + freeRight = append(freeRight, right[y]) + y++ + default: + resultDeltas = append(resultDeltas, deltaTable[x][y]) + x++ + y++ + } + } + + for ; x < sizeX-1; x++ { + freeLeft = append(freeLeft, left[x-1]) + } + + for ; y < sizeY-1; y++ { + freeRight = append(freeRight, right[y-1]) + } + + return resultDeltas, freeLeft, freeRight +} + +func deltasSimilarity(deltas []Delta) float64 { + similarity := float64(0) + + for _, delta := range deltas { + similarity += delta.Similarity() + } + + similarity /= float64(len(deltas)) + + return similarity +} + +func stringSimilarity(left, right string) (similarity float64) { + matchingLength := float64( + lcs.New( + stringToInterfaceSlice(left), + stringToInterfaceSlice(right), + ).Length(), + ) + similarity = (matchingLength / float64(len(left))) * (matchingLength / float64(len(right))) + + return similarity +} + +func stringToInterfaceSlice(str string) []interface{} { + s := make([]interface{}, len(str)) + for i, v := range str { + s[i] = v + } + + return s +} + +func sortedKeys(m map[string]interface{}) (keys []string) { + keys = make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + + sort.Strings(keys) + + return keys +} + +func maxFloat(first float64, rest ...float64) float64 { + m := first + for _, value := range rest { + if m < value { + m = value + } + } + + return m +} diff --git a/equal.go b/equal.go index a19a768..da91716 100644 --- a/equal.go +++ b/equal.go @@ -2,17 +2,11 @@ package assertjson import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "reflect" "strings" "github.com/bool64/shared" "github.com/stretchr/testify/assert" - "github.com/yudai/gojsondiff" - "github.com/yudai/gojsondiff/formatter" + "github.com/swaggest/assertjson/diff" ) // Comparer compares JSON documents. @@ -24,7 +18,7 @@ type Comparer struct { Vars *shared.Vars // FormatterConfig controls diff formatter configuration. - FormatterConfig formatter.AsciiFormatterConfig + FormatterConfig diff.ASCIIFormatterConfig // KeepFullDiff shows full diff in error message. KeepFullDiff bool @@ -106,80 +100,6 @@ func (c Comparer) EqualMarshal(t TestingT, expected []byte, actualValue interfac return c.Equal(t, expected, actual, msgAndArgs...) } -func (c Comparer) varCollected(s string, v interface{}) bool { - if c.Vars != nil && c.Vars.IsVar(s) { - if _, found := c.Vars.Get(s); !found { - if f, ok := v.(float64); ok && f == float64(int64(f)) { - v = int64(f) - } - - c.Vars.Set(s, v) - - return true - } - } - - return false -} - -func (c Comparer) filterDeltas(deltas []gojsondiff.Delta, ignoreAdded bool) []gojsondiff.Delta { - result := make([]gojsondiff.Delta, 0, len(deltas)) - - for _, delta := range deltas { - switch v := delta.(type) { - case *gojsondiff.Modified: - if c.IgnoreDiff == "" && c.Vars == nil { - break - } - - if s, ok := v.OldValue.(string); ok { - if s == c.IgnoreDiff { // discarding ignored diff - continue - } - - if c.varCollected(s, v.NewValue) { - continue - } - } - case *gojsondiff.Object: - v.Deltas = c.filterDeltas(v.Deltas, ignoreAdded) - if len(v.Deltas) == 0 { - continue - } - - delta = v - case *gojsondiff.Array: - v.Deltas = c.filterDeltas(v.Deltas, ignoreAdded) - if len(v.Deltas) == 0 { - continue - } - - delta = v - - case *gojsondiff.Added: - if ignoreAdded { - continue - } - } - - result = append(result, delta) - } - - return result -} - -type diff struct { - deltas []gojsondiff.Delta -} - -func (diff *diff) Deltas() []gojsondiff.Delta { - return diff.deltas -} - -func (diff *diff) Modified() bool { - return len(diff.deltas) > 0 -} - // FailNotEqual returns error if JSON payloads are different, nil otherwise. func FailNotEqual(expected, actual []byte) error { return defaultComparer.FailNotEqual(expected, actual) @@ -190,46 +110,6 @@ func FailNotEqualMarshal(expected []byte, actualValue interface{}) error { return defaultComparer.FailNotEqualMarshal(expected, actualValue) } -func (c Comparer) filterExpected(expected []byte) ([]byte, error) { - if c.Vars != nil { - for k, v := range c.Vars.GetAll() { - j, err := json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("failed to marshal var %s: %v", k, err) // Not wrapping to support go1.12. - } - - expected = bytes.Replace(expected, []byte(`"`+k+`"`), j, -1) //nolint:gocritic,staticcheck // To support go1.11. - } - } - - return expected, nil -} - -func (c Comparer) compare(expDecoded, actDecoded interface{}) (gojsondiff.Diff, error) { - switch v := expDecoded.(type) { - case []interface{}: - if actArray, ok := actDecoded.([]interface{}); ok { - return gojsondiff.New().CompareArrays(v, actArray), nil - } - - return nil, errors.New("types mismatch, array expected") - - case map[string]interface{}: - if actObject, ok := actDecoded.(map[string]interface{}); ok { - return gojsondiff.New().CompareObjects(v, actObject), nil - } - - return nil, errors.New("types mismatch, object expected") - - default: - if !reflect.DeepEqual(expDecoded, actDecoded) { // scalar value comparison - return nil, fmt.Errorf("values %v and %v are not equal", expDecoded, actDecoded) - } - } - - return nil, nil -} - // FailNotEqualMarshal returns error if expected JSON payload is not equal to marshaled actual value. func (c Comparer) FailNotEqualMarshal(expected []byte, actualValue interface{}) error { actual, err := MarshalIndentCompact(actualValue, "", " ", 80) @@ -245,117 +125,6 @@ func (c Comparer) FailNotEqual(expected, actual []byte) error { return c.fail(expected, actual, false) } -func (c Comparer) fail(expected, actual []byte, ignoreAdded bool) error { - var expDecoded, actDecoded interface{} - - expected, err := c.filterExpected(expected) - if err != nil { - return err - } - - err = json.Unmarshal(expected, &expDecoded) - if err != nil { - return fmt.Errorf("failed to unmarshal expected:\n%+v", err) - } - - err = json.Unmarshal(actual, &actDecoded) - if err != nil { - return fmt.Errorf("failed to unmarshal actual:\n%+v", err) - } - - if s, ok := expDecoded.(string); ok && c.Vars != nil && c.Vars.IsVar(s) { - if c.varCollected(s, actDecoded) { - return nil - } - - if v, found := c.Vars.Get(s); found { - expDecoded = v - } - } - - diffValue, err := c.compare(expDecoded, actDecoded) - if err != nil { - return err - } - - if diffValue == nil { - return nil - } - - if !diffValue.Modified() { - return nil - } - - diffValue = &diff{deltas: c.filterDeltas(diffValue.Deltas(), ignoreAdded)} - if !diffValue.Modified() { - return nil - } - - diffText, err := formatter.NewAsciiFormatter(expDecoded, c.FormatterConfig).Format(diffValue) - if err != nil { - return fmt.Errorf("failed to format diff:\n%+v", err) - } - - diffText = c.reduceDiff(diffText) - - return errors.New("not equal:\n" + diffText) -} - -func (c Comparer) reduceDiff(diffText string) string { - if c.KeepFullDiff { - return diffText - } - - if c.FullDiffMaxLines == 0 { - c.FullDiffMaxLines = 50 - } - - if c.DiffSurroundingLines == 0 { - c.DiffSurroundingLines = 5 - } - - diffRows := strings.Split(diffText, "\n") - if len(diffRows) <= c.FullDiffMaxLines { - return diffText - } - - var result []string - - prev := 0 - - for i, r := range diffRows { - if len(r) == 0 { - continue - } - - if r[0] == '-' || r[0] == '+' { - start := i - c.DiffSurroundingLines - if start < prev { - start = prev - } else if start > prev { - result = append(result, "...") - } - - end := i + c.DiffSurroundingLines - if end >= len(diffRows) { - end = len(diffRows) - 1 - } - - prev = end - - for k := start; k < end; k++ { - result = append(result, diffRows[k]) - } - } - } - - if prev < len(diffRows)-1 { - result = append(result, "...") - } - - return strings.Join(result, "\n") -} - // EqMarshal marshals actual value and compares two JSON documents ignoring string values "". func EqMarshal(t TestingT, expected string, actualValue interface{}, msgAndArgs ...interface{}) bool { if h, ok := t.(tHelper); ok { diff --git a/equal_test.go b/equal_test.go index f2d1d29..f86da9e 100644 --- a/equal_test.go +++ b/equal_test.go @@ -59,8 +59,8 @@ func TestEquals_message(t *testing.T) { assert.Equal(t, "\n%s", format) assert.Len(t, args, 1) - assert.Equal(t, ` Error Trace: equal.go:88 - equal.go:63 + assert.Equal(t, ` Error Trace: equal.go:82 + equal.go:57 equal_test.go:58 Error: Not equal: { @@ -100,7 +100,6 @@ func run( equal func(t assertjson.TestingT, expected, actual []byte, msgAndArgs ...interface{}) bool, ) { t.Helper() - t.Parallel() tt := testingT(func(format string, args ...interface{}) {}) @@ -114,6 +113,8 @@ func run( } func TestComparer_Equal_EmptyIgnoreDiff(t *testing.T) { + t.Parallel() + c := assertjson.Comparer{} run(t, []testcase{ @@ -237,7 +238,32 @@ func TestComparer_Equal_vars_scalar(t *testing.T) { b, found := v.Get("$varB") assert.True(t, found) - assert.Equal(t, []interface{}{123.0}, b) + assert.Equal(t, []interface{}{int64(123)}, b) + + assert.NoError(t, c.FailNotEqual([]byte(`"$varC"`), []byte(`{"a":17294094973108486143}`))) + assert.EqualError(t, c.FailNotEqual([]byte(`"$varC"`), []byte(`{"a":17294094973108486144}`)), + "not equal:\n {\n- \"a\": 17294094973108486143\n+ \"a\": 17294094973108486144\n }\n") + assert.NoError(t, c.FailNotEqual([]byte(`"$varC"`), []byte(`{"a":17294094973108486143}`))) + + c1, found := v.Get("$varC") + + assert.True(t, found) + assert.Equal(t, map[string]interface{}{"a": uint64(17294094973108486143)}, c1) +} + +func TestComparer_Equal_vars_uint64(t *testing.T) { + v := &shared.Vars{} + c := assertjson.Comparer{Vars: v} + + assert.NoError(t, c.FailNotEqual([]byte(`"$varC"`), []byte(`{"a":17294094973108486143}`))) + assert.EqualError(t, c.FailNotEqual([]byte(`"$varC"`), []byte(`{"a":17294094973108486144}`)), + "not equal:\n {\n- \"a\": 17294094973108486143\n+ \"a\": 17294094973108486144\n }\n") + assert.NoError(t, c.FailNotEqual([]byte(`"$varC"`), []byte(`{"a":17294094973108486143}`))) + + c1, found := v.Get("$varC") + + assert.True(t, found) + assert.Equal(t, map[string]interface{}{"a": uint64(17294094973108486143)}, c1) } func TestComparer_Equal_long(t *testing.T) { diff --git a/example_test.go b/example_test.go index 7097c48..78bfe0b 100644 --- a/example_test.go +++ b/example_test.go @@ -17,8 +17,8 @@ func Example() { ) // Output: - // Error Trace: equal.go:88 - // equal.go:63 + // Error Trace: equal.go:82 + // equal.go:57 // example_test.go:14 // Error: Not equal: // { diff --git a/go.mod b/go.mod index 56c5685..41a2f10 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,16 @@ go 1.17 require ( github.com/bool64/dev v0.2.43 - github.com/bool64/shared v0.1.5 + github.com/bool64/shared v0.1.6 github.com/iancoleman/orderedmap v0.3.0 + github.com/sergi/go-diff v1.4.0 github.com/stretchr/testify v1.4.0 github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff - github.com/yudai/gojsondiff v1.0.0 + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/mattn/go-colorable v0.1.8 // indirect - github.com/onsi/ginkgo v1.15.2 // indirect - github.com/onsi/gomega v1.11.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sergi/go-diff v1.1.0 // indirect - github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect - github.com/yudai/pp v2.0.1+incompatible // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index e6f44d8..bb9d246 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,10 @@ -github.com/bool64/dev v0.2.17/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/bool64/shared v0.1.6 h1:1u1IfTU84pZU285Mf1kQC5wX/VzSRE5E/+4KgFRGQ6o= +github.com/bool64/shared v0.1.6/go.mod h1:AByMlOFBjavJDk8VdFBH/atMgv1q7qrKXD1XLAQTgZA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -28,87 +12,20 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.15.2 h1:l77YT15o814C2qVL47NOyjV/6RbaP7kKdrvZnxQ3Org= -github.com/onsi/ginkgo v1.15.2/go.mod h1:Dd6YFfwBW84ETqqtL0CPyPXillHgY6XhQH3uuCCTr/o= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.11.0 h1:+CqWgvj0OZycCaqclBD1pxKHAU+tOkHmQIWvDHq2aug= -github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff h1:7YqG491bE4vstXRz1lD38rbSgbXnirvROz1lZiOnPO8= github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= -github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091 h1:DMyOG0U+gKfu8JZzg2UQe9MeaC1X+xQWlAKcRnjxjCw= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/json5/json5.go b/json5/json5.go index cc32ccf..fa092d5 100644 --- a/json5/json5.go +++ b/json5/json5.go @@ -60,7 +60,7 @@ func Unmarshal(data []byte, v interface{}) error { // Second decode to make sure there is only one JSON5 value in data and no garbage in tail. err = dec.Decode(&tail) - if err != io.EOF { + if !errors.Is(err, io.EOF) { return errors.New("unexpected bytes after JSON5 payload") }