diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 76b614e..b0ef5dc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,59 +1,84 @@ -# Development Guidelines +# CLAUDE.md -This document contains the critical information about working with the project codebase. -Follows these guidelines precisely to ensure consistency and maintainability of the code. +This file provides guidance to Claude Code (claude.ai/code) and other AI +assistants when working with code in this repository. Follow these guidelines +precisely to ensure consistency and maintainability. ## Stack -- Language: Go (Go 1.26) -- Framework: Go standard library +- Language: Go (Go 1.26+) +- Framework: Go standard library **only** — this library is intentionally + dependency-free - Testing: Go's built-in testing package - Dependency Management: Go modules - Version Control: Git -- Documentation: go doc +- Documentation: `go doc` / pkg.go.dev, and Markdown files under `docs/` - Code Review: Pull requests on GitHub - CI/CD: GitHub Actions -## Project Structure - -Since this is a library built in native Go, the files are organized following the -standard Go single-package layout in the repository root. - -- Library files are located in the root directory. -- `*_examples_test.go` files contain executable Go examples rendered by pkg.go.dev. -- .github/ contains GitHub-specific files such as workflows for CI/CD. -- .gitignore specifies files and directories to be ignored by Git. -- .vscode/ contains Visual Studio Code configuration files. -- LICENSE is the license file for the project. -- README.md provides an overview of the project, installation instructions, usage examples, and other relevant information. -- go.mod declares the module and Go toolchain version. -- go.sum should not exist unless external dependencies are intentionally introduced. -- \*.go files contain the main source code of the library. -- \*\_test.go files contain the test cases for the library. +## Key Conventions -## Code Style - -- Follow Go's idiomatic style defined in +- **Style:** Follow the Google Go Style Guide and Effective Go: - - - - - + - +- Keep functions small and focused on a single task; use dependency injection + for testability. - Use meaningful names for variables, functions, and packages. -- Keep functions small and focused on a single task. - Use comments to explain complex logic or decisions. +- Use `any`, never `interface{}`. - Prefer `for b.Loop()` over `for i := 0; i < b.N; i++` in benchmarks (Go 1.24+). -- don't use `interface{}` instead use `any` for better readability. +- **Tests:** Table-driven tests where practical. Test files are co-located with + source (`*_test.go`). Executable examples live in `*_examples_test.go` and are + verified by `go test`. +- **No external dependencies:** Do not add third-party modules without explicit + approval. `go.sum` should not exist unless a dependency is intentionally + introduced. + +## Project Structure + +This is a single-package Go library; source files live in the repository root. + +- `*.go` — library source code. +- `*_test.go` — unit tests and benchmarks. +- `*_examples_test.go` — executable examples rendered by pkg.go.dev. +- `doc.go` — package-level documentation. +- `docs/` — extensive usage documentation in Markdown. +- `.github/` — GitHub Actions workflows, CodeQL, Dependabot, release metadata. +- `.golangci.yaml` — optional local golangci-lint configuration. +- `.vscode/` — editor settings. +- `LICENSE` — Apache License 2.0. +- `README.md` — project overview and usage guide. +- `SECURITY.md` — vulnerability reporting policy. +- `go.mod` — module definition with no external requirements. ## Post-Change Checklist -Use standard Go commands after making changes: +Always run these after making changes, before committing: ```bash -go fix ./... -go fmt ./... -go vet ./... +go fmt ./... # Format code +go fix ./... # Apply Go 1.26+ modernizations +betteralign -apply ./... # Align struct fields for optimal memory layout (if installed) +go vet ./... # Vet +golangci-lint run ./... # Lint (if installed) go test -race -coverprofile=/tmp/comparator-coverage.txt -covermode=atomic ./... -go build ./... +go build ./... # Verify build ``` -Do not add external module dependencies without explicit approval; this project is intentionally standard-library-only. +Do not add external module dependencies without explicit approval; this project +is intentionally standard-library-only. + +## Commit Message & Pull Request Guidelines + +- Always work on a new branch unless explicitly told to use the current one. +- Use semantic (Conventional Commits) messages, e.g. `feat:`, `fix:`, `docs:`, + `test:`, `chore:`, `refactor:`. +- Keep the commit subject under 72 characters and the whole message concise. +- Group related changes into focused commits rather than one large commit. +- Open pull requests against `main` with a semantic title (e.g. + `feat: add JSON-tag-aware paths`) and a description that summarizes the + changes and references any related issues. +- Keep changes small, idiomatic, tested, documented, and dependency-free unless + there is a clear reason to expand the project scope. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..02dd134 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/README.md b/README.md index 8b162fc..44717d8 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,17 @@ Only the Go standard library is used. There are no third-party dependencies. - 🔍 **Difference detection** — detailed, path-addressed reports of what changed - 🎨 **Multiple output formats** — text (with ANSI color), JSON, Markdown, HTML - 📐 **Unified diff** — Unix `diff`-style output -- 🩹 **JSON Patch** — RFC 6902 patch documents +- 🩹 **JSON Patch** — generate *and apply* RFC 6902 patch documents - 🌳 **Visual tree diff** — hierarchical representation for viewers -- 🧠 **Custom comparators** — register domain-specific equality per type -- 📊 **Statistics & suggestions** — metrics and actionable hints +- 🧠 **Custom equality** — per-type comparators plus auto-detected + `Equatable`/`Comparable` interfaces +- 🔤 **Type-safe generics** — `EqualT[T]`, `DiffT[T]`, and friends +- ⏱️ **Context-aware** — cancel or bound long comparisons with `context.Context` +- 📡 **Streaming reporter** — observe each difference as it is found +- 🎨 **Pluggable formatters** — register custom output formats (CSV, JUnit, …) +- 🧪 **Testing helpers** — `AssertEqual` / `RequireEqual` for test suites - ⚙️ **Configurable behavior** — float precision, slice-order insensitivity, - field ignoring, depth limits, NaN handling, and more + field/path/map-key ignoring, depth limits, NaN handling, and more - 🚫 **Zero third-party dependencies** — standard library only - 📄 **Apache-2.0 licensed** @@ -131,8 +136,10 @@ on [pkg.go.dev](https://pkg.go.dev/github.com/slashdevops/comparator). | ⚙️ [Configuration & Options](docs/configuration.md) | Every option, its default, and when to use it. | | 🔍 [Diffing](docs/diffing.md) | `DiffResult`, `Difference`, diff modes. | | 🎨 [Output Formats](docs/output-formats.md) | Text/color, JSON, Markdown, HTML, unified, visual. | -| 🩹 [JSON Patch](docs/json-patch.md) | Generating RFC 6902 patch documents. | -| 🧠 [Custom Comparators](docs/custom-comparators.md) | Domain-specific equality logic. | +| 🩹 [JSON Patch](docs/json-patch.md) | Generating and applying RFC 6902 patches. | +| 🧠 [Custom Comparators](docs/custom-comparators.md) | Custom equality + `Equatable`/`Comparable`. | +| 🧩 [Extensibility](docs/extensibility.md) | Generics, reporters, context, pluggable formatters. | +| 🧪 [Testing](docs/testing.md) | `AssertEqual` and friends for test suites. | | ⚡ [Performance](docs/performance.md) | Cost model, benchmarks, tuning. | | ❓ [FAQ](docs/faq.md) | Gotchas, thread-safety, common questions. | @@ -144,17 +151,26 @@ on [pkg.go.dev](https://pkg.go.dev/github.com/slashdevops/comparator). | `IgnoreSliceOrder()` | `false` | Treat slices as sets (ignore order). | | `WithMaxDepth(int)` | `0` (unlimited) | Limit recursion depth. | | `IgnoreUnexported()` | `false` | Skip unexported struct fields. | -| `EquateEmpty()` | `true` | Treat nil and empty containers as equal. | +| `WithEquateEmpty(bool)` | `true` | Toggle nil/empty-container equivalence (both directions). | | `EquateNaNs()` | `false` | Treat NaN values as equal. | | `IgnoreStructFields(...string)` | none | Skip specific struct fields by name. | +| `WithIgnorePaths(...string)` | none | Skip struct fields at exact canonical paths. | +| `WithIgnorePathPatterns(...string)` | none | Skip struct fields whose path matches a regexp. | +| `WithIgnoreMapKeys(...string)` | none | Skip map entries by key. | +| `WithFieldNaming(FieldNaming)` | `GoFieldNaming` | Use Go field names or `json` tag names in paths/pointers. | | `WithTimeLayout(string)` | `time.RFC3339Nano` | Layout for rendering `time.Time`. | | `WithCustomComparator[T](func(T, T) bool)` | none | Register custom equality for a type. | +| `WithReporter(func(Difference))` | none | Stream each difference to a callback. | | `WithDiffMode(DiffMode)` | `DiffModeSimple` | Diff detail level. | | `WithMaxDiffs(int)` | `1000` | Cap the number of differences collected. | -| `WithOutputFormat(string)` | `"text"` | `text`, `json`, `markdown`, or `html`. | +| `WithOutputFormat(string)` | `"text"` | `text`, `json`, `markdown`, `html`, or a registered format. | | `WithColorize(bool)` | `false` | ANSI colors in text output. | | `WithIncludeEqual(bool)` | `false` | Include equal values in reports. | +Struct fields tagged `comparator:"-"` are always skipped. See +[Configuration & Options](docs/configuration.md) and +[Extensibility](docs/extensibility.md) for the full set. + See [Configuration & Options](docs/configuration.md) for details. ## 🧵 Thread Safety @@ -199,12 +215,19 @@ go build ./... |-- .github/ GitHub Actions, CodeQL, Dependabot, release metadata |-- .golangci.yaml Optional local golangci-lint configuration |-- docs/ Extensive usage documentation +|-- AGENTS.md AI assistant guidance (symlink to copilot-instructions) |-- doc.go Package documentation rendered by pkg.go.dev -|-- comparator.go Public comparison and diffing API -|-- comparator_test.go Unit tests and benchmarks -|-- comparator_examples_test.go Executable examples -|-- comparator_api_examples_test.go Executable examples -|-- comparator_options_examples_test.go Executable examples +|-- comparator.go Core comparison and diffing engine +|-- errors.go Sentinel errors +|-- generics.go Type-safe generic API (EqualT, DiffT, ...) +|-- context.go Context-aware comparison +|-- patch.go ApplyJSONPatch (RFC 6902 applier) +|-- formatters.go Pluggable output formatter registry +|-- options_ext.go Additional functional options +|-- stringer.go String() helpers for diff types +|-- assert.go Testing helpers (AssertEqual, ...) +|-- *_test.go Unit tests and benchmarks +|-- *_examples_test.go Executable examples |-- go.mod Module definition with no external requirements |-- LICENSE Apache License 2.0 |-- README.md Project overview and usage guide diff --git a/assert.go b/assert.go new file mode 100644 index 0000000..19e7061 --- /dev/null +++ b/assert.go @@ -0,0 +1,84 @@ +package comparator + +import ( + "fmt" + "strings" +) + +// TestingT is the subset of *testing.T (and *testing.B) used by the assertion +// helpers. Accepting an interface avoids importing the testing package into +// production builds and lets callers supply their own compatible type. +type TestingT interface { + Helper() + Errorf(format string, args ...any) +} + +// tbFatal is optionally implemented by *testing.T/*testing.B via Fatalf. When +// available, the Require* helpers use it to stop the test immediately. +type tbFatal interface { + Fatalf(format string, args ...any) +} + +// AssertEqual fails the test (via t.Errorf) when want and got are not equal +// under the given options, printing a readable diff of the differences. It +// returns true when the values are equal. Use it as a drop-in equality check in +// tests: +// +// comparator.AssertEqual(t, wantUser, gotUser, comparator.IgnoreStructFields("ID")) +func AssertEqual(t TestingT, want, got any, opts ...Option) bool { + t.Helper() + if ok, msg := diffMessage(want, got, opts...); !ok { + t.Errorf("%s", msg) + return false + } + return true +} + +// AssertNotEqual fails the test when want and got are equal under the given +// options. It returns true when the values differ. +func AssertNotEqual(t TestingT, want, got any, opts ...Option) bool { + t.Helper() + if EqualT(want, got, opts...) { + t.Errorf("expected values to differ, but they are equal") + return false + } + return true +} + +// RequireEqual behaves like AssertEqual but stops the test immediately (via +// Fatalf when t supports it) instead of merely marking it failed. +func RequireEqual(t TestingT, want, got any, opts ...Option) { + t.Helper() + if ok, msg := diffMessage(want, got, opts...); !ok { + fail(t, msg) + } +} + +// diffMessage reports whether want and got are equal and, when not, a +// human-readable multi-line message describing the differences. +func diffMessage(want, got any, opts ...Option) (bool, string) { + comp := NewDiffComparer(opts...) + result := comp.CompareWithDiff(want, got) + if result.Equal { + return true, "" + } + + var b strings.Builder + b.WriteString("values are not equal:\n") + b.WriteString(result.Summary) + for _, d := range result.Differences { + if d.Detail.Type == "equal" { + continue + } + fmt.Fprintf(&b, "\n %s: %s", d.Path, d.Message) + } + return false, b.String() +} + +func fail(t TestingT, msg string) { + if ft, ok := t.(tbFatal); ok { + ft.Fatalf("%s", msg) + return + } + t.Errorf("%s", msg) +} diff --git a/comparator.go b/comparator.go index 83329de..323ca70 100644 --- a/comparator.go +++ b/comparator.go @@ -1,9 +1,11 @@ package comparator import ( + "context" "encoding/json" "fmt" "reflect" + "regexp" "sort" "strings" "time" @@ -139,9 +141,14 @@ type Option func(*Config) // defaultConfig() or through New/NewWithOptions functions. type Config struct { ignoreStructFields map[string]bool + ignoreMapKeys map[string]bool + ignorePaths map[string]bool + ignorePathPatterns []*regexp.Regexp customComparators map[reflect.Type]any + reporter func(Difference) timeLayout string outputFormat string + fieldNaming FieldNaming floatPrecision float64 maxDepth int diffMode DiffMode @@ -156,6 +163,21 @@ type Config struct { colorize bool } +// FieldNaming selects how struct fields are named in difference paths and in +// JSON Patch pointers. +type FieldNaming int + +const ( + // GoFieldNaming uses the Go struct field name (e.g. "Name"). This is the + // default and preserves backward compatibility. + GoFieldNaming FieldNaming = iota + + // JSONTagNaming uses the name from the field's `json` struct tag when + // present, falling back to the Go field name otherwise. This makes JSON + // Patch pointers align with the JSON representation of the values. + JSONTagNaming +) + // DiffMode specifies the format and detail level for reporting differences. // Different modes are suitable for different use cases, from simple boolean checks // to detailed patch operations. @@ -196,7 +218,10 @@ func defaultConfig() *Config { showContext: true, contextSize: 3, outputFormat: "text", + fieldNaming: GoFieldNaming, ignoreStructFields: make(map[string]bool), + ignoreMapKeys: make(map[string]bool), + ignorePaths: make(map[string]bool), customComparators: make(map[reflect.Type]any), } } @@ -649,10 +674,79 @@ type visit struct { type defaultComparator struct { config *Config visited map[uintptr]visit + ctx context.Context pathStack []string differences []Difference stats PathStats currentLevel int + cancelled bool +} + +// canceled reports whether an associated context (set via the *Ctx methods) has +// been canceled. It is cheap enough to call on every recursion step and latches +// the cancelled flag so callers can surface ctx.Err(). +func (c *defaultComparator) canceled() bool { + if c.ctx == nil { + return false + } + if c.ctx.Err() != nil { + c.cancelled = true + return true + } + return false +} + +// record appends a difference and notifies the configured reporter, if any. +// All add*Diff helpers funnel through here so a WithReporter callback observes +// every difference as it is discovered. +func (c *defaultComparator) record(diff Difference) { + c.differences = append(c.differences, diff) + if c.config.reporter != nil { + c.config.reporter(diff) + } +} + +// fieldName returns the display name for a struct field, honoring the configured +// FieldNaming. With JSONTagNaming it uses the json tag name when present. +func (c *defaultComparator) fieldName(field reflect.StructField) string { + if c.config.fieldNaming == JSONTagNaming { + if tag := field.Tag.Get("json"); tag != "" && tag != "-" { + if name, _, _ := strings.Cut(tag, ","); name != "" { + return name + } + } + } + return field.Name +} + +// skipField reports whether a struct field must be skipped based on the +// `comparator:"-"` struct tag, the ignore-unexported option, or the +// ignore-by-name option. +func (c *defaultComparator) skipField(field reflect.StructField) bool { + if c.config.ignoreUnexported && field.PkgPath != "" { + return true + } + if field.Tag.Get("comparator") == "-" { + return true + } + return c.config.ignoreStructFields[field.Name] +} + +// pathIgnored reports whether a canonical field path (e.g. "Address.Zip") is +// ignored by an exact WithIgnorePaths entry or a WithIgnorePathPatterns regexp. +func (c *defaultComparator) pathIgnored(canonical string) bool { + if canonical == "" { + return false + } + if c.config.ignorePaths[canonical] { + return true + } + for _, re := range c.config.ignorePathPatterns { + if re.MatchString(canonical) { + return true + } + } + return false } // New creates a new Comparator with default configuration. @@ -859,47 +953,19 @@ func (c *defaultComparator) CompareWithDiff(a, b any) *DiffResult { // } // } func (c *defaultComparator) GetUnifiedDiff(a, b any) (*UnifiedDiff, error) { - aStr := fmt.Sprintf("%+v", a) - bStr := fmt.Sprintf("%+v", b) - - aLines := strings.Split(aStr, "\n") - bLines := strings.Split(bStr, "\n") - - chunks := make([]Chunk, 0) - changes := make([]Change, 0) + aLines := strings.Split(fmt.Sprintf("%+v", a), "\n") + bLines := strings.Split(fmt.Sprintf("%+v", b), "\n") contextSize := c.config.contextSize - if contextSize <= 0 { + if contextSize < 0 { contextSize = 3 } - _ = contextSize // contextSize will be used for context lines in future implementation - - for i := 0; i < min(len(aLines), len(bLines)); i++ { - if aLines[i] != bLines[i] { - changes = append(changes, Change{ - Type: "removed", - Content: "- " + aLines[i], - Line: i + 1, - }, Change{ - Type: "added", - Content: "+ " + bLines[i], - Line: i + 1, - }) - } else if c.config.showContext && len(changes) > 0 { - changes = append(changes, Change{ - Type: "context", - Content: " " + aLines[i], - Line: i + 1, - }) - } + if !c.config.showContext { + contextSize = 0 } - if len(changes) > 0 { - chunks = append(chunks, Chunk{ - Context: fmt.Sprintf("@@ -1,%d +1,%d @@", len(aLines), len(bLines)), - Changes: changes, - }) - } + ops := lineDiffOps(aLines, bLines) + chunks := buildHunks(ops, contextSize) return &UnifiedDiff{ Header: "--- a\n+++ b\n", @@ -907,6 +973,142 @@ func (c *defaultComparator) GetUnifiedDiff(a, b any) (*UnifiedDiff, error) { }, nil } +// diffOp is a single line-level operation produced by lineDiffOps. +type diffOp struct { + typ string // "context", "remove", or "add" + content string + aLine int // 1-based line number in the first input (context/remove) + bLine int // 1-based line number in the second input (context/add) +} + +// lineDiffOps computes a longest-common-subsequence line diff between a and b, +// returning an ordered list of context/remove/add operations. +func lineDiffOps(a, b []string) []diffOp { + n, m := len(a), len(b) + + lcs := make([][]int, n+1) + for i := range lcs { + lcs[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else if lcs[i+1][j] >= lcs[i][j+1] { + lcs[i][j] = lcs[i+1][j] + } else { + lcs[i][j] = lcs[i][j+1] + } + } + } + + ops := make([]diffOp, 0, n+m) + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + ops = append(ops, diffOp{typ: "context", content: a[i], aLine: i + 1, bLine: j + 1}) + i++ + j++ + case lcs[i+1][j] >= lcs[i][j+1]: + ops = append(ops, diffOp{typ: "remove", content: a[i], aLine: i + 1}) + i++ + default: + ops = append(ops, diffOp{typ: "add", content: b[j], bLine: j + 1}) + j++ + } + } + for ; i < n; i++ { + ops = append(ops, diffOp{typ: "remove", content: a[i], aLine: i + 1}) + } + for ; j < m; j++ { + ops = append(ops, diffOp{typ: "add", content: b[j], bLine: j + 1}) + } + return ops +} + +// buildHunks groups diff operations into hunks, keeping at most contextSize +// unchanged context lines around each run of changes and collapsing larger +// unchanged gaps. An input with no changes yields no hunks. +func buildHunks(ops []diffOp, contextSize int) []Chunk { + n := len(ops) + keep := make([]bool, n) + changed := false + for idx, op := range ops { + if op.typ == "context" { + continue + } + changed = true + lo := max(idx-contextSize, 0) + hi := min(idx+contextSize, n-1) + for t := lo; t <= hi; t++ { + keep[t] = true + } + } + if !changed { + return []Chunk{} + } + + chunks := make([]Chunk, 0) + for i := 0; i < n; { + if !keep[i] { + i++ + continue + } + start := i + for i < n && keep[i] { + i++ + } + chunks = append(chunks, makeChunk(ops[start:i])) + } + return chunks +} + +// makeChunk renders a group of operations into a Chunk with a unified-diff hunk +// header (@@ -aStart,aLen +bStart,bLen @@). +func makeChunk(ops []diffOp) Chunk { + changes := make([]Change, 0, len(ops)) + var aStart, bStart, aCount, bCount int + + for _, op := range ops { + switch op.typ { + case "context": + if aStart == 0 { + aStart = op.aLine + } + if bStart == 0 { + bStart = op.bLine + } + aCount++ + bCount++ + changes = append(changes, Change{Type: "context", Content: " " + op.content, Line: op.aLine}) + case "remove": + if aStart == 0 { + aStart = op.aLine + } + aCount++ + changes = append(changes, Change{Type: "remove", Content: "- " + op.content, Line: op.aLine}) + case "add": + if bStart == 0 { + bStart = op.bLine + } + bCount++ + changes = append(changes, Change{Type: "add", Content: "+ " + op.content, Line: op.bLine}) + } + } + if aStart == 0 { + aStart = 1 + } + if bStart == 0 { + bStart = 1 + } + + return Chunk{ + Context: fmt.Sprintf("@@ -%d,%d +%d,%d @@", aStart, aCount, bStart, bCount), + Changes: changes, + } +} + // GetJSONPatch generates a JSON Patch (RFC 6902) document describing the differences. // The returned operations can be applied to the first value to transform it into the second value. // @@ -1031,9 +1233,13 @@ func (c *defaultComparator) FormatDiff(result *DiffResult, format string) (strin return c.formatMarkdownDiff(result), nil case "html": return c.formatHTMLDiff(result), nil - default: - return c.formatTextDiff(result), nil } + + if fn, ok := lookupFormatter(format); ok { + return fn(result) + } + + return "", fmt.Errorf("%w: %q", ErrUnknownFormat, format) } // ==================== Internal Methods ==================== @@ -1044,6 +1250,7 @@ func (c *defaultComparator) resetState() { c.currentLevel = 0 c.stats = PathStats{} c.differences = make([]Difference, 0) + c.cancelled = false } func (c *defaultComparator) equal(av, bv reflect.Value, path []string) bool { @@ -1051,6 +1258,10 @@ func (c *defaultComparator) equal(av, bv reflect.Value, path []string) bool { c.visited = make(map[uintptr]visit) } + if c.canceled() { + return false + } + if c.config.maxDepth > 0 && len(path) >= c.config.maxDepth { return c.shallowEqual(av, bv) } @@ -1070,6 +1281,12 @@ func (c *defaultComparator) equal(av, bv reflect.Value, path []string) bool { return c.useCustomComparator(av, bv, comparator) } + if av.Type() == bv.Type() { + if result, ok := c.userEquality(av, bv); ok { + return result + } + } + if av.Type() != bv.Type() { return c.equalNumeric(av, bv) } @@ -1202,10 +1419,6 @@ func (c *defaultComparator) equalSortedSlices(av, bv reflect.Value, path []strin } func (c *defaultComparator) equalMaps(av, bv reflect.Value, path []string) bool { - if av.Len() != bv.Len() { - return false - } - if av.IsNil() || bv.IsNil() { if c.config.equateEmpty { return (av.IsNil() || av.Len() == 0) && (bv.IsNil() || bv.Len() == 0) @@ -1213,7 +1426,15 @@ func (c *defaultComparator) equalMaps(av, bv reflect.Value, path []string) bool return av.IsNil() == bv.IsNil() } + hasIgnored := len(c.config.ignoreMapKeys) > 0 + if !hasIgnored && av.Len() != bv.Len() { + return false + } + for _, key := range av.MapKeys() { + if c.mapKeyIgnored(key) { + continue + } bVal := bv.MapIndex(key) if !bVal.IsValid() { return false @@ -1224,9 +1445,29 @@ func (c *defaultComparator) equalMaps(av, bv reflect.Value, path []string) bool return false } } + + if hasIgnored { + for _, key := range bv.MapKeys() { + if c.mapKeyIgnored(key) { + continue + } + if !av.MapIndex(key).IsValid() { + return false + } + } + } return true } +// mapKeyIgnored reports whether a map key is excluded via WithIgnoreMapKeys. +// Keys are matched by their default string representation. +func (c *defaultComparator) mapKeyIgnored(key reflect.Value) bool { + if len(c.config.ignoreMapKeys) == 0 { + return false + } + return c.config.ignoreMapKeys[fmt.Sprintf("%v", key.Interface())] +} + func (c *defaultComparator) equalStructs(av, bv reflect.Value, path []string) bool { if av.Type() == reflect.TypeFor[time.Time]() { return c.equalTimes(av, bv) @@ -1235,15 +1476,15 @@ func (c *defaultComparator) equalStructs(av, bv reflect.Value, path []string) bo for i := 0; i < av.NumField(); i++ { field := av.Type().Field(i) - if c.config.ignoreUnexported && field.PkgPath != "" { + if c.skipField(field) { continue } - if c.config.ignoreStructFields[field.Name] { + name := c.fieldName(field) + newPath := append(path, name) + if c.pathIgnored(canonicalPath(newPath)) { continue } - - newPath := append(path, field.Name) if !c.equal(av.Field(i), bv.Field(i), newPath) { return false } @@ -1251,6 +1492,24 @@ func (c *defaultComparator) equalStructs(av, bv reflect.Value, path []string) bo return true } +// canonicalPath renders path segments as a dotted path with bracketed index or +// key segments kept inline, e.g. ["A", "B", "[0]", "C"] -> "A.B[0].C". It is the +// canonical form matched against WithIgnorePaths / WithIgnorePathPatterns. +func canonicalPath(segments []string) string { + var b strings.Builder + for _, s := range segments { + if strings.HasPrefix(s, "[") { + b.WriteString(s) + continue + } + if b.Len() > 0 { + b.WriteByte('.') + } + b.WriteString(s) + } + return b.String() +} + func (c *defaultComparator) equalPointers(av, bv reflect.Value, path []string) bool { if av.IsNil() || bv.IsNil() { return av.IsNil() == bv.IsNil() @@ -1364,6 +1623,43 @@ func (c *defaultComparator) useCustomComparator(av, bv reflect.Value, comparator return result[0].Bool() } +// boolType and intType are reused when validating user-defined equality methods. +var ( + boolType = reflect.TypeFor[bool]() + intType = reflect.TypeFor[int]() +) + +// userEquality honors the exported Equatable[T] and Comparable[T] interfaces. +// If av's type implements Equals(T) bool or CompareTo(T) int for its own type, +// that method decides equality. The second return value reports whether such a +// method was found and used. +// +// Custom comparators registered with WithCustomComparator take precedence and +// are checked before this method. +func (c *defaultComparator) userEquality(av, bv reflect.Value) (bool, bool) { + if !av.CanInterface() || !bv.CanInterface() { + return false, false + } + + typ := av.Type() + + if m, ok := typ.MethodByName("Equals"); ok && + m.Type.NumIn() == 2 && m.Type.NumOut() == 1 && + m.Type.In(1) == typ && m.Type.Out(0) == boolType { + out := av.MethodByName("Equals").Call([]reflect.Value{bv}) + return out[0].Bool(), true + } + + if m, ok := typ.MethodByName("CompareTo"); ok && + m.Type.NumIn() == 2 && m.Type.NumOut() == 1 && + m.Type.In(1) == typ && m.Type.Out(0) == intType { + out := av.MethodByName("CompareTo").Call([]reflect.Value{bv}) + return out[0].Int() == 0, true + } + + return false, false +} + func (c *defaultComparator) isSimpleType(typ reflect.Type) bool { switch typ.Kind() { case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, @@ -1390,6 +1686,10 @@ func (c *defaultComparator) collectDifferences(av, bv reflect.Value, currentPath return } + if c.canceled() { + return + } + c.stats.TotalNodes++ c.pathStack = append(c.pathStack, currentPath) c.currentLevel++ @@ -1578,12 +1878,16 @@ func (c *defaultComparator) compareSlicesIgnoreOrder(av, bv reflect.Value, path } func (c *defaultComparator) compareMaps(av, bv reflect.Value, path string) { - if av.Len() != bv.Len() { + if c.effectiveMapLen(av) != c.effectiveMapLen(bv) { c.addLengthDiff(av, bv, path) c.stats.DifferentNodes++ } for _, key := range av.MapKeys() { + if c.mapKeyIgnored(key) { + c.stats.IgnoredNodes++ + continue + } keyStr := fmt.Sprintf("%v", key.Interface()) elementPath := fmt.Sprintf("%s[%s]", path, keyStr) @@ -1597,6 +1901,9 @@ func (c *defaultComparator) compareMaps(av, bv reflect.Value, path string) { } for _, key := range bv.MapKeys() { + if c.mapKeyIgnored(key) { + continue + } if !av.MapIndex(key).IsValid() { keyStr := fmt.Sprintf("%v", key.Interface()) elementPath := fmt.Sprintf("%s[%s]", path, keyStr) @@ -1605,6 +1912,20 @@ func (c *defaultComparator) compareMaps(av, bv reflect.Value, path string) { } } +// effectiveMapLen counts map entries excluding any ignored keys. +func (c *defaultComparator) effectiveMapLen(m reflect.Value) int { + if len(c.config.ignoreMapKeys) == 0 { + return m.Len() + } + n := 0 + for _, key := range m.MapKeys() { + if !c.mapKeyIgnored(key) { + n++ + } + } + return n +} + func (c *defaultComparator) compareStructs(av, bv reflect.Value, path string) { if av.Type() == reflect.TypeFor[time.Time]() { if !c.equalTimes(av, bv) { @@ -1616,19 +1937,18 @@ func (c *defaultComparator) compareStructs(av, bv reflect.Value, path string) { for i := 0; i < av.NumField(); i++ { field := av.Type().Field(i) - fieldName := field.Name - if c.config.ignoreUnexported && field.PkgPath != "" { + if c.skipField(field) { c.stats.IgnoredNodes++ continue } - if c.config.ignoreStructFields[fieldName] { + name := c.fieldName(field) + fieldPath := path + "." + name + if c.pathIgnored(strings.TrimPrefix(fieldPath, ".")) { c.stats.IgnoredNodes++ continue } - - fieldPath := path + "." + fieldName c.collectDifferences(av.Field(i), bv.Field(i), fieldPath) } } @@ -1650,7 +1970,7 @@ func (c *defaultComparator) comparePointers(av, bv reflect.Value, path string) { // ==================== Diff Creation Helpers ==================== func (c *defaultComparator) addValueDiff(av, bv reflect.Value, path, message string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1671,7 +1991,7 @@ func (c *defaultComparator) addEqualDiff(av, bv reflect.Value, path, message str return } - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1687,7 +2007,7 @@ func (c *defaultComparator) addEqualDiff(av, bv reflect.Value, path, message str } func (c *defaultComparator) addTypeMismatchDiff(av, bv reflect.Value, path string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: fmt.Sprintf("type mismatch: %s vs %s", av.Type(), bv.Type()), @@ -1717,7 +2037,7 @@ func (c *defaultComparator) addMissingDiff(av, bv reflect.Value, path string) { message = "value missing in first structure" } - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1731,7 +2051,7 @@ func (c *defaultComparator) addMissingDiff(av, bv reflect.Value, path string) { } func (c *defaultComparator) addLengthDiff(av, bv reflect.Value, path string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: fmt.Sprintf("length mismatch: %d vs %d", av.Len(), bv.Len()), @@ -1749,7 +2069,7 @@ func (c *defaultComparator) addLengthDiff(av, bv reflect.Value, path string) { } func (c *defaultComparator) addExtraElementDiff(v reflect.Value, path, message string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1762,7 +2082,7 @@ func (c *defaultComparator) addExtraElementDiff(v reflect.Value, path, message s } func (c *defaultComparator) addMissingElementDiff(v reflect.Value, path, message string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1775,7 +2095,7 @@ func (c *defaultComparator) addMissingElementDiff(v reflect.Value, path, message } func (c *defaultComparator) addMissingKeyDiff(v reflect.Value, path, message string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1788,7 +2108,7 @@ func (c *defaultComparator) addMissingKeyDiff(v reflect.Value, path, message str } func (c *defaultComparator) addExtraKeyDiff(v reflect.Value, path, message string) { - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, @@ -1814,7 +2134,7 @@ func (c *defaultComparator) addNilDiff(av, bv reflect.Value, path string) { message = "expected value, got nil" } - c.differences = append(c.differences, Difference{ + c.record(Difference{ Path: path, Level: c.currentLevel, Message: message, diff --git a/comparator_ext_examples_test.go b/comparator_ext_examples_test.go new file mode 100644 index 0000000..9ea977c --- /dev/null +++ b/comparator_ext_examples_test.go @@ -0,0 +1,119 @@ +package comparator + +import ( + "context" + "fmt" +) + +// ExampleEqualT demonstrates the type-safe generic equality helper. +func ExampleEqualT() { + type point struct{ X, Y int } + + fmt.Println(EqualT(point{1, 2}, point{1, 2})) + fmt.Println(EqualT(point{1, 2}, point{3, 4})) + // Output: + // true + // false +} + +// ExampleWithEquateEmpty shows disabling the default nil/empty equivalence. +func ExampleWithEquateEmpty() { + var nilSlice []int + emptySlice := []int{} + + fmt.Println(NewWithOptions(WithEquateEmpty(true)).Equal(nilSlice, emptySlice)) + fmt.Println(NewWithOptions(WithEquateEmpty(false)).Equal(nilSlice, emptySlice)) + // Output: + // true + // false +} + +// ExampleWithFieldNaming shows JSON-tag-aware JSON Patch pointers. +func ExampleWithFieldNaming() { + type profile struct { + FullName string `json:"full_name"` + } + + patch, _ := GetJSONPatchT( + profile{FullName: "Jon"}, + profile{FullName: "John"}, + WithFieldNaming(JSONTagNaming), + ) + + fmt.Println(patch[0].Op, patch[0].Path) + // Output: + // replace /full_name +} + +// ExampleApplyJSONPatch applies a patch to a JSON-shaped document. +func ExampleApplyJSONPatch() { + doc := map[string]any{"name": "Jon"} + patch := []JSONPatchOperation{ + {Op: "replace", Path: "/name", Value: "John"}, + {Op: "add", Path: "/email", Value: "john@example.com"}, + } + + got, err := ApplyJSONPatch(doc, patch) + if err != nil { + fmt.Println("error:", err) + return + } + + m := got.(map[string]any) + fmt.Println(m["name"], m["email"]) + // Output: + // John john@example.com +} + +// ExampleWithIgnoreMapKeys ignores volatile map keys. +func ExampleWithIgnoreMapKeys() { + a := map[string]int{"value": 1, "updatedAt": 100} + b := map[string]int{"value": 1, "updatedAt": 999} + + fmt.Println(NewWithOptions(WithIgnoreMapKeys("updatedAt")).Equal(a, b)) + // Output: + // true +} + +// ExampleWithReporter streams differences via a callback. +func ExampleWithReporter() { + type cfg struct{ A, B int } + + count := 0 + comp := NewDiffComparer(WithReporter(func(Difference) { count++ })) + comp.CompareWithDiff(cfg{1, 2}, cfg{1, 3}) + + fmt.Println(count > 0) + // Output: + // true +} + +// ExampleEqualCtx shows a context-aware comparison that is canceled up front. +func ExampleEqualCtx() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := EqualCtx(ctx, []int{1, 2, 3}, []int{1, 2, 3}) + fmt.Println(err != nil) + // Output: + // true +} + +// ExampleRegisterFormatter adds a custom output format. +func ExampleRegisterFormatter() { + RegisterFormatter("summary-only", func(r *DiffResult) (string, error) { + if r.Equal { + return "equal", nil + } + return "different", nil + }) + defer UnregisterFormatter("summary-only") + + comp := NewDiffComparer() + result := comp.CompareWithDiff(1, 2) + out, _ := comp.FormatDiff(result, "summary-only") + + fmt.Println(out) + // Output: + // different +} diff --git a/comparator_ext_test.go b/comparator_ext_test.go new file mode 100644 index 0000000..af570d3 --- /dev/null +++ b/comparator_ext_test.go @@ -0,0 +1,458 @@ +package comparator + +import ( + "context" + "errors" + "testing" +) + +// ---- #1 Equatable / Comparable --------------------------------------------- + +type equatablePoint struct { + X, Y int + Tag string // deliberately not part of equality +} + +func (p equatablePoint) Equals(other equatablePoint) bool { + return p.X == other.X && p.Y == other.Y +} + +type comparableVersion struct { + Major int +} + +func (v comparableVersion) CompareTo(other comparableVersion) int { + return v.Major - other.Major +} + +func TestUserEquality_Equatable(t *testing.T) { + a := equatablePoint{X: 1, Y: 2, Tag: "left"} + b := equatablePoint{X: 1, Y: 2, Tag: "right"} + + if !New().Equal(a, b) { + t.Error("expected Equatable.Equals to make the points equal despite different Tag") + } + + c := equatablePoint{X: 9, Y: 9, Tag: "left"} + if New().Equal(a, c) { + t.Error("expected Equatable.Equals to report differing coordinates as unequal") + } +} + +func TestUserEquality_Comparable(t *testing.T) { + if !New().Equal(comparableVersion{Major: 3}, comparableVersion{Major: 3}) { + t.Error("expected CompareTo == 0 to mean equal") + } + if New().Equal(comparableVersion{Major: 3}, comparableVersion{Major: 4}) { + t.Error("expected CompareTo != 0 to mean not equal") + } +} + +func TestUserEquality_CustomComparatorTakesPrecedence(t *testing.T) { + // A custom comparator must win over the type's own Equals method. + comp := NewWithOptions(WithCustomComparator(func(a, b equatablePoint) bool { + return a.Tag == b.Tag + })) + + a := equatablePoint{X: 1, Y: 2, Tag: "same"} + b := equatablePoint{X: 5, Y: 6, Tag: "same"} + if !comp.Equal(a, b) { + t.Error("expected custom comparator (by Tag) to take precedence over Equals") + } +} + +// ---- #2 Unified diff context lines ----------------------------------------- + +func TestUnifiedDiff_TypesAndContext(t *testing.T) { + a := "line1\nline2\nline3\nline4\nline5" + b := "line1\nline2\nCHANGED\nline4\nline5" + + ud, err := NewDiffComparer().GetUnifiedDiff(a, b) + if err != nil { + t.Fatalf("GetUnifiedDiff: %v", err) + } + if len(ud.Chunks) == 0 { + t.Fatal("expected at least one chunk") + } + + var hasContext, hasAdd, hasRemove bool + for _, ch := range ud.Chunks { + for _, c := range ch.Changes { + switch c.Type { + case "context": + hasContext = true + case "add": + hasAdd = true + case "remove": + hasRemove = true + } + } + } + if !hasContext { + t.Error("expected context lines around the change") + } + if !hasAdd || !hasRemove { + t.Error("expected both add and remove changes") + } +} + +func TestUnifiedDiff_NoChangesNoChunks(t *testing.T) { + ud, err := NewDiffComparer().GetUnifiedDiff("same", "same") + if err != nil { + t.Fatalf("GetUnifiedDiff: %v", err) + } + if len(ud.Chunks) != 0 { + t.Errorf("expected no chunks for identical input, got %d", len(ud.Chunks)) + } +} + +// ---- #3 WithEquateEmpty toggle --------------------------------------------- + +func TestWithEquateEmpty_Disable(t *testing.T) { + var nilSlice []int + emptySlice := []int{} + + if !NewWithOptions(WithEquateEmpty(true)).Equal(nilSlice, emptySlice) { + t.Error("expected nil and empty to be equal when enabled") + } + if NewWithOptions(WithEquateEmpty(false)).Equal(nilSlice, emptySlice) { + t.Error("expected nil and empty to differ when disabled") + } +} + +// ---- #4 Context cancellation ----------------------------------------------- + +func TestEqualCtx_Canceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := EqualCtx(ctx, []int{1, 2, 3}, []int{1, 2, 3}) + if !errors.Is(err, ErrCanceled) { + t.Errorf("expected ErrCanceled, got %v", err) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected wrapped context.Canceled, got %v", err) + } +} + +func TestEqualCtx_Completes(t *testing.T) { + ok, err := EqualCtx(context.Background(), 42, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Error("expected equal") + } +} + +// ---- #5 Generics ----------------------------------------------------------- + +func TestGenerics(t *testing.T) { + if !EqualT(1, 1) { + t.Error("EqualT should report equal ints") + } + if !DeepEqualT([]string{"a"}, []string{"a"}) { + t.Error("DeepEqualT should report equal slices") + } + diffs, err := DiffT("a", "b") + if err != nil || len(diffs) == 0 { + t.Errorf("DiffT should report differences, got %d diffs err=%v", len(diffs), err) + } + if CompareWithDiffT(1, 2).Equal { + t.Error("CompareWithDiffT should report inequality") + } + patch, err := GetJSONPatchT(struct{ Name string }{"a"}, struct{ Name string }{"b"}) + if err != nil || len(patch) == 0 { + t.Errorf("GetJSONPatchT should produce a patch, got %d err=%v", len(patch), err) + } +} + +// ---- #6 JSON-tag field naming ---------------------------------------------- + +func TestFieldNaming_JSONTag(t *testing.T) { + type profile struct { + FullName string `json:"full_name"` + } + + patch, err := GetJSONPatchT( + profile{FullName: "Jon"}, + profile{FullName: "John"}, + WithFieldNaming(JSONTagNaming), + ) + if err != nil { + t.Fatalf("GetJSONPatch: %v", err) + } + if len(patch) != 1 || patch[0].Path != "/full_name" { + t.Errorf("expected pointer /full_name, got %+v", patch) + } +} + +// ---- #7 ApplyJSONPatch ----------------------------------------------------- + +func TestApplyJSONPatch_Operations(t *testing.T) { + doc := map[string]any{ + "name": "Jon", + "tags": []any{"a", "b"}, + "stale": true, + } + patch := []JSONPatchOperation{ + {Op: "replace", Path: "/name", Value: "John"}, + {Op: "add", Path: "/email", Value: "john@example.com"}, + {Op: "remove", Path: "/stale"}, + {Op: "add", Path: "/tags/-", Value: "c"}, + {Op: "test", Path: "/name", Value: "John"}, + } + + got, err := ApplyJSONPatch(doc, patch) + if err != nil { + t.Fatalf("ApplyJSONPatch: %v", err) + } + + m := got.(map[string]any) + if m["name"] != "John" { + t.Errorf("replace failed: %v", m["name"]) + } + if m["email"] != "john@example.com" { + t.Errorf("add failed: %v", m["email"]) + } + if _, ok := m["stale"]; ok { + t.Error("remove failed: stale still present") + } + if tags := m["tags"].([]any); len(tags) != 3 || tags[2] != "c" { + t.Errorf("array append failed: %v", tags) + } +} + +func TestApplyJSONPatch_RoundTrip(t *testing.T) { + type addr struct { + City string `json:"city"` + Zip string `json:"zip"` + } + type user struct { + Name string `json:"name"` + Addr addr `json:"addr"` + Aliases []string `json:"aliases"` + } + + from := user{Name: "Jon", Addr: addr{City: "NYC", Zip: "10001"}, Aliases: []string{"j"}} + to := user{Name: "John", Addr: addr{City: "NYC", Zip: "10002"}, Aliases: []string{"j", "johnny"}} + + patch, err := GetJSONPatchT(from, to, WithFieldNaming(JSONTagNaming)) + if err != nil { + t.Fatalf("GetJSONPatch: %v", err) + } + + normalizedFrom, _ := toJSONValue(from) + got, err := ApplyJSONPatch(normalizedFrom, patch) + if err != nil { + t.Fatalf("ApplyJSONPatch: %v", err) + } + + normalizedTo, _ := toJSONValue(to) + if !Equal(got, normalizedTo) { + t.Errorf("round trip mismatch:\n got=%v\nwant=%v", got, normalizedTo) + } +} + +func TestApplyJSONPatch_TestFailure(t *testing.T) { + _, err := ApplyJSONPatch(map[string]any{"n": 1}, []JSONPatchOperation{ + {Op: "test", Path: "/n", Value: 2}, + }) + if !errors.Is(err, ErrInvalidPatch) { + t.Errorf("expected ErrInvalidPatch on failed test op, got %v", err) + } +} + +// ---- #8 Reporter ----------------------------------------------------------- + +func TestWithReporter(t *testing.T) { + type cfg struct { + A, B, C int + } + + var seen int + comp := NewDiffComparer(WithReporter(func(Difference) { seen++ })) + result := comp.CompareWithDiff(cfg{1, 2, 3}, cfg{1, 9, 8}) + + if seen == 0 { + t.Fatal("reporter was never called") + } + if seen != len(result.Differences) { + t.Errorf("reporter calls (%d) != differences (%d)", seen, len(result.Differences)) + } +} + +// ---- #9 Richer ignore rules ------------------------------------------------ + +func TestWithIgnoreMapKeys(t *testing.T) { + a := map[string]int{"keep": 1, "ts": 100} + b := map[string]int{"keep": 1, "ts": 999} + + if !NewWithOptions(WithIgnoreMapKeys("ts")).Equal(a, b) { + t.Error("expected equality when ignoring the differing key") + } + if New().Equal(a, b) { + t.Error("expected inequality without ignore") + } +} + +func TestWithIgnorePaths(t *testing.T) { + type meta struct { + UpdatedAt string + } + type doc struct { + Name string + Meta meta + } + + a := doc{Name: "x", Meta: meta{UpdatedAt: "t1"}} + b := doc{Name: "x", Meta: meta{UpdatedAt: "t2"}} + + if !NewWithOptions(WithIgnorePaths("Meta.UpdatedAt")).Equal(a, b) { + t.Error("expected equality when ignoring Meta.UpdatedAt") + } +} + +func TestWithIgnorePathPatterns(t *testing.T) { + type creds struct { + User string + Password string + } + a := creds{User: "u", Password: "p1"} + b := creds{User: "u", Password: "p2"} + + if !NewWithOptions(WithIgnorePathPatterns(`(^|\.)Password$`)).Equal(a, b) { + t.Error("expected equality when ignoring Password by pattern") + } +} + +func TestStructTagIgnore(t *testing.T) { + type record struct { + Name string + Cache string `comparator:"-"` + } + a := record{Name: "x", Cache: "c1"} + b := record{Name: "x", Cache: "c2"} + + if !New().Equal(a, b) { + t.Error("expected the comparator:\"-\" tagged field to be ignored") + } +} + +// ---- #10 Pluggable formatters ---------------------------------------------- + +func TestRegisterFormatter(t *testing.T) { + const name = "count-test-format" + if !RegisterFormatter(name, func(r *DiffResult) (string, error) { + return "diffs=" + itoa(len(r.Differences)), nil + }) { + t.Fatal("RegisterFormatter should succeed for a new name") + } + defer UnregisterFormatter(name) + + comp := NewDiffComparer() + result := comp.CompareWithDiff(1, 2) + out, err := comp.FormatDiff(result, name) + if err != nil { + t.Fatalf("FormatDiff custom: %v", err) + } + if out == "" { + t.Error("expected custom formatter output") + } + + if RegisterFormatter("json", func(*DiffResult) (string, error) { return "", nil }) { + t.Error("RegisterFormatter must refuse to override a built-in format") + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// ---- #11 String helpers ---------------------------------------------------- + +func TestUnifiedDiffString(t *testing.T) { + ud, _ := NewDiffComparer().GetUnifiedDiff("a\nb", "a\nc") + s := ud.String() + if s == "" || !contains(s, "@@") { + t.Errorf("expected a rendered unified diff, got %q", s) + } +} + +func TestVisualDiffString(t *testing.T) { + type point struct{ X, Y int } + vd, err := NewDiffComparer().GetVisualDiff(point{1, 2}, point{1, 3}) + if err != nil { + t.Fatalf("GetVisualDiff: %v", err) + } + if vd.String() == "" { + t.Error("expected a rendered visual diff") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// ---- #13 Assertion helpers ------------------------------------------------- + +// fakeT captures assertion output for testing the helpers. +type fakeT struct { + failed bool + msg string +} + +func (f *fakeT) Helper() {} +func (f *fakeT) Errorf(format string, args ...any) { f.failed = true; f.msg = format } +func (f *fakeT) Fatalf(format string, args ...any) { f.failed = true; f.msg = format } + +func TestAssertEqual(t *testing.T) { + ft := &fakeT{} + if !AssertEqual(ft, 1, 1) { + t.Error("AssertEqual should pass for equal values") + } + if ft.failed { + t.Error("AssertEqual should not fail for equal values") + } + + ft = &fakeT{} + if AssertEqual(ft, 1, 2) { + t.Error("AssertEqual should fail for unequal values") + } + if !ft.failed { + t.Error("AssertEqual should mark the test failed for unequal values") + } +} + +func TestAssertNotEqual(t *testing.T) { + ft := &fakeT{} + if !AssertNotEqual(ft, 1, 2) { + t.Error("AssertNotEqual should pass for differing values") + } + if ft.failed { + t.Error("AssertNotEqual should not fail for differing values") + } +} diff --git a/comparator_test.go b/comparator_test.go index 3b53f28..64db80a 100644 --- a/comparator_test.go +++ b/comparator_test.go @@ -2,6 +2,7 @@ package comparator import ( "encoding/json" + "errors" "math" "strings" "testing" @@ -1880,7 +1881,7 @@ func TestFormatDiff_AllFormats(t *testing.T) { comp := NewDiffComparer() result := comp.CompareWithDiff(u1, u2) - formats := []string{"text", "json", "markdown", "html", "unknown"} + formats := []string{"text", "json", "markdown", "html"} for _, format := range formats { formatted, err := comp.FormatDiff(result, format) @@ -1908,6 +1909,12 @@ func TestFormatDiff_AllFormats(t *testing.T) { } } } + + // An unknown format now returns ErrUnknownFormat instead of silently + // falling back to text. + if _, err := comp.FormatDiff(result, "unknown"); !errors.Is(err, ErrUnknownFormat) { + t.Errorf("Expected ErrUnknownFormat for unknown format, got %v", err) + } } func TestGenerateSuggestions_Strings(t *testing.T) { diff --git a/context.go b/context.go new file mode 100644 index 0000000..c1dbd6a --- /dev/null +++ b/context.go @@ -0,0 +1,88 @@ +package comparator + +import ( + "context" + "fmt" +) + +// ContextComparator is implemented by the comparators returned from New, +// NewWithOptions, and NewDiffComparer. Its methods accept a context.Context so +// long-running comparisons over large structures can be canceled or bounded by +// a deadline. +// +// A canceled context causes the comparison to stop early and return an error +// that wraps both ErrCanceled and the context's own error. +type ContextComparator interface { + // EqualCtx behaves like Comparator.Equal but honors ctx cancellation. + EqualCtx(ctx context.Context, a, b any) (bool, error) + + // DiffCtx behaves like Comparator.Diff but honors ctx cancellation. + DiffCtx(ctx context.Context, a, b any) ([]Difference, error) + + // CompareWithDiffCtx behaves like DiffComparer.CompareWithDiff but honors + // ctx cancellation. + CompareWithDiffCtx(ctx context.Context, a, b any) (*DiffResult, error) +} + +// ctxError wraps the context's error together with ErrCanceled so that callers +// can branch with errors.Is on either sentinel. +func ctxError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("%w: %w", ErrCanceled, err) + } + return ErrCanceled +} + +// EqualCtx performs a deep equality check that stops early if ctx is canceled. +func (c *defaultComparator) EqualCtx(ctx context.Context, a, b any) (bool, error) { + c.ctx = ctx + defer func() { c.ctx = nil }() + + result := c.Equal(a, b) + if c.cancelled { + return false, ctxError(ctx) + } + return result, nil +} + +// DiffCtx collects differences and stops early if ctx is canceled. +func (c *defaultComparator) DiffCtx(ctx context.Context, a, b any) ([]Difference, error) { + c.ctx = ctx + defer func() { c.ctx = nil }() + + diffs, _ := c.Diff(a, b) + if c.cancelled { + return nil, ctxError(ctx) + } + return diffs, nil +} + +// CompareWithDiffCtx performs a comprehensive comparison and stops early if ctx +// is canceled. +func (c *defaultComparator) CompareWithDiffCtx(ctx context.Context, a, b any) (*DiffResult, error) { + c.ctx = ctx + defer func() { c.ctx = nil }() + + result := c.CompareWithDiff(a, b) + if c.cancelled { + return nil, ctxError(ctx) + } + return result, nil +} + +// EqualCtx is a package-level convenience for a one-off context-aware equality +// check. +func EqualCtx(ctx context.Context, a, b any, opts ...Option) (bool, error) { + return NewWithOptions(opts...).(ContextComparator).EqualCtx(ctx, a, b) +} + +// DiffCtx is a package-level convenience for a one-off context-aware diff. +func DiffCtx(ctx context.Context, a, b any, opts ...Option) ([]Difference, error) { + return NewWithOptions(opts...).(ContextComparator).DiffCtx(ctx, a, b) +} + +// CompareWithDiffCtx is a package-level convenience for a one-off context-aware +// comprehensive comparison. +func CompareWithDiffCtx(ctx context.Context, a, b any, opts ...Option) (*DiffResult, error) { + return NewDiffComparer(opts...).(ContextComparator).CompareWithDiffCtx(ctx, a, b) +} diff --git a/docs/README.md b/docs/README.md index 455dbaf..8235267 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,8 +14,10 @@ started, read the guides in order; otherwise jump straight to the topic you need | [Configuration & Options](configuration.md) | Every functional option, its default, and when to use it. | | [Diffing](diffing.md) | `DiffResult`, `Difference`, diff modes, and how to read a diff. | | [Output Formats](output-formats.md) | Text (with color), JSON, Markdown, HTML, unified diff, and visual tree. | -| [JSON Patch](json-patch.md) | Generating RFC 6902 patch documents. | -| [Custom Comparators](custom-comparators.md) | Registering domain-specific equality logic. | +| [JSON Patch](json-patch.md) | Generating and applying RFC 6902 patch documents. | +| [Custom Comparators](custom-comparators.md) | Custom equality, plus the `Equatable`/`Comparable` interfaces. | +| [Extensibility](extensibility.md) | Generics, streaming reporters, context, and pluggable formatters. | +| [Testing](testing.md) | `AssertEqual` and friends for use in test suites. | | [Performance](performance.md) | Benchmarks, cost model, and tuning tips. | | [FAQ](faq.md) | Common questions, gotchas, and thread-safety. | diff --git a/docs/configuration.md b/docs/configuration.md index 12fff12..9832bd7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,14 +31,30 @@ comp := comparator.NewWithOptions( | `IgnoreSliceOrder()` | `false` | Treat slices/arrays as sets; element order is ignored. | | `WithMaxDepth(int)` | `0` (unlimited) | Stop recursing beyond this depth; guards against very deep structures. | | `IgnoreUnexported()` | `false` | Skip unexported struct fields entirely. | -| `EquateEmpty()` | `true` | Treat `nil` and empty containers (slice/map) as equal. | +| `EquateEmpty()` | `true` | Treat `nil` and empty containers (slice/map) as equal. Enable-only; prefer `WithEquateEmpty`. | +| `WithEquateEmpty(bool)` | `true` | Toggle nil/empty equivalence in **both** directions (pass `false` to make them differ). | | `EquateNaNs()` | `false` | Treat `NaN == NaN` as `true` (IEEE-754 says `false`). | | `IgnoreStructFields(...string)` | none | Skip struct fields by name, at any nesting level. | | `WithTimeLayout(string)` | `time.RFC3339Nano` | Layout used to render `time.Time` in diff output. Equality still uses `time.Time.Equal`. | -> ℹ️ `EquateEmpty` defaults to **true** in this package. Pass it explicitly if -> you want to document the intent; there is currently no dedicated option to turn -> it back off, so rely on strict typing when nil-vs-empty must differ. +### Ignoring fields, paths, and keys + +| Option | Description | +| ------ | ----------- | +| `IgnoreStructFields(...string)` | Skip struct fields by **name**, anywhere they appear. | +| `WithIgnorePaths(...string)` | Skip struct fields at exact canonical paths, e.g. `"Meta.UpdatedAt"`. | +| `WithIgnorePathPatterns(...string)` | Skip struct fields whose canonical path matches a regular expression. | +| `WithIgnoreMapKeys(...string)` | Skip map entries by key (matched by string form), anywhere they appear. | +| struct tag `comparator:"-"` | Fields tagged this way are always skipped — the type describes its own exclusions. | + +Canonical paths use dotted field names with bracketed index/key segments inline +(the same form reported in `Difference.Path`), e.g. `User.Aliases[0]`. + +### Field naming + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `WithFieldNaming(FieldNaming)` | `GoFieldNaming` | `GoFieldNaming` uses Go field names; `JSONTagNaming` uses `json` tag names in paths and JSON Patch pointers. | ## 🧾 Diff Output @@ -65,6 +81,10 @@ comp := comparator.NewWithOptions( | Option | Description | | ------ | ----------- | | `WithCustomComparator[T](func(a, b T) bool)` | Register domain-specific equality for a concrete type `T`. See [Custom Comparators](custom-comparators.md). | +| `WithReporter(func(Difference))` | Stream each difference to a callback as it is discovered. See [Extensibility](extensibility.md). | + +The output format set is also extensible via `RegisterFormatter` — see +[Extensibility](extensibility.md). ## 🎛️ Putting It Together diff --git a/docs/custom-comparators.md b/docs/custom-comparators.md index eef9bbb..aa4951a 100644 --- a/docs/custom-comparators.md +++ b/docs/custom-comparators.md @@ -53,6 +53,35 @@ Both let you loosen equality, but they differ in intent: - Use **`WithCustomComparator`** when equality for a type is a genuine domain decision that you want to express as code. +## 🧬 Self-describing types: `Equatable` and `Comparable` + +A type can also define its own equality without any option, by implementing one +of the exported interfaces. The engine detects and uses them automatically: + +```go +// Equatable[T]: equal when Equals returns true. +type Point struct{ X, Y int; Label string } + +func (p Point) Equals(other Point) bool { return p.X == other.X && p.Y == other.Y } + +// Comparable[T]: equal when CompareTo returns 0. +type Version struct{ Major int } + +func (v Version) CompareTo(other Version) int { return v.Major - other.Major } +``` + +```go +comparator.Equal(Point{1, 2, "a"}, Point{1, 2, "b"}) // true — Label is not part of Equals +``` + +Rules: + +- The method must have a **value receiver** and the exact signature + `Equals(T) bool` or `CompareTo(T) int` for the type's own type `T`. +- A `WithCustomComparator` registered for the same type **takes precedence** over + the type's `Equals`/`CompareTo`. +- This mirrors how the standard `time.Time` type is compared by its own method. + ## 🧵 A Note on Diffs Custom comparators answer *equal or not*. When you need a detailed diff and a diff --git a/docs/extensibility.md b/docs/extensibility.md new file mode 100644 index 0000000..56bef61 --- /dev/null +++ b/docs/extensibility.md @@ -0,0 +1,85 @@ +# 🧩 Extensibility + +The package is designed to be extended without forking. This guide covers the +type-safe generic API, streaming reporters, context-aware comparison, and +pluggable output formatters. + +## 🔤 Type-safe generic API + +The generic helpers give compile-time type checking at the call site and +delegate to the same engine: + +```go +comparator.EqualT(user1, user2, comparator.IgnoreStructFields("ID")) // bool +comparator.DeepEqualT(a, b) // bool +comparator.DiffT(a, b) // ([]Difference, error) +comparator.CompareWithDiffT(a, b) // *DiffResult +comparator.GetJSONPatchT(a, b) // ([]JSONPatchOperation, error) +``` + +Because `EqualT[T]` requires both arguments to share type `T`, mixing types is a +compile error rather than a silent `false`. + +## 📡 Streaming with a reporter + +`WithReporter` registers a callback invoked for every difference as it is +discovered, in traversal order: + +```go +comp := comparator.NewDiffComparer(comparator.WithReporter(func(d comparator.Difference) { + log.Printf("%s: %s", d.Path, d.Message) +})) +comp.CompareWithDiff(a, b) +``` + +Use it for logging, progress reporting, or feeding another system without +waiting for the full `DiffResult`. + +## ⏱️ Context-aware comparison + +For large or untrusted inputs, use the context-aware entry points so a +comparison can be canceled or bounded by a deadline: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) +defer cancel() + +equal, err := comparator.EqualCtx(ctx, a, b) +if errors.Is(err, comparator.ErrCanceled) { + // canceled or deadline exceeded; err also matches context.Canceled / + // context.DeadlineExceeded +} +``` + +`DiffCtx` and `CompareWithDiffCtx` are the diffing counterparts. The comparators +returned by `New`, `NewWithOptions`, and `NewDiffComparer` also satisfy the +`ContextComparator` interface if you prefer method calls. + +## 🎨 Custom output formatters + +Register a `Formatter` to teach `FormatDiff` a new output format — CSV, JUnit, +SARIF, or anything else — without changing the package: + +```go +comparator.RegisterFormatter("summary", func(r *comparator.DiffResult) (string, error) { + if r.Equal { + return "no differences", nil + } + return fmt.Sprintf("%d differences", len(r.Differences)), nil +}) + +comp := comparator.NewDiffComparer() +out, _ := comp.FormatDiff(comp.CompareWithDiff(a, b), "summary") +``` + +Notes: + +- Names are case-insensitive and cannot override the built-in formats + (`text`, `json`, `markdown`, `html`). +- `RegisterFormatter` and `UnregisterFormatter` are safe for concurrent use. +- Requesting an unknown format returns an error wrapping `ErrUnknownFormat`. + +## ➡️ Next Steps + +- Use the comparator in tests: [Testing](testing.md). +- Review every option: [Configuration & Options](configuration.md). diff --git a/docs/faq.md b/docs/faq.md index 5aed1b9..1cb534d 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -33,16 +33,15 @@ times are *rendered* in diff output. ## Why do JSON Patch paths use Go field names, not JSON tags? -Paths are derived from the Go structure (e.g. field `Name` → `/Name`). If you -need JSON-tag names, marshal your inputs to `map[string]any` via `encoding/json` -before comparing, or rewrite the paths after generation. See +By default paths are derived from Go field names (e.g. field `Name` → `/Name`). +Pass `WithFieldNaming(JSONTagNaming)` to use `json` tag names instead, which makes +the pointers line up with JSON documents and with `ApplyJSONPatch`. See [JSON Patch](json-patch.md). ## Are `nil` and empty slices/maps equal? -By default, **yes** — `EquateEmpty` defaults to `true`. If nil-vs-empty must be a -difference for your use case, model it with distinct types or values so the -comparison is unambiguous. +By default, **yes** — nil/empty equivalence is on. Pass `WithEquateEmpty(false)` +to make `nil` and empty containers compare as different. ## Is `NaN` equal to `NaN`? diff --git a/docs/json-patch.md b/docs/json-patch.md index b14a3ff..9052044 100644 --- a/docs/json-patch.md +++ b/docs/json-patch.md @@ -48,20 +48,52 @@ type JSONPatchOperation struct { ## 🧭 Path Format -Paths are [JSON Pointers](https://datatracker.ietf.org/doc/html/rfc6901). Struct -fields appear by their **Go field name** (e.g. a field `Name` produces `/Name`), -and nested paths are slash-separated (e.g. `/Server/Port`). +Paths are [JSON Pointers](https://datatracker.ietf.org/doc/html/rfc6901). By +default, struct fields appear by their **Go field name** (e.g. a field `Name` +produces `/Name`), and nested paths are slash-separated (e.g. `/Server/Port`). -> 🔎 If you need the JSON-tag name instead of the Go field name in the pointer, -> transform the paths after generation, or marshal your inputs to -> `map[string]any` (via `encoding/json`) before comparing. +To make pointers line up with the JSON representation of your values, generate +the patch with `WithFieldNaming(JSONTagNaming)`: + +```go +type Profile struct { + FullName string `json:"full_name"` +} + +patch, _ := comparator.GetJSONPatch( + Profile{FullName: "Jon"}, + Profile{FullName: "John"}, + comparator.WithFieldNaming(comparator.JSONTagNaming), +) +// patch[0].Path == "/full_name" +``` ## 🛠️ Applying a Patch -This package **generates** patches; it does not apply them. To apply a patch, -marshal it to JSON and use any RFC 6902-compliant applier, or interpret the -operations yourself. Because the type has standard JSON tags, it round-trips -cleanly through `encoding/json`. +`ApplyJSONPatch` applies a patch and returns the modified document. It supports +`add`, `remove`, `replace`, `move`, `copy`, and `test`: + +```go +doc := map[string]any{"name": "Jon"} +patch := []comparator.JSONPatchOperation{ + {Op: "replace", Path: "/name", Value: "John"}, + {Op: "add", Path: "/email", Value: "john@example.com"}, +} + +updated, err := comparator.ApplyJSONPatch(doc, patch) +if err != nil { + // errors wrap comparator.ErrInvalidPatch (including a failed "test" op) + log.Fatal(err) +} +``` + +The document is normalized to JSON values (objects → `map[string]any`, arrays → +`[]any`) by round-tripping through `encoding/json`, so it must be +JSON-serializable. For a clean generate-then-apply round trip, generate the patch +with `JSONTagNaming` so its pointers match the object keys. + +A failed `test` operation, an unreachable path, or an unsupported op returns an +error wrapping `ErrInvalidPatch`. ## ➡️ Next Steps diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..94809ef --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,53 @@ +# 🧪 Testing Helpers + +The package ships assertion helpers that fail a test and print a readable diff, +making `comparator` a drop-in equality checker for your test suite. + +## ✅ AssertEqual / AssertNotEqual + +```go +func TestUser(t *testing.T) { + got := buildUser() + want := User{Name: "Alice", Role: "admin"} + + comparator.AssertEqual(t, want, got, comparator.IgnoreStructFields("ID", "CreatedAt")) +} +``` + +On failure the test is marked failed (via `t.Errorf`) with a message like: + +```text +values are not equal: +Found 1 difference(s) + .Role: user != admin +``` + +- `AssertEqual(t, want, got, opts...)` — fail unless the values are equal. + Returns `true` when equal. +- `AssertNotEqual(t, want, got, opts...)` — fail if the values are equal. +- `RequireEqual(t, want, got, opts...)` — like `AssertEqual` but stops the test + immediately (uses `t.Fatalf` when available). + +All accept the same [functional options](configuration.md) as the rest of the +package, so you can ignore fields, relax float precision, or ignore slice order +in assertions. + +## 🧷 The `TestingT` interface + +The helpers accept a small interface rather than `*testing.T` directly, so the +package does not import `testing` into production builds: + +```go +type TestingT interface { + Helper() + Errorf(format string, args ...any) +} +``` + +`*testing.T` and `*testing.B` satisfy it. `RequireEqual` additionally uses +`Fatalf` when the supplied value implements it. + +## ➡️ Next Steps + +- Extend output and behavior: [Extensibility](extensibility.md). +- Common questions: [FAQ](faq.md). diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..8ac43cf --- /dev/null +++ b/errors.go @@ -0,0 +1,23 @@ +package comparator + +import "errors" + +// Sentinel errors returned by the package. Use errors.Is to branch on them. +var ( + // ErrUnknownFormat is returned by FormatDiff when the requested format is + // not a built-in format ("text", "json", "markdown", "html") and no + // formatter has been registered for it with RegisterFormatter. + ErrUnknownFormat = errors.New("comparator: unknown output format") + + // ErrCanceled is returned by the context-aware comparison functions and + // methods when the provided context is canceled or its deadline is exceeded + // before the comparison completes. It wraps the context's own error, so + // errors.Is(err, context.Canceled) and errors.Is(err, context.DeadlineExceeded) + // also report true. + ErrCanceled = errors.New("comparator: comparison canceled") + + // ErrInvalidPatch is returned by ApplyJSONPatch when a patch operation is + // malformed, references an unreachable path, or specifies an unsupported + // operation. + ErrInvalidPatch = errors.New("comparator: invalid JSON patch") +) diff --git a/formatters.go b/formatters.go new file mode 100644 index 0000000..b3eb1a4 --- /dev/null +++ b/formatters.go @@ -0,0 +1,60 @@ +package comparator + +import ( + "strings" + "sync" +) + +// Formatter renders a DiffResult into a string representation. Register one with +// RegisterFormatter to teach FormatDiff a new output format (for example CSV, +// JUnit, or SARIF) without changing the package. +type Formatter func(result *DiffResult) (string, error) + +var ( + formatterMu sync.RWMutex + customFormatters = map[string]Formatter{} + builtinFormatters = map[string]bool{ + "text": true, "json": true, "markdown": true, "html": true, + } +) + +// RegisterFormatter registers a custom formatter under the given name so that +// FormatDiff(result, name) and WithOutputFormat(name) can use it. The name is +// case-insensitive and must not collide with a built-in format ("text", "json", +// "markdown", "html"). Registering a nil formatter, an empty name, or a +// built-in name is a no-op that returns false. +// +// RegisterFormatter is safe for concurrent use. Registering the same name again +// replaces the previous formatter. +func RegisterFormatter(name string, fn Formatter) bool { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" || fn == nil || builtinFormatters[name] { + return false + } + formatterMu.Lock() + defer formatterMu.Unlock() + customFormatters[name] = fn + return true +} + +// UnregisterFormatter removes a previously registered custom formatter. It +// returns true if a formatter was removed. +func UnregisterFormatter(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + formatterMu.Lock() + defer formatterMu.Unlock() + if _, ok := customFormatters[name]; !ok { + return false + } + delete(customFormatters, name) + return true +} + +// lookupFormatter returns the custom formatter registered for a name, if any. +func lookupFormatter(name string) (Formatter, bool) { + name = strings.ToLower(strings.TrimSpace(name)) + formatterMu.RLock() + defer formatterMu.RUnlock() + fn, ok := customFormatters[name] + return fn, ok +} diff --git a/generics.go b/generics.go new file mode 100644 index 0000000..e540f96 --- /dev/null +++ b/generics.go @@ -0,0 +1,43 @@ +package comparator + +// This file provides a thin, type-safe layer over the any-based API. The generic +// helpers give compile-time type checking at call sites while delegating to the +// same comparison engine. + +// EqualT reports whether two values of the same type are deeply equal. It is the +// type-safe counterpart of Equal: the compiler guarantees a and b share a type. +// +// Example: +// +// if comparator.EqualT(user1, user2, comparator.IgnoreStructFields("ID")) { +// // ... +// } +func EqualT[T any](a, b T, opts ...Option) bool { + return NewWithOptions(opts...).Equal(a, b) +} + +// DeepEqualT is an alias for EqualT, emphasizing the recursive comparison. It +// mirrors DeepEqual for callers who prefer that name. +func DeepEqualT[T any](a, b T, opts ...Option) bool { + return EqualT(a, b, opts...) +} + +// DiffT returns all differences between two values of the same type. It is the +// type-safe counterpart of a Comparator.Diff call. +func DiffT[T any](a, b T, opts ...Option) ([]Difference, error) { + return NewWithOptions(opts...).Diff(a, b) +} + +// CompareWithDiffT performs a comprehensive comparison between two values of the +// same type and returns a detailed result. It is the type-safe counterpart of +// CompareWithDiff. +func CompareWithDiffT[T any](a, b T, opts ...Option) *DiffResult { + return NewDiffComparer(opts...).CompareWithDiff(a, b) +} + +// GetJSONPatchT generates an RFC 6902 JSON Patch describing how to turn a into +// b, for values of the same type. It is the type-safe counterpart of +// GetJSONPatch. +func GetJSONPatchT[T any](a, b T, opts ...Option) ([]JSONPatchOperation, error) { + return NewDiffComparer(opts...).GetJSONPatch(a, b) +} diff --git a/options_ext.go b/options_ext.go new file mode 100644 index 0000000..4b4f102 --- /dev/null +++ b/options_ext.go @@ -0,0 +1,99 @@ +package comparator + +import "regexp" + +// WithEquateEmpty controls whether nil and empty containers (slices and maps) +// are treated as equal. Unlike EquateEmpty, which can only enable the behavior, +// this option can also disable it (the default is enabled). +// +// Example: +// +// // Treat nil and empty as distinct. +// comp := comparator.NewWithOptions(comparator.WithEquateEmpty(false)) +func WithEquateEmpty(enable bool) Option { + return func(c *Config) { + c.equateEmpty = enable + } +} + +// WithFieldNaming selects how struct field names appear in difference paths and +// JSON Patch pointers. The default is GoFieldNaming. Use JSONTagNaming to make +// paths and patches align with the JSON representation of the values. +// +// Example: +// +// comp := comparator.NewDiffComparer(comparator.WithFieldNaming(comparator.JSONTagNaming)) +// // A field `Name string json:"name"` produces the pointer "/name". +func WithFieldNaming(naming FieldNaming) Option { + return func(c *Config) { + c.fieldNaming = naming + } +} + +// WithReporter registers a callback invoked for every difference as it is +// discovered during CompareWithDiff, Diff, and the Get*Diff methods. It is +// useful for streaming, logging, or integrating with test frameworks without +// waiting for the full result to be materialized. +// +// The reporter is called in traversal order and must not retain the Difference +// beyond the callback if the caller mutates shared state. +// +// Example: +// +// comp := comparator.NewDiffComparer(comparator.WithReporter(func(d comparator.Difference) { +// log.Printf("%s: %s", d.Path, d.Message) +// })) +func WithReporter(reporter func(Difference)) Option { + return func(c *Config) { + c.reporter = reporter + } +} + +// WithIgnoreMapKeys skips the named map keys anywhere they appear during +// comparison. Keys are matched by their default string representation, so +// non-string keys such as integers are matched by their formatted value. +// +// Example: +// +// comp := comparator.NewWithOptions(comparator.WithIgnoreMapKeys("updatedAt", "etag")) +func WithIgnoreMapKeys(keys ...string) Option { + return func(c *Config) { + for _, k := range keys { + c.ignoreMapKeys[k] = true + } + } +} + +// WithIgnorePaths skips struct fields at the given canonical paths. Paths use +// dotted field names with bracketed index or key segments kept inline, matching +// the form reported in Difference.Path, e.g. "User.Address.Zip". +// +// Example: +// +// comp := comparator.NewWithOptions(comparator.WithIgnorePaths("Meta.UpdatedAt", "Meta.Etag")) +func WithIgnorePaths(paths ...string) Option { + return func(c *Config) { + for _, p := range paths { + c.ignorePaths[p] = true + } + } +} + +// WithIgnorePathPatterns skips struct fields whose canonical path matches any of +// the given regular expressions. Invalid patterns are ignored so option +// construction never panics; use regexp.Compile beforehand if you need to +// validate patterns. +// +// Example: +// +// // Ignore every field named "password" at any depth. +// comp := comparator.NewWithOptions(comparator.WithIgnorePathPatterns(`(^|\.)[Pp]assword$`)) +func WithIgnorePathPatterns(patterns ...string) Option { + return func(c *Config) { + for _, p := range patterns { + if re, err := regexp.Compile(p); err == nil { + c.ignorePathPatterns = append(c.ignorePathPatterns, re) + } + } + } +} diff --git a/patch.go b/patch.go new file mode 100644 index 0000000..081774c --- /dev/null +++ b/patch.go @@ -0,0 +1,262 @@ +package comparator + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// ApplyJSONPatch applies an RFC 6902 JSON Patch to a document and returns the +// modified document. It supports the "add", "remove", "replace", "move", "copy", +// and "test" operations. +// +// The document is first normalized to JSON values (objects become +// map[string]any and arrays become []any) by round-tripping through +// encoding/json, so doc must be JSON-serializable. The returned value uses those +// same JSON types. +// +// Patches produced by GetJSONPatch use Go field names by default; generate them +// with WithFieldNaming(JSONTagNaming) when you intend to apply them to +// JSON-shaped documents so the pointers line up with the object keys. +// +// A malformed operation, an unreachable path, or an unsupported op returns an +// error wrapping ErrInvalidPatch. A failed "test" operation also returns an +// error wrapping ErrInvalidPatch. +func ApplyJSONPatch(doc any, patch []JSONPatchOperation) (any, error) { + root, err := toJSONValue(doc) + if err != nil { + return nil, fmt.Errorf("%w: normalize document: %v", ErrInvalidPatch, err) + } + + for i, op := range patch { + root, err = applyOp(root, op) + if err != nil { + return nil, fmt.Errorf("operation %d (%s %s): %w", i, op.Op, op.Path, err) + } + } + return root, nil +} + +func toJSONValue(v any) (any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + var out any + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func applyOp(root any, op JSONPatchOperation) (any, error) { + switch op.Op { + case "add": + return setAt(root, parsePointer(op.Path), op.Value, true) + case "replace": + return setAt(root, parsePointer(op.Path), op.Value, false) + case "remove": + v, err := removeAt(root, parsePointer(op.Path)) + return v, err + case "test": + got, err := getAt(root, parsePointer(op.Path)) + if err != nil { + return nil, err + } + want, err := toJSONValue(op.Value) + if err != nil { + return nil, fmt.Errorf("%w: normalize test value: %v", ErrInvalidPatch, err) + } + if !Equal(got, want) { + return nil, fmt.Errorf("%w: test failed at %q", ErrInvalidPatch, op.Path) + } + return root, nil + case "move": + return moveOrCopy(root, op, true) + case "copy": + return moveOrCopy(root, op, false) + default: + return nil, fmt.Errorf("%w: unsupported op %q", ErrInvalidPatch, op.Op) + } +} + +func moveOrCopy(root any, op JSONPatchOperation, remove bool) (any, error) { + from := parsePointer(op.From) + val, err := getAt(root, from) + if err != nil { + return nil, err + } + if remove { + root, err = removeAt(root, from) + if err != nil { + return nil, err + } + } + return setAt(root, parsePointer(op.Path), val, true) +} + +// parsePointer splits an RFC 6901 JSON Pointer into unescaped reference tokens. +// The empty pointer ("") yields no tokens and refers to the whole document. +func parsePointer(pointer string) []string { + if pointer == "" { + return nil + } + pointer = strings.TrimPrefix(pointer, "/") + tokens := strings.Split(pointer, "/") + for i, t := range tokens { + t = strings.ReplaceAll(t, "~1", "/") + t = strings.ReplaceAll(t, "~0", "~") + tokens[i] = t + } + return tokens +} + +func getAt(root any, tokens []string) (any, error) { + cur := root + for _, tok := range tokens { + switch node := cur.(type) { + case map[string]any: + v, ok := node[tok] + if !ok { + return nil, fmt.Errorf("%w: missing key %q", ErrInvalidPatch, tok) + } + cur = v + case []any: + idx, err := arrayIndex(tok, len(node), false) + if err != nil { + return nil, err + } + cur = node[idx] + default: + return nil, fmt.Errorf("%w: cannot descend into %q", ErrInvalidPatch, tok) + } + } + return cur, nil +} + +func setAt(root any, tokens []string, value any, add bool) (any, error) { + value, err := toJSONValue(value) + if err != nil { + return nil, fmt.Errorf("%w: normalize value: %v", ErrInvalidPatch, err) + } + if len(tokens) == 0 { + return value, nil + } + + parent, err := getAt(root, tokens[:len(tokens)-1]) + if err != nil { + return nil, err + } + last := tokens[len(tokens)-1] + + switch node := parent.(type) { + case map[string]any: + if !add { + if _, ok := node[last]; !ok { + return nil, fmt.Errorf("%w: cannot replace missing key %q", ErrInvalidPatch, last) + } + } + node[last] = value + return root, nil + case []any: + return setInArray(root, tokens, node, last, value, add) + default: + return nil, fmt.Errorf("%w: cannot set %q on %T", ErrInvalidPatch, last, parent) + } +} + +func setInArray(root any, tokens []string, arr []any, last string, value any, add bool) (any, error) { + if add { + idx := len(arr) + if last != "-" { + var err error + idx, err = arrayIndex(last, len(arr)+1, true) + if err != nil { + return nil, err + } + } + arr = append(arr, nil) + copy(arr[idx+1:], arr[idx:]) + arr[idx] = value + return replaceArray(root, tokens[:len(tokens)-1], arr) + } + + idx, err := arrayIndex(last, len(arr), false) + if err != nil { + return nil, err + } + arr[idx] = value + return root, nil +} + +func removeAt(root any, tokens []string) (any, error) { + if len(tokens) == 0 { + return nil, fmt.Errorf("%w: cannot remove whole document", ErrInvalidPatch) + } + + parent, err := getAt(root, tokens[:len(tokens)-1]) + if err != nil { + return nil, err + } + last := tokens[len(tokens)-1] + + switch node := parent.(type) { + case map[string]any: + if _, ok := node[last]; !ok { + return nil, fmt.Errorf("%w: cannot remove missing key %q", ErrInvalidPatch, last) + } + delete(node, last) + return root, nil + case []any: + idx, err := arrayIndex(last, len(node), false) + if err != nil { + return nil, err + } + node = append(node[:idx], node[idx+1:]...) + return replaceArray(root, tokens[:len(tokens)-1], node) + default: + return nil, fmt.Errorf("%w: cannot remove %q from %T", ErrInvalidPatch, last, parent) + } +} + +// replaceArray writes a rebuilt slice back into its parent, since appending to +// or shrinking a []any produces a new header that the parent must reference. +func replaceArray(root any, parentTokens []string, arr []any) (any, error) { + if len(parentTokens) == 0 { + return arr, nil + } + grand, err := getAt(root, parentTokens[:len(parentTokens)-1]) + if err != nil { + return nil, err + } + key := parentTokens[len(parentTokens)-1] + switch node := grand.(type) { + case map[string]any: + node[key] = arr + case []any: + idx, err := arrayIndex(key, len(node), false) + if err != nil { + return nil, err + } + node[idx] = arr + default: + return nil, fmt.Errorf("%w: cannot attach array at %q", ErrInvalidPatch, key) + } + return root, nil +} + +func arrayIndex(tok string, length int, allowEnd bool) (int, error) { + idx, err := strconv.Atoi(tok) + if err != nil { + return 0, fmt.Errorf("%w: invalid array index %q", ErrInvalidPatch, tok) + } + limit := length + if !allowEnd { + limit = length - 1 + } + if idx < 0 || idx > limit { + return 0, fmt.Errorf("%w: array index %d out of range", ErrInvalidPatch, idx) + } + return idx, nil +} diff --git a/stringer.go b/stringer.go new file mode 100644 index 0000000..2d3dc95 --- /dev/null +++ b/stringer.go @@ -0,0 +1,66 @@ +package comparator + +import "strings" + +// String renders a unified diff in the familiar text form: the header followed +// by each hunk's context header and its change lines. Change contents already +// carry their "+", "-", or space prefix. +func (u *UnifiedDiff) String() string { + if u == nil { + return "" + } + + var b strings.Builder + b.WriteString(u.Header) + for _, chunk := range u.Chunks { + b.WriteString(chunk.Context) + b.WriteByte('\n') + for _, ch := range chunk.Changes { + b.WriteString(ch.Content) + b.WriteByte('\n') + } + } + return b.String() +} + +// String renders a visual diff tree as an indented, one-node-per-line string. +// Each node is prefixed by a status symbol: "+" added, "-" removed, "~" +// different, and a space for unchanged nodes. +func (v *VisualDiff) String() string { + if v == nil || v.Root == nil { + return "" + } + var b strings.Builder + writeVisualNode(&b, v.Root, 0) + return b.String() +} + +func writeVisualNode(b *strings.Builder, node *VisualNode, depth int) { + if node == nil { + return + } + + symbol := " " + switch node.Status { + case "added": + symbol = "+" + case "removed": + symbol = "-" + case "different": + symbol = "~" + } + + b.WriteString(strings.Repeat(" ", depth)) + b.WriteString(symbol) + b.WriteByte(' ') + b.WriteString(node.Path) + if node.Value != "" { + b.WriteString(": ") + b.WriteString(node.Value) + } + b.WriteByte('\n') + + for _, child := range node.Children { + writeVisualNode(b, child, depth+1) + } +}