diff --git a/assert/assert_assertions_go126.go b/assert/assert_assertions_go126.go new file mode 100644 index 000000000..2541109cc --- /dev/null +++ b/assert/assert_assertions_go126.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package assert + +import ( + "github.com/go-openapi/testify/v2/internal/assertions" +) + +// ErrorAsType asserts that at least one of the errors in err's chain is of type E. +// +// It is the type-safe counterpart of [ErrorAs], built on the go1.26 [errors.AsType]: +// the expected type is the type parameter E (checked at compile time, no reflection), +// rather than the untyped any target used by [ErrorAs]. +// +// target receives the matched error when the assertion succeeds. It may be nil, for +// callers that only want to know whether the chain holds an error of type E: in that +// case E cannot be inferred and must be supplied explicitly. +// +// This assertion requires go1.26 or newer; it is unavailable on older toolchains. +// +// # Usage +// +// // capture the matched error (E is inferred from target): +// var target *MyError +// assertions.ErrorAsType(t, err, &target) +// +// // only check, discarding the value (E given explicitly): +// assertions.ErrorAsType[*MyError](t, err, nil) +// +// # Examples +// +// success: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +// failure: ErrTest, new(*dummyError) +// +// Upon failure, the test [T] is marked as failed and continues execution. +func ErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.ErrorAsType[E](t, err, target, msgAndArgs...) +} + +// NotErrorAsType asserts that none of the errors in err's chain is of type E. +// +// It is the type-safe counterpart of [NotErrorAs], built on the go1.26 [errors.AsType]. +// +// target is only used to infer the type parameter E and is never assigned; it may be nil, +// in which case E must be supplied explicitly. +// +// This assertion requires go1.26 or newer; it is unavailable on older toolchains. +// +// # Usage +// +// var target *MyError +// assertions.NotErrorAsType(t, err, &target) +// +// // or, supplying E explicitly: +// assertions.NotErrorAsType[*MyError](t, err, nil) +// +// # Examples +// +// success: ErrTest, new(*dummyError) +// failure: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +// +// Upon failure, the test [T] is marked as failed and continues execution. +func NotErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.NotErrorAsType[E](t, err, target, msgAndArgs...) +} diff --git a/assert/assert_assertions_go126_test.go b/assert/assert_assertions_go126_test.go new file mode 100644 index 000000000..481b98f22 --- /dev/null +++ b/assert/assert_assertions_go126_test.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package assert + +import ( + "fmt" + "testing" +) + +func TestErrorAsType(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := ErrorAsType(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) + if !result { + t.Error("ErrorAsType should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := ErrorAsType(mock, ErrTest, new(*dummyError)) + if result { + t.Error("ErrorAsType should return false on failure") + } + if !mock.failed { + t.Error("ErrorAsType should mark test as failed") + } + }) +} + +func TestNotErrorAsType(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := NotErrorAsType(mock, ErrTest, new(*dummyError)) + if !result { + t.Error("NotErrorAsType should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := NotErrorAsType(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) + if result { + t.Error("NotErrorAsType should return false on failure") + } + if !mock.failed { + t.Error("NotErrorAsType should mark test as failed") + } + }) +} diff --git a/assert/assert_examples_go126_test.go b/assert/assert_examples_go126_test.go new file mode 100644 index 000000000..4198f3b8a --- /dev/null +++ b/assert/assert_examples_go126_test.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package assert_test + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func ExampleErrorAsType() { + t := new(testing.T) // should come from testing, e.g. func TestErrorAsType(t *testing.T) + success := assert.ErrorAsType(t, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) + fmt.Printf("success: %t\n", success) + + // Output: success: true +} + +func ExampleNotErrorAsType() { + t := new(testing.T) // should come from testing, e.g. func TestNotErrorAsType(t *testing.T) + success := assert.NotErrorAsType(t, assert.ErrTest, new(*dummyError)) + fmt.Printf("success: %t\n", success) + + // Output: success: true +} diff --git a/assert/assert_format_go126.go b/assert/assert_format_go126.go new file mode 100644 index 000000000..8520eb6e8 --- /dev/null +++ b/assert/assert_format_go126.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package assert + +import ( + "github.com/go-openapi/testify/v2/internal/assertions" +) + +// ErrorAsTypef is the same as [ErrorAsType], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func ErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.ErrorAsType[E](t, err, target, forwardArgs(msg, args)...) +} + +// NotErrorAsTypef is the same as [NotErrorAsType], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func NotErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.NotErrorAsType[E](t, err, target, forwardArgs(msg, args)...) +} diff --git a/assert/assert_format_go126_test.go b/assert/assert_format_go126_test.go new file mode 100644 index 000000000..d46262deb --- /dev/null +++ b/assert/assert_format_go126_test.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package assert + +import ( + "fmt" + "testing" +) + +func TestErrorAsTypef(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := ErrorAsTypef(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError), "test message") + if !result { + t.Error("ErrorAsTypef should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := ErrorAsTypef(mock, ErrTest, new(*dummyError), "test message") + if result { + t.Error("ErrorAsTypef should return false on failure") + } + if !mock.failed { + t.Error("ErrorAsTypef should mark test as failed") + } + }) +} + +func TestNotErrorAsTypef(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := NotErrorAsTypef(mock, ErrTest, new(*dummyError), "test message") + if !result { + t.Error("NotErrorAsTypef should return true on success") + } + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := NotErrorAsTypef(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError), "test message") + if result { + t.Error("NotErrorAsTypef should return false on failure") + } + if !mock.failed { + t.Error("NotErrorAsTypef should mark test as failed") + } + }) +} diff --git a/codegen/internal/generator/doc_generator.go b/codegen/internal/generator/doc_generator.go index d880eeca6..f7319e3dd 100644 --- a/codegen/internal/generator/doc_generator.go +++ b/codegen/internal/generator/doc_generator.go @@ -302,6 +302,7 @@ func buildQuickIndex(docsByDomain iter.Seq2[string, model.Document]) model.Quick Domain: domain, IsGeneric: fn.IsGeneric, IsHelper: fn.IsHelper, + GoBuild: fn.GoBuild, } if opposite != "" { if gn, ok := genericNames[opposite]; ok { diff --git a/codegen/internal/generator/generator.go b/codegen/internal/generator/generator.go index 3153de5e3..b59f75aaf 100644 --- a/codegen/internal/generator/generator.go +++ b/codegen/internal/generator/generator.go @@ -4,12 +4,15 @@ package generator import ( + "bytes" "embed" "errors" "fmt" + "log" "os" "path" "path/filepath" + "regexp" "text/template" "github.com/go-openapi/testify/codegen/v2/internal/model" @@ -43,11 +46,13 @@ type Generator struct { type genCtx struct { generateOptions - index map[string]string - templates map[string]*template.Template - target *model.AssertionPackage - docs *model.Documentation - targetBase string + index map[string]string + templates map[string]*template.Template + target *model.AssertionPackage + docs *model.Documentation + targetBase string + variantSuffix string // filename suffix for the current build-variant (e.g. "_go126"), empty for the default + rendered map[string]bool // base filenames this run accounted for (written or removed-when-empty) } func New(source *model.AssertionPackage, opts ...Option) *Generator { @@ -82,13 +87,111 @@ func (g *Generator) Generate(opts ...GenerateOption) error { g.buildDocs() } - { - // auto-generated assertions + // Type constraints are not version-guarded (functions-only scope): generate them once, + // from the full model, into the default (unsuffixed) file. + if err := g.generateTypes(); err != nil { + // assertion_types.gotmpl + return err + } + + // Build constraints are file-level in Go, so guarded functions must land in their own + // generated files carrying the same //go:build line. We partition the functions by + // constraint and render a parallel set of files per variant. Empty category files + // (e.g. a go1.26 variant with no helpers) are skipped by render(). + full := g.ctx.target + for _, constraint := range full.Functions.BuildVariants() { + g.ctx.target = variantTarget(full, constraint) + g.ctx.variantSuffix = model.GoBuildTag(constraint) + if g.ctx.variantSuffix != "" { + g.ctx.variantSuffix = "_" + g.ctx.variantSuffix + } + + if err := g.generateVariant(); err != nil { + return err + } + } + g.ctx.target = full // restore the full model for Documentation() + + // Remove generated build-variant files left over from a variant that no longer exists + // (e.g. the last go1.26-guarded assertion was deleted). Without this, those files would + // linger and fail to compile against the now-missing source symbols. + if err := g.sweepOrphanVariants(); err != nil { + return err + } + + return nil +} - if err := g.generateTypes(); err != nil { - // assertion_types.gotmpl +// orphanVariantRx matches a generated build-variant filename for the given target package, +// e.g. "assert_assertions_go126.go" or "assert_forward_go126_test.go". The "_go" infix is +// what distinguishes a variant file from the default (unsuffixed) ones, which are always +// regenerated and never swept. +func orphanVariantRx(targetBase string) *regexp.Regexp { + return regexp.MustCompile(`^` + regexp.QuoteMeta(targetBase) + `_.+_go\d+(_test)?\.go$`) +} + +// sweepOrphanVariants removes generated build-variant files in the target package directory +// that were not produced by this run. To guard against deleting anything we shouldn't, a file +// is removed only when it (a) matches the variant filename pattern, (b) was not rendered this +// run, and (c) actually carries our generated-code marker. Hand-authored files — even ones +// that happen to match the name pattern — are left untouched, and every removal is logged. +// +// Note on `go generate ./...`: that command eagerly snapshots every package's file list +// before running directives, so the run that removes an orphan may then fail with a benign +// "no such file" as go generate tries to scan the file we just deleted. The removal itself +// succeeds and a rerun is clean; invoking codegen directly (go run ./codegen/main.go) avoids +// the race entirely. This only happens when a whole guarded variant is deleted/renamed. +func (g *Generator) sweepOrphanVariants() error { + dir := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase) + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("can't scan target folder for orphans: %w", err) + } + + pattern := orphanVariantRx(g.ctx.targetBase) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !pattern.MatchString(name) || g.ctx.rendered[name] { + continue + } + + isGen, err := isGeneratedFile(filepath.Join(dir, name)) + if err != nil { return err } + if !isGen { + continue // not ours: never remove a hand-authored file + } + + if err := os.Remove(filepath.Join(dir, name)); err != nil { + return fmt.Errorf("can't remove orphaned variant file %q: %w", name, err) + } + // Announce every removal: an orphan disappearing should never be silent. + log.Printf("codegen: removed orphaned build-variant file %s (its guarded source no longer exists)", filepath.Join(g.ctx.targetBase, name)) + } + + return nil +} + +// isGeneratedFile reports whether the file carries our "DO NOT EDIT" generated-code marker. +func isGeneratedFile(path string) (bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is built from a controlled target directory listing + if err != nil { + return false, fmt.Errorf("can't read candidate orphan %q: %w", path, err) + } + + return bytes.Contains(data, []byte("DO NOT EDIT.")), nil +} + +// generateVariant renders every per-function artifact (functions, format, forward, +// helpers and their tests + examples) for the currently selected build-variant. +func (g *Generator) generateVariant() error { + { + // auto-generated assertions if err := g.generateAssertions(); err != nil { return err @@ -138,6 +241,23 @@ func (g *Generator) Generate(opts ...GenerateOption) error { return nil } +// variantTarget clones the transformed model, keeping only the functions belonging to the +// given build constraint, and stamps the constraint so templates emit the //go:build line. +func variantTarget(base *model.AssertionPackage, constraint string) *model.AssertionPackage { + tgt := base.Clone() + tgt.BuildConstraint = constraint + + filtered := tgt.Functions[:0:0] + for _, fn := range base.Functions { + if fn.GoBuild == constraint { + filtered = append(filtered, fn) + } + } + tgt.Functions = filtered + + return tgt +} + // Documentation yields the transformed package model as a [model.Documentation] // usable by a generation step by the [DocGenerator]. func (g *Generator) Documentation() model.Documentation { @@ -148,6 +268,7 @@ func (g *Generator) initContext(opts []GenerateOption) error { // prepare options g.ctx = &genCtx{ generateOptions: generateOptionsWithDefaults(opts), + rendered: make(map[string]bool), } if g.ctx.targetPkg == "" { return errors.New("a target package is required") @@ -336,16 +457,31 @@ func (g *Generator) transformFunc(fn model.Function) model.Function { return fn } +// codeFile builds the path of a generated code file for a category, honoring the +// current build-variant suffix (e.g. "assert/assert_assertions_go126.go"). +func (g *Generator) codeFile(category string) string { + name := g.ctx.targetBase + "_" + category + g.ctx.variantSuffix + ".go" + + return filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, name) +} + +// testFile builds the path of a generated test file for a category, honoring the +// current build-variant suffix (e.g. "assert/assert_assertions_go126_test.go"). +func (g *Generator) testFile(category string) string { + name := g.ctx.targetBase + "_" + category + g.ctx.variantSuffix + "_test.go" + + return filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, name) +} + func (g *Generator) generateTypes() error { + // type constraints are unguarded: always written to the default (unsuffixed) file file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_types.go") return g.render("types", file, g.ctx.target) } func (g *Generator) generateAssertions() error { - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_assertions.go") - - return g.render("assertions", file, g.ctx.target) + return g.render("assertions", g.codeFile("assertions"), g.ctx.target) } func (g *Generator) generateFormatFuncs() error { @@ -353,9 +489,7 @@ func (g *Generator) generateFormatFuncs() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_format.go") - - return g.render("format", file, g.ctx.target) + return g.render("format", g.codeFile("format"), g.ctx.target) } func (g *Generator) generateForwardFuncs() error { @@ -363,18 +497,15 @@ func (g *Generator) generateForwardFuncs() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_forward.go") - - return g.render("forward", file, g.ctx.target) + return g.render("forward", g.codeFile("forward"), g.ctx.target) } func (g *Generator) generateHelpers() error { if !g.ctx.generateHelpers { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_helpers.go") - return g.render("helpers", file, g.ctx.target) + return g.render("helpers", g.codeFile("helpers"), g.ctx.target) } func (g *Generator) generateAssertionsTests() error { @@ -382,9 +513,7 @@ func (g *Generator) generateAssertionsTests() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_assertions_test.go") - - return g.render("assertions_test", file, g.ctx.target) + return g.render("assertions_test", g.testFile("assertions"), g.ctx.target) } func (g *Generator) generateFormatTests() error { @@ -392,9 +521,7 @@ func (g *Generator) generateFormatTests() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_format_test.go") - - return g.render("format_test", file, g.ctx.target) + return g.render("format_test", g.testFile("format"), g.ctx.target) } func (g *Generator) generateForwardTests() error { @@ -402,9 +529,7 @@ func (g *Generator) generateForwardTests() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_forward_test.go") - - return g.render("forward_test", file, g.ctx.target) + return g.render("forward_test", g.testFile("forward"), g.ctx.target) } func (g *Generator) generateExampleTests() error { @@ -412,9 +537,7 @@ func (g *Generator) generateExampleTests() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_examples_test.go") - - return g.render("examples_test", file, g.ctx.target) + return g.render("examples_test", g.testFile("examples"), g.ctx.target) } func (g *Generator) generateHelpersTests() error { @@ -422,12 +545,12 @@ func (g *Generator) generateHelpersTests() error { return nil } - file := filepath.Join(g.ctx.targetRoot, g.ctx.targetBase, g.ctx.targetBase+"_helpers_test.go") - - return g.render("helpers_test", file, g.ctx.target) + return g.render("helpers_test", g.testFile("helpers"), g.ctx.target) } func (g *Generator) render(name string, target string, data any) error { + g.ctx.rendered[filepath.Base(target)] = true // account for this file even if render() drops it when empty + return renderTemplate( g.ctx.index, g.ctx.templates, diff --git a/codegen/internal/generator/orphan_test.go b/codegen/internal/generator/orphan_test.go new file mode 100644 index 000000000..db846b9d3 --- /dev/null +++ b/codegen/internal/generator/orphan_test.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/testify/codegen/v2/internal/model" +) + +// guardedSource returns the minimal fixture plus a go1.26-guarded assertion. +func guardedSource(testDataPath string) *model.AssertionPackage { + pkg := minimalSource(testDataPath) + pkg.Functions = append(pkg.Functions, model.Function{ + Name: "GuardedAssert", + SourcePackage: assertions, + TargetPackage: assertions, + DocString: "GuardedAssert is a go1.26-guarded assertion.", + GoBuild: "go1.26", + AllParams: model.Parameters{ + {Name: "t", GoType: "T"}, + {Name: "value", GoType: "bool"}, + {Name: "msgAndArgs", GoType: "any", IsVariadic: true}, + }, + Params: model.Parameters{ + {Name: "value", GoType: "bool"}, + }, + Returns: model.Parameters{{GoType: "bool"}}, + Tests: []model.Test{ + {TestedValue: "true", ExpectedOutcome: model.TestSuccess, IsFirst: true}, + {TestedValue: "false", ExpectedOutcome: model.TestFailure}, + }, + }) + + return pkg +} + +func generateAssert(t *testing.T, source *model.AssertionPackage, root string) { + t.Helper() + + err := New(source).Generate( + WithTargetPackage(pkgAssert), + WithTargetRoot(root), + WithIncludeFormatFuncs(true), + WithIncludeForwardFuncs(true), + WithIncludeTests(true), + WithIncludeHelpers(true), + WithIncludeExamples(true), + ) + if err != nil { + t.Fatalf("Generate(assert) failed: %v", err) + } +} + +// TestOrphanVariantCleanup verifies that build-variant files for a variant that no longer +// exists are removed on the next run, while hand-authored lookalikes are preserved. +func TestOrphanVariantCleanup(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + assertDir := filepath.Join(tmpDir, "assert") + + // 1. Generate WITH the guarded assertion: go126 variant files appear. + generateAssert(t, guardedSource(tmpDir), tmpDir) + + guardedFile := filepath.Join(assertDir, "assert_assertions_go126.go") + if _, err := os.Stat(guardedFile); err != nil { + t.Fatalf("expected guarded file to be generated first: %v", err) + } + + // 2. Drop a hand-authored file matching the variant pattern but WITHOUT our marker. + // The sweep must never touch it. + decoy := filepath.Join(assertDir, "assert_handwritten_go126.go") + const decoyBody = "package assert\n\n// hand-authored, not generated\nfunc handwritten() {}\n" + if err := os.WriteFile(decoy, []byte(decoyBody), 0o600); err != nil { + t.Fatal(err) + } + + // 3. Regenerate WITHOUT the guarded assertion: the variant disappears entirely. + generateAssert(t, minimalSource(tmpDir), tmpDir) + + // All generated go126 files must be gone. + leftovers, _ := filepath.Glob(filepath.Join(assertDir, "*_go126*.go")) + for _, f := range leftovers { + if f != decoy { + t.Errorf("orphaned generated variant file not removed: %s", filepath.Base(f)) + } + } + + // The hand-authored decoy must survive (guard against undue deletion). + if _, err := os.Stat(decoy); err != nil { + t.Errorf("hand-authored lookalike was unduly removed: %v", err) + } + + // And the default (unsuffixed) files must still be present. + if _, err := os.Stat(filepath.Join(assertDir, "assert_assertions.go")); err != nil { + t.Errorf("default assertions file should remain: %v", err) + } +} diff --git a/codegen/internal/generator/render.go b/codegen/internal/generator/render.go index 99970551e..60979f04f 100644 --- a/codegen/internal/generator/render.go +++ b/codegen/internal/generator/render.go @@ -6,6 +6,9 @@ package generator import ( "bytes" "fmt" + "go/ast" + "go/parser" + "go/token" "os" "text/template" @@ -25,9 +28,38 @@ func render(tpl *template.Template, target string, data any, o *imports.Options) return fmt.Errorf("error formatting go code: %w:%w", err, fmt.Errorf("details available at: %v", target)) } + // A rendered file with no declaration beyond imports carries no value: this happens + // for build-variant partitions that have no function in a given category (e.g. a + // go1.26 variant has no helpers). Skip writing it, and remove any stale leftover so + // orphaned variant files don't accumulate. + if !hasDeclarations(formatted) { + _ = os.Remove(target) + + return nil + } + return os.WriteFile(target, formatted, filePermissions) } +// hasDeclarations reports whether the Go source carries at least one top-level +// declaration other than imports. +func hasDeclarations(src []byte) bool { + file, err := parser.ParseFile(token.NewFileSet(), "", src, parser.SkipObjectResolution) + if err != nil { + return true // be conservative: if we can't parse, let the normal write path keep it + } + + for _, decl := range file.Decls { + if gen, ok := decl.(*ast.GenDecl); ok && gen.Tok == token.IMPORT { + continue + } + + return true + } + + return false +} + func renderMD(tpl *template.Template, target string, data any) error { var buffer bytes.Buffer diff --git a/codegen/internal/generator/templates.go b/codegen/internal/generator/templates.go index 06a6eeba3..f0411ff0a 100644 --- a/codegen/internal/generator/templates.go +++ b/codegen/internal/generator/templates.go @@ -14,6 +14,9 @@ import ( "github.com/go-openapi/testify/codegen/v2/internal/generator/funcmaps" ) +// goTplExt is the file extension of Go code templates (as opposed to ".md.gotmpl" docs). +const goTplExt = ".gotmpl" + // buildTemplateIndex extracts template names from the index and returns them sorted. // // This helper reduces duplication between Generator and DocGenerator template loading. @@ -49,6 +52,9 @@ func loadTemplatesFromIndex( for _, name := range needed { file := name + tplExt files := []string{path.Join("templates", file)} + if tplExt == goTplExt { // Go code templates share the file header (copyright, DO NOT EDIT, //go:build guard) + files = append(files, path.Join("templates", "header.gotmpl")) + } if strings.Contains(name, "_test") { // test templates use a set of shared definitions files = append(files, path.Join("templates", "assertion_test_shared.gotmpl")) } diff --git a/codegen/internal/generator/templates/assertion_assertions.gotmpl b/codegen/internal/generator/templates/assertion_assertions.gotmpl index d92d618e1..8d4ac5d65 100644 --- a/codegen/internal/generator/templates/assertion_assertions.gotmpl +++ b/codegen/internal/generator/templates/assertion_assertions.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/assertion_assertions_test.gotmpl b/codegen/internal/generator/templates/assertion_assertions_test.gotmpl index 85d22031d..3ae2e3c12 100644 --- a/codegen/internal/generator/templates/assertion_assertions_test.gotmpl +++ b/codegen/internal/generator/templates/assertion_assertions_test.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} @@ -17,6 +15,7 @@ func Test{{ .Name }}(t *testing.T) { {{- template "test-case" (testSetup . "assertions" "") }} } {{- end }} +{{- if not .BuildConstraint }}{{/* shared test fixtures belong only to the default (unguarded) test file */}} // mockT is a mock testing.T for assertion tests type mockT struct { @@ -46,3 +45,4 @@ func (m *mockFailNowT) FailNow() { } {{ template "test-helpers" . }} +{{- end }} diff --git a/codegen/internal/generator/templates/assertion_examples_test.gotmpl b/codegen/internal/generator/templates/assertion_examples_test.gotmpl index cdddf4101..1c2216e38 100644 --- a/codegen/internal/generator/templates/assertion_examples_test.gotmpl +++ b/codegen/internal/generator/templates/assertion_examples_test.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }}_test @@ -67,7 +65,9 @@ func Example{{ .Name }}() { {{- end }} {{- end }} +{{- if not .BuildConstraint }}{{/* shared test fixtures belong only to the default (unguarded) test file */}} // Test helpers (also in the tests for package {{ .Package }}. // // This code is duplicated because the current test is run as a separate test package: {{ .Package }}_test. {{ template "test-helpers" .WithTestPackage }} +{{- end }} diff --git a/codegen/internal/generator/templates/assertion_format.gotmpl b/codegen/internal/generator/templates/assertion_format.gotmpl index 244668245..05f69d54d 100644 --- a/codegen/internal/generator/templates/assertion_format.gotmpl +++ b/codegen/internal/generator/templates/assertion_format.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} @@ -21,6 +19,7 @@ func {{ .GenericName "f" }}(t T, {{ params .Params }}, msg string, args ...any) return {{ .TargetPackage }}.{{ .GenericCallName }}(t, {{ forward .Params }}, forwardArgs(msg, args)...) } {{- end }} +{{- if not .BuildConstraint }}{{/* package-level boilerplate belongs only to the default (unguarded) file */}} func forwardArgs(msg string, args []any) []any { result := make([]any, len(args)+1) @@ -29,3 +28,4 @@ func forwardArgs(msg string, args []any) []any { return result } +{{- end }} diff --git a/codegen/internal/generator/templates/assertion_format_test.gotmpl b/codegen/internal/generator/templates/assertion_format_test.gotmpl index 6480e1c86..fc7581daa 100644 --- a/codegen/internal/generator/templates/assertion_format_test.gotmpl +++ b/codegen/internal/generator/templates/assertion_format_test.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/assertion_forward.gotmpl b/codegen/internal/generator/templates/assertion_forward.gotmpl index 462ed1202..53ad8d51a 100644 --- a/codegen/internal/generator/templates/assertion_forward.gotmpl +++ b/codegen/internal/generator/templates/assertion_forward.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} @@ -10,6 +8,7 @@ import ( {{ imports .Imports }} ) {{- end }} +{{- if not .BuildConstraint }}{{/* the receiver type and its constructor belong only to the default (unguarded) file */}} // {{ .Receiver }} exposes all assertion functions as methods. // @@ -26,6 +25,7 @@ func New(t T) *{{ .Receiver }} { T: t, } } +{{- end }} {{- range .Functions.Scope "exclude-generics" . }}{{/* generics can't be added to the receiver */}} diff --git a/codegen/internal/generator/templates/assertion_forward_test.gotmpl b/codegen/internal/generator/templates/assertion_forward_test.gotmpl index 15cae985b..16d60f391 100644 --- a/codegen/internal/generator/templates/assertion_forward_test.gotmpl +++ b/codegen/internal/generator/templates/assertion_forward_test.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/assertion_helpers.gotmpl b/codegen/internal/generator/templates/assertion_helpers.gotmpl index 278a6ee98..330979763 100644 --- a/codegen/internal/generator/templates/assertion_helpers.gotmpl +++ b/codegen/internal/generator/templates/assertion_helpers.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/assertion_helpers_test.gotmpl b/codegen/internal/generator/templates/assertion_helpers_test.gotmpl index 62c79b31d..8205dade8 100644 --- a/codegen/internal/generator/templates/assertion_helpers_test.gotmpl +++ b/codegen/internal/generator/templates/assertion_helpers_test.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/assertion_types.gotmpl b/codegen/internal/generator/templates/assertion_types.gotmpl index 626915395..2e1c0d7aa 100644 --- a/codegen/internal/generator/templates/assertion_types.gotmpl +++ b/codegen/internal/generator/templates/assertion_types.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/doc_metrics.md.gotmpl b/codegen/internal/generator/templates/doc_metrics.md.gotmpl index 7489f1c06..86c56c8dd 100644 --- a/codegen/internal/generator/templates/doc_metrics.md.gotmpl +++ b/codegen/internal/generator/templates/doc_metrics.md.gotmpl @@ -35,6 +35,6 @@ Table of core assertions, excluding variants. Each function is side by side with | Assertion | Opposite | Domain | Kind | | ------------------------ | ----------------- | ------ | ---- | {{- range .QuickIndex }} -| [{{ .Name }}]({{ .Domain }}/#{{ .Anchor }}){{ if .IsGeneric }} {{ hopen }}% icon icon="star" color=orange %{{ hclose }}{{ end }} | {{ if .Opposite }}[{{ .Opposite }}]({{ .Domain }}/#{{ .OppositeAnchor }}){{ end }} | {{ .Domain }} | {{ if .IsHelper }}helper{{ end }} | +| [{{ .Name }}]({{ .Domain }}/#{{ .Anchor }}){{ if .IsGeneric }} {{ hopen }}% icon icon="star" color=orange %{{ hclose }}{{ end }}{{ if .GoBuild }} {{ hopen }}% goversion "{{ .GoBuild }}" %{{ hclose }}{{ end }} | {{ if .Opposite }}[{{ .Opposite }}]({{ .Domain }}/#{{ .OppositeAnchor }}){{ end }} | {{ .Domain }} | {{ if .IsHelper }}helper{{ end }} | {{- end }} diff --git a/codegen/internal/generator/templates/doc_page.md.gotmpl b/codegen/internal/generator/templates/doc_page.md.gotmpl index 2bb6c1a80..de8cad815 100644 --- a/codegen/internal/generator/templates/doc_page.md.gotmpl +++ b/codegen/internal/generator/templates/doc_page.md.gotmpl @@ -2,7 +2,7 @@ ```tree {{- with .Package }} {{- range .Functions.Scope "include-generics" . }}{{/* functions in internal/assertions annotated with "domain:" */}} -- [{{ .GenericName }}](#{{ slugize .GenericName }}) | {{ if .IsGeneric}}star | orange{{ else }}angles-right{{ end }} +- [{{ .GenericName }}](#{{ slugize .GenericName }}){{ if .GoBuild }} ({{ .GoBuild }}+){{ end }} | {{ if .IsGeneric}}star | orange{{ else }}angles-right{{ end }} {{- end }} {{- end }} ``` @@ -62,7 +62,7 @@ {{- end }} {{- define "FUNC-SECTION" }}{{/* data: Function ; complete description of an assertion function */}} -### {{ .GenericName }}{{ if .IsGeneric }} {{ hopen }}% icon icon="star" color=orange %{{ hclose }}{{ end }}{#{{ slugize .GenericName }}} +### {{ .GenericName }}{{ if .IsGeneric }} {{ hopen }}% icon icon="star" color=orange %{{ hclose }}{{ end }}{{ if .GoBuild }} {{ hopen }}% goversion "{{ .GoBuild }}" %{{ hclose }}{{ end }}{#{{ slugize .GenericName }}} {{- if .IsDeprecated }} @@ -107,6 +107,9 @@ This domain exposes {{ .RefCount }} functionalities. {{- if .HasGenerics }} Generic assertions are marked with a {{ hopen }}% icon icon="star" color=orange %{{ hclose }}. {{- end }} +{{- if .HasGoGuards }} +Assertions requiring a newer Go toolchain are marked with a version badge, e.g. {{ hopen }}% goversion "go1.26" %{{ hclose }} (the assertion is unavailable on older toolchains). +{{- end }} {{ template "TOC" . }} {{- with .Package }} diff --git a/codegen/internal/generator/templates/header.gotmpl b/codegen/internal/generator/templates/header.gotmpl new file mode 100644 index 000000000..d8047c8b2 --- /dev/null +++ b/codegen/internal/generator/templates/header.gotmpl @@ -0,0 +1,9 @@ +{{- define "header" -}} +{{ comment .Copyright }} + +// Code generated with {{ .Tool }}; DO NOT EDIT. +{{- if .BuildConstraint }} + +//go:build {{ .BuildConstraint }} +{{- end -}} +{{- end -}} diff --git a/codegen/internal/generator/templates/requirement_assertions.gotmpl b/codegen/internal/generator/templates/requirement_assertions.gotmpl index eda21b21f..d73e343bd 100644 --- a/codegen/internal/generator/templates/requirement_assertions.gotmpl +++ b/codegen/internal/generator/templates/requirement_assertions.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} diff --git a/codegen/internal/generator/templates/requirement_format.gotmpl b/codegen/internal/generator/templates/requirement_format.gotmpl index 926db2d3b..17f3a9b21 100644 --- a/codegen/internal/generator/templates/requirement_format.gotmpl +++ b/codegen/internal/generator/templates/requirement_format.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} @@ -33,6 +31,7 @@ func {{ .GenericName "f" }}(t T, {{ params .Params }}, msg string, args ...any) {{- end }} {{- end }} {{- end }} +{{- if not .BuildConstraint }}{{/* package-level boilerplate belongs only to the default (unguarded) file */}} func forwardArgs(msg string, args []any) []any { result := make([]any, len(args)+1) @@ -41,3 +40,4 @@ func forwardArgs(msg string, args []any) []any { return result } +{{- end }} diff --git a/codegen/internal/generator/templates/requirement_forward.gotmpl b/codegen/internal/generator/templates/requirement_forward.gotmpl index 7f9b8117d..55ccd7fd1 100644 --- a/codegen/internal/generator/templates/requirement_forward.gotmpl +++ b/codegen/internal/generator/templates/requirement_forward.gotmpl @@ -1,6 +1,4 @@ -{{ comment .Copyright }} - -// Code generated with {{ .Tool }}; DO NOT EDIT. +{{ template "header" . }} package {{ .Package }} @@ -10,6 +8,7 @@ import ( {{ imports .Imports }} ) {{- end }} +{{- if not .BuildConstraint }}{{/* the receiver type and its constructor belong only to the default (unguarded) file */}} // {{ .Receiver }} exposes all assertion functions as methods. // @@ -26,8 +25,9 @@ func New(t T) *{{ .Receiver }} { T: t, } } +{{- end }} -{{- range .Functions }} +{{- range .Functions }} {{- if and (not .IsGeneric) (not .IsHelper) (not .IsConstructor) }}{{/* generics can't be added to the receiver */}} {{ docStringFor "forward" .Name }} diff --git a/codegen/internal/model/buildtags_test.go b/codegen/internal/model/buildtags_test.go new file mode 100644 index 000000000..7ac5cc6df --- /dev/null +++ b/codegen/internal/model/buildtags_test.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "slices" + "testing" +) + +func TestGoBuildTag(t *testing.T) { + cases := map[string]string{ + "": "", + "go1.26": "go126", + "go1.27": "go127", + "foo bar": "foobar", + } + for in, want := range cases { + if got := GoBuildTag(in); got != want { + t.Errorf("GoBuildTag(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBuildVariants(t *testing.T) { + fns := Functions{ + {Name: "A", GoBuild: ""}, + {Name: "B", GoBuild: "go1.27"}, + {Name: "C", GoBuild: "go1.26"}, + {Name: "D", GoBuild: "go1.26"}, + {Name: "E", GoBuild: ""}, + } + + got := fns.BuildVariants() + want := []string{"", "go1.26", "go1.27"} // default first, rest sorted + + if !slices.Equal(got, want) { + t.Errorf("BuildVariants() = %v, want %v", got, want) + } +} diff --git a/codegen/internal/model/documentation.go b/codegen/internal/model/documentation.go index 281a5ce25..590d4e0c7 100644 --- a/codegen/internal/model/documentation.go +++ b/codegen/internal/model/documentation.go @@ -103,6 +103,22 @@ func (d Document) HasGenerics() bool { return false } +// HasGoGuards reports whether any function in the document requires a minimum Go version +// (carries a //go:build go1.N guard), used to decide whether to show the version-badge legend. +func (d Document) HasGoGuards() bool { + if d.Package == nil { + return false + } + + for _, fn := range d.Package.Functions { + if fn.GoBuild != "" { + return true + } + } + + return false +} + type ExtraPackages []*AssertionPackage func (pkgs ExtraPackages) LookupFunction(name string) []FunctionWithContext { @@ -179,4 +195,5 @@ type QuickIndexEntry struct { Domain string IsGeneric bool IsHelper bool + GoBuild string // //go:build version guard (e.g. "go1.26"); empty when unguarded } diff --git a/codegen/internal/model/model.go b/codegen/internal/model/model.go index 603be8b14..b42bed8cf 100644 --- a/codegen/internal/model/model.go +++ b/codegen/internal/model/model.go @@ -38,6 +38,11 @@ type AssertionPackage struct { Consts []Ident Vars []Ident + // BuildConstraint, when non-empty, is the //go:build expression to stamp on a + // rendered file (e.g. "go1.26"). It is set per build-variant during generation + // and is empty for the default (unguarded) partition. + BuildConstraint string + // extraneous information when scanning in collectDoc mode ExtraComments []ExtraComment Context *Document @@ -126,6 +131,43 @@ const ( ScopeKindHelpers ScopeKind = "only-helpers" ) +// GoBuildTag converts a //go:build version expression into a filename-safe suffix tag, +// e.g. "go1.26" -> "go126". Returns "" for an empty constraint (the default partition). +// +// Only simple single-term go-version constraints are supported for now; dots and spaces +// are stripped to keep the suffix free of any implicit GOOS/GOARCH filename semantics. +func GoBuildTag(expr string) string { + if expr == "" { + return "" + } + + r := strings.NewReplacer(".", "", " ", "") + + return r.Replace(expr) +} + +// BuildVariants returns the distinct //go:build constraints across the functions, +// in deterministic order, always starting with the default (empty) partition. +func (f Functions) BuildVariants() []string { + seen := map[string]struct{}{"": {}} + variants := []string{""} // default partition is always present + + for _, fn := range f { + if fn.GoBuild == "" { + continue + } + if _, ok := seen[fn.GoBuild]; ok { + continue + } + seen[fn.GoBuild] = struct{}{} + variants = append(variants, fn.GoBuild) + } + + slices.Sort(variants[1:]) // keep "" first, sort the rest for stable output + + return variants +} + type Functions []Function func (f Functions) Scope(scope ScopeKind, ctx *AssertionPackage) (iter.Seq[Function], error) { @@ -192,6 +234,9 @@ type Function struct { TargetPackage string DocString string UseMock string + // GoBuild is the //go:build version constraint guarding the source file this + // function was declared in (e.g. "go1.26"). Empty when the function is unguarded. + GoBuild string Params Parameters AllParams Parameters Returns Parameters diff --git a/codegen/internal/scanner/buildtags.go b/codegen/internal/scanner/buildtags.go new file mode 100644 index 000000000..cf8572da4 --- /dev/null +++ b/codegen/internal/scanner/buildtags.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "go/ast" + "go/build/constraint" + "go/token" + + "golang.org/x/tools/go/packages" +) + +// buildFileConstraints maps every syntax file of a package to its //go:build expression +// (e.g. "go1.26"), or "" when the file carries no build constraint. +// +// Build constraints are file-level in Go, so this is the unit at which we detect a guard +// and replicate it across generated files. +func buildFileConstraints(pkg *packages.Package) map[*token.File]string { + constraints := make(map[*token.File]string, len(pkg.Syntax)) + + for _, astFile := range pkg.Syntax { + tokenFile := pkg.Fset.File(astFile.Pos()) + constraints[tokenFile] = fileBuildConstraint(astFile) + } + + return constraints +} + +// fileBuildConstraint returns the raw //go:build expression of a file (e.g. "go1.26"), +// or "" when the file has none. +// +// Only comment groups appearing before the package clause are considered, per the Go +// build-constraint placement rule. +func fileBuildConstraint(f *ast.File) string { + for _, group := range f.Comments { + if group.Pos() >= f.Package { + break // build constraints must precede the package clause + } + + for _, comment := range group.List { + if !constraint.IsGoBuild(comment.Text) { + continue + } + + expr, err := constraint.Parse(comment.Text) + if err != nil { + return "" // malformed constraint: treat as unguarded (the compiler will complain) + } + + return expr.String() + } + } + + return "" +} diff --git a/codegen/internal/scanner/buildtags_test.go b/codegen/internal/scanner/buildtags_test.go new file mode 100644 index 000000000..54010a688 --- /dev/null +++ b/codegen/internal/scanner/buildtags_test.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "os" + "testing" +) + +// TestBuildConstraintDetection verifies the scanner attaches the //go:build version +// constraint of the source file to each guarded function. +// +// It relies on ErrorAsType, guarded behind //go:build go1.26 (see internal/assertions/ +// error_go126.go), and the unguarded ErrorAs in the same domain. +// NOTE: this requires running codegen on a toolchain >= go1.26 (latest stable). +func TestBuildConstraintDetection(t *testing.T) { + s := New() + + pkg, err := s.Scan() + if err != nil { + t.Fatalf("scan: %v", err) + } + + var guarded, unguarded int + for _, fn := range pkg.Functions { + switch fn.Name { + case "ErrorAsType": + if fn.GoBuild != "go1.26" { + t.Errorf("ErrorAsType: expected GoBuild %q, got %q", "go1.26", fn.GoBuild) + } + guarded++ + case "ErrorAs": + if fn.GoBuild != "" { + t.Errorf("ErrorAs: expected no GoBuild, got %q", fn.GoBuild) + } + unguarded++ + } + } + + if guarded == 0 { + if os.Getenv("CI") == "" { + // local tests spew error + t.Error("did not observe the guarded ErrorAsType function; is codegen running on go1.26+?") + } else { + // expected error when CI is running with GOTOOLCHAIN=local on go1.25 + t.Log("WARN: did not observe the guarded ErrorAsType function; is codegen running on go1.26+?") + } + } + if unguarded == 0 { + t.Error("did not observe the unguarded ErrorAs function") + } +} diff --git a/codegen/internal/scanner/scanner.go b/codegen/internal/scanner/scanner.go index 21baf11a1..932635aee 100644 --- a/codegen/internal/scanner/scanner.go +++ b/codegen/internal/scanner/scanner.go @@ -31,6 +31,7 @@ type Scanner struct { typedPackage *types.Package typesInfo *types.Info filesMap map[*token.File]*ast.File + fileConstraints map[*token.File]string // per-file //go:build constraint (e.g. "go1.26") fileSet *token.FileSet importAliases map[string]string // import path -> alias name used in source sigExtractor *signature.Extractor @@ -80,6 +81,7 @@ func (s *Scanner) Scan() (*model.AssertionPackage, error) { // stash everything we need from [packages.Package] s.typesInfo = pkg.TypesInfo s.filesMap = comments.BuildFilesMap(pkg) + s.fileConstraints = buildFileConstraints(pkg) s.fileSet = pkg.Fset s.importAliases = buildImportAliases(pkg) s.sigExtractor = signature.New(s.typedPackage, s.importAliases) @@ -155,6 +157,22 @@ func (s *Scanner) resolveScope() error { return nil } +// objectConstraint returns the //go:build version constraint guarding the source file +// where object is declared (e.g. "go1.26"), or "" when it is unguarded. +func (s *Scanner) objectConstraint(object types.Object) string { + pos := object.Pos() + if !pos.IsValid() { + return "" + } + + tokenFile := s.fileSet.File(pos) + if tokenFile == nil { + return "" + } + + return s.fileConstraints[tokenFile] +} + func (s *Scanner) addImport(pkg *types.Package) { if s.result.Imports == nil { s.result.Imports = make(model.ImportMap) @@ -180,6 +198,7 @@ func (s *Scanner) addFunction(object *types.Func) { function.ID = object.Id() function.SourcePackage = object.Pkg().Path() // full package name function.TargetPackage = object.Pkg().Name() // short package name + function.GoBuild = s.objectConstraint(object) function.DocString = docComment.Text() function.Tests = parser.ParseTestExamples(docComment.Text()) diff --git a/codegen/internal/scanner/toolchain_invariant_test.go b/codegen/internal/scanner/toolchain_invariant_test.go new file mode 100644 index 000000000..245663384 --- /dev/null +++ b/codegen/internal/scanner/toolchain_invariant_test.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strconv" + "testing" +) + +// repoRootFromScanner is the path from this test package to the repository root. +const repoRootFromScanner = "../../.." + +var goMinorRx = regexp.MustCompile(`go1\.(\d+)`) + +// TestToolchainFloorCoversGuards enforces the invariant that every //go:build go1.N guard +// used in internal/assertions is covered by the go.work toolchain floor. +// +// codegen runs in workspace mode, where the go.work toolchain line selects the toolchain. +// A guard above that floor could be silently dropped (go/packages would not even load the +// file), producing incomplete generated output. Bumping the floor must therefore happen in +// lockstep with introducing a higher guard. +func TestToolchainFloorCoversGuards(t *testing.T) { + floor := workToolchainMinor(t, filepath.Join(repoRootFromScanner, "go.work")) + maxGuard := maxAssertionGuardMinor(t, filepath.Join(repoRootFromScanner, "internal", "assertions")) + + t.Logf("go.work toolchain floor: go1.%d, highest internal/assertions guard: go1.%d", floor, maxGuard) + + if maxGuard > floor { + t.Fatalf( + "internal/assertions uses //go:build go1.%d but the go.work toolchain floor is go1.%d; "+ + "bump the go.work toolchain line to at least go1.%d so codegen observes the guarded file", + maxGuard, floor, maxGuard, + ) + } +} + +// workToolchainMinor returns the minor version of the go.work toolchain floor, falling back +// to the workspace `go` directive when no explicit toolchain line is present. +func workToolchainMinor(t *testing.T, path string) int { + t.Helper() + + data, err := os.ReadFile(path) // sometimes a false positive: nolint:gosec // test reads a fixed in-repo file + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + + // Prefer the toolchain directive (e.g. "toolchain go1.26.0"); otherwise the go directive + // (e.g. "go 1.25.0") acts as the effective floor. + if m := regexp.MustCompile(`(?m)^toolchain go1\.(\d+)`).FindSubmatch(data); m != nil { + return mustAtoi(t, string(m[1])) + } + if m := regexp.MustCompile(`(?m)^go 1\.(\d+)`).FindSubmatch(data); m != nil { + return mustAtoi(t, string(m[1])) + } + + t.Fatalf("could not find a toolchain or go directive in %s", path) + + return 0 +} + +// maxAssertionGuardMinor textually scans every Go file in dir for //go:build go1.N guards +// and returns the highest minor version found (0 when none). +// +// The scan is textual (via go/parser, not go/packages) on purpose: a guard above the +// running toolchain would be excluded from a typed load, which is exactly the situation +// this invariant must detect. +func maxAssertionGuardMinor(t *testing.T, dir string) int { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir %s: %v", dir, err) + } + + fset := token.NewFileSet() + maxMinor := 0 + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || filepath.Ext(name) != ".go" { + continue + } + + file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + + constraintExpr := fileBuildConstraint(file) + if constraintExpr == "" { + continue + } + + for _, m := range goMinorRx.FindAllStringSubmatch(constraintExpr, -1) { + if minor := mustAtoi(t, m[1]); minor > maxMinor { + maxMinor = minor + } + } + } + + return maxMinor +} + +func mustAtoi(t *testing.T, s string) int { + t.Helper() + + n, err := strconv.Atoi(s) + if err != nil { + t.Fatalf("invalid integer %q: %v", s, err) + } + + return n +} diff --git a/docs/doc-site/api/_index.md b/docs/doc-site/api/_index.md index 5c167c590..a3a5cd3a1 100644 --- a/docs/doc-site/api/_index.md +++ b/docs/doc-site/api/_index.md @@ -36,7 +36,7 @@ Each domain contains assertions regrouped by their use case (e.g. http, json, er - [Comparison](./comparison.md) - Comparing Ordered Values (12) - [Condition](./condition.md) - Expressing Assertions Using Conditions (9) - [Equality](./equality.md) - Asserting Two Things Are Equal (16) -- [Error](./error.md) - Asserting Errors (8) +- [Error](./error.md) - Asserting Errors (10) - [File](./file.md) - Asserting OS Files (6) - [Http](./http.md) - Asserting HTTP Response And Body (7) - [Json](./json.md) - Asserting JSON Documents (5) diff --git a/docs/doc-site/api/error.md b/docs/doc-site/api/error.md index 4e5159b72..eae95beb3 100644 --- a/docs/doc-site/api/error.md +++ b/docs/doc-site/api/error.md @@ -11,6 +11,8 @@ keywords: - "Errorf" - "ErrorAs" - "ErrorAsf" + - "ErrorAsType" + - "ErrorAsTypef" - "ErrorContains" - "ErrorContainsf" - "ErrorIs" @@ -19,6 +21,8 @@ keywords: - "NoErrorf" - "NotErrorAs" - "NotErrorAsf" + - "NotErrorAsType" + - "NotErrorAsTypef" - "NotErrorIs" - "NotErrorIsf" --- @@ -32,16 +36,20 @@ Asserting Errors _All links point to _ -This domain exposes 8 functionalities. +This domain exposes 10 functionalities. +Generic assertions are marked with a {{% icon icon="star" color=orange %}}. +Assertions requiring a newer Go toolchain are marked with a version badge, e.g. {{% goversion "go1.26" %}} (the assertion is unavailable on older toolchains). ```tree - [EqualError](#equalerror) | angles-right - [Error](#error) | angles-right - [ErrorAs](#erroras) | angles-right +- [ErrorAsType[E error]](#errorastypee-error) (go1.26+) | star | orange - [ErrorContains](#errorcontains) | angles-right - [ErrorIs](#erroris) | angles-right - [NoError](#noerror) | angles-right - [NotErrorAs](#noterroras) | angles-right +- [NotErrorAsType[E error]](#noterrorastypee-error) (go1.26+) | star | orange - [NotErrorIs](#noterroris) | angles-right ``` @@ -397,6 +405,101 @@ func (d *dummyError) Error() string { {{% /tab %}} {{< /tabs >}} +### ErrorAsType[E error] {{% icon icon="star" color=orange %}} {{% goversion "go1.26" %}}{#errorastypee-error} +ErrorAsType asserts that at least one of the errors in err's chain is of type E. + +It is the type-safe counterpart of [ErrorAs](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#ErrorAs), built on the go1.26 [errors.AsType](https://pkg.go.dev/errors#AsType): +the expected type is the type parameter E (checked at compile time, no reflection), +rather than the untyped any target used by [ErrorAs](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#ErrorAs). + +target receives the matched error when the assertion succeeds. It may be nil, for +callers that only want to know whether the chain holds an error of type E: in that +case E cannot be inferred and must be supplied explicitly. + +This assertion requires go1.26 or newer; it is unavailable on older toolchains. + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + // capture the matched error (E is inferred from target): + var target *MyError + assertions.ErrorAsType(t, err, &target) + // only check, discarding the value (E given explicitly): + assertions.ErrorAsType[*MyError](t, err, nil) + success: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) + failure: ErrTest, new(*dummyError) +``` +{{< /tab >}} +{{% tab title="Testable Examples (assert)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestErrorAsType(t *testing.T) +t := new(testing.T) +success := assert.ErrorAsType(t, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) +fmt.Printf("success: %t\n", success) +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{% tab title="Testable Examples (require)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestErrorAsType(t *testing.T) +t := new(testing.T) +require.ErrorAsType(t, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) +fmt.Println("passed") +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.ErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#ErrorAsType) | package-level function | +| [`assert.ErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#ErrorAsTypef) | formatted variant | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.ErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#ErrorAsType) | package-level function | +| [`require.ErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#ErrorAsTypef) | formatted variant | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`assertions.ErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#ErrorAsType) | internal implementation | + +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#ErrorAsType](https://github.com/go-openapi/testify/blob/master/internal/assertions/error_go126.go#L39) +{{% /tab %}} +{{< /tabs >}} + ### ErrorContains{#errorcontains} ErrorContains asserts that a function returned a non-nil error (i.e. an error) and that the error contains the specified substring. @@ -865,6 +968,97 @@ func (d *dummyError) Error() string { {{% /tab %}} {{< /tabs >}} +### NotErrorAsType[E error] {{% icon icon="star" color=orange %}} {{% goversion "go1.26" %}}{#noterrorastypee-error} +NotErrorAsType asserts that none of the errors in err's chain is of type E. + +It is the type-safe counterpart of [NotErrorAs](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NotErrorAs), built on the go1.26 [errors.AsType](https://pkg.go.dev/errors#AsType). + +target is only used to infer the type parameter E and is never assigned; it may be nil, +in which case E must be supplied explicitly. + +This assertion requires go1.26 or newer; it is unavailable on older toolchains. + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + var target *MyError + assertions.NotErrorAsType(t, err, &target) + // or, supplying E explicitly: + assertions.NotErrorAsType[*MyError](t, err, nil) + success: ErrTest, new(*dummyError) + failure: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +``` +{{< /tab >}} +{{% tab title="Testable Examples (assert)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestNotErrorAsType(t *testing.T) +t := new(testing.T) +success := assert.NotErrorAsType(t, assert.ErrTest, new(*dummyError)) +fmt.Printf("success: %t\n", success) +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{% tab title="Testable Examples (require)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestNotErrorAsType(t *testing.T) +t := new(testing.T) +require.NotErrorAsType(t, require.ErrTest, new(*dummyError)) +fmt.Println("passed") +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.NotErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NotErrorAsType) | package-level function | +| [`assert.NotErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NotErrorAsTypef) | formatted variant | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.NotErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#NotErrorAsType) | package-level function | +| [`require.NotErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#NotErrorAsTypef) | formatted variant | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`assertions.NotErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NotErrorAsType) | internal implementation | + +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NotErrorAsType](https://github.com/go-openapi/testify/blob/master/internal/assertions/error_go126.go#L87) +{{% /tab %}} +{{< /tabs >}} + ### NotErrorIs{#noterroris} NotErrorIs asserts that none of the errors in err's chain matches target. diff --git a/docs/doc-site/api/metrics.md b/docs/doc-site/api/metrics.md index 7e01cdb18..8eca277d7 100644 --- a/docs/doc-site/api/metrics.md +++ b/docs/doc-site/api/metrics.md @@ -15,14 +15,14 @@ Counts for core functionality, and generated variants (formatted, forward, forwa | Kind | Count | Note | | ------------------------- | ----------------- | ---- | -| All core functions | 141 | Maintained core | -| All core assertions | 137 | Usage with `*testing.T` | -| Generic assertions | 53 | Type-safe assertions ("T" suffix) | +| All core functions | 143 | Maintained core | +| All core assertions | 139 | Usage with `*testing.T` | +| Generic assertions | 55 | Type-safe assertions ("T" suffix) | | Helpers (not assertions) | 4 | General-purpose utilities, not assertions | | Others | 0 | | -| assert/require variants | 442 | Generated variants | -| Total assertions variants | 884 | Available assertions API | -| Total API surface | 894 | | +| assert/require variants | 446 | Generated variants | +| Total assertions variants | 892 | Available assertions API | +| Total API surface | 902 | | ## Quick index @@ -47,6 +47,7 @@ Table of core assertions, excluding variants. Each function is side by side with | [EqualValues](equality/#equalvalues) | [NotEqualValues](equality/#notequalvalues) | equality | | | [Error](error/#error) | [NoError](error/#noerror) | error | | | [ErrorAs](error/#erroras) | [NotErrorAs](error/#noterroras) | error | | +| [ErrorAsType[E error]](error/#errorastypee-error) {{% icon icon="star" color=orange %}} {{% goversion "go1.26" %}} | [NotErrorAsType](error/#noterrorastypee-error) | error | | | [ErrorContains](error/#errorcontains) | | error | | | [ErrorIs](error/#erroris) | [NotErrorIs](error/#noterroris) | error | | | [EventuallyWith[C CollectibleConditioner]](condition/#eventuallywithc-collectibleconditioner) {{% icon icon="star" color=orange %}} | | condition | | diff --git a/docs/doc-site/project/maintainers/ROADMAP.md b/docs/doc-site/project/maintainers/ROADMAP.md index 6e401d62d..cc1ee625e 100644 --- a/docs/doc-site/project/maintainers/ROADMAP.md +++ b/docs/doc-site/project/maintainers/ROADMAP.md @@ -43,9 +43,12 @@ timeline : redactor functons for JSON & YAML assertions : export internal tools (spew, difflib) : go1.25+ - 🔍 v2.6 (June 2026) : (tentative) - : go build guards (codegen) - : ErrorAsType (go1.26+) + ✅ v2.6 (June 2026) : go build guards (codegen) + : ErrorAsType, NotErrorAsType (go1.26+) + section Q3 2026 + 🔍 v2.7 (Sep 2026) : (tentative) + : generic assertions as forward methods (go1.27+) + : go1.27+ {{< /mermaid >}} ## Dropped enveavors @@ -89,7 +92,7 @@ We actively monitor [github.com/stretchr/testify](https://github.com/stretchr/te **Review frequency**: Quarterly (next review: April 2026) -**Processed items**: 31 upstream PRs and issues have been reviewed, with 23 implemented/merged, 4 superseded by our implementation, 2 informational, and 2 currently under consideration. +**Processed items**: 38 upstream PRs and issues have been reviewed, with 28 implemented/merged, 5 superseded by our implementation, 4 informational, and 1 currently under consideration. For a complete catalog of all upstream PRs and issues we've processed (implemented, adapted, superseded, or monitoring), see the [Upstream Tracking](../../usage/TRACKING.md). diff --git a/docs/doc-site/usage/CHANGES.md b/docs/doc-site/usage/CHANGES.md index d8f8a333a..3c0c69005 100644 --- a/docs/doc-site/usage/CHANGES.md +++ b/docs/doc-site/usage/CHANGES.md @@ -293,7 +293,22 @@ See also a quick [migration guide](./MIGRATION.md). ### Error -**New functions**: None +{{% expand title="Generics" %}} + +#### New Generic Functions (2) + +| Function | Type Parameters | Origin | Description | +|----------|-----------------|--------|-------------| +| `ErrorAsType[E error]` | Expected error type | [#1860] | Type-safe `ErrorAs` built on go1.26 `errors.AsType`; writes the matched error to a typed `*E` target (pass `nil` to only check) | +| `NotErrorAsType[E error]` | Expected error type | [#1860] | Negative type-safe error-type check | + +**Origin**: [#1860] — adapted. We keep the `bool`-returning assertion convention and pass a typed `*E` target (the type-safe counterpart of `ErrorAs`'s untyped `any`), rather than the upstream `(E, bool)` return shape. + +**Note**: these assertions are guarded by `//go:build go1.26` and are only available when building with Go 1.26 or newer. On older toolchains they are transparently absent — the library still targets go1.25 oldstable. This is the first use of the codegen's go-version guard mechanism (see [ROADMAP](../project/maintainers/ROADMAP.md)). + +[#1860]: https://github.com/stretchr/testify/issues/1860 + +{{% /expand %}} **Behavior changes**: None diff --git a/docs/doc-site/usage/TRACKING.md b/docs/doc-site/usage/TRACKING.md index 16e178dfc..4ea1ab5ef 100644 --- a/docs/doc-site/usage/TRACKING.md +++ b/docs/doc-site/usage/TRACKING.md @@ -21,6 +21,7 @@ We continue to monitor and selectively adopt changes from the upstream repositor - ✅ [#1839] - `InEpsilonSymmetric` (number equality with symmetric role) - ✅ [#1840] - JSON/YAML `Redactor` pattern (dynamic input redaction, inspired by Insta) - ✅ [#1859] - Channel assertions (`Blocked` / `NotBlocked`) +- ✅ [#1860] - `ErrorAsType` / `NotErrorAsType` (go1.26+, adapted with a typed `*E` target) ### Superseded by Our Implementation - ✅ [#1801] - Error message on large collections for `Len` @@ -83,6 +84,7 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1839] | PR | Number equality with symmetric role | ✅ Adapted | | [#1840] | Issue | JSON presence check without exact values | ✅ Adapted | | [#1859] | Issue | Channel assertions | ✅ Adapted | +| [#1860] | Issue (PR [#1861]) | `ErrorAsType[E]` for Go 1.26+ | ✅ Adapted - implemented as `ErrorAsType` / `NotErrorAsType` with a typed `*E` target and a `bool` return (not the upstream `(E, bool)` shape), guarded by `//go:build go1.26`. First user of the codegen go-version guard. | [#994]: https://github.com/stretchr/testify/pull/994 [#1232]: https://github.com/stretchr/testify/pull/1232 @@ -119,7 +121,6 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Reference | Type | Summary | Status | |-----------|------|---------|--------| | [#1576] | Issue/PR | `EqualValues` assertion | 🔍 Monitoring [#1863]- Wrong equality when comparing float32 and float64| -| [#1860] | Issue+PR | `ErrorAsType[E]` for Go 1.26+ - PR: [#1861] | 🔍 Monitoring - Interesting UX syntax | ### Informational (Not Implemented) @@ -147,9 +148,9 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | Category | Count | |----------|-------| -| **Implemented/Merged** | 27 | +| **Implemented/Merged** | 28 | | **Superseded** | 5 | -| **Monitoring** | 2 | +| **Monitoring** | 1 | | **Informational** | 4 | | **Total Processed** | 38 | diff --git a/go.work b/go.work index 95c5740ea..b0d468430 100644 --- a/go.work +++ b/go.work @@ -8,3 +8,10 @@ use ( ) go 1.25.0 + +// Dev/codegen toolchain floor. Must be >= the highest //go:build go1.N guard used in +// internal/assertions so codegen (run in workspace mode) always observes guarded files. +// Enforced by TestToolchainFloorCoversGuards. This line is NOT published to consumers: +// a dependency's toolchain directive is ignored downstream — only our go.mod `go 1.25.0` +// floor reaches them, so go1.25 users still build (guarded files excluded). +toolchain go1.26.0 diff --git a/hack/doc-site/hugo/layouts/partials/custom-header.html b/hack/doc-site/hugo/layouts/partials/custom-header.html index 50cd9ae33..85c22ba6d 100644 --- a/hack/doc-site/hugo/layouts/partials/custom-header.html +++ b/hack/doc-site/hugo/layouts/partials/custom-header.html @@ -6,6 +6,21 @@ /* This allows a markdown badge to remain left-aligned */ margin: 10px !important; } +.go-version-badge { + /* Explicit "minimum Go version" pill for build-guarded assertions (e.g. go1.26). + Uses the official Go brand blue. Rendered via the goversion shortcode. */ + display: inline-block; + padding: 0.05em 0.55em; + border-radius: 0.9em; + background-color: #00ADD8; + color: #fff; + font-family: var(--CODE-font, monospace); + font-size: 0.78em; + font-weight: 600; + line-height: 1.5; + vertical-align: middle; + white-space: nowrap; +} .card-container > li:only-child { /* When a card container has a single card (e.g. one code example per tab), span the full grid width instead of being squished to 1/3. */ diff --git a/hack/doc-site/hugo/layouts/shortcodes/goversion.html b/hack/doc-site/hugo/layouts/shortcodes/goversion.html new file mode 100644 index 000000000..11d220574 --- /dev/null +++ b/hack/doc-site/hugo/layouts/shortcodes/goversion.html @@ -0,0 +1,8 @@ +{{- /* + goversion: renders an explicit "minimum Go version" pill for assertions guarded by a + //go:build constraint. Usage: {{% goversion "go1.26" %}} + The argument is the build constraint as emitted by codegen (e.g. "go1.26"). +*/ -}} +{{- $v := .Get 0 -}} +{{ $v }} +{{- /* trailing trim keeps the badge inline in headings and table cells */ -}} diff --git a/hack/doc-site/hugo/metrics.yaml b/hack/doc-site/hugo/metrics.yaml index 9bed5a14c..4ec16ab6b 100644 --- a/hack/doc-site/hugo/metrics.yaml +++ b/hack/doc-site/hugo/metrics.yaml @@ -1,9 +1,9 @@ params: metrics: domains: 19 - functions: 141 - assertions: 137 - generics: 53 + functions: 143 + assertions: 139 + generics: 55 nongeneric_assertions: 84 helpers: 4 others: 0 @@ -28,7 +28,7 @@ params: count: 16 error: name: Error - count: 8 + count: 10 file: name: File count: 6 @@ -65,6 +65,6 @@ params: yaml: name: Yaml count: 5 - package_variants: 442 - total_variants: 884 - total_functions: 894 + package_variants: 446 + total_variants: 892 + total_functions: 902 diff --git a/internal/assertions/error_go126.go b/internal/assertions/error_go126.go new file mode 100644 index 000000000..7bf48b115 --- /dev/null +++ b/internal/assertions/error_go126.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build go1.26 + +package assertions + +import ( + "errors" + "fmt" + "reflect" +) + +// ErrorAsType asserts that at least one of the errors in err's chain is of type E. +// +// It is the type-safe counterpart of [ErrorAs], built on the go1.26 [errors.AsType]: +// the expected type is the type parameter E (checked at compile time, no reflection), +// rather than the untyped any target used by [ErrorAs]. +// +// target receives the matched error when the assertion succeeds. It may be nil, for +// callers that only want to know whether the chain holds an error of type E: in that +// case E cannot be inferred and must be supplied explicitly. +// +// This assertion requires go1.26 or newer; it is unavailable on older toolchains. +// +// # Usage +// +// // capture the matched error (E is inferred from target): +// var target *MyError +// assertions.ErrorAsType(t, err, &target) +// +// // only check, discarding the value (E given explicitly): +// assertions.ErrorAsType[*MyError](t, err, nil) +// +// # Examples +// +// success: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +// failure: ErrTest, new(*dummyError) +func ErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool { + // Domain: error + // Opposite: NotErrorAsType + if h, ok := t.(H); ok { + h.Helper() + } + if found, ok := errors.AsType[E](err); ok { + if target != nil { + *target = found + } + return true + } + + expectedType := reflect.TypeFor[E]().String() + if err == nil { + return Fail(t, fmt.Sprintf("An error is expected but got nil.\n"+ + "expected: %s", expectedType), msgAndArgs...) + } + + chain := buildErrorChainString(err, true) + + return Fail(t, fmt.Sprintf("Should be in error chain:\n"+ + "expected: %s\n"+ + "in chain: %s", expectedType, truncatingFormat("%s", chain), + ), msgAndArgs...) +} + +// NotErrorAsType asserts that none of the errors in err's chain is of type E. +// +// It is the type-safe counterpart of [NotErrorAs], built on the go1.26 [errors.AsType]. +// +// target is only used to infer the type parameter E and is never assigned; it may be nil, +// in which case E must be supplied explicitly. +// +// This assertion requires go1.26 or newer; it is unavailable on older toolchains. +// +// # Usage +// +// var target *MyError +// assertions.NotErrorAsType(t, err, &target) +// +// // or, supplying E explicitly: +// assertions.NotErrorAsType[*MyError](t, err, nil) +// +// # Examples +// +// success: ErrTest, new(*dummyError) +// failure: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +func NotErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) bool { + // Domain: error + if h, ok := t.(H); ok { + h.Helper() + } + _ = target // present only so E can be inferred from a typed argument; never assigned + if _, ok := errors.AsType[E](err); !ok { + return true + } + + chain := buildErrorChainString(err, true) + + return Fail(t, fmt.Sprintf("Target error should not be in err chain:\n"+ + "found: %s\n"+ + "in chain: %s", reflect.TypeFor[E]().String(), truncatingFormat("%s", chain), + ), msgAndArgs...) +} diff --git a/internal/assertions/error_go126_test.go b/internal/assertions/error_go126_test.go new file mode 100644 index 000000000..a8e0a2d4f --- /dev/null +++ b/internal/assertions/error_go126_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build go1.26 + +package assertions + +import ( + "fmt" + "testing" +) + +// TestErrorAsTypeNilTarget covers the check-only path (nil target), which the +// example-driven generated tests cannot exercise (a nil target makes E uninferable, +// so it must be specified explicitly here). +func TestErrorAsTypeNilTarget(t *testing.T) { + t.Parallel() + + t.Run("match, nil target ignored", func(t *testing.T) { + t.Parallel() + mock := new(mockT) + + res := ErrorAsType[*customError](mock, fmt.Errorf("wrap: %w", &customError{}), nil) + shouldPassOrFail(t, mock, res, true) + }) + + t.Run("no match, nil target", func(t *testing.T) { + t.Parallel() + mock := new(mockT) + + res := ErrorAsType[*customError](mock, ErrTest, nil) + shouldPassOrFail(t, mock, res, false) + }) +} + +// TestErrorAsTypeCapturesTarget verifies the matched error is written to target. +func TestErrorAsTypeCapturesTarget(t *testing.T) { + t.Parallel() + mock := new(mockT) + + sentinel := &customError{} + var target *customError + + res := ErrorAsType(mock, fmt.Errorf("wrap: %w", sentinel), &target) + shouldPassOrFail(t, mock, res, true) + if target != sentinel { + t.Errorf("expected target to capture the wrapped error %p, got %p", sentinel, target) + } +} + +// TestNotErrorAsType covers both outcomes of the negative assertion, including the +// nil-target (check-only) form. +func TestNotErrorAsType(t *testing.T) { + t.Parallel() + + t.Run("absent type passes", func(t *testing.T) { + t.Parallel() + mock := new(mockT) + + res := NotErrorAsType[*customError](mock, ErrTest, nil) + shouldPassOrFail(t, mock, res, true) + }) + + t.Run("present type fails", func(t *testing.T) { + t.Parallel() + mock := new(mockT) + var target *customError + + res := NotErrorAsType(mock, fmt.Errorf("wrap: %w", &customError{}), &target) + shouldPassOrFail(t, mock, res, false) + }) +} diff --git a/require/require_assertions_go126.go b/require/require_assertions_go126.go new file mode 100644 index 000000000..5d3ef9b63 --- /dev/null +++ b/require/require_assertions_go126.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package require + +import ( + "github.com/go-openapi/testify/v2/internal/assertions" +) + +// ErrorAsType asserts that at least one of the errors in err's chain is of type E. +// +// It is the type-safe counterpart of [ErrorAs], built on the go1.26 [errors.AsType]: +// the expected type is the type parameter E (checked at compile time, no reflection), +// rather than the untyped any target used by [ErrorAs]. +// +// target receives the matched error when the assertion succeeds. It may be nil, for +// callers that only want to know whether the chain holds an error of type E: in that +// case E cannot be inferred and must be supplied explicitly. +// +// This assertion requires go1.26 or newer; it is unavailable on older toolchains. +// +// # Usage +// +// // capture the matched error (E is inferred from target): +// var target *MyError +// assertions.ErrorAsType(t, err, &target) +// +// // only check, discarding the value (E given explicitly): +// assertions.ErrorAsType[*MyError](t, err, nil) +// +// # Examples +// +// success: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +// failure: ErrTest, new(*dummyError) +// +// Upon failure, the test [T] is marked as failed and stops execution. +func ErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.ErrorAsType[E](t, err, target, msgAndArgs...) { + return + } + + t.FailNow() +} + +// NotErrorAsType asserts that none of the errors in err's chain is of type E. +// +// It is the type-safe counterpart of [NotErrorAs], built on the go1.26 [errors.AsType]. +// +// target is only used to infer the type parameter E and is never assigned; it may be nil, +// in which case E must be supplied explicitly. +// +// This assertion requires go1.26 or newer; it is unavailable on older toolchains. +// +// # Usage +// +// var target *MyError +// assertions.NotErrorAsType(t, err, &target) +// +// // or, supplying E explicitly: +// assertions.NotErrorAsType[*MyError](t, err, nil) +// +// # Examples +// +// success: ErrTest, new(*dummyError) +// failure: fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError) +// +// Upon failure, the test [T] is marked as failed and stops execution. +func NotErrorAsType[E error](t T, err error, target *E, msgAndArgs ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.NotErrorAsType[E](t, err, target, msgAndArgs...) { + return + } + + t.FailNow() +} diff --git a/require/require_assertions_go126_test.go b/require/require_assertions_go126_test.go new file mode 100644 index 000000000..65e2c2e79 --- /dev/null +++ b/require/require_assertions_go126_test.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package require + +import ( + "fmt" + "testing" +) + +func TestErrorAsType(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + ErrorAsType(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + ErrorAsType(mock, ErrTest, new(*dummyError)) + // require functions don't return a value + if !mock.failed { + t.Error("ErrorAsType should call FailNow()") + } + }) +} + +func TestNotErrorAsType(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + NotErrorAsType(mock, ErrTest, new(*dummyError)) + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + NotErrorAsType(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) + // require functions don't return a value + if !mock.failed { + t.Error("NotErrorAsType should call FailNow()") + } + }) +} diff --git a/require/require_examples_go126_test.go b/require/require_examples_go126_test.go new file mode 100644 index 000000000..6c0ea6740 --- /dev/null +++ b/require/require_examples_go126_test.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package require_test + +import ( + "fmt" + "testing" + + "github.com/go-openapi/testify/v2/require" +) + +func ExampleErrorAsType() { + t := new(testing.T) // should come from testing, e.g. func TestErrorAsType(t *testing.T) + require.ErrorAsType(t, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError)) + fmt.Println("passed") + + // Output: passed +} + +func ExampleNotErrorAsType() { + t := new(testing.T) // should come from testing, e.g. func TestNotErrorAsType(t *testing.T) + require.NotErrorAsType(t, require.ErrTest, new(*dummyError)) + fmt.Println("passed") + + // Output: passed +} diff --git a/require/require_format_go126.go b/require/require_format_go126.go new file mode 100644 index 000000000..60265144e --- /dev/null +++ b/require/require_format_go126.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package require + +import ( + "github.com/go-openapi/testify/v2/internal/assertions" +) + +// ErrorAsTypef is the same as [ErrorAsType], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func ErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.ErrorAsType[E](t, err, target, forwardArgs(msg, args)...) { + return + } + + t.FailNow() +} + +// NotErrorAsTypef is the same as [NotErrorAsType], but it accepts a format string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func NotErrorAsTypef[E error](t T, err error, target *E, msg string, args ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.NotErrorAsType[E](t, err, target, forwardArgs(msg, args)...) { + return + } + + t.FailNow() +} diff --git a/require/require_format_go126_test.go b/require/require_format_go126_test.go new file mode 100644 index 000000000..fc63ba32c --- /dev/null +++ b/require/require_format_go126_test.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated with github.com/go-openapi/testify/codegen/v2; DO NOT EDIT. + +//go:build go1.26 + +package require + +import ( + "fmt" + "testing" +) + +func TestErrorAsTypef(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + ErrorAsTypef(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError), "test message") + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + ErrorAsTypef(mock, ErrTest, new(*dummyError), "test message") + // require functions don't return a value + if !mock.failed { + t.Error("ErrorAsTypef should call FailNow()") + } + }) +} + +func TestNotErrorAsTypef(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + NotErrorAsTypef(mock, ErrTest, new(*dummyError), "test message") + // require functions don't return a value + }) + + t.Run("failure", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + NotErrorAsTypef(mock, fmt.Errorf("wrap: %w", &dummyError{}), new(*dummyError), "test message") + // require functions don't return a value + if !mock.failed { + t.Error("NotErrorAsTypef should call FailNow()") + } + }) +}