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: map[string]interface {}{"foo":"bar", "hello":"world"} + actual : map[string]interface {}{"foo":"bar", "hello":"worldwide"} + + Diff: + --- Expected + +++ Actual + @@ -2,3 +2,3 @@ +  (string) (len=3) "foo": (string) (len=3) "bar", + - (string) (len=5) "hello": (string) (len=5) "world" + + (string) (len=5) "hello": (string) (len=9) "worldwide" +  } +  +` + + expectedColorizedArrayDiff = `Not equal: + expected: []interface {}{"foo", map[string]interface {}{"hello":"world", "nested":"hash"}} + actual : []interface {}{"bar", map[string]interface {}{"hello":"world", "nested":"hash"}} + + Diff: + --- Expected + +++ Actual + @@ -1,3 +1,3 @@ +  ([]interface {}) (len=2) { + - (string) (len=3) "foo", + + (string) (len=3) "bar", +  (map[string]interface {}) (len=2) { +  +` +) 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, ` (spew_test.stringer) (len=1) stringer 1: (int) 1, +-(spew_test.stringer) (len=1) stringer 2: (int) 2, ++(spew_test.stringer) (len=1) stringer 2: (int) 3, + (spew_test.stringer) (len=1) stringer 3: (int) 3 +-(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 + } + } +}