diff --git a/README.md b/README.md
index adf1d08ac..b487a6dbd 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ This is the go-openapi fork of the great [testify](https://github.com/stretchr/t
Main features:
* zero external dependencies
-* opt-in dependencies for extra features (e.g. asserting YAML)
+* opt-in dependencies for extra features (e.g. asserting YAML, colorized output)
* [searchable documentation][doc-url]
## Announcements
diff --git a/assert/enable/colors/enable_colors.go b/assert/enable/colors/enable_colors.go
new file mode 100644
index 000000000..19b8d81cb
--- /dev/null
+++ b/assert/enable/colors/enable_colors.go
@@ -0,0 +1,56 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+// Package colors is an indirection to handle colorized output.
+//
+// This package allows the builder to override the indirection with an alternative implementation
+// of colorized printers.
+package colors
+
+import (
+ colorstub "github.com/go-openapi/testify/v2/internal/assertions/enable/colors"
+)
+
+// Enable registers colorized options for pretty-printing the output of assertions.
+//
+// The provided enabler defers the initialization, so we may retrieve flags after command line parsing
+// or other initialization tasks.
+//
+// This is not intended for concurrent use.
+func Enable(enabler func() []Option) {
+ colorstub.Enable(enabler)
+}
+
+// re-exposed internal types.
+type (
+ // Option is a colorization option.
+ Option = colorstub.Option
+
+ // Theme is a colorization theme for testify output.
+ Theme = colorstub.Theme
+)
+
+// WithEnable enables colorization.
+func WithEnable(enabled bool) Option {
+ return colorstub.WithEnable(enabled)
+}
+
+// WithSanitizedTheme sets a colorization theme from a string.
+func WithSanitizedTheme(theme string) Option {
+ return colorstub.WithSanitizedTheme(theme)
+}
+
+// WithTheme sets a colorization theme.
+func WithTheme(theme Theme) Option {
+ return colorstub.WithTheme(theme)
+}
+
+// WithDark sets the [ThemeDark] color theme.
+func WithDark() Option {
+ return colorstub.WithDark()
+}
+
+// WithLight sets the [ThemeLight] color theme.
+func WithLight() Option {
+ return colorstub.WithLight()
+}
diff --git a/assert/enable/yaml/enable_yaml.go b/assert/enable/yaml/enable_yaml.go
index c0a67c736..221ce1fee 100644
--- a/assert/enable/yaml/enable_yaml.go
+++ b/assert/enable/yaml/enable_yaml.go
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
// Package yaml is an indirection to handle YAML deserialization.
//
// This package allows the builder to override the indirection with an alternative implementation
diff --git a/docs/doc-site/examples/EXAMPLES.md b/docs/doc-site/examples/EXAMPLES.md
index 5297a4e96..24731d146 100644
--- a/docs/doc-site/examples/EXAMPLES.md
+++ b/docs/doc-site/examples/EXAMPLES.md
@@ -435,6 +435,72 @@ name: Alice
---
+## Colorized Output (Optional)
+
+Testify can colorize test failure output for better readability. This is an opt-in feature.
+
+### Enabling Colors
+
+```go
+import (
+ "testing"
+ "github.com/go-openapi/testify/v2/assert"
+ _ "github.com/go-openapi/testify/enable/colors/v2" // Enable colorized output
+)
+
+func TestExample(t *testing.T) {
+ assert.Equal(t, "expected", "actual") // Failure will be colorized
+}
+```
+
+### Activation
+
+Colors are activated via command line flag or environment variable:
+
+```bash
+# Via flag
+go test -v -testify.colorized ./...
+
+# Via environment variable
+TESTIFY_COLORIZED=true go test -v ./...
+```
+
+### Themes
+
+Two themes are available for different terminal backgrounds:
+
+```bash
+# Dark theme (default) - bright colors for dark terminals
+go test -v -testify.colorized ./...
+
+# Light theme - normal colors for light terminals
+go test -v -testify.colorized -testify.theme=light ./...
+
+# Or via environment
+TESTIFY_COLORIZED=true TESTIFY_THEME=light go test -v ./...
+```
+
+### CI Environments
+
+By default, colorization is disabled when output is not a terminal. To force colors in CI environments that support ANSI codes:
+
+```bash
+TESTIFY_COLORIZED=true TESTIFY_COLORIZED_NOTTY=true go test -v ./...
+```
+
+### What Gets Colorized
+
+- **Expected values** in assertion failures (green)
+- **Actual values** in assertion failures (red)
+- **Diff output**:
+ - Deleted lines (red)
+ - Inserted lines (yellow)
+ - Context lines (green)
+
+**Note:** Without the `enable/colors` import, output remains uncolored (no panic, just no colors).
+
+---
+
## Best Practices
1. **Use `require` for preconditions** - Stop test immediately if setup fails
diff --git a/enable/colors/assertions_test.go b/enable/colors/assertions_test.go
new file mode 100644
index 000000000..a6393a6ec
--- /dev/null
+++ b/enable/colors/assertions_test.go
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ target "github.com/go-openapi/testify/v2/assert"
+ colorstub "github.com/go-openapi/testify/v2/assert/enable/colors"
+)
+
+func TestMain(m *testing.M) {
+ // we can't easily simulate arg flags in CI (uses gotestsum etc).
+ // Similarly, env vars are evaluated too early.
+ colorstub.Enable(
+ func() []colorstub.Option {
+ return []colorstub.Option{
+ colorstub.WithEnable(true),
+ colorstub.WithSanitizedTheme(flags.theme),
+ }
+ })
+
+ os.Exit(m.Run())
+}
+
+func TestAssertJSONEq(t *testing.T) {
+ t.Parallel()
+
+ mockT := new(mockT)
+ res := target.JSONEq(mockT, `{"hello": "world", "foo": "bar"}`, `{"hello": "worldwide", "foo": "bar"}`)
+
+ target.False(t, res)
+
+ output := mockT.errorString()
+ t.Log(output) // best to visualize the output
+ target.Contains(t, neuterize(output), neuterize(expectedColorizedDiff))
+}
+
+func TestAssertJSONEq_Array(t *testing.T) {
+ t.Parallel()
+
+ mockT := new(mockT)
+ res := target.JSONEq(mockT, `["foo", {"hello": "world", "nested": "hash"}]`, `["bar", {"nested": "hash", "hello": "world"}]`)
+
+ target.False(t, res)
+ output := mockT.errorString()
+ t.Log(output) // best to visualize the output
+ target.Contains(t, neuterize(output), neuterize(expectedColorizedArrayDiff))
+}
+
+func neuterize(str string) string {
+ // remove blanks and replace escape sequences for readability
+ blankRemover := strings.NewReplacer("\t", "", " ", "", "\x1b", "^[")
+ return blankRemover.Replace(str)
+}
+
+type mockT struct {
+ errorFmt string
+ args []any
+}
+
+// Helper is like [testing.T.Helper] but does nothing.
+func (mockT) Helper() {}
+
+func (m *mockT) Errorf(format string, args ...any) {
+ m.errorFmt = format
+ m.args = args
+}
+
+func (m *mockT) Failed() bool {
+ return m.errorFmt != ""
+}
+
+func (m *mockT) errorString() string {
+ return fmt.Sprintf(m.errorFmt, m.args...)
+}
+
+// captured output (indentation is not checked)
+//
+//nolint:staticcheck // indeed we want to check the escape sequences in this test
+const (
+ expectedColorizedDiff = ` Not equal:
+ expected: [0;92mmap[string]interface {}{"foo":"bar", "hello":"world"}[0m
+ actual : [0;91mmap[string]interface {}{"foo":"bar", "hello":"worldwide"}[0m
+
+ Diff:
+ --- Expected
+ +++ Actual
+ @@ -2,3 +2,3 @@
+ [0;92m (string) (len=3) "foo": (string) (len=3) "bar",
+ [0m[0;91m- (string) (len=5) "hello": (string) (len=5) "world"
+ [0m[0;93m+ (string) (len=5) "hello": (string) (len=9) "worldwide"
+ [0m[0;92m }
+ [0m
+`
+
+ expectedColorizedArrayDiff = `Not equal:
+ expected: [0;92m[]interface {}{"foo", map[string]interface {}{"hello":"world", "nested":"hash"}}[0m
+ actual : [0;91m[]interface {}{"bar", map[string]interface {}{"hello":"world", "nested":"hash"}}[0m
+
+ Diff:
+ --- Expected
+ +++ Actual
+ @@ -1,3 +1,3 @@
+ [0;92m ([]interface {}) (len=2) {
+ [0m[0;91m- (string) (len=3) "foo",
+ [0m[0;93m+ (string) (len=3) "bar",
+ [0m[0;92m (map[string]interface {}) (len=2) {
+ [0m
+`
+)
diff --git a/enable/colors/doc.go b/enable/colors/doc.go
new file mode 100644
index 000000000..0fe7be61a
--- /dev/null
+++ b/enable/colors/doc.go
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+// Package colors enables colorized tests with basic and portable ANSI terminal codes.
+//
+// Colorization is disabled by default when the standard output is not a terminal.
+//
+// Colors are somewhat limited, but the package works on unix and windows without any extra dependencies.
+//
+// # Command line arguments
+//
+// - testify.colorized={true|false}
+// - testify.theme={dark|light}
+// - testify.colorized.notty={true|false} (enable colorization even when the output is not a terminal)
+//
+// The default theme used is dark.
+//
+// To run tests on a terminal with colorized output:
+//
+// - run: go test -v -testify.colorized ./...
+//
+// # Environment variables
+//
+// Colorization may be enabled from environment:
+//
+// - TESTIFY_COLORIZED=true
+// - TESTIFY_THEME=dark
+// - TESTIFY_COLORIZED_NOTTY=true
+//
+// Command line arguments take precedence over environment.
+package colors
diff --git a/enable/colors/enable.go b/enable/colors/enable.go
new file mode 100644
index 000000000..6fe82a3eb
--- /dev/null
+++ b/enable/colors/enable.go
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+import (
+ "flag"
+ "os"
+ "strconv"
+ "strings"
+
+ "golang.org/x/term"
+
+ colorstub "github.com/go-openapi/testify/v2/assert/enable/colors"
+)
+
+const (
+ envVarColorize = "TESTIFY_COLORIZED"
+ envVarTheme = "TESTIFY_THEME"
+ envVarNoTTY = "TESTIFY_COLORIZED_NOTTY"
+)
+
+var flags cliFlags //nolint:gochecknoglobals // it's okay to store the state CLI flags in a package global
+
+type cliFlags struct {
+ colorized bool
+ theme string
+ notty bool
+}
+
+func init() { //nolint:gochecknoinits // it's okay: we want to declare CLI flags when a blank import references this package
+ isTerminal := term.IsTerminal(1)
+
+ flag.BoolVar(&flags.colorized, "testify.colorized", colorizeFromEnv(), "testify: colorized output")
+ flag.StringVar(&flags.theme, "testify.theme", themeFromEnv(), "testify: color theme (light,dark)")
+ flag.BoolVar(&flags.notty, "testify.colorized.notty", nottyFromEnv(), "testify: force colorization, even if not a tty")
+
+ colorstub.Enable(
+ func() []colorstub.Option {
+ return []colorstub.Option{
+ colorstub.WithEnable(flags.colorized && (isTerminal || flags.notty)),
+ colorstub.WithSanitizedTheme(flags.theme),
+ }
+ })
+}
+
+func colorizeFromEnv() bool {
+ envColorize := os.Getenv(envVarColorize)
+ isEnvConfigured, _ := strconv.ParseBool(envColorize)
+
+ return isEnvConfigured
+}
+
+func themeFromEnv() string {
+ envTheme := os.Getenv(envVarTheme)
+
+ return strings.ToLower(envTheme)
+}
+
+func nottyFromEnv() bool {
+ envNoTTY := os.Getenv(envVarNoTTY)
+ isEnvNoTTY, _ := strconv.ParseBool(envNoTTY)
+
+ return isEnvNoTTY
+}
diff --git a/enable/colors/go.mod b/enable/colors/go.mod
new file mode 100644
index 000000000..e30f50f6f
--- /dev/null
+++ b/enable/colors/go.mod
@@ -0,0 +1,12 @@
+module github.com/go-openapi/testify/enable/colors/v2
+
+require (
+ github.com/go-openapi/testify/v2 v2.1.8
+ golang.org/x/term v0.39.0
+)
+
+require golang.org/x/sys v0.40.0 // indirect
+
+replace github.com/go-openapi/testify/v2 => ../..
+
+go 1.24.0
diff --git a/enable/colors/go.sum b/enable/colors/go.sum
new file mode 100644
index 000000000..a4e064915
--- /dev/null
+++ b/enable/colors/go.sum
@@ -0,0 +1,4 @@
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
diff --git a/enable/yaml/assertions_test.go b/enable/yaml/assertions_test.go
index 15c53c54c..24bd6f6d0 100644
--- a/enable/yaml/assertions_test.go
+++ b/enable/yaml/assertions_test.go
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
package yaml
import (
diff --git a/enable/yaml/forward_assertions_test.go b/enable/yaml/forward_assertions_test.go
index e4cd96bcd..b0907f9dc 100644
--- a/enable/yaml/forward_assertions_test.go
+++ b/enable/yaml/forward_assertions_test.go
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
package yaml
import (
diff --git a/enable/yaml/forward_requirements_test.go b/enable/yaml/forward_requirements_test.go
index 90d800f70..4e3beccef 100644
--- a/enable/yaml/forward_requirements_test.go
+++ b/enable/yaml/forward_requirements_test.go
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
package yaml
import (
diff --git a/enable/yaml/requirements_test.go b/enable/yaml/requirements_test.go
index 8d5ae5a1a..dc7144928 100644
--- a/enable/yaml/requirements_test.go
+++ b/enable/yaml/requirements_test.go
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
package yaml
import (
diff --git a/go.work b/go.work
index 096ac4347..ba6bd80e3 100644
--- a/go.work
+++ b/go.work
@@ -1,6 +1,7 @@
use (
.
./codegen
+ ./enable/colors
./enable/yaml
./internal/testintegration
)
diff --git a/internal/assertions/diff.go b/internal/assertions/diff.go
new file mode 100644
index 000000000..26596ebf1
--- /dev/null
+++ b/internal/assertions/diff.go
@@ -0,0 +1,71 @@
+package assertions
+
+import (
+ "reflect"
+ "time"
+
+ "github.com/go-openapi/testify/v2/internal/assertions/enable/colors"
+ "github.com/go-openapi/testify/v2/internal/difflib"
+)
+
+// diff returns a diff of both values as long as both are of the same type and
+// are a struct, map, slice, array or string. Otherwise it returns an empty string.
+func diff(expected any, actual any) string {
+ if expected == nil || actual == nil {
+ return ""
+ }
+
+ et, ek := typeAndKind(expected)
+ at, _ := typeAndKind(actual)
+
+ if et != at {
+ return ""
+ }
+
+ if ek != reflect.Struct && ek != reflect.Map && ek != reflect.Slice && ek != reflect.Array && ek != reflect.String {
+ return ""
+ }
+
+ var e, a string
+
+ switch et {
+ case reflect.TypeFor[string]():
+ e = reflect.ValueOf(expected).String()
+ a = reflect.ValueOf(actual).String()
+ case reflect.TypeFor[time.Time]():
+ e = spewConfigStringerEnabled.Sdump(expected)
+ a = spewConfigStringerEnabled.Sdump(actual)
+ default:
+ e = spewConfig.Sdump(expected)
+ a = spewConfig.Sdump(actual)
+ }
+
+ unified := difflib.UnifiedDiff{
+ A: difflib.SplitLines(e),
+ B: difflib.SplitLines(a),
+ FromFile: "Expected",
+ FromDate: "",
+ ToFile: "Actual",
+ ToDate: "",
+ Context: 1,
+ }
+
+ if colors.Enabled() {
+ unified.Options = colors.Options()
+ }
+
+ diff, _ := difflib.GetUnifiedDiffString(unified)
+
+ return "\n\nDiff:\n" + diff
+}
+
+func typeAndKind(v any) (reflect.Type, reflect.Kind) {
+ t := reflect.TypeOf(v)
+ k := t.Kind() // Proposal for enhancement: check if t is not nil
+
+ if k == reflect.Ptr {
+ t = t.Elem()
+ k = t.Kind()
+ }
+ return t, k
+}
diff --git a/internal/assertions/enable/colors/colors.go b/internal/assertions/enable/colors/colors.go
new file mode 100644
index 000000000..39442b15a
--- /dev/null
+++ b/internal/assertions/enable/colors/colors.go
@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+import (
+ "bufio"
+
+ "github.com/go-openapi/testify/v2/internal/difflib"
+)
+
+const (
+ greenMark = "\033[0;32m"
+ redMark = "\033[0;31m"
+ yellowMark = "\033[0;33m" // aka orange
+ cyanMark = "\033[0;36m"
+
+ brightGreenMark = "\033[0;92m"
+ brightRedMark = "\033[0;91m"
+ brightYellowMark = "\033[0;93m" // aka yellow
+ brightCyanMark = "\033[0;96m" // aka turquoise
+
+ // color codes for future use.
+
+ // blackMark = "\033[0;30m"
+ // blueMark = "\033[0;34m"
+ // magentaMark = "\033[0;35m"
+ // greyMark = "\033[0;37m".
+
+ // darkGreyMark = "\033[0;90m"
+ // brightBlueMark = "\033[0;94m"
+ // brightMagentaMark = "\033[0;95m"
+ // brightWhiteMark = "\033[0;97m".
+
+ endMark = "\033[0m"
+)
+
+// StringColorizer wraps a string with ANSI escape codes.
+//
+// This is a simpler alternative to [difflib.PrinterBuilder] for cases
+// where streaming to a [bufio.Writer] is not needed.
+type StringColorizer func(string) string
+
+func makeColorizer(mark string) StringColorizer {
+ return func(s string) string {
+ return mark + s + endMark
+ }
+}
+
+// noopColorizer returns the input string unchanged.
+func noopColorizer(s string) string {
+ return s
+}
+
+//nolint:gochecknoglobals // internal colorizers may safely be shared at the package-level
+var (
+ greenColorizer = makeColorizer(greenMark)
+ redColorizer = makeColorizer(redMark)
+ // yellowColorizer = makeColorizer(yellowMark)
+ // cyanColorizer = makeColorizer(cyanMark).
+
+ brightGreenColorizer = makeColorizer(brightGreenMark)
+ brightRedColorizer = makeColorizer(brightRedMark)
+ // brightYellowColorizer = makeColorizer(brightYellowMark)
+ // brightCyanColorizer = makeColorizer(brightCyanMark).
+)
+
+//nolint:gochecknoglobals // internal printer builders may safely be shared at the package-level
+var (
+ greenPrinterBuilder = ansiPrinterBuilder(greenMark)
+ redPrinterBuilder = ansiPrinterBuilder(redMark)
+ yellowPrinterBuilder = ansiPrinterBuilder(yellowMark)
+ cyanPrinterBuilder = ansiPrinterBuilder(cyanMark)
+
+ brightGreenPrinterBuilder = ansiPrinterBuilder(brightGreenMark)
+ brightRedPrinterBuilder = ansiPrinterBuilder(brightRedMark)
+ brightYellowPrinterBuilder = ansiPrinterBuilder(brightYellowMark)
+ brightCyanPrinterBuilder = ansiPrinterBuilder(brightCyanMark)
+
+ // magentaPrinterBuilder = ansiPrinterBuilder(magentaMark)
+ // bluePrinterBuilder = ansiPrinterBuilder(blueMark).
+
+ // brightMagentaPrinterBuilder = ansiPrinterBuilder(brightMagentaMark)
+ // brightBluePrinterBuilder = ansiPrinterBuilder(brightBlueMark).
+)
+
+func ansiPrinterBuilder(mark string) difflib.PrinterBuilder {
+ return func(w *bufio.Writer) difflib.Printer {
+ return func(str string) (err error) {
+ _, err = w.WriteString(mark)
+ if err != nil {
+ return
+ }
+ _, err = w.WriteString(str)
+ if err != nil {
+ return
+ }
+ _, err = w.WriteString(endMark)
+ if err != nil {
+ return
+ }
+
+ return nil
+ }
+ }
+}
diff --git a/internal/assertions/enable/colors/colors_test.go b/internal/assertions/enable/colors/colors_test.go
new file mode 100644
index 000000000..4f52a9cfd
--- /dev/null
+++ b/internal/assertions/enable/colors/colors_test.go
@@ -0,0 +1,144 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+import (
+ "iter"
+ "slices"
+ "testing"
+)
+
+func TestStringColorizer(t *testing.T) {
+ t.Parallel()
+
+ for tc := range colorizerTestCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := tc.colorizer(tc.input)
+ if result != tc.expected {
+ t.Errorf("expected %q, got %q", tc.expected, result)
+ }
+ })
+ }
+}
+
+func TestMakeColorizer(t *testing.T) {
+ t.Parallel()
+
+ t.Run("creates colorizer with custom mark", func(t *testing.T) {
+ t.Parallel()
+
+ customMark := "\033[1;35m" // bold magenta
+ colorizer := makeColorizer(customMark)
+
+ result := colorizer("test")
+ expected := "\033[1;35mtest\033[0m"
+
+ if result != expected {
+ t.Errorf("expected %q, got %q", expected, result)
+ }
+ })
+
+ t.Run("colorizer is reusable", func(t *testing.T) {
+ t.Parallel()
+
+ colorizer := makeColorizer(greenMark)
+
+ result1 := colorizer("first")
+ result2 := colorizer("second")
+
+ expected1 := "\033[0;32mfirst\033[0m"
+ expected2 := "\033[0;32msecond\033[0m"
+
+ if result1 != expected1 {
+ t.Errorf("first call: expected %q, got %q", expected1, result1)
+ }
+ if result2 != expected2 {
+ t.Errorf("second call: expected %q, got %q", expected2, result2)
+ }
+ })
+}
+
+func TestNoopColorizer(t *testing.T) {
+ t.Parallel()
+
+ inputs := []string{
+ "",
+ "simple",
+ "with\nnewline",
+ "\033[0;31malready colored\033[0m",
+ }
+
+ for _, input := range inputs {
+ t.Run(input, func(t *testing.T) {
+ t.Parallel()
+
+ result := noopColorizer(input)
+ if result != input {
+ t.Errorf("noopColorizer should return input unchanged: expected %q, got %q", input, result)
+ }
+ })
+ }
+}
+
+type colorizerTestCase struct {
+ name string
+ colorizer StringColorizer
+ input string
+ expected string
+}
+
+func colorizerTestCases() iter.Seq[colorizerTestCase] {
+ return slices.Values([]colorizerTestCase{
+ {
+ name: "green colorizer",
+ colorizer: greenColorizer,
+ input: "hello",
+ expected: "\033[0;32mhello\033[0m",
+ },
+ {
+ name: "red colorizer",
+ colorizer: redColorizer,
+ input: "world",
+ expected: "\033[0;31mworld\033[0m",
+ },
+ {
+ name: "bright green colorizer",
+ colorizer: brightGreenColorizer,
+ input: "expected",
+ expected: "\033[0;92mexpected\033[0m",
+ },
+ {
+ name: "bright red colorizer",
+ colorizer: brightRedColorizer,
+ input: "actual",
+ expected: "\033[0;91mactual\033[0m",
+ },
+ {
+ name: "noop colorizer",
+ colorizer: noopColorizer,
+ input: "unchanged",
+ expected: "unchanged",
+ },
+ {
+ name: "empty string",
+ colorizer: greenColorizer,
+ input: "",
+ expected: "\033[0;32m\033[0m",
+ },
+ {
+ name: "string with special characters",
+ colorizer: redColorizer,
+ input: "line1\nline2\ttab",
+ expected: "\033[0;31mline1\nline2\ttab\033[0m",
+ },
+ {
+ name: "string with existing ANSI codes",
+ colorizer: greenColorizer,
+ input: "\033[1mbold\033[0m",
+ expected: "\033[0;32m\033[1mbold\033[0m\033[0m",
+ },
+ })
+}
diff --git a/internal/assertions/enable/colors/enable_colors.go b/internal/assertions/enable/colors/enable_colors.go
new file mode 100644
index 000000000..8ce74f323
--- /dev/null
+++ b/internal/assertions/enable/colors/enable_colors.go
@@ -0,0 +1,77 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+import (
+ "sync"
+
+ "github.com/go-openapi/testify/v2/internal/difflib"
+)
+
+//nolint:gochecknoglobals // in this particular case, we need a global to enable the feature from another module
+var (
+ resolveOptionsOnce sync.Once
+ optionsEnabler func() []Option
+ colorOptions *difflib.Options
+ stringColorizers colorizers
+)
+
+// Enable registers colorized options for pretty-printing the output of assertions.
+//
+// The argument passed is a function that is executed after package initialization and CLI arg parsing.
+//
+// This is not intended for concurrent use as it sets a package-level state.
+func Enable(enabler func() []Option) {
+ optionsEnabler = enabler
+}
+
+// Enabled indicates if a global color options setting has been enabled.
+func Enabled() bool {
+ return optionsEnabler != nil
+}
+
+// Options returns the colorization options for [difflib].
+//
+// It yields nil if the colorization feature is not enabled (non blocking: colors just won't display).
+func Options() *difflib.Options {
+ resolveOptions()
+
+ return colorOptions
+}
+
+// ExpectedColorizer returns a colorizer for expected values.
+//
+// It returns a no-op colorizer if colorization is not enabled.
+func ExpectedColorizer() StringColorizer {
+ resolveOptions()
+
+ return stringColorizers.expected
+}
+
+// ActualColorizer returns a colorizer for actual values.
+//
+// It returns a no-op colorizer if colorization is not enabled.
+func ActualColorizer() StringColorizer {
+ resolveOptions()
+
+ return stringColorizers.actual
+}
+
+func resolveOptions() {
+ resolveOptionsOnce.Do(func() {
+ // defers the resolution of options until first usage
+ if optionsEnabler == nil {
+ stringColorizers = colorizers{
+ expected: noopColorizer,
+ actual: noopColorizer,
+ }
+
+ return
+ }
+
+ o := optionsWithDefaults(optionsEnabler())
+ colorOptions = makeDiffOptions(o)
+ stringColorizers = setColorizers(o)
+ })
+}
diff --git a/internal/assertions/enable/colors/options.go b/internal/assertions/enable/colors/options.go
new file mode 100644
index 000000000..f080c03b2
--- /dev/null
+++ b/internal/assertions/enable/colors/options.go
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+// Theme is a colorization theme for testify output.
+type Theme string
+
+func (t Theme) String() string {
+ return string(t)
+}
+
+const (
+ // ThemeLight uses normal ANSI colors.
+ ThemeLight Theme = "light"
+
+ // ThemeDark uses bright ANSI colors.
+ ThemeDark Theme = "dark"
+)
+
+// Option is a colorization option.
+type Option func(o *options)
+
+type options struct {
+ enabled bool
+ theme Theme
+}
+
+// WithSanitizedTheme sets a colorization theme from its name or does nothing if the theme is not supported.
+func WithSanitizedTheme(theme string) Option {
+ th := Theme(theme)
+ switch th {
+ case ThemeDark, ThemeLight:
+ return WithTheme(th)
+ default:
+ return func(*options) { // noop
+ }
+ }
+}
+
+// WithEnable enables colorized output.
+func WithEnable(enabled bool) Option {
+ return func(o *options) {
+ o.enabled = enabled
+ }
+}
+
+// WithTheme sets a colorization theme.
+func WithTheme(theme Theme) Option {
+ return func(o *options) {
+ o.theme = theme
+ }
+}
+
+// WithDark sets the [ThemeDark] color theme.
+func WithDark() Option {
+ return WithTheme(ThemeDark)
+}
+
+// WithLight sets the [ThemeLight] color theme.
+func WithLight() Option {
+ return WithTheme(ThemeLight)
+}
+
+func optionsWithDefaults(opts []Option) options {
+ o := options{
+ theme: ThemeDark, // default theme
+ }
+
+ for _, apply := range opts {
+ apply(&o)
+ }
+
+ return o
+}
diff --git a/internal/assertions/enable/colors/themes.go b/internal/assertions/enable/colors/themes.go
new file mode 100644
index 000000000..8525fef38
--- /dev/null
+++ b/internal/assertions/enable/colors/themes.go
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package colors
+
+import "github.com/go-openapi/testify/v2/internal/difflib"
+
+// colorizers holds string colorizers for a theme.
+type colorizers struct {
+ expected StringColorizer
+ actual StringColorizer
+}
+
+// makeDiffOptions transforms preset options into the
+// detailed options for difflib.
+func makeDiffOptions(o options) *difflib.Options {
+ if !o.enabled {
+ return nil
+ }
+
+ switch o.theme {
+ case ThemeLight:
+ return &difflib.Options{
+ EqualPrinter: greenPrinterBuilder,
+ DeletePrinter: redPrinterBuilder,
+ UpdatePrinter: cyanPrinterBuilder,
+ InsertPrinter: yellowPrinterBuilder,
+ }
+ case ThemeDark:
+ return &difflib.Options{
+ EqualPrinter: brightGreenPrinterBuilder,
+ DeletePrinter: brightRedPrinterBuilder,
+ UpdatePrinter: brightCyanPrinterBuilder,
+ InsertPrinter: brightYellowPrinterBuilder,
+ }
+ default:
+ return nil
+ }
+}
+
+// setColorizers returns string colorizers for the given options.
+func setColorizers(o options) colorizers {
+ if !o.enabled {
+ return colorizers{
+ expected: noopColorizer,
+ actual: noopColorizer,
+ }
+ }
+
+ switch o.theme {
+ case ThemeLight:
+ return colorizers{
+ expected: greenColorizer,
+ actual: redColorizer,
+ }
+ case ThemeDark:
+ return colorizers{
+ expected: brightGreenColorizer,
+ actual: brightRedColorizer,
+ }
+ default:
+ return colorizers{
+ expected: noopColorizer,
+ actual: noopColorizer,
+ }
+ }
+}
diff --git a/internal/assertions/enable/doc.go b/internal/assertions/enable/doc.go
index 30ff081d1..596e0036e 100644
--- a/internal/assertions/enable/doc.go
+++ b/internal/assertions/enable/doc.go
@@ -1,3 +1,10 @@
-// Package enable registers extra features
-// and possibly additional dependencies.
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+// Package enable allows to register extra features.
+//
+// # Available extensions
+//
+// - yaml: enable the YAML assertions
+// - colors: enable colorization of diff output
package enable
diff --git a/internal/assertions/enable/yaml/enable_yaml.go b/internal/assertions/enable/yaml/enable_yaml.go
index 7daa1289d..0a66aa736 100644
--- a/internal/assertions/enable/yaml/enable_yaml.go
+++ b/internal/assertions/enable/yaml/enable_yaml.go
@@ -1,3 +1,6 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
// Package yaml is an indirection to handle YAML deserialization.
//
// This package allows the builder to override the indirection with an alternative implementation
diff --git a/internal/assertions/equal.go b/internal/assertions/equal.go
index 88384e94c..554bbb9ca 100644
--- a/internal/assertions/equal.go
+++ b/internal/assertions/equal.go
@@ -8,6 +8,8 @@ import (
"fmt"
"reflect"
"time"
+
+ "github.com/go-openapi/testify/v2/internal/assertions/enable/colors"
)
// Equal asserts that two objects are equal.
@@ -37,10 +39,21 @@ func Equal(t T, expected, actual any, msgAndArgs ...any) bool {
if !ObjectsAreEqual(expected, actual) {
diff := diff(expected, actual)
- expected, actual = formatUnequalValues(expected, actual)
- return Fail(t, fmt.Sprintf("Not equal: \n"+
- "expected: %s\n"+
- "actual : %s%s", expected, actual, diff), msgAndArgs...)
+ expectedStr, actualStr := formatUnequalValues(expected, actual)
+
+ if colors.Enabled() {
+ expectedStr = colors.ExpectedColorizer()(expectedStr)
+ actualStr = colors.ActualColorizer()(actualStr)
+ }
+
+ return Fail(t,
+ fmt.Sprintf("Not equal: \n"+
+ "expected: %s\n"+
+ "actual : %s%s",
+ expectedStr,
+ actualStr, diff),
+ msgAndArgs...,
+ )
}
return true
diff --git a/internal/assertions/helpers.go b/internal/assertions/helpers.go
index 6ebce19d7..d68baf952 100644
--- a/internal/assertions/helpers.go
+++ b/internal/assertions/helpers.go
@@ -6,72 +6,12 @@ package assertions
import (
"bufio"
"fmt"
- "reflect"
- "time"
-
- "github.com/go-openapi/testify/v2/internal/difflib"
)
/*
Helper functions
*/
-// diff returns a diff of both values as long as both are of the same type and
-// are a struct, map, slice, array or string. Otherwise it returns an empty string.
-func diff(expected any, actual any) string {
- if expected == nil || actual == nil {
- return ""
- }
-
- et, ek := typeAndKind(expected)
- at, _ := typeAndKind(actual)
-
- if et != at {
- return ""
- }
-
- if ek != reflect.Struct && ek != reflect.Map && ek != reflect.Slice && ek != reflect.Array && ek != reflect.String {
- return ""
- }
-
- var e, a string
-
- switch et {
- case reflect.TypeFor[string]():
- e = reflect.ValueOf(expected).String()
- a = reflect.ValueOf(actual).String()
- case reflect.TypeFor[time.Time]():
- e = spewConfigStringerEnabled.Sdump(expected)
- a = spewConfigStringerEnabled.Sdump(actual)
- default:
- e = spewConfig.Sdump(expected)
- a = spewConfig.Sdump(actual)
- }
-
- diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
- A: difflib.SplitLines(e),
- B: difflib.SplitLines(a),
- FromFile: "Expected",
- FromDate: "",
- ToFile: "Actual",
- ToDate: "",
- Context: 1,
- })
-
- return "\n\nDiff:\n" + diff
-}
-
-func typeAndKind(v any) (reflect.Type, reflect.Kind) {
- t := reflect.TypeOf(v)
- k := t.Kind()
-
- if k == reflect.Ptr {
- t = t.Elem()
- k = t.Kind()
- }
- return t, k
-}
-
// truncatingFormat formats the data and truncates it if it's too long.
//
// This helps keep formatted error messages lines from exceeding the
diff --git a/internal/difflib/difflib.go b/internal/difflib/difflib.go
index 8642002d4..183c892b6 100644
--- a/internal/difflib/difflib.go
+++ b/internal/difflib/difflib.go
@@ -12,430 +12,18 @@ import (
"strings"
)
-type Match struct {
- A int
- B int
- Size int
-}
-
-type OpCode struct {
- Tag byte
- I1 int
- I2 int
- J1 int
- J2 int
-}
-
-// SequenceMatcher compares sequence of strings. The basic
-// algorithm predates, and is a little fancier than, an algorithm
-// published in the late 1980's by Ratcliff and Obershelp under the
-// hyperbolic name "gestalt pattern matching". The basic idea is to find
-// the longest contiguous matching subsequence that contains no "junk"
-// elements (R-O doesn't address junk). The same idea is then applied
-// recursively to the pieces of the sequences to the left and to the right
-// of the matching subsequence. This does not yield minimal edit
-// sequences, but does tend to yield matches that "look right" to people.
-//
-// SequenceMatcher tries to compute a "human-friendly diff" between two
-// sequences. Unlike e.g. UNIX(tm) diff, the fundamental notion is the
-// longest *contiguous* & junk-free matching subsequence. That's what
-// catches peoples' eyes. The Windows(tm) windiff has another interesting
-// notion, pairing up elements that appear uniquely in each sequence.
-// That, and the method here, appear to yield more intuitive difference
-// reports than does diff. This method appears to be the least vulnerable
-// to synching up on blocks of "junk lines", though (like blank lines in
-// ordinary text files, or maybe "
" lines in HTML files). That may be
-// because this is the only method of the 3 that has a *concept* of
-// "junk" .
-//
-// Timing: Basic R-O is cubic time worst case and quadratic time expected
-// case. SequenceMatcher is quadratic time for the worst case and has
-// expected-case behavior dependent in a complicated way on how many
-// elements the sequences have in common; best case time is linear.
-type SequenceMatcher struct {
- a []string
- b []string
- b2j map[string][]int
- IsJunk func(string) bool
- autoJunk bool
- bJunk map[string]struct{}
- matchingBlocks []Match
- fullBCount map[string]int
- bPopular map[string]struct{}
- opCodes []OpCode
-}
-
-func NewMatcher(a, b []string) *SequenceMatcher {
- m := SequenceMatcher{autoJunk: true}
- m.SetSeqs(a, b)
- return &m
-}
-
-// SetSeqs sets two sequences to be compared.
-func (m *SequenceMatcher) SetSeqs(a, b []string) {
- m.SetSeq1(a)
- m.SetSeq2(b)
-}
-
-// SetSeq1 sets the first sequence to be compared. The second sequence to be compared is
-// not changed.
-//
-// SequenceMatcher computes and caches detailed information about the second
-// sequence, so if you want to compare one sequence S against many sequences,
-// use .SetSeq2(s) once and call .SetSeq1(x) repeatedly for each of the other
-// sequences.
-//
-// See also SetSeqs() and SetSeq2().
-func (m *SequenceMatcher) SetSeq1(a []string) {
- if &a == &m.a {
- return
- }
- m.a = a
- m.matchingBlocks = nil
- m.opCodes = nil
-}
-
-// SetSeq2 sets the second sequence to be compared. The first sequence to be compared is
-// not changed.
-func (m *SequenceMatcher) SetSeq2(b []string) {
- if &b == &m.b {
- return
- }
- m.b = b
- m.matchingBlocks = nil
- m.opCodes = nil
- m.fullBCount = nil
- m.chainB()
-}
-
-// GetMatchingBlocks return the list of triples describing matching subsequences.
-//
-// Each triple is of the form (i, j, n), and means that
-// a[i:i+n] == b[j:j+n]. The triples are monotonically increasing in
-// i and in j. It's also guaranteed that if (i, j, n) and (i', j', n') are
-// adjacent triples in the list, and the second is not the last triple in the
-// list, then i+n != i' or j+n != j'. IOW, adjacent triples never describe
-// adjacent equal blocks.
-//
-// The last triple is a dummy, (len(a), len(b), 0), and is the only
-// triple with n==0.
-func (m *SequenceMatcher) GetMatchingBlocks() []Match {
- if m.matchingBlocks != nil {
- return m.matchingBlocks
- }
-
- var matchBlocks func(alo, ahi, blo, bhi int, matched []Match) []Match
- matchBlocks = func(alo, ahi, blo, bhi int, matched []Match) []Match {
- match := m.findLongestMatch(alo, ahi, blo, bhi)
- i, j, k := match.A, match.B, match.Size
- if match.Size > 0 {
- if alo < i && blo < j {
- matched = matchBlocks(alo, i, blo, j, matched)
- }
- matched = append(matched, match)
- if i+k < ahi && j+k < bhi {
- matched = matchBlocks(i+k, ahi, j+k, bhi, matched)
- }
- }
- return matched
- }
- matched := matchBlocks(0, len(m.a), 0, len(m.b), nil)
-
- // It's possible that we have adjacent equal blocks in the
- // matching_blocks list now.
- nonAdjacent := []Match{}
- i1, j1, k1 := 0, 0, 0
- for _, b := range matched {
- // Is this block adjacent to i1, j1, k1?
- i2, j2, k2 := b.A, b.B, b.Size
- if i1+k1 == i2 && j1+k1 == j2 {
- // Yes, so collapse them -- this just increases the length of
- // the first block by the length of the second, and the first
- // block so lengthened remains the block to compare against.
- k1 += k2
- } else {
- // Not adjacent. Remember the first block (k1==0 means it's
- // the dummy we started with), and make the second block the
- // new block to compare against.
- if k1 > 0 {
- nonAdjacent = append(nonAdjacent, Match{i1, j1, k1})
- }
- i1, j1, k1 = i2, j2, k2
- }
- }
- if k1 > 0 {
- nonAdjacent = append(nonAdjacent, Match{i1, j1, k1})
- }
-
- nonAdjacent = append(nonAdjacent, Match{len(m.a), len(m.b), 0})
- m.matchingBlocks = nonAdjacent
- return m.matchingBlocks
-}
-
-// GetOpCodes return the list of 5-tuples describing how to turn a into b.
-//
-// Each tuple is of the form (tag, i1, i2, j1, j2). The first tuple
-// has i1 == j1 == 0, and remaining tuples have i1 == the i2 from the
-// tuple preceding it, and likewise for j1 == the previous j2.
-//
-// The tags are characters, with these meanings:
-//
-// 'r' (replace): a[i1:i2] should be replaced by b[j1:j2]
-//
-// 'd' (delete): a[i1:i2] should be deleted, j1==j2 in this case.
-//
-// 'i' (insert): b[j1:j2] should be inserted at a[i1:i1], i1==i2 in this case.
-//
-// 'e' (equal): a[i1:i2] == b[j1:j2].
-func (m *SequenceMatcher) GetOpCodes() []OpCode {
- if m.opCodes != nil {
- return m.opCodes
- }
- i, j := 0, 0
- matching := m.GetMatchingBlocks()
- opCodes := make([]OpCode, 0, len(matching))
- for _, m := range matching {
- // invariant: we've pumped out correct diffs to change
- // a[:i] into b[:j], and the next matching block is
- // a[ai:ai+size] == b[bj:bj+size]. So we need to pump
- // out a diff to change a[i:ai] into b[j:bj], pump out
- // the matching block, and move (i,j) beyond the match
- ai, bj, size := m.A, m.B, m.Size
- tag := byte(0)
- switch {
- case i < ai && j < bj:
- tag = 'r'
- case i < ai:
- tag = 'd'
- case j < bj:
- tag = 'i'
- }
-
- if tag > 0 {
- opCodes = append(opCodes, OpCode{tag, i, ai, j, bj})
- }
- i, j = ai+size, bj+size
- // the list of matching blocks is terminated by a
- // sentinel with size 0
- if size > 0 {
- opCodes = append(opCodes, OpCode{'e', ai, i, bj, j})
- }
- }
- m.opCodes = opCodes
- return m.opCodes
-}
-
-// GetGroupedOpCodes isolates change clusters by eliminating ranges with no changes.
-//
-// Return a generator of groups with up to n lines of context.
-// Each group is in the same format as returned by GetOpCodes().
-func (m *SequenceMatcher) GetGroupedOpCodes(n int) [][]OpCode {
- if n < 0 {
- n = 3
- }
- codes := m.GetOpCodes()
- if len(codes) == 0 {
- codes = []OpCode{{'e', 0, 1, 0, 1}}
- }
- // Fixup leading and trailing groups if they show no changes.
- if codes[0].Tag == 'e' {
- c := codes[0]
- i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
- codes[0] = OpCode{c.Tag, max(i1, i2-n), i2, max(j1, j2-n), j2}
- }
- if codes[len(codes)-1].Tag == 'e' {
- c := codes[len(codes)-1]
- i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
- codes[len(codes)-1] = OpCode{c.Tag, i1, min(i2, i1+n), j1, min(j2, j1+n)}
- }
- nn := n + n
- groups := [][]OpCode{}
- group := []OpCode{}
- for _, c := range codes {
- i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
- // End the current group and start a new one whenever
- // there is a large range with no changes.
- if c.Tag == 'e' && i2-i1 > nn {
- group = append(group, OpCode{c.Tag, i1, min(i2, i1+n),
- j1, min(j2, j1+n)})
- groups = append(groups, group)
- group = []OpCode{}
- i1, j1 = max(i1, i2-n), max(j1, j2-n)
- }
- group = append(group, OpCode{c.Tag, i1, i2, j1, j2})
- }
- if len(group) > 0 && (len(group) != 1 || group[0].Tag != 'e') {
- groups = append(groups, group)
- }
- return groups
-}
-
-func (m *SequenceMatcher) chainB() {
- // Populate line -> index mapping
- b2j := map[string][]int{}
- for i, s := range m.b {
- indices := b2j[s]
- indices = append(indices, i)
- b2j[s] = indices
- }
-
- // Purge junk elements
- m.bJunk = map[string]struct{}{}
- if m.IsJunk != nil {
- junk := m.bJunk
- for s := range b2j {
- if m.IsJunk(s) {
- junk[s] = struct{}{}
- }
- }
- for s := range junk {
- delete(b2j, s)
- }
- }
-
- // Purge remaining popular elements
- const (
- hundred = 100
- maxDisplayElements = 2 * hundred
- )
- popular := map[string]struct{}{}
- n := len(m.b)
- if m.autoJunk && n >= maxDisplayElements {
- ntest := n/hundred + 1
- for s, indices := range b2j {
- if len(indices) > ntest {
- popular[s] = struct{}{}
- }
- }
- for s := range popular {
- delete(b2j, s)
- }
- }
- m.bPopular = popular
- m.b2j = b2j
-}
-
-func (m *SequenceMatcher) isBJunk(s string) bool {
- _, ok := m.bJunk[s]
- return ok
-}
-
-// Find longest matching block in a[alo:ahi] and b[blo:bhi].
-//
-// If IsJunk is not defined:
-//
-// Return (i,j,k) such that a[i:i+k] is equal to b[j:j+k], where
-//
-// alo <= i <= i+k <= ahi
-// blo <= j <= j+k <= bhi
-//
-// and for all (i',j',k') meeting those conditions,
-//
-// k >= k'
-// i <= i'
-// and if i == i', j <= j'
-//
-// In other words, of all maximal matching blocks, return one that
-// starts earliest in a, and of all those maximal matching blocks that
-// start earliest in a, return the one that starts earliest in b.
-//
-// If IsJunk is defined, first the longest matching block is
-// determined as above, but with the additional restriction that no
-// junk element appears in the block. Then that block is extended as
-// far as possible by matching (only) junk elements on both sides. So
-// the resulting block never matches on junk except as identical junk
-// happens to be adjacent to an "interesting" match.
-//
-// If no blocks match, return (alo, blo, 0).
-func (m *SequenceMatcher) findLongestMatch(alo, ahi, blo, bhi int) Match {
- // CAUTION: stripping common prefix or suffix would be incorrect.
- // E.g.,
- // ab
- // acab
- // Longest matching block is "ab", but if common prefix is
- // stripped, it's "a" (tied with "b"). UNIX(tm) diff does so
- // strip, so ends up claiming that ab is changed to acab by
- // inserting "ca" in the middle. That's minimal but unintuitive:
- // "it's obvious" that someone inserted "ac" at the front.
- // Windiff ends up at the same place as diff, but by pairing up
- // the unique 'b's and then matching the first two 'a's.
- besti, bestj, bestsize := alo, blo, 0
-
- // find longest junk-free match
- // during an iteration of the loop, j2len[j] = length of longest
- // junk-free match ending with a[i-1] and b[j]
- j2len := map[int]int{}
- for i := alo; i != ahi; i++ {
- // look at all instances of a[i] in b; note that because
- // b2j has no junk keys, the loop is skipped if a[i] is junk
- newj2len := map[int]int{}
- for _, j := range m.b2j[m.a[i]] {
- // a[i] matches b[j]
- if j < blo {
- continue
- }
- if j >= bhi {
- break
- }
- k := j2len[j-1] + 1
- newj2len[j] = k
- if k > bestsize {
- besti, bestj, bestsize = i-k+1, j-k+1, k
- }
- }
- j2len = newj2len
- }
-
- // Extend the best by non-junk elements on each end. In particular,
- // "popular" non-junk elements aren't in b2j, which greatly speeds
- // the inner loop above, but also means "the best" match so far
- // doesn't contain any junk *or* popular non-junk elements.
- for besti > alo && bestj > blo && !m.isBJunk(m.b[bestj-1]) &&
- m.a[besti-1] == m.b[bestj-1] {
- besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
- }
- for besti+bestsize < ahi && bestj+bestsize < bhi &&
- !m.isBJunk(m.b[bestj+bestsize]) &&
- m.a[besti+bestsize] == m.b[bestj+bestsize] {
- bestsize++
- }
-
- // Now that we have a wholly interesting match (albeit possibly
- // empty!), we may as well suck up the matching junk on each
- // side of it too. Can't think of a good reason not to, and it
- // saves post-processing the (possibly considerable) expense of
- // figuring out what to do with it. In the case of an empty
- // interesting match, this is clearly the right thing to do,
- // because no other kind of match is possible in the regions.
- for besti > alo && bestj > blo && m.isBJunk(m.b[bestj-1]) &&
- m.a[besti-1] == m.b[bestj-1] {
- besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
- }
- for besti+bestsize < ahi && bestj+bestsize < bhi &&
- m.isBJunk(m.b[bestj+bestsize]) &&
- m.a[besti+bestsize] == m.b[bestj+bestsize] {
- bestsize++
- }
-
- return Match{A: besti, B: bestj, Size: bestsize}
-}
-
-// Convert range to the "ed" format.
-func formatRangeUnified(start, stop int) string {
- // Per the diff spec at http://www.unix.org/single_unix_specification/
- beginning := start + 1 // lines start numbering with one
- length := stop - start
- if length == 1 {
- return strconv.Itoa(beginning)
- }
- if length == 0 {
- beginning-- // empty ranges begin at line just before the range
- }
- return fmt.Sprintf("%d,%d", beginning, length)
+// SplitLines splits a string on "\n" while preserving them. The output can be used
+// as input for UnifiedDiff and ContextDiff structures.
+func SplitLines(s string) []string {
+ lines := strings.SplitAfter(s, "\n")
+ lines[len(lines)-1] += "\n"
+ return lines
}
// UnifiedDiff holds the unified diff parameters.
type UnifiedDiff struct {
+ *Options
+
A []string // First sequence lines
FromFile string // First file name
FromDate string // First file time
@@ -444,10 +32,31 @@ type UnifiedDiff struct {
ToDate string // Second file time
Eol string // Headers end of line, defaults to LF
Context int // Number of context lines
+
+ wsE Printer
+ wsD Printer
+ wsU Printer
+ wsI Printer
+ wsO Printer
+ wf Formatter
}
-type formatter func(format string, args ...any) error
-type printer func(string) error
+func (u *UnifiedDiff) applyWriter(buf *bufio.Writer) {
+ u.wsE = u.EqualPrinter(buf)
+ u.wsD = u.DeletePrinter(buf)
+ u.wsU = u.UpdatePrinter(buf)
+ u.wsI = u.InsertPrinter(buf)
+ u.wsO = u.OtherPrinter(buf)
+ u.wf = u.Formatter(buf)
+}
+
+// GetUnifiedDiffString is like WriteUnifiedDiff but returns the diff a string.
+func GetUnifiedDiffString(diff UnifiedDiff) (string, error) {
+ w := new(bytes.Buffer)
+ err := WriteUnifiedDiff(w, diff)
+
+ return w.String(), err
+}
// WriteUnifiedDiff write the comparison between two sequences of lines.
// It generates the delta as a unified diff.
@@ -469,18 +78,11 @@ type printer func(string) error
// 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'.
// The modification times are normally expressed in the ISO 8601 format.
func WriteUnifiedDiff(writer io.Writer, diff UnifiedDiff) error {
+ diff.Options = optionsWithDefaults(diff.Options)
buf := bufio.NewWriter(writer)
defer buf.Flush()
- wf := func(format string, args ...any) error {
- _, err := fmt.Fprintf(buf, format, args...)
- return err
- }
-
- ws := func(s string) error {
- _, err := buf.WriteString(s)
- return err
- }
+ diff.applyWriter(buf)
if len(diff.Eol) == 0 {
diff.Eol = "\n"
@@ -493,12 +95,12 @@ func WriteUnifiedDiff(writer io.Writer, diff UnifiedDiff) error {
return nil
}
- if err := writeFirstGroup(groups[0], diff, wf, ws); err != nil {
+ if err := writeFirstGroup(groups[0], diff); err != nil {
return err
}
for _, g := range groups[1:] {
- if err := writeGroup(g, diff, wf, ws); err != nil {
+ if err := writeGroup(g, diff); err != nil {
return err
}
}
@@ -506,7 +108,7 @@ func WriteUnifiedDiff(writer io.Writer, diff UnifiedDiff) error {
return nil
}
-func writeFirstGroup(g []OpCode, diff UnifiedDiff, wf formatter, ws printer) error {
+func writeFirstGroup(g []OpCode, diff UnifiedDiff) error {
fromDate := ""
if len(diff.FromDate) > 0 {
fromDate = "\t" + diff.FromDate
@@ -518,24 +120,24 @@ func writeFirstGroup(g []OpCode, diff UnifiedDiff, wf formatter, ws printer) err
}
if diff.FromFile != "" || diff.ToFile != "" {
- err := wf("--- %s%s%s", diff.FromFile, fromDate, diff.Eol)
+ err := diff.wf("--- %s%s%s", diff.FromFile, fromDate, diff.Eol)
if err != nil {
return err
}
- err = wf("+++ %s%s%s", diff.ToFile, toDate, diff.Eol)
+ err = diff.wf("+++ %s%s%s", diff.ToFile, toDate, diff.Eol)
if err != nil {
return err
}
}
- return writeGroup(g, diff, wf, ws)
+ return writeGroup(g, diff)
}
-func writeGroup(group []OpCode, diff UnifiedDiff, wf formatter, ws printer) error {
+func writeGroup(group []OpCode, diff UnifiedDiff) error {
first, last := group[0], group[len(group)-1]
range1 := formatRangeUnified(first.I1, last.I2)
range2 := formatRangeUnified(first.J1, last.J2)
- if err := wf("@@ -%s +%s @@%s", range1, range2, diff.Eol); err != nil {
+ if err := diff.wf("@@ -%s +%s @@%s", range1, range2, diff.Eol); err != nil {
return err
}
@@ -551,22 +153,22 @@ func writeGroup(group []OpCode, diff UnifiedDiff, wf formatter, ws printer) erro
i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
switch c.Tag {
case 'e':
- if err := writeEqual(diff.A[i1:i2], ws); err != nil {
+ if err := writeEqual(diff.A[i1:i2], diff.wsE); err != nil {
return err
}
case 'd':
- if err := writeReplaceOrDelete(diff.A[i1:i2], ws); err != nil {
+ if err := writeReplaceOrDelete(diff.A[i1:i2], diff.wsD); err != nil {
return err
}
case 'i':
- if err := writeReplaceOrInsert(diff.B[j1:j2], ws); err != nil {
+ if err := writeReplaceOrInsert(diff.B[j1:j2], diff.wsI); err != nil {
return err
}
case 'r':
- if err := writeReplaceOrDelete(diff.A[i1:i2], ws); err != nil {
+ if err := writeReplaceOrDelete(diff.A[i1:i2], diff.wsD); err != nil {
return err
}
- if err := writeReplaceOrInsert(diff.B[j1:j2], ws); err != nil {
+ if err := writeReplaceOrInsert(diff.B[j1:j2], diff.wsI); err != nil {
return err
}
}
@@ -575,7 +177,7 @@ func writeGroup(group []OpCode, diff UnifiedDiff, wf formatter, ws printer) erro
return nil
}
-func writeEqual(lines []string, ws printer) error {
+func writeEqual(lines []string, ws Printer) error {
for _, line := range lines {
if err := ws(" " + line); err != nil {
return err
@@ -585,7 +187,7 @@ func writeEqual(lines []string, ws printer) error {
return nil
}
-func writeReplaceOrDelete(lines []string, ws printer) error {
+func writeReplaceOrDelete(lines []string, ws Printer) error {
for _, line := range lines {
if err := ws("-" + line); err != nil {
return err
@@ -595,7 +197,7 @@ func writeReplaceOrDelete(lines []string, ws printer) error {
return nil
}
-func writeReplaceOrInsert(lines []string, ws printer) error {
+func writeReplaceOrInsert(lines []string, ws Printer) error {
for _, line := range lines {
if err := ws("+" + line); err != nil {
return err
@@ -605,13 +207,6 @@ func writeReplaceOrInsert(lines []string, ws printer) error {
return nil
}
-// GetUnifiedDiffString is like WriteUnifiedDiff but returns the diff a string.
-func GetUnifiedDiffString(diff UnifiedDiff) (string, error) {
- w := new(bytes.Buffer)
- err := WriteUnifiedDiff(w, diff)
- return w.String(), err
-}
-
// Convert range to the "ed" format.
func formatRangeContext(start, stop int) string {
// Per the diff spec at http://www.unix.org/single_unix_specification/
@@ -626,10 +221,16 @@ func formatRangeContext(start, stop int) string {
return fmt.Sprintf("%d,%d", beginning, beginning+length-1)
}
-// SplitLines splits a string on "\n" while preserving them. The output can be used
-// as input for UnifiedDiff and ContextDiff structures.
-func SplitLines(s string) []string {
- lines := strings.SplitAfter(s, "\n")
- lines[len(lines)-1] += "\n"
- return lines
+// Convert range to the "ed" format.
+func formatRangeUnified(start, stop int) string {
+ // Per the diff spec at http://www.unix.org/single_unix_specification/
+ beginning := start + 1 // lines start numbering with one
+ length := stop - start
+ if length == 1 {
+ return strconv.Itoa(beginning)
+ }
+ if length == 0 {
+ beginning-- // empty ranges begin at line just before the range
+ }
+ return fmt.Sprintf("%d,%d", beginning, length)
}
diff --git a/internal/difflib/difflib_benchmarks_test.go b/internal/difflib/difflib_benchmarks_test.go
new file mode 100644
index 000000000..db914ead4
--- /dev/null
+++ b/internal/difflib/difflib_benchmarks_test.go
@@ -0,0 +1,27 @@
+package difflib
+
+import (
+ "strings"
+ "testing"
+)
+
+func BenchmarkSplitLines100(b *testing.B) {
+ b.Run("splitLines", benchmarkSplitLines(100))
+}
+
+func BenchmarkSplitLines10000(b *testing.B) {
+ b.Run("splitLines", benchmarkSplitLines(10000))
+}
+
+func benchmarkSplitLines(count int) func(*testing.B) {
+ return func(b *testing.B) {
+ str := strings.Repeat("foo\n", count)
+
+ b.ResetTimer()
+
+ n := 0
+ for b.Loop() {
+ n += len(SplitLines(str))
+ }
+ }
+}
diff --git a/internal/difflib/difflib_test.go b/internal/difflib/difflib_test.go
index 2a14b74bc..cedb6d7b7 100644
--- a/internal/difflib/difflib_test.go
+++ b/internal/difflib/difflib_test.go
@@ -11,34 +11,6 @@ import (
"testing"
)
-/*
-func assertAlmostEqual(t *testing.T, a, b float64, places int) {
- t.Helper()
-
- if math.Abs(a-b) > math.Pow10(-places) {
- t.Errorf("%.7f != %.7f", a, b)
- }
-}
-*/
-
-func assertEqual(t *testing.T, a, b any) {
- t.Helper()
-
- if !reflect.DeepEqual(a, b) {
- t.Errorf("%v != %v", a, b)
- }
-}
-
-func splitChars(s string) []string {
- chars := make([]string, 0, len(s))
- // Assume ASCII inputs
- for _, r := range s {
- chars = append(chars, string(r))
- }
-
- return chars
-}
-
func TestGetOptCodes(t *testing.T) {
a := "qabxcd"
b := "abycdf"
@@ -65,7 +37,7 @@ func TestGroupedOpCodes(t *testing.T) {
for i := 0; i != 39; i++ {
a = append(a, fmt.Sprintf("%02d", i))
}
- b := []string{}
+ b := make([]string, 0, len(a)+3)
b = append(b, a[:8]...)
b = append(b, " i")
b = append(b, a[8:19]...)
@@ -248,23 +220,20 @@ func TestSplitLines(t *testing.T) {
}
}
-func benchmarkSplitLines(count int) func(*testing.B) {
- return func(b *testing.B) {
- str := strings.Repeat("foo\n", count)
-
- b.ResetTimer()
+func assertEqual(t *testing.T, a, b any) {
+ t.Helper()
- n := 0
- for b.Loop() {
- n += len(SplitLines(str))
- }
+ if !reflect.DeepEqual(a, b) {
+ t.Errorf("%v != %v", a, b)
}
}
-func BenchmarkSplitLines100(b *testing.B) {
- b.Run("splitLines", benchmarkSplitLines(100))
-}
+func splitChars(s string) []string {
+ chars := make([]string, 0, len(s))
+ // Assume ASCII inputs
+ for _, r := range s {
+ chars = append(chars, string(r))
+ }
-func BenchmarkSplitLines10000(b *testing.B) {
- b.Run("splitLines", benchmarkSplitLines(10000))
+ return chars
}
diff --git a/internal/difflib/matcher.go b/internal/difflib/matcher.go
new file mode 100644
index 000000000..ec971face
--- /dev/null
+++ b/internal/difflib/matcher.go
@@ -0,0 +1,411 @@
+package difflib
+
+type Match struct {
+ A int
+ B int
+ Size int
+}
+
+type OpCode struct {
+ Tag byte
+ I1 int
+ I2 int
+ J1 int
+ J2 int
+}
+
+// SequenceMatcher compares sequence of strings. The basic
+// algorithm predates, and is a little fancier than, an algorithm
+// published in the late 1980's by Ratcliff and Obershelp under the
+// hyperbolic name "gestalt pattern matching". The basic idea is to find
+// the longest contiguous matching subsequence that contains no "junk"
+// elements (R-O doesn't address junk). The same idea is then applied
+// recursively to the pieces of the sequences to the left and to the right
+// of the matching subsequence. This does not yield minimal edit
+// sequences, but does tend to yield matches that "look right" to people.
+//
+// SequenceMatcher tries to compute a "human-friendly diff" between two
+// sequences. Unlike e.g. UNIX(tm) diff, the fundamental notion is the
+// longest *contiguous* & junk-free matching subsequence. That's what
+// catches peoples' eyes. The Windows(tm) windiff has another interesting
+// notion, pairing up elements that appear uniquely in each sequence.
+// That, and the method here, appear to yield more intuitive difference
+// reports than does diff. This method appears to be the least vulnerable
+// to synching up on blocks of "junk lines", though (like blank lines in
+// ordinary text files, or maybe "" lines in HTML files). That may be
+// because this is the only method of the 3 that has a *concept* of
+// "junk" .
+//
+// Timing: Basic R-O is cubic time worst case and quadratic time expected
+// case. SequenceMatcher is quadratic time for the worst case and has
+// expected-case behavior dependent in a complicated way on how many
+// elements the sequences have in common; best case time is linear.
+type SequenceMatcher struct {
+ a []string
+ b []string
+ b2j map[string][]int
+ IsJunk func(string) bool
+ autoJunk bool
+ bJunk map[string]struct{}
+ matchingBlocks []Match
+ fullBCount map[string]int
+ bPopular map[string]struct{}
+ opCodes []OpCode
+}
+
+func NewMatcher(a, b []string) *SequenceMatcher {
+ m := SequenceMatcher{autoJunk: true}
+ m.SetSeqs(a, b)
+ return &m
+}
+
+// SetSeqs sets two sequences to be compared.
+func (m *SequenceMatcher) SetSeqs(a, b []string) {
+ m.SetSeq1(a)
+ m.SetSeq2(b)
+}
+
+// SetSeq1 sets the first sequence to be compared. The second sequence to be compared is
+// not changed.
+//
+// SequenceMatcher computes and caches detailed information about the second
+// sequence, so if you want to compare one sequence S against many sequences,
+// use .SetSeq2(s) once and call .SetSeq1(x) repeatedly for each of the other
+// sequences.
+//
+// See also SetSeqs() and SetSeq2().
+func (m *SequenceMatcher) SetSeq1(a []string) {
+ if &a == &m.a {
+ return
+ }
+ m.a = a
+ m.matchingBlocks = nil
+ m.opCodes = nil
+}
+
+// SetSeq2 sets the second sequence to be compared. The first sequence to be compared is
+// not changed.
+func (m *SequenceMatcher) SetSeq2(b []string) {
+ if &b == &m.b {
+ return
+ }
+ m.b = b
+ m.matchingBlocks = nil
+ m.opCodes = nil
+ m.fullBCount = nil
+ m.chainB()
+}
+
+// GetMatchingBlocks return the list of triples describing matching subsequences.
+//
+// Each triple is of the form (i, j, n), and means that
+// a[i:i+n] == b[j:j+n]. The triples are monotonically increasing in
+// i and in j. It's also guaranteed that if (i, j, n) and (i', j', n') are
+// adjacent triples in the list, and the second is not the last triple in the
+// list, then i+n != i' or j+n != j'. IOW, adjacent triples never describe
+// adjacent equal blocks.
+//
+// The last triple is a dummy, (len(a), len(b), 0), and is the only
+// triple with n==0.
+func (m *SequenceMatcher) GetMatchingBlocks() []Match {
+ if m.matchingBlocks != nil {
+ return m.matchingBlocks
+ }
+
+ var matchBlocks func(alo, ahi, blo, bhi int, matched []Match) []Match
+ matchBlocks = func(alo, ahi, blo, bhi int, matched []Match) []Match {
+ match := m.findLongestMatch(alo, ahi, blo, bhi)
+ i, j, k := match.A, match.B, match.Size
+ if match.Size > 0 {
+ if alo < i && blo < j {
+ matched = matchBlocks(alo, i, blo, j, matched)
+ }
+ matched = append(matched, match)
+ if i+k < ahi && j+k < bhi {
+ matched = matchBlocks(i+k, ahi, j+k, bhi, matched)
+ }
+ }
+ return matched
+ }
+ matched := matchBlocks(0, len(m.a), 0, len(m.b), nil)
+
+ // It's possible that we have adjacent equal blocks in the
+ // matching_blocks list now.
+ nonAdjacent := []Match{}
+ i1, j1, k1 := 0, 0, 0
+ for _, b := range matched {
+ // Is this block adjacent to i1, j1, k1?
+ i2, j2, k2 := b.A, b.B, b.Size
+ if i1+k1 == i2 && j1+k1 == j2 {
+ // Yes, so collapse them -- this just increases the length of
+ // the first block by the length of the second, and the first
+ // block so lengthened remains the block to compare against.
+ k1 += k2
+ } else {
+ // Not adjacent. Remember the first block (k1==0 means it's
+ // the dummy we started with), and make the second block the
+ // new block to compare against.
+ if k1 > 0 {
+ nonAdjacent = append(nonAdjacent, Match{i1, j1, k1})
+ }
+ i1, j1, k1 = i2, j2, k2
+ }
+ }
+ if k1 > 0 {
+ nonAdjacent = append(nonAdjacent, Match{i1, j1, k1})
+ }
+
+ nonAdjacent = append(nonAdjacent, Match{len(m.a), len(m.b), 0})
+ m.matchingBlocks = nonAdjacent
+ return m.matchingBlocks
+}
+
+// GetOpCodes return the list of 5-tuples describing how to turn a into b.
+//
+// Each tuple is of the form (tag, i1, i2, j1, j2). The first tuple
+// has i1 == j1 == 0, and remaining tuples have i1 == the i2 from the
+// tuple preceding it, and likewise for j1 == the previous j2.
+//
+// The tags are characters, with these meanings:
+//
+// 'r' (replace): a[i1:i2] should be replaced by b[j1:j2]
+//
+// 'd' (delete): a[i1:i2] should be deleted, j1==j2 in this case.
+//
+// 'i' (insert): b[j1:j2] should be inserted at a[i1:i1], i1==i2 in this case.
+//
+// 'e' (equal): a[i1:i2] == b[j1:j2].
+func (m *SequenceMatcher) GetOpCodes() []OpCode {
+ if m.opCodes != nil {
+ return m.opCodes
+ }
+ i, j := 0, 0
+ matching := m.GetMatchingBlocks()
+ opCodes := make([]OpCode, 0, len(matching))
+ for _, m := range matching {
+ // invariant: we've pumped out correct diffs to change
+ // a[:i] into b[:j], and the next matching block is
+ // a[ai:ai+size] == b[bj:bj+size]. So we need to pump
+ // out a diff to change a[i:ai] into b[j:bj], pump out
+ // the matching block, and move (i,j) beyond the match
+ ai, bj, size := m.A, m.B, m.Size
+ tag := byte(0)
+ switch {
+ case i < ai && j < bj:
+ tag = 'r'
+ case i < ai:
+ tag = 'd'
+ case j < bj:
+ tag = 'i'
+ }
+
+ if tag > 0 {
+ opCodes = append(opCodes, OpCode{tag, i, ai, j, bj})
+ }
+ i, j = ai+size, bj+size
+ // the list of matching blocks is terminated by a
+ // sentinel with size 0
+ if size > 0 {
+ opCodes = append(opCodes, OpCode{'e', ai, i, bj, j})
+ }
+ }
+ m.opCodes = opCodes
+ return m.opCodes
+}
+
+// GetGroupedOpCodes isolates change clusters by eliminating ranges with no changes.
+//
+// Return a generator of groups with up to n lines of context.
+// Each group is in the same format as returned by GetOpCodes().
+func (m *SequenceMatcher) GetGroupedOpCodes(n int) [][]OpCode {
+ if n < 0 {
+ n = 3
+ }
+ codes := m.GetOpCodes()
+ if len(codes) == 0 {
+ codes = []OpCode{{'e', 0, 1, 0, 1}}
+ }
+ // Fixup leading and trailing groups if they show no changes.
+ if codes[0].Tag == 'e' {
+ c := codes[0]
+ i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
+ codes[0] = OpCode{c.Tag, max(i1, i2-n), i2, max(j1, j2-n), j2}
+ }
+ if codes[len(codes)-1].Tag == 'e' {
+ c := codes[len(codes)-1]
+ i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
+ codes[len(codes)-1] = OpCode{c.Tag, i1, min(i2, i1+n), j1, min(j2, j1+n)}
+ }
+ nn := n + n
+ groups := [][]OpCode{}
+ group := []OpCode{}
+ for _, c := range codes {
+ i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2
+ // End the current group and start a new one whenever
+ // there is a large range with no changes.
+ if c.Tag == 'e' && i2-i1 > nn {
+ group = append(group, OpCode{
+ c.Tag, i1, min(i2, i1+n),
+ j1, min(j2, j1+n),
+ })
+ groups = append(groups, group)
+ group = []OpCode{}
+ i1, j1 = max(i1, i2-n), max(j1, j2-n)
+ }
+ group = append(group, OpCode{c.Tag, i1, i2, j1, j2})
+ }
+ if len(group) > 0 && (len(group) != 1 || group[0].Tag != 'e') {
+ groups = append(groups, group)
+ }
+ return groups
+}
+
+func (m *SequenceMatcher) chainB() {
+ // Populate line -> index mapping
+ b2j := map[string][]int{}
+ for i, s := range m.b {
+ indices := b2j[s]
+ indices = append(indices, i)
+ b2j[s] = indices
+ }
+
+ // Purge junk elements
+ m.bJunk = map[string]struct{}{}
+ if m.IsJunk != nil {
+ junk := m.bJunk
+ for s := range b2j {
+ if m.IsJunk(s) {
+ junk[s] = struct{}{}
+ }
+ }
+ for s := range junk {
+ delete(b2j, s)
+ }
+ }
+
+ // Purge remaining popular elements
+ const (
+ hundred = 100
+ maxDisplayElements = 2 * hundred
+ )
+ popular := map[string]struct{}{}
+ n := len(m.b)
+ if m.autoJunk && n >= maxDisplayElements {
+ ntest := n/hundred + 1
+ for s, indices := range b2j {
+ if len(indices) > ntest {
+ popular[s] = struct{}{}
+ }
+ }
+ for s := range popular {
+ delete(b2j, s)
+ }
+ }
+ m.bPopular = popular
+ m.b2j = b2j
+}
+
+func (m *SequenceMatcher) isBJunk(s string) bool {
+ _, ok := m.bJunk[s]
+ return ok
+}
+
+// Find longest matching block in a[alo:ahi] and b[blo:bhi].
+//
+// If IsJunk is not defined:
+//
+// Return (i,j,k) such that a[i:i+k] is equal to b[j:j+k], where
+//
+// alo <= i <= i+k <= ahi
+// blo <= j <= j+k <= bhi
+//
+// and for all (i',j',k') meeting those conditions,
+//
+// k >= k'
+// i <= i'
+// and if i == i', j <= j'
+//
+// In other words, of all maximal matching blocks, return one that
+// starts earliest in a, and of all those maximal matching blocks that
+// start earliest in a, return the one that starts earliest in b.
+//
+// If IsJunk is defined, first the longest matching block is
+// determined as above, but with the additional restriction that no
+// junk element appears in the block. Then that block is extended as
+// far as possible by matching (only) junk elements on both sides. So
+// the resulting block never matches on junk except as identical junk
+// happens to be adjacent to an "interesting" match.
+//
+// If no blocks match, return (alo, blo, 0).
+func (m *SequenceMatcher) findLongestMatch(alo, ahi, blo, bhi int) Match {
+ // CAUTION: stripping common prefix or suffix would be incorrect.
+ // E.g.,
+ // ab
+ // acab
+ // Longest matching block is "ab", but if common prefix is
+ // stripped, it's "a" (tied with "b"). UNIX(tm) diff does so
+ // strip, so ends up claiming that ab is changed to acab by
+ // inserting "ca" in the middle. That's minimal but unintuitive:
+ // "it's obvious" that someone inserted "ac" at the front.
+ // Windiff ends up at the same place as diff, but by pairing up
+ // the unique 'b's and then matching the first two 'a's.
+ besti, bestj, bestsize := alo, blo, 0
+
+ // find longest junk-free match
+ // during an iteration of the loop, j2len[j] = length of longest
+ // junk-free match ending with a[i-1] and b[j]
+ j2len := map[int]int{}
+ for i := alo; i != ahi; i++ {
+ // look at all instances of a[i] in b; note that because
+ // b2j has no junk keys, the loop is skipped if a[i] is junk
+ newj2len := map[int]int{}
+ for _, j := range m.b2j[m.a[i]] {
+ // a[i] matches b[j]
+ if j < blo {
+ continue
+ }
+ if j >= bhi {
+ break
+ }
+ k := j2len[j-1] + 1
+ newj2len[j] = k
+ if k > bestsize {
+ besti, bestj, bestsize = i-k+1, j-k+1, k
+ }
+ }
+ j2len = newj2len
+ }
+
+ // Extend the best by non-junk elements on each end. In particular,
+ // "popular" non-junk elements aren't in b2j, which greatly speeds
+ // the inner loop above, but also means "the best" match so far
+ // doesn't contain any junk *or* popular non-junk elements.
+ for besti > alo && bestj > blo && !m.isBJunk(m.b[bestj-1]) &&
+ m.a[besti-1] == m.b[bestj-1] {
+ besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
+ }
+ for besti+bestsize < ahi && bestj+bestsize < bhi &&
+ !m.isBJunk(m.b[bestj+bestsize]) &&
+ m.a[besti+bestsize] == m.b[bestj+bestsize] {
+ bestsize++
+ }
+
+ // Now that we have a wholly interesting match (albeit possibly
+ // empty!), we may as well suck up the matching junk on each
+ // side of it too. Can't think of a good reason not to, and it
+ // saves post-processing the (possibly considerable) expense of
+ // figuring out what to do with it. In the case of an empty
+ // interesting match, this is clearly the right thing to do,
+ // because no other kind of match is possible in the regions.
+ for besti > alo && bestj > blo && m.isBJunk(m.b[bestj-1]) &&
+ m.a[besti-1] == m.b[bestj-1] {
+ besti, bestj, bestsize = besti-1, bestj-1, bestsize+1
+ }
+ for besti+bestsize < ahi && bestj+bestsize < bhi &&
+ m.isBJunk(m.b[bestj+bestsize]) &&
+ m.a[besti+bestsize] == m.b[bestj+bestsize] {
+ bestsize++
+ }
+
+ return Match{A: besti, B: bestj, Size: bestsize}
+}
diff --git a/internal/difflib/options.go b/internal/difflib/options.go
new file mode 100644
index 000000000..3637b3d74
--- /dev/null
+++ b/internal/difflib/options.go
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers
+// SPDX-License-Identifier: Apache-2.0
+
+package difflib
+
+import (
+ "bufio"
+ "fmt"
+)
+
+type (
+ Formatter func(format string, args ...any) error
+ Printer func(string) error
+)
+
+type (
+ FormatterBuilder func(*bufio.Writer) Formatter
+ PrinterBuilder func(*bufio.Writer) Printer
+)
+
+type Options struct {
+ EqualPrinter PrinterBuilder
+ DeletePrinter PrinterBuilder
+ UpdatePrinter PrinterBuilder
+ InsertPrinter PrinterBuilder
+ OtherPrinter PrinterBuilder
+ Formatter FormatterBuilder
+}
+
+func optionsWithDefaults(in *Options) *Options {
+ o := &Options{
+ EqualPrinter: defaultPrinterBuilder,
+ DeletePrinter: defaultPrinterBuilder,
+ UpdatePrinter: defaultPrinterBuilder,
+ InsertPrinter: defaultPrinterBuilder,
+ OtherPrinter: defaultPrinterBuilder,
+ Formatter: defaultFormatterBuilder,
+ }
+
+ if in == nil {
+ return o
+ }
+ if in.EqualPrinter != nil {
+ o.EqualPrinter = in.EqualPrinter
+ }
+ if in.DeletePrinter != nil {
+ o.DeletePrinter = in.DeletePrinter
+ }
+ if in.UpdatePrinter != nil {
+ o.UpdatePrinter = in.UpdatePrinter
+ }
+ if in.InsertPrinter != nil {
+ o.InsertPrinter = in.InsertPrinter
+ }
+ if in.OtherPrinter != nil {
+ o.OtherPrinter = in.OtherPrinter
+ }
+ if in.Formatter != nil {
+ o.Formatter = in.Formatter
+ }
+
+ return o
+}
+
+func DefaultPrinterBuilder(buf *bufio.Writer) Printer {
+ return defaultPrinterBuilder(buf)
+}
+
+func defaultPrinterBuilder(buf *bufio.Writer) Printer {
+ return func(s string) error {
+ _, err := buf.WriteString(s)
+ return err
+ }
+}
+
+func defaultFormatterBuilder(buf *bufio.Writer) Formatter {
+ return func(format string, args ...any) error {
+ _, err := fmt.Fprintf(buf, format, args...)
+ return err
+ }
+}
diff --git a/internal/difflib/options_test.go b/internal/difflib/options_test.go
new file mode 100644
index 000000000..62dbecbfb
--- /dev/null
+++ b/internal/difflib/options_test.go
@@ -0,0 +1,93 @@
+package difflib
+
+import (
+ "bufio"
+ "strings"
+ "testing"
+)
+
+// a few colors for testing.
+//
+// The complete set may be found in ../assertions/enable/colors.
+const (
+ redMark = "\033[0;31m"
+ greenMark = "\033[0;32m"
+ yellowMark = "\033[0;33m"
+ cyanMark = "\033[0;36m"
+ endMark = "\033[0m"
+)
+
+func TestOptions(t *testing.T) {
+ const (
+ a = "(map[spew_test.stringer]int) (len=3) {\n" +
+ "(spew_test.stringer) (len=1) stringer 1: (int) 1,\n" +
+ "(spew_test.stringer) (len=1) stringer 2: (int) 2,\n" +
+ "(spew_test.stringer) (len=1) stringer 3: (int) 3\n" +
+ "(spew_test.stringer) (len=1) stringer 5: (int) 3\n" +
+ "}\n"
+ b = "(map[spew_test.stringer]int) (len=3) {\n" +
+ "(spew_test.stringer) (len=1) stringer 1: (int) 1,\n" +
+ "(spew_test.stringer) (len=1) stringer 2: (int) 3,\n" +
+ "(spew_test.stringer) (len=1) stringer 3: (int) 3\n" +
+ "(spew_test.stringer) (len=1) stringer 4: (int) 8\n" +
+ "(spew_test.stringer) (len=1) stringer 6: (int) 9\n" +
+ "}\n"
+ )
+ greenPrinterBuilder := ansiPrinterBuilder(greenMark)
+ cyanPrinterBuilder := ansiPrinterBuilder(cyanMark)
+ redPrinterBuilder := ansiPrinterBuilder(redMark)
+ yellowPrinterBuilder := ansiPrinterBuilder(yellowMark)
+
+ diff, err := GetUnifiedDiffString(UnifiedDiff{
+ A: SplitLines(a),
+ B: SplitLines(b),
+ FromFile: "Expected",
+ FromDate: "",
+ ToFile: "Actual",
+ ToDate: "",
+ Context: 1,
+ Options: &Options{
+ EqualPrinter: greenPrinterBuilder,
+ DeletePrinter: redPrinterBuilder,
+ UpdatePrinter: cyanPrinterBuilder,
+ InsertPrinter: yellowPrinterBuilder,
+ },
+ })
+ if err != nil {
+ t.Fatalf("did not expect an error, but got: %v", err)
+ }
+
+ //nolint:staticcheck // ST1018: for this test we specifically want to check escape sequences
+ if !strings.Contains(diff, `[0;32m (spew_test.stringer) (len=1) stringer 1: (int) 1,
+[0m[0;31m-(spew_test.stringer) (len=1) stringer 2: (int) 2,
+[0m[0;33m+(spew_test.stringer) (len=1) stringer 2: (int) 3,
+[0m[0;32m (spew_test.stringer) (len=1) stringer 3: (int) 3
+[0m[0;31m-(spew_test.stringer) (len=1) stringer 5: (int) 3`,
+ ) {
+ t.Errorf("expected matching ansi color sequences for diff")
+ }
+
+ // a visualization is better in this case...
+ t.Log("\n\nDiff:\n" + diff)
+}
+
+func ansiPrinterBuilder(mark string) PrinterBuilder {
+ return func(w *bufio.Writer) Printer {
+ return func(str string) (err error) {
+ _, err = w.WriteString(mark)
+ if err != nil {
+ return
+ }
+ _, err = w.WriteString(str)
+ if err != nil {
+ return
+ }
+ _, err = w.WriteString(endMark)
+ if err != nil {
+ return
+ }
+
+ return nil
+ }
+ }
+}