diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..75394df --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The test suites compare formatted Go byte for byte against goldens and fixtures, and the formatter +# emits LF. A checkout that translates line endings - which is the default on a Windows runner - makes +# every one of those comparisons fail on the line ending alone. Check text out with LF everywhere. +* text=auto eol=lf diff --git a/formatting/binding.go b/formatting/binding.go new file mode 100644 index 0000000..dbbbec9 --- /dev/null +++ b/formatting/binding.go @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "go/ast" + "strconv" + + "github.com/go-openapi/codegen/formatting/internal/std" +) + +// importKind separates the imports that bind a qualifier from the ones that do not. +type importKind int + +const ( + kindNamed importKind = iota // an ordinary import, aliased or bare + kindBlank // _ "embed": runs an init and binds nothing + kindDot // . "strings": spills names into the file scope + kindCgo // "C": carries a preamble, and nothing may touch it +) + +// binding holds one import, the name it declares, and the evidence for that name. +// +// certain is set by the three sources that state the name outright: an alias written in the source, +// the generated standard library table, and the map a caller passes to [WithResolvedImports]. +// Everything else fills candidates and leaves certain false, and [prune] may then keep the import but +// never delete it. +type binding struct { + spec *ast.ImportSpec + path string + alias string // as written, "" for a bare import + kind importKind + name string // the qualifier the import binds, when it is known + proven string // the name the package declares, from the table or the caller's map, "" otherwise + certain bool + candidates []string // the guesses, when the name is not known + pruned bool // set by prune when the import was deleted +} + +// describeImports reads every import in the file and says what is known about the name it binds. +func describeImports(file *ast.File, resolved map[string]string) []binding { + bindings := make([]binding, 0, len(file.Imports)) + + for _, spec := range file.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + + bindings = append(bindings, describeImport(spec, importPath, resolved)) + } + + return bindings +} + +func describeImport(spec *ast.ImportSpec, importPath string, resolved map[string]string) binding { + described := binding{spec: spec, path: importPath, kind: kindNamed} + + if importPath == cgoImport { + described.kind = kindCgo + + return described + } + + // what the package declares, whether or not an alias renames it here + described.proven = provenName(importPath, resolved) + + if spec.Name != nil { + described.alias = spec.Name.Name + + switch described.alias { + case "_": + described.kind = kindBlank + case ".": + described.kind = kindDot + default: + // the alias names the package here, whatever the package calls itself + described.name = described.alias + described.certain = true + } + + return described + } + + if described.proven != "" { + described.name = described.proven + described.certain = true + + return described + } + + described.candidates = importedPackageNames(importPath) + + return described +} + +// provenName returns the name the package at importPath declares, or "" when nothing states it. +// +// The caller's map is asked first, so a name supplied through [WithResolvedImports] settles a path +// the generated table also holds. The table answers for the rest of the standard library. +func provenName(importPath string, resolved map[string]string) string { + if name, ok := resolved[importPath]; ok { + return name + } + + if name, ok := std.Name(importPath); ok { + return name + } + + return "" +} + +// isUsed reports whether the file writes the name this import binds. +// +// A certain binding answers exactly. A guess answers "yes" as soon as one candidate appears, which +// keeps the import; it never answers "no" with authority, so [prune] asks [inDoubt] before deleting. +func (b binding) isUsed(used map[string]bool) bool { + if b.certain { + return used[b.name] + } + + for _, candidate := range b.candidates { + if used[candidate] { + return true + } + } + + return false +} + +// prunable reports whether this import may be deleted when the file does not use it. +// +// Without forced pruning only a certain binding qualifies. Under it the caller promises every bare +// import declares the name [ImportedPackageName] gives, so a guess counts too — and the check still +// runs over every candidate, so "k8s.io/api/apps/v1" survives in a file writing v1.Pod. +func (b binding) prunable(forced bool) bool { + if b.kind != kindNamed { + return false // a blank, dot or cgo import binds no qualifier, so nothing marks it unused + } + + return b.certain || forced +} + +// effectiveName returns the qualifier this import binds in this file. +// +// A certain binding knows it outright. A guess settles on the one candidate the file writes: an +// import offering both v1 and apps in a file writing v1.Pod binds v1. Several candidates written at +// once leave the answer open, and so does none, and both return "". +func (b binding) effectiveName(used map[string]bool) string { + if b.kind != kindNamed { + return b.alias // "_" or "." as written, and "" for cgo + } + + if b.certain { + return b.name + } + + settled := "" + + for _, candidate := range b.candidates { + if !used[candidate] { + continue + } + + if settled != "" { + return "" + } + + settled = candidate + } + + return settled +} + +// status says what became of this import, before any collision is taken into account. +func (b binding) status(used map[string]bool) ImportStatus { + switch b.kind { + case kindCgo: + return ImportCgo + case kindBlank: + return ImportBlank + case kindDot: + return ImportDot + case kindNamed: + } + + if b.pruned { + return ImportPruned + } + + if b.isUsed(used) { + return ImportUsed + } + + return ImportInDoubt +} + +// redundantAlias reports whether this import's alias repeats what the path already says. +// +// Two things have to hold. The alias must match the name the package declares, which only the +// standard library table or [WithResolvedImports] can state - dropping an alias on a guess breaks the +// build the moment the guess is wrong. And that name must be the one [ImportedPackageName] gives, so +// the bare import left behind still says what it binds. +// +// The second test is why jsoniter "github.com/json-iterator/go" keeps its alias even when the map +// proves the name. Dropping it would compile, and would throw away the only thing in the file that +// says which package that is. +func (b binding) redundantAlias() bool { + if b.kind != kindNamed || b.alias == "" || b.pruned { + return false + } + + if b.proven == "" || b.alias != b.proven { + return false + } + + return b.proven == ImportedPackageName(b.path) +} + +// simplifyAliases drops every alias that repeats the name the package declares. +func simplifyAliases(bindings []binding) { + for i := range bindings { + described := &bindings[i] + + if !described.redundantAlias() { + continue + } + + described.spec.Name = nil + described.alias = "" + } +} diff --git a/formatting/blanklines_test.go b/formatting/blanklines_test.go new file mode 100644 index 0000000..eeaa11e --- /dev/null +++ b/formatting/blanklines_test.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + + "github.com/go-openapi/codegen/formatting" +) + +// TestBlankLinesAreNotGroups pins that the source's own blank lines change nothing. +// +// gofmt and goimports sort each blank-line-separated run of imports on its own, so a template +// writing "bytes" in two groups gets both back and the file does not compile. Format sorts the +// whole block, then writes the blank lines [formatting.WithImportGroups] asks for. +func TestBlankLinesAreNotGroups(t *testing.T) { + t.Parallel() + + t.Run("should keep one import when two groups repeat a path", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "duplicate-across-groups"), + formatting.WithImportGroups("github.com/go-openapi"), + ) + + assert.Equal(t, [][]string{ + {"bytes", "context"}, + {"github.com/go-openapi/strfmt"}, + }, importBlocks(t, out)) + }) + + t.Run("should sort across the blank lines the source wrote", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "unsorted-across-groups")) + + assert.Equal(t, [][]string{{"bytes", "context", "errors", "strings"}}, importBlocks(t, out)) + }) +} diff --git a/formatting/consistency.go b/formatting/consistency.go new file mode 100644 index 0000000..a53d471 --- /dev/null +++ b/formatting/consistency.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "fmt" + "maps" + "slices" + "strconv" + "strings" +) + +// checkImports reports the imports a generator wrote inconsistently, and marks the doubtful ones. +// +// Two shapes are wrong, and both come from a template importing a package a second time under another +// name: +// +// - one package under two names, as "bytes" beside b "bytes". This compiles, and the code it comes +// from reads as though b and bytes were different packages. +// - one name bound to two packages, as rand "math/rand" beside "crypto/rand" in a file writing +// rand.Read. The compiler rejects it with "rand redeclared in this block". +// +// What follows from a clash depends on the evidence. Names [Format] knows - an alias, the standard +// library table, [WithResolvedImports], or any name at all under [WithForceImportsPruning] - make it +// an error wrapping [ErrInconsistentImports], and nothing is printed. A clash between guessed names +// may not be real, since either package may declare something no rule reading the path produces, so +// those records are marked [ImportCollision] and the caller decides. +// +// One error names every mismatch, separated by "; ", so a template with three bad imports is fixed in +// one pass. +func checkImports(report *ImportsReport, forced bool) error { + namesOf := make(map[string][]string) // import path -> the names bound to it + pathsOf := make(map[string][]string) // name -> the import paths bound to it + guessed := make(map[string]bool) // names that came from a guess + + for _, record := range report.records { + if record.Name == "" || !bindsQualifier(record.Status) { + continue + } + + namesOf[record.Path] = appendNew(namesOf[record.Path], record.Name) + pathsOf[record.Name] = appendNew(pathsOf[record.Name], record.Path) + + if !record.Certain { + guessed[record.Name] = true + } + } + + var mismatches []string + clashing := make(map[string]bool) // names whose clash is only a guess + + for _, importPath := range slices.Sorted(maps.Keys(namesOf)) { + names := namesOf[importPath] + if len(names) <= 1 { + continue + } + + slices.Sort(names) + + if certainClash(names, guessed, forced) { + mismatches = append(mismatches, fmt.Sprintf( + "the package %q is imported under %d names, %s", + importPath, len(names), quoteAll(names), + )) + + continue + } + + for _, name := range names { + clashing[name] = true + } + } + + for _, name := range slices.Sorted(maps.Keys(pathsOf)) { + paths := pathsOf[name] + if len(paths) <= 1 { + continue + } + + slices.Sort(paths) + + if !guessed[name] || forced { + mismatches = append(mismatches, fmt.Sprintf( + "the name %q is bound to %d packages, %s", + name, len(paths), quoteAll(paths), + )) + + continue + } + + clashing[name] = true + } + + report.markCollisions(clashing) + + if len(mismatches) == 0 { + return nil + } + + return fmt.Errorf("%s: %w", strings.Join(mismatches, "; "), ErrInconsistentImports) +} + +// certainClash reports whether every name in a clash was stated rather than guessed. +func certainClash(names []string, guessed map[string]bool, forced bool) bool { + if forced { + return true // the caller promised the guesses are right, so a clash between them is real + } + + for _, name := range names { + if guessed[name] { + return false + } + } + + return true +} + +// bindsQualifier reports whether an import of this status declares a name the file could write. +func bindsQualifier(status ImportStatus) bool { + switch status { + case ImportUsed, ImportInDoubt, ImportCollision: + return true + case ImportPruned, ImportBlank, ImportDot, ImportCgo: + return false + default: + return false + } +} + +// markCollisions moves every record holding one of the clashing names to [ImportCollision]. +func (r *ImportsReport) markCollisions(clashing map[string]bool) { + if len(clashing) == 0 { + return + } + + for i := range r.records { + record := &r.records[i] + + if clashing[record.Name] && bindsQualifier(record.Status) { + record.Status = ImportCollision + } + } +} + +func quoteAll(values []string) string { + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, strconv.Quote(value)) + } + + return strings.Join(quoted, " and ") +} diff --git a/formatting/consistency_test.go b/formatting/consistency_test.go new file mode 100644 index 0000000..4c12769 --- /dev/null +++ b/formatting/consistency_test.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" +) + +func TestInconsistentImports(t *testing.T) { + t.Parallel() + + // each fixture names the mismatch its message has to carry + reported := map[string]string{ + "one-package-two-names": `the package "bytes" is imported under 2 names, "b" and "bytes"`, + "one-alias-two-packages": `the name "x" is bound to 2 packages, "bytes" and "strings"`, + "two-packages-same-base": `the name "rand" is bound to 2 packages, "crypto/rand" and "math/rand"`, + "alias-shadows-another-base": `the name "rand" is bound to 2 packages, "crypto/rand" and "math/rand"`, + "base-collides-with-a-longer-path": `the name "core" is bound to 2 packages, ` + + `"github.com/go-openapi/core" and "k8s.io/api/core/v1"`, + } + + for fixture, toPin := range sourceSet(t, "inconsistent") { + src := toPin + + t.Run("should reject "+caseName(fixture), func(t *testing.T) { + t.Parallel() + + var out countingWriter + _, err := formatting.Format(&out, []byte(src)) + + require.Error(t, err) + assert.ErrorIs(t, err, formatting.ErrInconsistentImports) + assert.ErrorIs(t, err, formatting.ErrFormat) + assert.Zero(t, out.writes, "nothing is printed") + + if message, pinned := reported[fixture]; pinned { + assert.Contains(t, err.Error(), message) + } + }) + } + + t.Run("should name every mismatch at once", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(source(t, "inconsistent/several-mismatches"))) + + require.Error(t, err) + assert.Contains(t, err.Error(), `the package "bytes" is imported under 2 names`) + assert.Contains(t, err.Error(), `the name "rand" is bound to 2 packages`) + assert.Contains(t, err.Error(), `the name "x" is bound to 2 packages`) + }) +} + +func TestConsistentImports(t *testing.T) { + t.Parallel() + + for fixture, toPin := range sourceSet(t, "consistent") { + src := toPin + + t.Run("should accept "+caseName(fixture), func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(src)) + require.NoError(t, err) + }) + } + + t.Run("should check what pruning left, not what the source wrote", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "consistent/collision-pruned-away")) + + assert.NotContains(t, out, "rand", "both imports go, so the collision goes with them") + }) +} diff --git a/formatting/corpus_test.go b/formatting/corpus_test.go new file mode 100644 index 0000000..72a7713 --- /dev/null +++ b/formatting/corpus_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "flag" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" +) + +// corpusDir holds one package per fixture: an .input the formatter is given and a .go golden it has +// to produce. The goldens compile, which TestCorpusCompiles checks. +const corpusDir = "testdata/corpus" + +var update = flag.Bool("update", false, "rewrite the corpus goldens") + +// corpusGroups groups the imports of every fixture. +var corpusGroups = formatting.WithImportGroups("github.com/go-openapi") + +func TestCorpus(t *testing.T) { + t.Parallel() + + for _, input := range corpusInputs(t) { + golden := strings.TrimSuffix(input, ".input") + ".go" + + t.Run("should format "+filepath.Base(filepath.Dir(input)), func(t *testing.T) { + t.Parallel() + + src, err := os.ReadFile(input) + require.NoError(t, err) + + var out bytes.Buffer + _, err = formatting.Format(&out, src, corpusGroups) + require.NoError(t, err) + + if *update { + require.NoError(t, os.WriteFile(golden, out.Bytes(), 0o600)) + + return + } + + expected, err := os.ReadFile(golden) + require.NoError(t, err, "run go test -update to write the goldens") + assert.Equal(t, string(expected), out.String()) + }) + } +} + +// TestCorpusCompiles builds the corpus module. +// +// A golden that is merely well formatted proves little: the failure worth catching is an import +// dropped although the code uses it, and only the compiler reports that. +func TestCorpusCompiles(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("builds the corpus module, which needs the module cache") + } + + build := exec.CommandContext(t.Context(), "go", "build", "./...") + build.Dir = corpusDir + // the corpus is a module on purpose and is not in go.work, so the workspace has to stand aside + build.Env = append(os.Environ(), "GOWORK=off") + + out, err := build.CombinedOutput() + require.NoError(t, err, "the corpus goldens do not compile:\n%s", out) +} + +func corpusInputs(t *testing.T) []string { + t.Helper() + + inputs, err := filepath.Glob(filepath.Join(corpusDir, "*", "*.input")) + require.NoError(t, err) + require.NotEmpty(t, inputs, "the corpus is empty") + + return inputs +} diff --git a/formatting/crlf_test.go b/formatting/crlf_test.go new file mode 100644 index 0000000..61d8c4e --- /dev/null +++ b/formatting/crlf_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "go/format" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" +) + +// TestCRLF pins what Format makes of Windows line endings. +// +// go/printer writes \n and offers no way to ask for anything else, so a whole file comes back with +// LF whatever went in. A fragment is the exception: [Format] puts back the bytes that surrounded it, +// and those keep their \r\n. go/format.Source answers the same on both, which is the point — the +// fixtures cannot check this, since .gitattributes checks them out with LF everywhere. +func TestCRLF(t *testing.T) { + t.Parallel() + + t.Run("should write a whole file with LF, as gofmt does", func(t *testing.T) { + t.Parallel() + + const src = "package p\r\n\r\nimport (\r\n\"strings\"\r\n\"bytes\"\r\n)\r\n\r\n" + + "var _ bytes.Buffer\r\nvar _ = strings.NewReader\r\n" + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(src)) + require.NoError(t, err) + + assert.NotContains(t, out.String(), "\r", "go/printer writes \\n") + + gofmted, err := format.Source([]byte(src)) + require.NoError(t, err) + assert.Equal(t, string(gofmted), out.String()) + }) + + t.Run("should restore the space that surrounded a fragment", func(t *testing.T) { + t.Parallel() + + const src = "\r\n\r\n\tx := 1\r\n\t_ = x\r\n\r\n" + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(src)) + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(out.String(), "\r\n\r\n\t"), + "the leading space comes back as written: %q", out.String()) + assert.True(t, strings.HasSuffix(out.String(), "\r\n\r\n"), "and so does the trailing space") + + gofmted, err := format.Source([]byte(src)) + require.NoError(t, err) + assert.Equal(t, string(gofmted), out.String(), + "mixed endings and all, this is what go/format produces") + }) +} diff --git a/formatting/doc.go b/formatting/doc.go new file mode 100644 index 0000000..f074de0 --- /dev/null +++ b/formatting/doc.go @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package formatting formats generated Go source. +// +// [Format] parses the source, drops the imports nothing uses, sorts and groups the rest, rejects the +// ones that contradict each other, and prints the result to an [io.Writer]: +// +// err := formatting.Format(file, rendered, +// formatting.WithImportGroups("github.com/go-openapi", baseImport), +// ) +// +// # Rendering many files +// +// A generator renders a template into a buffer and formats what it rendered. Reset one buffer and +// use it again rather than allocating one per template: Format takes a [bytes.Buffer] as its source, +// reads its bytes without copying them and leaves it untouched. +// +// var rendered bytes.Buffer +// +// for _, tpl := range templates { +// rendered.Reset() +// +// if err := tpl.Execute(&rendered, data); err != nil { +// return err +// } +// +// if err := formatting.Format(out, &rendered, groups); err != nil { +// return err +// } +// } +// +// That saves about a tenth of the memory a file costs to render and format. Passing rendered.Bytes() +// does the same thing; passing an [io.Reader] could not, which is why [Source] does not admit one. +// +// # Imports are never resolved +// +// Format removes an import no code uses. It never adds one. goimports resolves a missing import by +// searching the module cache and the build list, which makes the output depend on the machine that +// ran the generator: the same template and the same spec produce different files, and a maintainer +// cannot reproduce what a user reports. A template writes the imports its code needs, and code that +// names a package it did not import fails to compile, which is a better answer than a guess. +// +// # What pruning can know +// +// The identifier an import binds is the imported package's own package clause, and the path only says +// where to find it. "github.com/json-iterator/go" declares jsoniter, +// "github.com/prometheus/client_model/go" declares io_prometheus_client, and reading either name +// means loading the package. So Format deletes an import only when it knows the name, from one of +// three places: +// +// - an alias, which states the name in the source; +// - the standard library, held in a generated table built from "go list std"; +// - [WithResolvedImports], where the caller states the name. +// +// A bare third-party import is a guess. A guess keeps an import when one of the names it could +// declare appears as a qualifier, and never deletes one, so this survives: +// +// import "github.com/go-openapi/strfmt" // nothing writes strfmt., and the import stays +// +// Two ways to get it deleted. [WithForceImportsPruning] promises every bare import declares the name +// [ImportedPackageName] gives. [WithResolvedImports] states the awkward names instead of promising +// there are none, and a map built once serves every build. +// +// A third way costs no option at all: write the alias even where it repeats the package name. +// +// import strfmt "github.com/go-openapi/strfmt" // the binding is certain, so the import is pruned +// +// gofmt leaves that alone, and only revive's redundant-import-alias rule reports it, which is off by +// default. A generator holding either the promise or the map needs it only for the packages that +// break the convention, where the alias is not redundant and writing it is ordinary Go. +// +// [WithSimplifiedImportAliases] takes such an alias back out once the name is proven, so a template +// may write every alias for safety and hand the reader ordinary Go. It drops nothing on a guess, and +// nothing whose alias the path cannot replace. +// +// A blank import runs an init and binds no qualifier, so it is never pruned. A dot import spills its +// names into the file scope and no qualifier ever appears, so nothing about it can be checked: use +// goimports on a file that relies on them. +// +// # What Format does not check +// +// Format reads one file's syntax and nothing else. An import of another module's internal package +// formats like any other, a //go:build line is copied through untouched, and config_linux.go is +// formatted the same as any other file. The go compiler enforces those rules, and Format does not +// duplicate them. +// +// # The imports report +// +// Format returns an [ImportsReport] beside its error, holding one [ImportRecord] per import: the +// path, the name it binds, whether that name was stated or guessed, and what became of it. +// +// report, err := formatting.Format(out, rendered) +// if err != nil { +// return err +// } +// +// if report.HasImportsInDoubt() { +// log.Printf("could not name: %v", report.PathsInDoubt()) +// } +// +// A report with nothing in doubt means every import was decided, so the output holds no import the +// file does not use. Anything in doubt is a path to resolve: +// github.com/go-openapi/codegen/formatting/resolve reads the names from the packages themselves and +// returns the map [WithResolvedImports] takes. Run it once, commit the map, and every build agrees. +// +// A clash between names Format knows is an error. A clash between guessed names may not be real — +// either package may declare something the path does not show — so those imports come back as +// [ImportCollision] and neither is pruned. +// +// # Duplicate imports +// +// The blank lines a template wrote inside its import block mean nothing to Format: it sorts the +// whole block at once and keeps one spec per path. A template that writes +// +// import ( +// "bytes" +// +// "bytes" +// "context" +// ) +// +// gets "bytes" and "context" back, in one group. +// +// gofmt and goimports both answer differently. They sort each blank-line-separated run on its own +// and never move an import between runs, so both keep the second "bytes" and the file fails to +// compile with "bytes redeclared in this block". A template assembling its imports from several +// fragments hits this whenever two fragments contribute the same package. +// +// # Inconsistent imports +// +// Two imports left after pruning may still contradict each other, and Format returns +// [ErrInconsistentImports] rather than print a file the caller has to debug: +// +// - one package under two names, as "bytes" beside b "bytes". The go compiler accepts it; the code +// reads as though b and bytes were different packages. +// - one name bound to two packages, as "crypto/rand" beside "math/rand" in a file writing +// rand.Read. The go compiler rejects it. +// +// A name is compared as the file writes it: an alias is the name it declares, and a bare import binds +// whichever of its guessed names the file writes as a qualifier. So "crypto/rand" beside "math/rand" +// passes when nothing writes rand. — pruning takes both — and "github.com/go-openapi/core" beside +// "k8s.io/api/core/v1" passes when the file writes both core. and v1., because then the two bind +// different names. +// +// One error names every mismatch, so a template with three bad imports is fixed in one pass. _ and . +// bind no qualifier and are left alone, and so is an import whose package Format cannot name. +// +// # Naming an import +// +// [ImportedPackageName] returns the identifier to qualify an import path with, version elements +// dropped, so a generator can name an import it is about to write. Format settles the other question +// — which name an existing import already binds — for itself, and reports what it could not settle. +// +// # Grouping +// +// Without [WithImportGroups] the output has two groups, the standard library and everything else. +// Each prefix passed to [WithImportGroups] adds a group between them, in the order given: +// +// WithImportGroups("github.com/go-openapi", "example.com/petstore") +// +// import ( +// "context" // standard library +// +// "github.com/go-openapi/runtime" +// +// "example.com/petstore/models" +// +// "github.com/google/uuid" // everything else +// ) +// +// The prefixes travel with the call, so two goroutines may format with different grouping. +// +// A later gofmt or goimports keeps this layout. Both sort each blank-line-separated run of imports on +// its own and never move an import from one run into another, so they leave the groups where Format +// put them, and a "golangci-lint fmt" over generated code changes nothing. gci is the exception: it +// enforces one order over the whole block and regroups. +// +// # gofumpt +// +// [WithGoFumpt] applies the gofumpt rules before printing. gofumpt is an optional dependency and +// lives in its own module, so a build that does not ask for it does not pay for it. Enable it with a +// blank import: +// +// import _ "github.com/go-openapi/codegen/formatting/enable/gofumpt" +// +// Without that import, [WithGoFumpt] makes [Format] return [ErrNoGoFumpt]. +// +// # Line endings +// +// Format writes \n. [go/printer] offers no way to ask for anything else, so a source written with +// \r\n comes back with \n, exactly as gofmt rewrites it. A fragment is the one exception: the bytes +// that surrounded it are put back as they were written, so its \r\n survive around a body that uses +// \n. go/format.Source answers the same. +// +// # Fragments +// +// A source with no package clause is parsed as a declaration list, then as a statement list, the way +// [go/format.Source] does. A fragment cannot stream: Format prints it to a buffer, strips the +// wrapping and restores the surrounding white space before writing. +package formatting diff --git a/formatting/enable/gofumpt/doc.go b/formatting/enable/gofumpt/doc.go new file mode 100644 index 0000000..ee37e17 --- /dev/null +++ b/formatting/enable/gofumpt/doc.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package gofumpt enables [github.com/go-openapi/codegen/formatting.WithGoFumpt]. +// +// # Usage +// +// Blank-import this package, then pass the option: +// +// import ( +// "github.com/go-openapi/codegen/formatting" +// +// _ "github.com/go-openapi/codegen/formatting/enable/gofumpt" +// ) +// +// err := formatting.Format(w, src, formatting.WithGoFumpt()) +// +// It lives in a module of its own so that a build which does not want gofumpt does not require +// mvdan.cc/gofumpt. Without the blank import, formatting.WithGoFumpt makes Format return +// formatting.ErrNoGoFumpt. +// +// # Settings +// +// [Configure] sets what the rules do, for the whole program. Call it before formatting anything: +// +// gofumpt.Configure( +// gofumpt.WithLangVersion("go1.25"), +// gofumpt.WithModulePath("example.com/petstore"), +// gofumpt.WithExtraRules("group_params", "clothe_returns"), +// ) +// +// The extra rules are named by string rather than by field. gofumpt documents its Extra struct as a +// set that may gain and lose members, and points API users at the string form for that reason. +// +// # gofumpt and generated code +// +// The gofumpt command leaves a file carrying a "// Code generated ... DO NOT EDIT." line alone, +// unless it was named on the command line. That gate is in the command, not in the library this +// package calls, so enabling gofumpt here applies the rules to generated files. That is the point of +// the option, and it is a deliberate departure from what the tool does on its own. +package gofumpt diff --git a/formatting/enable/gofumpt/enable.go b/formatting/enable/gofumpt/enable.go new file mode 100644 index 0000000..2042ec7 --- /dev/null +++ b/formatting/enable/gofumpt/enable.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package gofumpt + +import ( + "fmt" + "go/ast" + "go/token" + "sync" + + "github.com/go-openapi/codegen/formatting/internal/rules" + fumpt "mvdan.cc/gofumpt/format" +) + +// settings holds what the registered pass applies. Configure writes it, Format reads it. +var ( + mx sync.RWMutex + settings fumpt.Options +) + +func init() { //nolint:gochecknoinits // a blank import exists to run this + rules.Register(apply) +} + +// apply runs the gofumpt rules over a parsed file. +func apply(fset *token.FileSet, file *ast.File) { + mx.RLock() + current := settings + mx.RUnlock() + + fumpt.File(fset, file, current) +} + +// Option configures the gofumpt rules. +type Option func(*fumpt.Options) error + +// Configure sets the rules for the whole program. +// +// Call it once, before formatting anything. It returns an error when an option is not one gofumpt +// knows, and leaves the previous settings in place. +func Configure(opts ...Option) error { + next := fumpt.Options{} + + for _, apply := range opts { + if err := apply(&next); err != nil { + return err + } + } + + mx.Lock() + settings = next + mx.Unlock() + + return nil +} + +// WithLangVersion sets the Go version whose rules apply, as in "go1.25". +// +// gofumpt holds back the rules that need a language newer than the code targets. Empty means +// go1, which holds back all of them. +func WithLangVersion(version string) Option { + return func(o *fumpt.Options) error { + o.LangVersion = version + + return nil + } +} + +// WithModulePath sets the module the formatted code belongs to, as in "example.com/petstore". +// +// gofumpt reads it to decide which import paths are outside the standard library when it puts the +// standard library imports first. +func WithModulePath(path string) Option { + return func(o *fumpt.Options) error { + o.ModulePath = path + + return nil + } +} + +// WithExtraRules turns on rules gofumpt leaves off, named as gofumpt names them on its command line: +// "group_params", "clothe_returns", "balance_calls". Passing "true" turns all of them on. +func WithExtraRules(rules ...string) Option { + return func(o *fumpt.Options) error { + for _, rule := range rules { + if err := o.Extra.Set(rule); err != nil { + return fmt.Errorf("unknown gofumpt rule %q: %w", rule, err) + } + } + + return nil + } +} diff --git a/formatting/enable/gofumpt/enable_test.go b/formatting/enable/gofumpt/enable_test.go new file mode 100644 index 0000000..8877159 --- /dev/null +++ b/formatting/enable/gofumpt/enable_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package gofumpt_test + +import ( + "bytes" + "embed" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" + "github.com/go-openapi/codegen/formatting/enable/gofumpt" +) + +// sources holds the Go the tests format. A fixture is a file so that it reads as the Go it is; the +// .input extension keeps gofmt away from source that is deliberately misformatted. +// +//go:embed testdata +var sources embed.FS + +func source(t *testing.T, name string) string { + t.Helper() + + content, err := sources.ReadFile("testdata/" + name + ".input") + require.NoError(t, err) + + return string(content) +} + +func TestEnable(t *testing.T) { + t.Run("should apply the rules once the package is imported", func(t *testing.T) { + require.NoError(t, gofumpt.Configure(gofumpt.WithLangVersion("go1.25"))) + + out := format(t, source(t, "generated"), formatting.WithGoFumpt()) + + assert.Contains(t, out, "x := []int{1, 2, 3}", "gofumpt tightens the composite literal") + }) + + t.Run("should apply an extra rule when asked for it by name", func(t *testing.T) { + require.NoError(t, gofumpt.Configure( + gofumpt.WithLangVersion("go1.25"), + gofumpt.WithExtraRules("group_params"), + )) + + assert.Contains(t, format(t, source(t, "generated"), formatting.WithGoFumpt()), "func F(a, b int)") + }) + + t.Run("should leave the parameters alone without the extra rule", func(t *testing.T) { + require.NoError(t, gofumpt.Configure(gofumpt.WithLangVersion("go1.25"))) + + assert.Contains(t, format(t, source(t, "generated"), formatting.WithGoFumpt()), "func F(a int, b int)") + }) + + t.Run("should format a generated file, which the gofumpt command would skip", func(t *testing.T) { + require.NoError(t, gofumpt.Configure()) + + out := format(t, source(t, "generated"), formatting.WithGoFumpt()) + + assert.Contains(t, out, "// Code generated by go-swagger; DO NOT EDIT.", "the header survives") + assert.Contains(t, out, "x := []int{1, 2, 3}", "and the rules still ran") + }) + + t.Run("should reject a rule gofumpt does not know", func(t *testing.T) { + err := gofumpt.Configure(gofumpt.WithExtraRules("no_such_rule")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no_such_rule") + }) + + t.Run("should keep the previous settings when an option fails", func(t *testing.T) { + require.NoError(t, gofumpt.Configure(gofumpt.WithExtraRules("group_params"))) + require.Error(t, gofumpt.Configure(gofumpt.WithExtraRules("no_such_rule"))) + + assert.Contains(t, format(t, source(t, "generated"), formatting.WithGoFumpt()), "func F(a, b int)") + }) + + t.Run("should still prune and group", func(t *testing.T) { + require.NoError(t, gofumpt.Configure()) + + out := format(t, source(t, "grouped"), + formatting.WithGoFumpt(), formatting.WithImportGroups("github.com/go-openapi")) + + assert.NotContains(t, out, `"strings"`) + assert.Contains(t, out, "\t\"context\"\n\n\t\"github.com/go-openapi/swag/conv\"\n") + }) +} + +func format(t *testing.T, src string, opts ...formatting.Option) string { + t.Helper() + + var out bytes.Buffer + + _, err := formatting.Format(&out, []byte(src), opts...) + require.NoError(t, err) + + return out.String() +} diff --git a/formatting/enable/gofumpt/go.mod b/formatting/enable/gofumpt/go.mod new file mode 100644 index 0000000..155bdbf --- /dev/null +++ b/formatting/enable/gofumpt/go.mod @@ -0,0 +1,15 @@ +module github.com/go-openapi/codegen/formatting/enable/gofumpt + +go 1.25.0 + +require ( + github.com/go-openapi/codegen v0.0.0 + github.com/go-openapi/testify/v2 v2.6.1 + mvdan.cc/gofumpt v0.11.0 +) + +replace github.com/go-openapi/codegen => ../../.. + +require golang.org/x/tools v0.49.0 // indirect + +replace github.com/go-openapi/codegen/mangling => ../../../mangling diff --git a/formatting/enable/gofumpt/go.sum b/formatting/enable/gofumpt/go.sum new file mode 100644 index 0000000..0264771 --- /dev/null +++ b/formatting/enable/gofumpt/go.sum @@ -0,0 +1,20 @@ +github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= +github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc= +mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo= diff --git a/formatting/enable/gofumpt/testdata/generated.input b/formatting/enable/gofumpt/testdata/generated.input new file mode 100644 index 0000000..c3b9293 --- /dev/null +++ b/formatting/enable/gofumpt/testdata/generated.input @@ -0,0 +1,15 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package p + +import ( + "fmt" + "os" +) + +func F(a int, b int) { + x := []int{ 1,2,3 } + if _, err := fmt.Println(a, b, x); err != nil { + os.Exit(1) + } +} diff --git a/formatting/enable/gofumpt/testdata/grouped.input b/formatting/enable/gofumpt/testdata/grouped.input new file mode 100644 index 0000000..567d4c1 --- /dev/null +++ b/formatting/enable/gofumpt/testdata/grouped.input @@ -0,0 +1,12 @@ +package p + +import ( + "strings" + "github.com/go-openapi/swag/conv" + "context" +) + +var ( + _ = context.TODO + _ = conv.Pointer[int] +) diff --git a/formatting/errors.go b/formatting/errors.go new file mode 100644 index 0000000..6bc2643 --- /dev/null +++ b/formatting/errors.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +// Error is a string that implements error, so a sentinel below can be a constant. +type Error string + +func (e Error) Error() string { return string(e) } + +const ( + // ErrFormat matches every error [Format] returns. + ErrFormat Error = "formatting error" + + // ErrInconsistentImports is returned when the imports left after pruning contradict one another: + // one package imported under two names, or one name bound to two packages. The message names + // every mismatch [Format] found, and nothing is printed. + ErrInconsistentImports Error = "inconsistent imports" + + // ErrNoGoFumpt is returned when [WithGoFumpt] is passed but the gofumpt rules were never + // registered. Blank-import github.com/go-openapi/codegen/formatting/enable/gofumpt. + ErrNoGoFumpt Error = "gofumpt requested but not enabled: blank-import " + + `_ "github.com/go-openapi/codegen/formatting/enable/gofumpt"` +) diff --git a/formatting/example_names_test.go b/formatting/example_names_test.go new file mode 100644 index 0000000..fb24997 --- /dev/null +++ b/formatting/example_names_test.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "fmt" + + "github.com/go-openapi/codegen/formatting" +) + +// ExampleImportedPackageName shows a generator naming the imports it is about to write. +// +// The two Kubernetes packages both declare v1, so they collide under that name. The alias carries the +// name this function picked into the file. +func ExampleImportedPackageName() { + for _, importPath := range []string{ + "context", + "k8s.io/api/apps/v1", + "k8s.io/api/core/v1", + "github.com/go-openapi/testify/v2", + } { + fmt.Printf("%s %q\n", formatting.ImportedPackageName(importPath), importPath) + } + + // Output: + // context "context" + // apps "k8s.io/api/apps/v1" + // core "k8s.io/api/core/v1" + // testify "github.com/go-openapi/testify/v2" +} diff --git a/formatting/example_test.go b/formatting/example_test.go new file mode 100644 index 0000000..2b3fe6f --- /dev/null +++ b/formatting/example_test.go @@ -0,0 +1,273 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "fmt" + "io" + "log" + "os" + + "github.com/go-openapi/codegen/formatting" +) + +// rendered stands for what a template produced: misformatted, unsorted, and importing more than the +// code uses. +const rendered = `package petstore + +import ( +"context" +"github.com/go-openapi/strfmt" +"strings" + +"github.com/go-openapi/swag/conv" +) + +func New(ctx context.Context) *strfmt.DateTime { +_ = ctx +_ = conv.Pointer(1) +return nil +} +` + +func ExampleFormat() { + if _, err := formatting.Format(os.Stdout, []byte(rendered)); err != nil { + log.Fatal(err) + } + // Output: + // package petstore + // + // import ( + // "context" + // + // "github.com/go-openapi/strfmt" + // "github.com/go-openapi/swag/conv" + // ) + // + // func New(ctx context.Context) *strfmt.DateTime { + // _ = ctx + // _ = conv.Pointer(1) + // return nil + // } +} + +func ExampleWithImportGroups() { + if _, err := formatting.Format(os.Stdout, []byte(rendered), + formatting.WithImportGroups("github.com/go-openapi/swag", "github.com/go-openapi"), + ); err != nil { + log.Fatal(err) + } + // Output: + // package petstore + // + // import ( + // "context" + // + // "github.com/go-openapi/swag/conv" + // + // "github.com/go-openapi/strfmt" + // ) + // + // func New(ctx context.Context) *strfmt.DateTime { + // _ = ctx + // _ = conv.Pointer(1) + // return nil + // } +} + +// ExampleWithForceImportsPruning shows what the promise buys, on an import nothing uses. +func ExampleWithForceImportsPruning() { + const src = `package p + +import ( + "bytes" + "github.com/go-openapi/strfmt" +) + +var _ bytes.Buffer +` + + show := func(label string, opts ...formatting.Option) { + fmt.Println(label) + + if _, err := formatting.Format(os.Stdout, []byte(src), opts...); err != nil { + log.Fatal(err) + } + } + + show("// strfmt stays: nothing states what that package declares") + show("// and goes once the caller promises it follows the convention", + formatting.WithForceImportsPruning()) + + // Output: + // // strfmt stays: nothing states what that package declares + // package p + // + // import ( + // "bytes" + // + // "github.com/go-openapi/strfmt" + // ) + // + // var _ bytes.Buffer + // // and goes once the caller promises it follows the convention + // package p + // + // import ( + // "bytes" + // ) + // + // var _ bytes.Buffer +} + +// ExampleWithResolvedImports shows the map covering a package the promise gets wrong. +// +// github.com/json-iterator/go declares jsoniter, and no rule reading the path says so. +func ExampleWithResolvedImports() { + const src = `package p + +import "github.com/json-iterator/go" + +var _ = jsoniter.Marshal +` + + show := func(label string, opts ...formatting.Option) { + fmt.Println(label) + + if _, err := formatting.Format(os.Stdout, []byte(src), opts...); err != nil { + log.Fatal(err) + } + } + + show("// the promise alone deletes an import the code uses", + formatting.WithForceImportsPruning()) + show("// naming the package keeps it, and the promise still covers the rest", + formatting.WithForceImportsPruning(), + formatting.WithResolvedImports(map[string]string{ + "github.com/json-iterator/go": "jsoniter", + }), + ) + + // Output: + // // the promise alone deletes an import the code uses + // package p + // + // var _ = jsoniter.Marshal + // // naming the package keeps it, and the promise still covers the rest + // package p + // + // import "github.com/json-iterator/go" + // + // var _ = jsoniter.Marshal +} + +// ExampleWithSimplifiedImportAliases shows an alias written for safety being taken back out. +// +// A template that aliases every import gets exact pruning with no option at all, because an alias +// states the name. This hands the reader ordinary Go once the name is proven. +func ExampleWithSimplifiedImportAliases() { + const src = `package p + +import ( + fmt "fmt" + strfmt "github.com/go-openapi/strfmt" + jsoniter "github.com/json-iterator/go" +) + +var ( + _ = fmt.Sprint + _ = strfmt.Date{} + _ = jsoniter.Marshal +) +` + + if _, err := formatting.Format(os.Stdout, []byte(src), + formatting.WithSimplifiedImportAliases(), + formatting.WithResolvedImports(map[string]string{ + "github.com/go-openapi/strfmt": "strfmt", + "github.com/json-iterator/go": "jsoniter", + }), + ); err != nil { + log.Fatal(err) + } + + // jsoniter keeps its alias although the name is proven: the path does not say jsoniter, so the + // bare import would leave nothing that does. + + // Output: + // package p + // + // import ( + // "fmt" + // + // "github.com/go-openapi/strfmt" + // jsoniter "github.com/json-iterator/go" + // ) + // + // var ( + // _ = fmt.Sprint + // _ = strfmt.Date{} + // _ = jsoniter.Marshal + // ) +} + +// ExampleImportsReport shows what Format could and could not decide. +func ExampleImportsReport() { + const src = `package p + +import ( + "bytes" + _ "embed" + "strings" + sf "github.com/go-openapi/swag" + "github.com/go-openapi/strfmt" + "github.com/json-iterator/go" +) + +var ( + _ bytes.Buffer + _ = jsoniter.Marshal +) +` + + report, err := formatting.Format(io.Discard, []byte(src)) + if err != nil { + log.Fatal(err) + } + + fmt.Println(report) + fmt.Println() + fmt.Println("in doubt:", report.PathsInDoubt()) + + // Output: + // bytes (bytes) used + // embed (_) blank + // github.com/go-openapi/strfmt (?) in doubt + // github.com/go-openapi/swag (sf) pruned + // github.com/json-iterator/go (?) in doubt + // strings (strings) pruned + // + // in doubt: [github.com/go-openapi/strfmt github.com/json-iterator/go] +} + +// ExampleImportsReport_HasImportsInDoubt shows the check worth making before trusting the output. +func ExampleImportsReport_HasImportsInDoubt() { + const settled = `package p + +import "bytes" + +var _ bytes.Buffer +` + + report, err := formatting.Format(io.Discard, []byte(settled)) + if err != nil { + log.Fatal(err) + } + + // nothing left in doubt: pruning was exact, and the output holds no import the file does not use + fmt.Println(report.HasImportsInDoubt()) + + // Output: + // false +} diff --git a/formatting/format.go b/formatting/format.go new file mode 100644 index 0000000..71a4b7a --- /dev/null +++ b/formatting/format.go @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "bytes" + "fmt" + "go/ast" + "go/printer" + "go/token" + "io" + + "github.com/go-openapi/codegen/formatting/internal/rules" +) + +// gofmtMode holds the printer mode bits gofmt uses. +// +// printer.UseSpaces and printer.TabIndent are exported. The third bit canonicalizes number literal +// prefixes and exponents — 0XFF prints as 0xFF — and go/printer defines it for go/format and gofmt +// alone, so it has no exported name. Reaching for [go/format.Node] instead is not an option: it +// treats every parenthesized import block as unsorted, re-parses the printed output and runs +// [go/ast.SortImports], which sorts by path and undoes the grouping. TestMatchesGofmt pins the bit +// by comparing our output against [go/format.Source]. +const gofmtMode = printer.UseSpaces | printer.TabIndent | 1<<30 + +// gofmtTabWidth is the width a tab is taken to have. go/format and cmd/gofmt both fix it at 8 and +// carry a comment telling each other to stay in step. +// +// Under UseSpaces and TabIndent the value never reaches the output: indentation is written with +// tabs, alignment is padded with spaces measured from an indent the aligned lines share, and the +// width assumed for that indent cancels. Printing every fixture at 8 and at 4 gives the same bytes, +// which is why TestMatchesGofmt cannot pin this the way it pins the mode. It is 8 because gofmt +// says 8. +const gofmtTabWidth = 8 + +// Source lists the types [Format] accepts as source. +// +// Both terms give up their bytes without copying them, so those are the only two. An +// [io.Reader] is deliberately absent: Format reads the source more than once — the parser retries a +// fragment as a declaration list and then as a statement list, a file whose imports may be shadowed +// is parsed a second time with scopes, and a fragment's original text is needed again at the end to +// restore the space around it — so a reader would be drained into a buffer at the door and the +// signature would promise a streaming that cannot happen. +type Source interface { + []byte | *bytes.Buffer +} + +// Format formats Go source and writes the result to w. +// +// It drops the imports nothing uses, sorts and groups the rest, and prints in gofmt style. It never +// adds an import: see the package documentation for why. The blank lines the source wrote inside its +// import block are ignored, so a path written in two groups is one import in the output. +// +// It returns [ErrInconsistentImports] when the imports left after pruning contradict each other — +// one package under two names, or one name bound to two packages. +// +// The [ImportsReport] accounts for every import: what was pruned, what stayed, and what stayed only +// because Format could not name the package. It comes back whenever the source parsed, an +// [ErrInconsistentImports] included, and is nil only when parsing failed. Ask +// [ImportsReport.HasImportsInDoubt] before trusting that pruning was exact. +// +// Passing a [bytes.Buffer] hands over its bytes and leaves it as it was: Format does not drain it, +// so a caller rendering one template after another resets it and writes the next. +// +// The source is parsed, pruned, sorted, grouped and checked before a byte is written, so a source +// that does not parse or whose imports contradict each other leaves w untouched. Once printing +// starts only w itself can fail, and a fragment is printed to a buffer and copied in one write. +func Format[T Source](w io.Writer, src T, opts ...Option) (*ImportsReport, error) { + return format(w, sourceBytes(src), opts...) +} + +// sourceBytes takes the bytes out of a [Source] without copying them. +// +// The union holds []byte rather than ~[]byte on purpose: a type switch on a named byte slice would +// match neither term, and a conversion cannot serve both terms at once. +func sourceBytes[T Source](src T) []byte { + if buffer, ok := any(src).(*bytes.Buffer); ok { + if buffer == nil { + return nil + } + + return buffer.Bytes() + } + + return any(src).([]byte) +} + +func format(w io.Writer, src []byte, opts ...Option) (*ImportsReport, error) { + o := optionsWithDefaults(opts) + + var extraRules rules.Func + if o.goFumpt { + if extraRules = rules.Registered(); extraRules == nil { + return nil, ErrNoGoFumpt + } + } + + fset, file, adjust, err := parse(src, o.resolved) + if err != nil { + return nil, fmt.Errorf("cannot parse source: %w: %w", err, ErrFormat) + } + + bindings, used := prune(fset, file, o) + + if o.simplifyAliases { + simplifyAliases(bindings) + } + + mergeImports(file) + sortImports(fset.File(file.FileStart), file, o.groups) + + report := newImportsReport(bindings, file, used) + + if err := checkImports(report, o.forcePruning); err != nil { + return report, fmt.Errorf("%w: %w", err, ErrFormat) + } + + breaks := groupBreaks(fset, file, o.groups) + + if extraRules != nil { + extraRules(fset, file) + } + + if adjust != nil { + return report, printFragment(w, fset, file, src, breaks, adjust) + } + + return report, printFile(w, fset, file, breaks) +} + +// printFile prints a whole file straight to w, one line at a time. +// parse reads the source, once if it can and twice if it must. +// +// The first parse skips the parser's scope building, which costs about a sixth of everything Format +// allocates. [needsResolution] then says whether [prune] can tell a package qualifier from a +// shadowed name without those scopes; when it cannot, the source is parsed again with them. The two +// paths answer alike, so the second parse buys correctness in the rare file rather than in every +// file. +func parse(src []byte, resolved map[string]string) (*token.FileSet, *ast.File, adjustFunc, error) { + fset := token.NewFileSet() + + file, adjust, err := parseFile(fset, src, fastMode) + if err != nil { + return nil, nil, nil, err + } + + if !needsResolution(file, resolved) { + return fset, file, adjust, nil + } + + fset = token.NewFileSet() + + file, adjust, err = parseFile(fset, src, resolvedMode) + if err != nil { + return nil, nil, nil, err + } + + return fset, file, adjust, nil +} + +func printFile(w io.Writer, fset *token.FileSet, file *ast.File, breaks []string) error { + spaced := newSpacer(w, breaks) + + if err := fprint(spaced, fset, file); err != nil { + return err + } + + if err := spaced.Flush(); err != nil { + return fmt.Errorf("cannot write formatted source: %w: %w", err, ErrFormat) + } + + return nil +} + +// printFragment prints a fragment, then puts back the white space that surrounded it. +// +// A fragment was parsed by wrapping it, and the wrapping comes off the printed bytes rather than the +// tree, so only this path holds the whole output at once. +func printFragment( + w io.Writer, + fset *token.FileSet, + file *ast.File, + src []byte, + breaks []string, + adjust adjustFunc, +) error { + var printed bytes.Buffer + + spaced := newSpacer(&printed, breaks) + if err := fprint(spaced, fset, file); err != nil { + return err + } + + if err := spaced.Flush(); err != nil { + return fmt.Errorf("cannot print fragment: %w: %w", err, ErrFormat) + } + + if _, err := w.Write(adjust(src, printed.Bytes())); err != nil { + return fmt.Errorf("cannot write formatted fragment: %w: %w", err, ErrFormat) + } + + return nil +} + +// fprint writes the tree to w in gofmt style. +func fprint(w io.Writer, fset *token.FileSet, file *ast.File) error { + config := printer.Config{Mode: gofmtMode, Tabwidth: gofmtTabWidth} + + if err := config.Fprint(w, fset, file); err != nil { + return fmt.Errorf("cannot print formatted source: %w: %w", err, ErrFormat) + } + + return nil +} diff --git a/formatting/format_lite.go b/formatting/format_lite.go deleted file mode 100644 index 433fd49..0000000 --- a/formatting/format_lite.go +++ /dev/null @@ -1,354 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package formatting - -import ( - "bytes" - "fmt" - "go/ast" - "go/parser" - "go/printer" - "go/token" - "path" - "slices" - "strconv" - "strings" - "sync" - "unicode" - "unicode/utf8" - - "golang.org/x/tools/go/ast/astutil" - "golang.org/x/tools/imports" -) - -// FormatLite is a fast, AST-based Go formatter that fixes imports (remove unused, -// add well-known ones) and normalises blank lines in import blocks before handing -// off to [golang.org/x/tools/imports.Process] for final sorting. -// -// It is less thorough than a full goimports pass but significantly faster, which -// makes it a good fit for formatting generated code. -func FormatLite(filename string, content []byte, opts ...FormatOption) ([]byte, error) { - fset, file, clean, err := parseGoOrFragment(filename, content) - if err != nil { - return nil, err - } - - removeBlankLines(fset, file) // so that goimports sorts all imports together - fixImports(fset, file) - removeUnecessaryImportParens(file) - - printConfig := &printer.Config{ - Mode: printer.UseSpaces | printer.TabIndent, - Tabwidth: DefaultIndent, - } - var buf bytes.Buffer - err = printConfig.Fprint(&buf, fset, file) - if err != nil { - return nil, err - } - - tmp := buf.Bytes() - if clean != nil { - tmp = clean(tmp) - } - - out, err := formatByImports(filename, tmp, FormatOptsWithDefault(opts)) - if err != nil { - return nil, err - } - - return out, nil -} - -const formatImport = "fmt" - -func parseGoOrFragment(filename string, content []byte) (*token.FileSet, *ast.File, func([]byte) []byte, error) { - fset, file, err := parseGo(filename, content) - if err == nil { - return fset, file, nil, nil - } - - // In case content doesn't have a package statement, we consider it may be a fragment and try to parse with package statement. - // For other cases, we give up and return the error. - if !strings.Contains(err.Error(), "expected 'package'") { - return nil, nil, nil, err - } - - content = append([]byte("package main;\n"), content...) - fset, file, err = parseGo(filename, content) - if err != nil { - return nil, nil, nil, err - } - - cleanup := func(out []byte) []byte { - out = bytes.TrimPrefix(out, []byte("package main;\n")) - return out - } - return fset, file, cleanup, nil -} - -func parseGo(ffn string, content []byte) (*token.FileSet, *ast.File, error) { - fset := token.NewFileSet() - mode := parser.ParseComments | parser.AllErrors - file, err := parser.ParseFile(fset, ffn, content, mode) - if err != nil { - return nil, nil, err - } - return fset, file, nil -} - -// fixImports -// - removes unused imports -// - adds missing imports for top-level names. -func fixImports(fset *token.FileSet, file *ast.File) { - seen := make(map[string]*ast.ImportSpec) - shouldRemove := []*ast.ImportSpec{} - usedNames := collectTopNames(file) - for _, impt := range file.Imports { - name := importPathToAssumedName(importPath(impt)) - if impt.Name != nil { - name = impt.Name.String() - } - if name == "_" || name == "." { - continue - } - - // astutil.UsesImport is not precise enough for our needs: https://github.com/golang/go/issues/30331#issuecomment-466174437 - if !usedNames[name] { - shouldRemove = append(shouldRemove, impt) - continue - } - - // latter import wins for same name. this is heuristic and might be incorrect for some cases. - if prev := seen[name]; prev != nil { - shouldRemove = append(shouldRemove, prev) - } - seen[name] = impt - } - - for name := range usedNames { - if name == "_" || name == "." { - continue - } - if _, ok := seen[name]; ok { - continue - } - if pkg, ok := autoImports[name]; ok { - if !astutil.AddImport(fset, file, pkg) { - panic("failed to add import " + pkg + " for " + name) - } - } - } - - for _, impt := range shouldRemove { - deleteImportSpec(fset, file, impt) - } -} - -func deleteImportSpec(fset *token.FileSet, file *ast.File, spec *ast.ImportSpec) { - // remove from file.Imports - i := slices.IndexFunc(file.Imports, func(i *ast.ImportSpec) bool { - return i == spec - }) - if i >= 0 { - file.Imports = slices.Delete(file.Imports, i, i+1) - } - - // remove from file.Decls - gen := importDecl(file) - if gen == nil { - return - } - i = slices.IndexFunc(gen.Specs, func(i ast.Spec) bool { - return i == spec - }) - if i < 0 { - return - } - if i > 0 && gen.Rparen.IsValid() { - impspec, ok := gen.Specs[i].(*ast.ImportSpec) - if !ok { - panic(fmt.Errorf("expected specs to be *ast.ImportSpec, but got %T instead", gen.Specs[i])) - } - line := fset.PositionFor(impspec.Path.ValuePos, false).Line - fset.File(gen.Rparen).MergeLine(line) - } - gen.Specs = slices.Delete(gen.Specs, i, i+1) -} - -func removeBlankLines(fset *token.FileSet, file *ast.File) { - gen := importDecl(file) - if gen == nil { - return - } - specs := gen.Specs - for i := 0; i+1 < len(specs); i++ { - spec, ok1 := specs[i].(*ast.ImportSpec) - nextSpec, ok2 := specs[i+1].(*ast.ImportSpec) - if !ok1 || !ok2 { - continue - } - line := fset.PositionFor(spec.Path.ValuePos, false).Line - nextLine := fset.PositionFor(nextSpec.Path.ValuePos, false).Line - if nextLine-line > 1 { - fset.File(gen.Rparen).MergeLine(line) - } - } -} - -func importDecl(file *ast.File) *ast.GenDecl { - for _, decl := range file.Decls { - gen, ok := decl.(*ast.GenDecl) - if !ok || gen.Tok != token.IMPORT { - continue - } - return gen - } - return nil -} - -func removeUnecessaryImportParens(file *ast.File) { - for _, decl := range file.Decls { - gen, ok := decl.(*ast.GenDecl) - if !ok { - break - } - if gen.Tok != token.IMPORT { - break - } - if len(gen.Specs) != 1 { - continue - } - gen.Lparen = token.NoPos - gen.Rparen = token.NoPos - } -} - -// importPath returns the unquoted import path of s, -// or "" if the path is not properly quoted. -// Taken from [golang.org/x/tools/ast/astutil](https://cs.opensource.google/go/x/tools/+/refs/tags/v0.32.0:go/ast/astutil/imports.go;l=424). -func importPath(s *ast.ImportSpec) string { - t, err := strconv.Unquote(s.Path.Value) - if err != nil { - return "" - } - return t -} - -func collectTopNames(n ast.Node) map[string]bool { - names := make(map[string]bool) - ast.Walk(visitFn(func(n ast.Node) { - s, ok := n.(*ast.SelectorExpr) - if !ok { - return - } - id, ok := s.X.(*ast.Ident) - if !ok { - return - } - if id.Obj != nil { - return - } - names[id.Name] = true - }), n) - return names -} - -type visitFn func(node ast.Node) - -func (fn visitFn) Visit(node ast.Node) ast.Visitor { - fn(node) - return fn -} - -// importPathToAssumedName returns the assumed package name of an import path. -// it is taken from [tools/internal/imports/fix.go](https://github.com/golang/tools/blob/v0.33.0/internal/imports/fix.go#L1233) -func importPathToAssumedName(importPath string) string { - base := path.Base(importPath) - if strings.HasPrefix(base, "v") { - if _, err := strconv.Atoi(base[1:]); err == nil { - dir := path.Dir(importPath) - if dir != "." { - base = path.Base(dir) - } - } - } - base = strings.TrimPrefix(base, "go-") - if i := strings.IndexFunc(base, notIdentifier); i >= 0 { - base = base[:i] - } - return base -} - -// notIdentifier reports whether ch is an invalid identifier character. -// it is taken from [tools/internal/imports/fix.go](https://github.com/golang/tools/blob/v0.33.0/internal/imports/fix.go#L1233) -func notIdentifier(ch rune) bool { - if 'a' <= ch && ch <= 'z' { - return false - } - if 'A' <= ch && ch <= 'Z' { - return false - } - if '0' <= ch && ch <= '9' { - return false - } - if ch == '_' { - return false - } - if ch < utf8.RuneSelf { - return true - } - return !unicode.IsLetter(ch) && !unicode.IsDigit(ch) -} - -var autoImports map[string]string - -func init() { - autoImports = make(map[string]string) - - stdlibs := []string{ - "bytes", - "context", - "encoding/json", - formatImport, - "io", - "mime/multipart", - "os", - "strconv", - } - - for _, pkg := range stdlibs { - autoImports[importPathToAssumedName((pkg))] = pkg - } - - goOpenAPIs := []string{ - "github.com/go-openapi/loads/fmts", - "github.com/go-openapi/runtime", - "github.com/go-openapi/runtime/client", - "github.com/go-openapi/runtime/yamlpc", - "github.com/go-openapi/strfmt", - } - for _, pkg := range goOpenAPIs { - autoImports[importPathToAssumedName((pkg))] = pkg - } -} - -// mutex for imports.LocalPrefix global variable. -var localPrefixMutex sync.RWMutex - -// formatByImports runs imports.Process to sort imports. -func formatByImports(filename string, content []byte, opts FormatOpts) ([]byte, error) { - lp := strings.Join(opts.LocalPrefixes, ",") - localPrefixMutex.RLock() - if lp == imports.LocalPrefix { - defer localPrefixMutex.RUnlock() - return imports.Process(filename, content, &opts.Options) - } - localPrefixMutex.RUnlock() - - localPrefixMutex.Lock() - defer localPrefixMutex.Unlock() - imports.LocalPrefix = lp - return imports.Process(filename, content, &opts.Options) -} diff --git a/formatting/format_lite_test.go b/formatting/format_lite_test.go deleted file mode 100644 index aa3cbcc..0000000 --- a/formatting/format_lite_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package formatting - -import ( - "strings" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestFormatLite_ValidSource(t *testing.T) { - src := []byte("package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hello\") }\n") - res, err := FormatLite("test.go", src) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "package main") - assert.StringContainsT(t, string(res), `"fmt"`) -} - -func TestFormatLite_Fragment(t *testing.T) { - // no package statement: treated as a fragment - src := []byte("func hello() { fmt.Println(\"hi\") }\n") - res, err := FormatLite("frag.go", src) - require.NoError(t, err) - - // fragment parsing succeeded: output contains the function - assert.StringContainsT(t, string(res), "hello()") -} - -func TestFormatLite_InvalidSource(t *testing.T) { - src := []byte("this is not go code at all {{{") - _, err := FormatLite("bad.go", src) - require.Error(t, err) -} - -func TestFormatLite_RemovesUnusedImports(t *testing.T) { - src := []byte(`package main - -import ( - "fmt" - "os" -) - -func main() { fmt.Println("hello") } -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - - output := string(res) - assert.StringContainsT(t, output, `"fmt"`) - assert.FalseT(t, strings.Contains(output, `"os"`)) -} - -func TestFormatLite_AddsKnownImports(t *testing.T) { - // uses "fmt" without importing it — fixImports should add it - src := []byte(`package main - -func main() { fmt.Println("hello") } -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - assert.StringContainsT(t, string(res), `"fmt"`) -} - -func TestFormatLite_RemovesBlankLinesBetweenImports(t *testing.T) { - src := []byte(`package main - -import ( - "fmt" - - "os" -) - -func main() { - fmt.Println(os.Args) -} -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - - output := string(res) - assert.StringContainsT(t, output, `"fmt"`) - assert.StringContainsT(t, output, `"os"`) -} - -func TestFormatLite_SingleImportNoParen(t *testing.T) { - src := []byte(`package main - -import ( - "fmt" -) - -func main() { fmt.Println("hi") } -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - - output := string(res) - // single import should have parens removed - assert.StringContainsT(t, output, `import "fmt"`) -} - -func TestFormatLite_DuplicateImportLastWins(t *testing.T) { - src := []byte(`package main - -import ( - "fmt" - "fmt" -) - -func main() { fmt.Println("hi") } -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - - output := string(res) - assert.StringContainsT(t, output, `"fmt"`) - // should have only one import of fmt - assert.EqualT(t, 1, strings.Count(output, `"fmt"`)) -} - -func TestFormatLite_WithFormatOptions(t *testing.T) { - src := []byte(`package main - -import "fmt" - -func main() { fmt.Println("hello") } -`) - res, err := FormatLite("test.go", src, WithFormatOnly(true)) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "package main") -} - -func TestFormatLite_AliasedImport(t *testing.T) { - // aliased import: name != assumed name from path - src := []byte(`package main - -import myalias "fmt" - -func main() { myalias.Println("hi") } -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - assert.StringContainsT(t, string(res), `myalias "fmt"`) -} - -func TestFormatLite_BlankAndDotImports(t *testing.T) { - // blank and dot imports should be preserved, not removed - src := []byte(`package main - -import ( - _ "embed" - . "fmt" -) - -func main() { Println("hi") } -`) - res, err := FormatLite("test.go", src) - require.NoError(t, err) - - output := string(res) - assert.StringContainsT(t, output, `_ "embed"`) - assert.StringContainsT(t, output, `. "fmt"`) -} - -func TestFormatLite_NonPackageParseError(t *testing.T) { - // has package statement but still invalid syntax: not a "expected 'package'" error - src := []byte("package main\n\nfunc { broken }\n") - _, err := FormatLite("bad.go", src) - require.Error(t, err) -} - -func TestFormatLite_FragmentWithImport(t *testing.T) { - // a function without package statement exercises the fragment+cleanup path - src := []byte("func greet() string { return fmt.Sprintf(\"hello %s\", \"world\") }\n") - res, err := FormatLite("frag.go", src) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "Sprintf") -} - -func TestFormatByImports_MutexPaths(t *testing.T) { - src := []byte(`package main - -import "fmt" - -func main() { fmt.Println("hello") } -`) - opts := FormatOptsWithDefault(nil) - - // first call sets the LocalPrefix - res, err := formatByImports("test.go", src, opts) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "package main") - - // second call with same prefix takes the fast path (RLock) - res, err = formatByImports("test.go", src, opts) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "package main") - - // call with different prefix takes the slow path (Lock) - opts2 := FormatOptsWithDefault([]FormatOption{WithFormatLocalPrefixes("github.com/other")}) - res, err = formatByImports("test.go", src, opts2) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "package main") -} - -func TestImportPathToAssumedName(t *testing.T) { - // simple package - assert.EqualT(t, "fmt", importPathToAssumedName("fmt")) - - // nested package - assert.EqualT(t, "runtime", importPathToAssumedName("github.com/go-openapi/runtime")) - - // versioned import: strips v2 and uses parent dir - assert.EqualT(t, "testify", importPathToAssumedName("github.com/go-openapi/testify/v2")) - - // go- prefix stripped - assert.EqualT(t, "openapi", importPathToAssumedName("github.com/go-openapi")) - - // non-identifier characters truncated - assert.EqualT(t, "pkg", importPathToAssumedName("example.com/pkg@v1.0.0")) -} - -func TestNotIdentifier(t *testing.T) { - // lowercase letter - assert.FalseT(t, notIdentifier('a')) - // uppercase letter - assert.FalseT(t, notIdentifier('Z')) - // digit - assert.FalseT(t, notIdentifier('5')) - // underscore - assert.FalseT(t, notIdentifier('_')) - // ASCII non-identifier - assert.TrueT(t, notIdentifier('@')) - assert.TrueT(t, notIdentifier('.')) - // unicode letter (beyond ASCII) - assert.FalseT(t, notIdentifier('é')) - // unicode non-letter, non-digit - assert.TrueT(t, notIdentifier('→')) -} diff --git a/formatting/format_test.go b/formatting/format_test.go new file mode 100644 index 0000000..f6d2ad9 --- /dev/null +++ b/formatting/format_test.go @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "errors" + "go/ast" + "go/format" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" +) + +func TestGrouping(t *testing.T) { + t.Parallel() + + grouped := source(t, "grouped") + + t.Run("should put the standard library first and everything else after it", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, [][]string{ + {"context"}, + {"example.com/petstore/models", "github.com/go-openapi/strfmt", "github.com/google/uuid"}, + }, importBlocks(t, format2(t, grouped))) + }) + + t.Run("should open one group per prefix, in the order given", func(t *testing.T) { + t.Parallel() + + out := format2(t, grouped, + formatting.WithImportGroups("github.com/go-openapi", "example.com/petstore"), + ) + + assert.Equal(t, [][]string{ + {"context"}, + {"github.com/go-openapi/strfmt"}, + {"example.com/petstore/models"}, + {"github.com/google/uuid"}, + }, importBlocks(t, out)) + }) + + t.Run("should claim an import for the first prefix that matches", func(t *testing.T) { + t.Parallel() + + out := format2(t, grouped, + formatting.WithImportGroups("github.com", "github.com/go-openapi"), + ) + + assert.Equal(t, [][]string{ + {"context"}, + {"github.com/go-openapi/strfmt", "github.com/google/uuid"}, + {"example.com/petstore/models"}, + }, importBlocks(t, out)) + }) + + t.Run("should ignore an empty prefix", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, + importBlocks(t, format2(t, grouped)), + importBlocks(t, format2(t, grouped, formatting.WithImportGroups(""))), + ) + }) + + t.Run("should merge separate import declarations into one", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "separate-decls")) + + assert.Equal(t, [][]string{{"bytes", "context"}}, importBlocks(t, out)) + assert.Equal(t, 1, strings.Count(out, "import")) + }) +} + +func TestFragment(t *testing.T) { + t.Parallel() + + t.Run("should format a declaration list, keeping no package clause", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "fragment-decls")) + + assert.NotContains(t, out, "package") + assert.Contains(t, out, "func F() int {") + }) + + t.Run("should format a statement list", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "fragment-stmts")) + + assert.NotContains(t, out, "package") + assert.NotContains(t, out, "func _()") + assert.Contains(t, out, "x := 1") + }) + + t.Run("should keep the package clause a main function earns", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "fragment-main")) + + assert.Contains(t, out, "package main") + }) + + t.Run("should restore the space that surrounded the fragment", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "fragment-spaced")) + + assert.True(t, strings.HasPrefix(out, "\n\n\t"), "leading blank lines and indent come back") + assert.True(t, strings.HasSuffix(out, "\n\n"), "trailing space comes back") + }) +} + +func TestFormatErrors(t *testing.T) { + t.Parallel() + + t.Run("should report a source it cannot parse", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(source(t, "broken-decl"))) + + require.Error(t, err) + assert.ErrorIs(t, err, formatting.ErrFormat) + assert.Contains(t, err.Error(), "3:9", "the parser's position survives; naming the file is the caller's job") + }) + + t.Run("should report the whole-file error, not the fragment one", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(source(t, "broken-expr"))) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "expected 'package'") + }) + + t.Run("should refuse gofumpt when the enable module is absent", func(t *testing.T) { + t.Parallel() + + _, err := formatting.Format(failingWriter{}, []byte(source(t, "empty-package")), formatting.WithGoFumpt()) + + require.Error(t, err) + assert.ErrorIs(t, err, formatting.ErrNoGoFumpt) + assert.Contains(t, err.Error(), "enable/gofumpt") + }) + + t.Run("should write nothing when the source does not parse", func(t *testing.T) { + t.Parallel() + + for _, fixture := range []string{"broken-decl", "broken-expr"} { + var out countingWriter + _, err := formatting.Format(&out, []byte(source(t, fixture))) + require.Error(t, err) + + assert.Zero(t, out.writes, "%s: the printer never started", fixture) + assert.Zero(t, out.Len(), "%s: and w is untouched", fixture) + } + }) + + t.Run("should report a writer that fails", func(t *testing.T) { + t.Parallel() + + _, err := formatting.Format(failingWriter{}, []byte(source(t, "grouped"))) + + require.Error(t, err) + assert.ErrorIs(t, err, errWriter) + }) +} + +func TestIdempotent(t *testing.T) { + t.Parallel() + + for fixture, toPin := range sourceSet(t, "idempotent") { + src := toPin + t.Run("should not change "+caseName(fixture)+" on a second pass", func(t *testing.T) { + t.Parallel() + + once := format2(t, src, formatting.WithImportGroups("github.com/go-openapi")) + twice := format2(t, once, formatting.WithImportGroups("github.com/go-openapi")) + + assert.Equal(t, once, twice) + }) + } +} + +// TestMatchesGofmt pins how Format prints. +// +// Format cannot call [go/format.Node], which re-sorts a grouped import block by path and undoes the +// grouping, so it prints with a [go/printer.Config] carrying the mode bits gofmt uses. One of those +// bits has no exported name. This test fails the day it stops meaning what it means. +func TestMatchesGofmt(t *testing.T) { + t.Parallel() + + for fixture, toPin := range sourceSet(t, "gofmt") { + src := toPin + t.Run("should print "+caseName(fixture)+" the way gofmt does", func(t *testing.T) { + t.Parallel() + + gofmted, err := format.Source([]byte(src)) + require.NoError(t, err) + + // every source here lands in one group, where our layout and gofmt's coincide + assert.Equal(t, string(gofmted), format2(t, src)) + }) + } +} + +// format2 formats src and fails the test if it cannot. +func format2(t *testing.T, src string, opts ...formatting.Option) string { + t.Helper() + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(src), opts...) + require.NoError(t, err) + + return out.String() +} + +// importBlocks lists the import paths of formatted source, one slice per blank-line-separated group. +func importBlocks(t *testing.T, src string) [][]string { + t.Helper() + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "p.go", src, parser.ParseComments) + require.NoError(t, err) + + var blocks [][]string + current := make([]string, 0, len(file.Imports)) + previousLine := 0 + + for _, spec := range file.Imports { + line := fset.Position(spec.Pos()).Line + if previousLine != 0 && line > previousLine+1 { + blocks = append(blocks, current) + current = nil + } + + current = append(current, importPathOf(spec)) + previousLine = line + } + + if len(current) > 0 { + blocks = append(blocks, current) + } + + return blocks +} + +func importPathOf(spec *ast.ImportSpec) string { + return strings.Trim(spec.Path.Value, `"`) +} + +// countingWriter records how many times the printer wrote to it. +type countingWriter struct { + bytes.Buffer + + writes int +} + +func (c *countingWriter) Write(p []byte) (int, error) { + c.writes++ + + return c.Buffer.Write(p) +} + +var errWriter = errors.New("writer refused") + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errWriter } + +func TestSource(t *testing.T) { + t.Parallel() + + src := source(t, "grouped") + + t.Run("should format a byte slice", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + _, err := formatting.Format(&out, []byte(src)) + require.NoError(t, err) + assert.Contains(t, out.String(), "package p") + }) + + t.Run("should format a buffer and leave it as it was", func(t *testing.T) { + t.Parallel() + + var rendered bytes.Buffer + rendered.WriteString(src) + + var out bytes.Buffer + _, err := formatting.Format(&out, &rendered) + require.NoError(t, err) + + assert.Equal(t, src, rendered.String(), "Format reads the buffer without draining it") + assert.Equal(t, format2(t, src), out.String(), "and formats it the same as the bytes") + }) + + t.Run("should read the buffer without copying it", func(t *testing.T) { + t.Parallel() + + var rendered bytes.Buffer + rendered.WriteString(src) + + var fromBuffer, fromBytes bytes.Buffer + _, err := formatting.Format(&fromBuffer, &rendered) + require.NoError(t, err) + + _, err = formatting.Format(&fromBytes, rendered.Bytes()) + require.NoError(t, err) + + assert.Equal(t, fromBytes.String(), fromBuffer.String()) + }) + + t.Run("should take a nil buffer for an empty source", func(t *testing.T) { + t.Parallel() + + var nothing *bytes.Buffer + + var out bytes.Buffer + _, err := formatting.Format(&out, nothing) + + require.Error(t, err, "an empty source has no package clause") + assert.ErrorIs(t, err, formatting.ErrFormat) + }) + + t.Run("should serve a buffer reset between templates", func(t *testing.T) { + t.Parallel() + + var rendered bytes.Buffer + + for _, fixture := range []string{"grouped", "separate-decls"} { + rendered.Reset() + rendered.WriteString(source(t, fixture)) + + var out bytes.Buffer + _, err := formatting.Format(&out, &rendered) + require.NoError(t, err) + assert.Equal(t, format2(t, source(t, fixture)), out.String()) + } + }) +} diff --git a/formatting/golang.go b/formatting/golang.go deleted file mode 100644 index 78d6c66..0000000 --- a/formatting/golang.go +++ /dev/null @@ -1,250 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package formatting - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path" - "path/filepath" - "regexp" - goruntime "runtime" - "sort" - "strings" - - "golang.org/x/tools/imports" -) - -var moduleRe = regexp.MustCompile(`module[ \t]+([^\s]+)`) - -// GolangOpts returns [Options] for rendering items as golang code. -func GolangOpts(extraInitialisms ...string) *Options { - opts := new(Options) - opts.ExtraInitialisms = extraInitialisms - opts.formatFunc = defaultGoFormatFunc() // this default may be overridden by [GenOpts] - opts.ImportsFunc = defaultGoImportsFunc() - opts.ArrayInitializerFunc = defaultGoArrayInitializerFunc() - opts.BaseImportFunc = defaultGoBaseImportFunc() - - opts.Init() - - return opts -} - -func defaultGoFormatFunc() FormatterFunc { - return func(ffn string, content []byte, fmtOpts ...FormatOption) ([]byte, error) { - o := FormatOptsWithDefault(fmtOpts) - imports.LocalPrefix = strings.Join(o.LocalPrefixes, ",") // regroup these packages - return imports.Process(ffn, content, &o.Options) - } -} - -func defaultGoImportsFunc() func(map[string]string) string { - return func(imports map[string]string) string { - if len(imports) == 0 { - return "" - } - result := make([]string, 0, len(imports)) - for k, v := range imports { - _, name := path.Split(v) - if name != k { - result = append(result, fmt.Sprintf("\t%s %q", k, v)) - } else { - result = append(result, fmt.Sprintf("\t%q", v)) - } - } - sort.Strings(result) - return strings.Join(result, "\n") - } -} - -func defaultGoArrayInitializerFunc() func(any) (string, error) { - return func(data any) (string, error) { - b, err := json.Marshal(data) - if err != nil { - return "", err - } - return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(string(b), "}", ",}"), "[", "{"), "]", ",}"), "{,}", "{}"), nil - } -} - -func defaultGoBaseImportFunc() MangleFunc { - return func(target string) string { - base, err := defaultGoBaseImportErr(target) - if err != nil { - // NOTE: historically this called log.Fatalln. We now panic to avoid - // pulling in generator-specific logging, while preserving the "fail hard" semantics. - panic(fmt.Sprintf("base import resolution failed: %v", err)) - } - - return base - } -} - -// DefaultGoBaseImportErr resolves the Go import path for the given target directory. -func DefaultGoBaseImportErr(target string) (string, error) { - return defaultGoBaseImportErr(target) -} - -func defaultGoBaseImportErr(target string) (string, error) { - target = filepath.Clean(target) - if target == "" { - target = "." - } - - targetAbsPath, err := filepath.Abs(target) - if err != nil { - return "", fmt.Errorf("could not evaluate base import path with target %q: %w", target, err) - } - - targetAbsPathExtended, err := filepath.EvalSymlinks(targetAbsPath) - if err != nil { - return "", fmt.Errorf("could not evaluate base import path with target %q (with symlink resolution): %w", targetAbsPath, err) - } - - gopath := os.Getenv("GOPATH") - if gopath == "" { - homeDir, herr := os.UserHomeDir() - if herr != nil { - return "", fmt.Errorf("could not evaluate home dir for current user: %w", herr) - } - - gopath = filepath.Join(homeDir, "go") - } - - pth, err := exploreGoPath(gopath, targetAbsPath, targetAbsPathExtended) - if err != nil { - return "", err - } - - mod, goModuleAbsPath, err := tryResolveModule(targetAbsPath) - switch { - case err != nil: - return "", fmt.Errorf("failed to resolve module using go.mod file: %w", err) - case mod != "": - relTgt := relPathToRelGoPath(goModuleAbsPath, targetAbsPath) - if !strings.HasSuffix(mod, relTgt) { - return filepath.ToSlash(mod + relTgt), nil - } - - return filepath.ToSlash(mod), nil - } - - if pth == "" { - return "", errors.New("target must reside inside a location within $GOPATH/src or be a module") - } - - return filepath.ToSlash(pth), nil -} - -func exploreGoPath(gopath, targetAbsPath, targetAbsPathExtended string) (pth string, err error) { - for _, gp := range filepath.SplitList(gopath) { - _, err := os.Stat(filepath.Join(gp, "src")) //nolint:gosec // GOPATH traversal is expected - if err != nil { - if os.IsNotExist(err) { - continue - } - - return "", err - } - - gopathExtended, err := filepath.EvalSymlinks(gp) - if err != nil { - return "", err - } - - gopathExtended = filepath.Join(gopathExtended, "src") - gp = filepath.Join(gp, "src") - - if ok, relativepath := CheckPrefixAndFetchRelativePath(targetAbsPath, gp); ok { - pth = relativepath - break - } - - if ok, relativepath := CheckPrefixAndFetchRelativePath(targetAbsPath, gopathExtended); ok { - pth = relativepath - break - } - - if ok, relativepath := CheckPrefixAndFetchRelativePath(targetAbsPathExtended, gopathExtended); ok { - pth = relativepath - break - } - } - - return pth, nil -} - -func resolveGoModFile(dir string) (*os.File, string, error) { - goModPath := filepath.Join(dir, "go.mod") - f, err := os.Open(goModPath) - if err != nil { - if os.IsNotExist(err) && dir != filepath.Dir(dir) { - return resolveGoModFile(filepath.Dir(dir)) - } - - return nil, "", err - } - - return f, dir, nil -} - -func relPathToRelGoPath(modAbsPath, absPath string) string { - if absPath == "." { - return "" - } - - path := strings.TrimPrefix(absPath, modAbsPath) - pathItems := strings.Split(path, string(filepath.Separator)) - return strings.Join(pathItems, "/") -} - -func tryResolveModule(baseTargetPath string) (string, string, error) { - f, goModAbsPath, err := resolveGoModFile(baseTargetPath) - switch { - case os.IsNotExist(err): - return "", "", nil - case err != nil: - return "", "", err - } - defer func() { - _ = f.Close() - }() - - src, err := io.ReadAll(f) - if err != nil { - return "", "", err - } - - match := moduleRe.FindSubmatch(src) - const matchSubExpression = 2 - if len(match) != matchSubExpression { - return "", "", nil - } - - return string(match[1]), goModAbsPath, nil -} - -// CheckPrefixAndFetchRelativePath checks if childpath is under parentpath -// and returns the relative path if so. -func CheckPrefixAndFetchRelativePath(childpath string, parentpath string) (bool, string) { - cp, pp := childpath, parentpath - if goruntime.GOOS == "windows" { - cp = strings.ToLower(cp) - pp = strings.ToLower(pp) - } - - if strings.HasPrefix(cp, pp) { - pth, err := filepath.Rel(parentpath, childpath) - if err != nil { - return false, "" - } - return true, pth - } - - return false, "" -} diff --git a/formatting/golang_test.go b/formatting/golang_test.go deleted file mode 100644 index e52d3c0..0000000 --- a/formatting/golang_test.go +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package formatting - -import ( - "strings" - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestGolang_MangleFileName(t *testing.T) { - o := &Options{} - o.Init() - res := o.MangleFileName("aFileEndingInOsNameWindows") - assert.FalseT(t, strings.HasSuffix(res, "_windows")) - assert.TrueT(t, strings.HasSuffix(res, "_windows_swagger")) - - o = GolangOpts() - res = o.MangleFileName("aFileEndingInOsNameWindows") - assert.TrueT(t, strings.HasSuffix(res, "_windows_swagger")) - res = o.MangleFileName("aFileEndingInOsNameWindowsAmd64") - assert.TrueT(t, strings.HasSuffix(res, "_windows_amd64_swagger")) - res = o.MangleFileName("aFileEndingInTest") - assert.TrueT(t, strings.HasSuffix(res, "_test_swagger")) -} - -func TestGolang_ManglePackage(t *testing.T) { - const defaultPackage = "default" - o := GolangOpts() - - for _, v := range []struct { - tested string - expectedPath string - expectedName string - }{ - {tested: "", expectedPath: defaultPackage, expectedName: defaultPackage}, - {tested: "select", expectedPath: "selectpkg", expectedName: "selectpkg"}, // a package path may use a go keyword? - {tested: "x", expectedPath: "x", expectedName: "x"}, - {tested: "a/b/c-d/e_f/g", expectedPath: "a/b/c-d/e_f/g", expectedName: "g"}, - {tested: "a/b/c-d/e_f/g-h", expectedPath: "a/b/c-d/e_f/g-h", expectedName: "h"}, - {tested: "a/b/c-d/e_f/2A", expectedPath: "a/b/c-d/e_f/2-a", expectedName: "a"}, - {tested: "a/b/c-d/e_f/#", expectedPath: "a/b/c-d/e_f/hash", expectedName: "hash"}, - {tested: "#help", expectedPath: "hash-help", expectedName: "help"}, - {tested: "vendor", expectedPath: "vendorpkg", expectedName: "vendorpkg"}, - {tested: "internal", expectedPath: "internalpkg", expectedName: "internalpkg"}, - } { - res := o.ManglePackagePath(v.tested, defaultPackage) - assert.EqualTf(t, v.expectedPath, res, "expected ManglePackagePath(%q) to yield %q but go %q", v.tested, v.expectedPath, res) - res = o.ManglePackageName(v.tested, defaultPackage) - assert.EqualTf(t, v.expectedName, res, "expected ManglePackageName(%q) to yield %q but go %q", v.tested, v.expectedName, res) - } -} - -// Go literal initializer func. -func TestGolang_SliceInitializer(t *testing.T) { - o := GolangOpts() - goSliceInitializer := o.ArrayInitializerFunc - - a0 := []any{"a", "b"} - res, err := goSliceInitializer(a0) - require.NoError(t, err) - assert.EqualT(t, `{"a","b",}`, res) - - a1 := []any{[]any{"a", "b"}, []any{"c", "d"}} - res, err = goSliceInitializer(a1) - require.NoError(t, err) - assert.EqualT(t, `{{"a","b",},{"c","d",},}`, res) - - a2 := map[string]any{"a": "y", "b": "z"} - res, err = goSliceInitializer(a2) - require.NoError(t, err) - assert.EqualT(t, `{"a":"y","b":"z",}`, res) - - _, err = goSliceInitializer(struct { - A string `json:"a"` - B func() string - }{A: "good", B: func() string { return "" }}) - require.Error(t, err) - - a3 := []any{} - res, err = goSliceInitializer(a3) - require.NoError(t, err) - assert.EqualT(t, `{}`, res) -} - -func TestGolang_Imports(t *testing.T) { - o := GolangOpts() - - // empty map: returns "" - assert.Empty(t, o.Imports(map[string]string{})) - - // unaliased import (name matches last path component) - res := o.Imports(map[string]string{formatImport: formatImport}) - assert.StringContainsT(t, res, `"`+formatImport+`"`) - - // aliased import (name differs from last path component) - res = o.Imports(map[string]string{"myalias": "github.com/example/pkg"}) - assert.StringContainsT(t, res, `myalias "github.com/example/pkg"`) -} - -func TestDefaultGoFormatFunc(t *testing.T) { - o := GolangOpts() - - src := []byte("package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hello\") }\n") - res, err := o.FormatContent("test.go", src) - require.NoError(t, err) - assert.StringContainsT(t, string(res), "package main") - assert.StringContainsT(t, string(res), `"`+formatImport+`"`) -} - -func TestRelPathToRelGoPath(t *testing.T) { - assert.EqualT(t, "", relPathToRelGoPath("/base", ".")) - assert.EqualT(t, "/sub/pkg", relPathToRelGoPath("/base", "/base/sub/pkg")) - assert.EqualT(t, "/pkg", relPathToRelGoPath("/base", "/base/pkg")) -} - -func TestCheckPrefixAndFetchRelativePath(t *testing.T) { - ok, rel := CheckPrefixAndFetchRelativePath("/home/user/go/src/mypackage", "/home/user/go/src") - assert.TrueT(t, ok) - assert.EqualT(t, "mypackage", rel) - - ok, _ = CheckPrefixAndFetchRelativePath("/other/path", "/home/user/go/src") - assert.FalseT(t, ok) -} diff --git a/formatting/internal/rules/rules.go b/formatting/internal/rules/rules.go new file mode 100644 index 0000000..14d627a --- /dev/null +++ b/formatting/internal/rules/rules.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package rules holds the formatting rules an enable module registers. +// +// A rule set that would cost a dependency does not ship with +// [github.com/go-openapi/codegen/formatting]. It lives in a module of its own under +// formatting/enable, which registers it here from an init, so a build that does not import that +// module does not require what it requires. +// +// The package is internal because registering is not something a caller does: a caller states the +// intent with a blank import, and the enable module calls [Register]. +package rules + +import ( + "go/ast" + "go/token" + "sync/atomic" +) + +// Func applies a set of formatting rules to a parsed file, in place. +type Func func(*token.FileSet, *ast.File) + +// registered is written once from an init and read on every format, so it is atomic. +var registered atomic.Pointer[Func] + +// Register makes a rule set available. Registering twice replaces the previous set, and registering +// nil removes it. +func Register(rules Func) { + if rules == nil { + registered.Store(nil) + + return + } + + registered.Store(&rules) +} + +// Registered returns the rule set, or nil when no enable module was imported. +func Registered() Func { + if rules := registered.Load(); rules != nil { + return *rules + } + + return nil +} diff --git a/formatting/internal/rules/rules_test.go b/formatting/internal/rules/rules_test.go new file mode 100644 index 0000000..8d227ab --- /dev/null +++ b/formatting/internal/rules/rules_test.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "go/ast" + "go/token" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestRegister(t *testing.T) { + t.Cleanup(func() { Register(nil) }) + + t.Run("should register nothing to begin with", func(t *testing.T) { + Register(nil) + + assert.Nil(t, Registered()) + }) + + t.Run("should return what was registered", func(t *testing.T) { + called := 0 + Register(func(*token.FileSet, *ast.File) { called++ }) + + rules := Registered() + require.NotNil(t, rules) + + rules(nil, nil) + assert.Equal(t, 1, called) + }) + + t.Run("should let a second registration replace the first", func(t *testing.T) { + first, second := 0, 0 + Register(func(*token.FileSet, *ast.File) { first++ }) + Register(func(*token.FileSet, *ast.File) { second++ }) + + Registered()(nil, nil) + + assert.Zero(t, first) + assert.Equal(t, 1, second) + }) + + t.Run("should forget a rule set when nil is registered", func(t *testing.T) { + Register(func(*token.FileSet, *ast.File) {}) + Register(nil) + + assert.Nil(t, Registered()) + }) +} diff --git a/formatting/internal/std/gen.go b/formatting/internal/std/gen.go new file mode 100644 index 0000000..c1c587e --- /dev/null +++ b/formatting/internal/std/gen.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build ignore + +// Command gen writes packages.go from "go list std". +// +// It asks once per platform and merges the answers, because the standard library is not the same +// everywhere: runtime/cgo is absent on windows and syscall/js exists only on js/wasm. Merging keeps +// the table the same whoever regenerates it, so "go generate" on a Mac does not churn the file. +// +// Run it with "go generate ./internal/std" after a Go release adds packages to the standard library. +package main + +import ( + "bytes" + "fmt" + "go/format" + "log" + "os" + "os/exec" + "runtime" + "sort" + "strings" +) + +// platforms covers the standard library between them. linux carries runtime/cgo, js/wasm carries +// syscall/js, and darwin and windows are there to catch anything either of them holds alone. +var platforms = []struct{ goos, goarch string }{ + {"linux", "amd64"}, + {"darwin", "arm64"}, + {"windows", "amd64"}, + {"js", "wasm"}, +} + +func main() { + names := make(map[string]string) + + for _, platform := range platforms { + for importPath, name := range listStd(platform.goos, platform.goarch) { + if seen, ok := names[importPath]; ok && seen != name { + log.Fatalf("%s declares %q on %s/%s and %q elsewhere", + importPath, name, platform.goos, platform.goarch, seen) + } + + names[importPath] = name + } + } + + paths := make([]string, 0, len(names)) + for importPath := range names { + paths = append(paths, importPath) + } + sort.Strings(paths) + + var buf bytes.Buffer + fmt.Fprintf(&buf, `// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by "go generate ./internal/std". DO NOT EDIT. + +package std + +// GeneratedFor names the Go release this table was read from, as in "go1.27". +// +// A later release adds packages the table does not hold, and [Name] then answers false for them, +// which leaves the formatter guessing rather than wrong. TestTableMatchesToolchain checks the table +// exactly on this release and checks only the names they share on any other. +const GeneratedFor = %q + +// names maps every importable standard library path to the name its package clause declares. +var names = map[string]string{ +`, minorVersion(runtime.Version())) + + for _, importPath := range paths { + fmt.Fprintf(&buf, "\t%q: %q,\n", importPath, names[importPath]) + } + + buf.WriteString("}\n") + + source, err := format.Source(buf.Bytes()) + if err != nil { + log.Fatalf("cannot format the generated table: %v", err) + } + + if err := os.WriteFile("packages.go", source, 0o600); err != nil { + log.Fatal(err) + } + + log.Printf("wrote packages.go with %d standard library packages", len(paths)) +} + +// minorVersion trims a Go version to its minor release: "go1.27.0" becomes "go1.27". +func minorVersion(version string) string { + parts := strings.SplitN(version, ".", 3) + if len(parts) < 2 { + return version + } + + return parts[0] + "." + parts[1] +} + +// listStd returns the importable standard library of one platform, keyed by import path. +func listStd(goos, goarch string) map[string]string { + command := exec.Command("go", "list", "-f", "{{.ImportPath}} {{.Name}}", "std") + command.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch) + + out, err := command.Output() + if err != nil { + log.Fatalf("go list std for %s/%s: %v", goos, goarch, err) + } + + names := make(map[string]string) + + for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { + importPath, name, ok := strings.Cut(line, " ") + if !ok || name == "main" { + continue + } + + // nothing outside the standard library may import these, so they would only pad the table + if strings.HasPrefix(importPath, "internal/") || strings.Contains(importPath, "/internal/") || + strings.HasPrefix(importPath, "vendor/") || strings.Contains(importPath, "/vendor/") { + continue + } + + names[importPath] = name + } + + return names +} diff --git a/formatting/internal/std/packages.go b/formatting/internal/std/packages.go new file mode 100644 index 0000000..8ddb228 --- /dev/null +++ b/formatting/internal/std/packages.go @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by "go generate ./internal/std". DO NOT EDIT. + +package std + +// GeneratedFor names the Go release this table was read from, as in "go1.27". +// +// A later release adds packages the table does not hold, and [Name] then answers false for them, +// which leaves the formatter guessing rather than wrong. TestTableMatchesToolchain checks the table +// exactly on this release and checks only the names they share on any other. +const GeneratedFor = "go1.27" + +// names maps every importable standard library path to the name its package clause declares. +var names = map[string]string{ + "archive/tar": "tar", + "archive/zip": "zip", + "bufio": "bufio", + "bytes": "bytes", + "cmp": "cmp", + "compress/bzip2": "bzip2", + "compress/flate": "flate", + "compress/gzip": "gzip", + "compress/lzw": "lzw", + "compress/zlib": "zlib", + "container/heap": "heap", + "container/list": "list", + "container/ring": "ring", + "context": "context", + "crypto": "crypto", + "crypto/aes": "aes", + "crypto/cipher": "cipher", + "crypto/des": "des", + "crypto/dsa": "dsa", + "crypto/ecdh": "ecdh", + "crypto/ecdsa": "ecdsa", + "crypto/ed25519": "ed25519", + "crypto/elliptic": "elliptic", + "crypto/fips140": "fips140", + "crypto/hkdf": "hkdf", + "crypto/hmac": "hmac", + "crypto/hpke": "hpke", + "crypto/md5": "md5", + "crypto/mldsa": "mldsa", + "crypto/mlkem": "mlkem", + "crypto/mlkem/mlkemtest": "mlkemtest", + "crypto/pbkdf2": "pbkdf2", + "crypto/rand": "rand", + "crypto/rc4": "rc4", + "crypto/rsa": "rsa", + "crypto/sha1": "sha1", + "crypto/sha256": "sha256", + "crypto/sha3": "sha3", + "crypto/sha512": "sha512", + "crypto/subtle": "subtle", + "crypto/tls": "tls", + "crypto/x509": "x509", + "crypto/x509/pkix": "pkix", + "database/sql": "sql", + "database/sql/driver": "driver", + "database/sql/internal": "internal", + "debug/buildinfo": "buildinfo", + "debug/dwarf": "dwarf", + "debug/elf": "elf", + "debug/gosym": "gosym", + "debug/macho": "macho", + "debug/pe": "pe", + "debug/plan9obj": "plan9obj", + "embed": "embed", + "encoding": "encoding", + "encoding/ascii85": "ascii85", + "encoding/asn1": "asn1", + "encoding/base32": "base32", + "encoding/base64": "base64", + "encoding/binary": "binary", + "encoding/csv": "csv", + "encoding/gob": "gob", + "encoding/hex": "hex", + "encoding/json": "json", + "encoding/json/internal": "internal", + "encoding/json/jsontext": "jsontext", + "encoding/json/v2": "json", + "encoding/pem": "pem", + "encoding/xml": "xml", + "errors": "errors", + "expvar": "expvar", + "flag": "flag", + "fmt": "fmt", + "go/ast": "ast", + "go/build": "build", + "go/build/constraint": "constraint", + "go/constant": "constant", + "go/doc": "doc", + "go/doc/comment": "comment", + "go/format": "format", + "go/importer": "importer", + "go/parser": "parser", + "go/printer": "printer", + "go/scanner": "scanner", + "go/token": "token", + "go/types": "types", + "go/version": "version", + "hash": "hash", + "hash/adler32": "adler32", + "hash/crc32": "crc32", + "hash/crc64": "crc64", + "hash/fnv": "fnv", + "hash/maphash": "maphash", + "html": "html", + "html/template": "template", + "image": "image", + "image/color": "color", + "image/color/palette": "palette", + "image/draw": "draw", + "image/gif": "gif", + "image/jpeg": "jpeg", + "image/png": "png", + "index/suffixarray": "suffixarray", + "io": "io", + "io/fs": "fs", + "io/ioutil": "ioutil", + "iter": "iter", + "log": "log", + "log/internal": "internal", + "log/slog": "slog", + "log/slog/internal": "internal", + "log/syslog": "syslog", + "maps": "maps", + "math": "math", + "math/big": "big", + "math/bits": "bits", + "math/cmplx": "cmplx", + "math/rand": "rand", + "math/rand/v2": "rand", + "mime": "mime", + "mime/multipart": "multipart", + "mime/quotedprintable": "quotedprintable", + "net": "net", + "net/http": "http", + "net/http/cgi": "cgi", + "net/http/cookiejar": "cookiejar", + "net/http/fcgi": "fcgi", + "net/http/httptest": "httptest", + "net/http/httptrace": "httptrace", + "net/http/httputil": "httputil", + "net/http/internal": "internal", + "net/http/pprof": "pprof", + "net/mail": "mail", + "net/netip": "netip", + "net/rpc": "rpc", + "net/rpc/jsonrpc": "jsonrpc", + "net/smtp": "smtp", + "net/textproto": "textproto", + "net/url": "url", + "os": "os", + "os/exec": "exec", + "os/signal": "signal", + "os/user": "user", + "path": "path", + "path/filepath": "filepath", + "plugin": "plugin", + "reflect": "reflect", + "regexp": "regexp", + "regexp/syntax": "syntax", + "runtime": "runtime", + "runtime/cgo": "cgo", + "runtime/coverage": "coverage", + "runtime/debug": "debug", + "runtime/metrics": "metrics", + "runtime/pprof": "pprof", + "runtime/race": "race", + "runtime/trace": "trace", + "slices": "slices", + "sort": "sort", + "strconv": "strconv", + "strings": "strings", + "structs": "structs", + "sync": "sync", + "sync/atomic": "atomic", + "syscall": "syscall", + "syscall/js": "js", + "testing": "testing", + "testing/cryptotest": "cryptotest", + "testing/fstest": "fstest", + "testing/iotest": "iotest", + "testing/quick": "quick", + "testing/slogtest": "slogtest", + "testing/synctest": "synctest", + "text/scanner": "scanner", + "text/tabwriter": "tabwriter", + "text/template": "template", + "text/template/parse": "parse", + "time": "time", + "time/tzdata": "tzdata", + "unicode": "unicode", + "unicode/utf16": "utf16", + "unicode/utf8": "utf8", + "unique": "unique", + "unsafe": "unsafe", + "uuid": "uuid", + "weak": "weak", +} diff --git a/formatting/internal/std/std.go b/formatting/internal/std/std.go new file mode 100644 index 0000000..01021ba --- /dev/null +++ b/formatting/internal/std/std.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package std answers which name a standard library import declares. +// +// The table in packages.go is generated from "go list std" and holds the answer outright, so nothing +// here guesses. That is what lets the formatter prune an unused standard library import: the name is +// known, not inferred. +// +// A path the table does not hold is not treated as standard library, whatever it looks like. A +// package added by a newer Go release, and a local module such as "myapp/models" whose first path +// element holds no dot, are both unknown until the table is regenerated, and unknown means the +// formatter falls back to guessing. +package std + +//go:generate go run gen.go + +// Name returns the name the standard library package at importPath declares. +// +// The second result is false when the path is not in the table. +func Name(importPath string) (string, bool) { + name, ok := names[importPath] + + return name, ok +} + +// Len returns the number of packages in the table. Tests use it to notice an empty generation. +func Len() int { return len(names) } diff --git a/formatting/internal/std/std_test.go b/formatting/internal/std/std_test.go new file mode 100644 index 0000000..cbda3bd --- /dev/null +++ b/formatting/internal/std/std_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package std_test + +import ( + "os/exec" + "runtime" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting/internal/std" +) + +func TestName(t *testing.T) { + t.Parallel() + + t.Run("should hold the standard library", func(t *testing.T) { + t.Parallel() + + assert.Greater(t, std.Len(), 150, "the generated table looks empty") + }) + + t.Run("should name a package the path does not name", func(t *testing.T) { + t.Parallel() + + name, ok := std.Name("math/rand/v2") + + require.True(t, ok) + assert.Equal(t, "rand", name, "the version element names no package") + }) + + t.Run("should not answer for a path outside the standard library", func(t *testing.T) { + t.Parallel() + + for _, importPath := range []string{ + "github.com/go-openapi/strfmt", + "myapp/models", // a local module: no dot, and not standard library either + "internal/abi", // nothing outside the standard library may import it + "math/rand/v99", // no such package + } { + _, ok := std.Name(importPath) + assert.False(t, ok, importPath) + } + }) +} + +// TestTableMatchesToolchain checks the generated table against the toolchain running the test. +// +// The table is not the same everywhere. A newer Go release adds packages, and the standard library +// differs by platform: runtime/cgo is absent on windows, syscall/js exists only on js/wasm. So the +// hard check covers correctness alone: a path both sides hold must declare the same name. A path only +// "go list std" holds means the table is behind, which leaves the formatter guessing rather than +// wrong, and is reported rather than failed. +// +// On [std.GeneratedFor], the release the table was read from, the two must agree exactly. +// +// Regenerate with "go generate ./internal/std". +func TestTableMatchesToolchain(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("shells out to go list") + } + + out, err := exec.CommandContext(t.Context(), "go", "list", "-f", "{{.ImportPath}} {{.Name}}", "std").Output() + require.NoError(t, err) + + var absent []string + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + importPath, name, ok := strings.Cut(line, " ") + if !ok || name == "main" || strings.Contains(importPath, "internal/") || strings.Contains(importPath, "vendor/") { + continue + } + + got, found := std.Name(importPath) + if !found { + absent = append(absent, importPath) + + continue + } + + assert.Equal(t, name, got, "%s: the table names it wrongly", importPath) + } + + if len(absent) == 0 { + return + } + + if runningRelease() == std.GeneratedFor { + assert.Empty(t, absent, "the table was read from %s and is missing packages it holds", std.GeneratedFor) + + return + } + + t.Logf("%d packages of %s are absent from the table, read from %s: %v", + len(absent), runningRelease(), std.GeneratedFor, absent) +} + +// runningRelease names the Go release running the test, as in "go1.27". +func runningRelease() string { + parts := strings.SplitN(runtime.Version(), ".", 3) + if len(parts) < 2 { + return runtime.Version() + } + + return parts[0] + "." + parts[1] +} diff --git a/formatting/names.go b/formatting/names.go new file mode 100644 index 0000000..e7b32e4 --- /dev/null +++ b/formatting/names.go @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "go/token" + "path" + "slices" + "strconv" + "strings" +) + +// ImportedPackageName returns the single identifier a generator should qualify importPath with. +// +// A version element is never the answer, so "k8s.io/api/apps/v1" and "k8s.io/api/core/v1" give apps +// and core instead of v1 twice. That is the point: two imports of the same API version collide under +// the name they declare and not under this one. +// +// The package at that path really does declare v1, so write the name as an alias whenever the path +// carries a version element: +// +// apps "k8s.io/api/apps/v1" +// +// It returns "" when no part of the path is a legal Go identifier, as for "example.com/2fa". +// +// Use [importedPackageNames] to ask the other question, which name an existing import is already +// bound to. The two disagree exactly where an alias is needed. +func ImportedPackageName(importPath string) string { + names := elementNames(unversioned(importPath)) + if len(names) == 0 { + return "" + } + + return names[0] +} + +// importedPackageNames lists every name the package at importPath could already declare, guessed from +// the path. +// +// [prune] keeps an import when the file writes any of these, and [checkImports] reads which name an +// import binds by matching them against the qualifiers the file writes. Both need every candidate, +// because one guess does not cover a version element: "k8s.io/api/apps/v1" declares v1, while +// "github.com/go-openapi/testify/v2" declares testify, and nothing in either path separates the two. +// +// A hyphen is the other awkward case, because no Go package name holds one. "example.com/my-pkg" +// comes back as pkg, mypkg, my, in that order, which covers 89% of the hyphenated packages in a +// module cache. The rest are past guessing: "github.com/go-critic/go-critic" declares gorules. +// +// An empty result means no candidate is a legal Go identifier, as for "example.com/2fa". It means +// "unknown", not "declares nothing", and [binding] then marks the import in doubt. +// +// This stays unexported: a caller naming an import wants [ImportedPackageName], and a caller asking +// what became of one wants [ImportsReport]. A list of guesses answers neither question on its own. +// +// golang.org/x/tools/internal/imports answers with ImportPathToAssumedName, which returns one name +// and cuts the element at the first character an identifier may not hold, so it reads +// "example.com/my-pkg" as my. That package is internal, so no caller can import it. +func importedPackageNames(importPath string) []string { + base := path.Base(importPath) + + names := elementNames(base) + if !isMajorVersion(base) { + return names + } + + // a version element is a module major version in "github.com/go-openapi/testify/v2" and a real + // directory in "k8s.io/api/apps/v1", and only the module boundary tells them apart. Offer both. + for _, name := range elementNames(unversioned(importPath)) { + names = appendNew(names, name) + } + + return names +} + +// unversioned returns the last element of importPath that does not read as a version. +// +// "go.mongodb.org/mongo-driver/internal/aws/signer/v4" is a directory named v4 and a module path +// ending in a major version at the same time, and the path does not say which. Both name signer. +func unversioned(importPath string) string { + for { + base := path.Base(importPath) + if !isMajorVersion(base) { + return base + } + + dir := path.Dir(importPath) + if dir == "." || dir == "/" || dir == importPath { + return base + } + + importPath = dir + } +} + +// elementNames lists the identifiers one path element could name, likeliest first. +func elementNames(element string) []string { + var names []string + + add := func(candidate string) { + // token.IsIdentifier accepts "_", which the compiler rejects with "invalid package name _", + // and which qualifies nothing anyway + if candidate != "_" && token.IsIdentifier(candidate) { + names = appendNew(names, candidate) + } + } + + if token.IsIdentifier(element) { + add(element) + + return names + } + + // "gopkg.in/yaml.v3" declares yaml + if dot := strings.IndexByte(element, '.'); dot >= 0 { + add(element[:dot]) + } + + addHyphenated(add, element) + + return names +} + +// addHyphenated offers the names a hyphenated path element could declare, likeliest first. +// +// A Go package name holds no hyphen, so "example.com/my-pkg" declares something else and the path +// does not say what. Measured over the 35 hyphenated package directories in a module cache, the last +// segment is right 74% of the time, dropping the hyphens 9%, and the first segment 6%; the go- and +// -go affixes account for another 46% and 6% between them. Offering all five raises the hit rate from +// 46% to 89%, with the right name first in 80% of them. +// +// The rest are past guessing: "go-critic" declares gorules and "universal-translator" declares ut. +func addHyphenated(add func(string), element string) { + // "github.com/jessevdk/go-flags" declares flags, "github.com/googleapis/gax-go" declares gax + add(strings.TrimPrefix(element, "go-")) + add(strings.TrimSuffix(element, "-go")) + + segments := strings.Split(element, "-") + + add(segments[len(segments)-1]) // "example.com/my-pkg" declares pkg + add(strings.ReplaceAll(element, "-", "")) // and mypkg is the next best guess + add(segments[0]) // "github.com/dgrijalva/jwt-go" declares jwt +} + +// isMajorVersion reports whether a path element reads as a module major version, as in v2 or v3. +func isMajorVersion(element string) bool { + if len(element) < 2 || element[0] != 'v' { + return false + } + + _, err := strconv.Atoi(element[1:]) + + return err == nil +} + +// appendNew adds value unless values already holds it. +func appendNew(values []string, value string) []string { + if slices.Contains(values, value) { + return values + } + + return append(values, value) +} diff --git a/formatting/names_internal_test.go b/formatting/names_internal_test.go new file mode 100644 index 0000000..5d65629 --- /dev/null +++ b/formatting/names_internal_test.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestImportedPackageNames(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + importPath string + expected []string + }{ + { + name: "should name a standard library package after its path", + importPath: "strings", + expected: []string{"strings"}, + }, + { + name: "should name a package after the last element", + importPath: "github.com/go-openapi/strfmt", + expected: []string{"strfmt"}, + }, + { + name: "should offer the directory above a major version suffix", + importPath: "github.com/go-openapi/testify/v2", + expected: []string{"v2", "testify"}, + }, + { + name: "should keep the version itself, since a package may be named v1", + importPath: "k8s.io/api/apps/v1", + expected: []string{"v1", "apps"}, + }, + { + name: "should drop a gopkg.in version suffix", + importPath: "gopkg.in/yaml.v3", + expected: []string{"yaml"}, + }, + { + name: "should drop a go- prefix", + importPath: "github.com/jessevdk/go-flags", + expected: []string{"flags", "goflags"}, + }, + { + name: "should offer the last element of a hyphenated name first", + importPath: "example.com/my-pkg", + expected: []string{"pkg", "mypkg", "my"}, + }, + { + name: "should drop a -go suffix", + importPath: "github.com/googleapis/gax-go", + expected: []string{"gax", "gaxgo"}, + }, + { + name: "should name nothing when no candidate is an identifier", + importPath: "example.com/2fa", + expected: nil, + }, + { + name: "should offer the directory above a version element that is a real directory", + importPath: "go.mongodb.org/mongo-driver/internal/aws/signer/v4", + expected: []string{"v4", "signer"}, + }, + { + name: "should not name a keyword", + importPath: "example.com/range", + expected: nil, + }, + { + name: "should not name the blank identifier", + importPath: "example.com/_", + expected: nil, + }, + { + name: "should offer the likelier name first", + importPath: "gopkg.in/check.v1", + expected: []string{"check"}, + }, + } + + for _, toPin := range tests { + test := toPin + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, importedPackageNames(test.importPath)) + }) + } +} diff --git a/formatting/names_test.go b/formatting/names_test.go new file mode 100644 index 0000000..48e7cc5 --- /dev/null +++ b/formatting/names_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + + "github.com/go-openapi/codegen/formatting" +) + +func TestImportedPackageName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + importPath string + expected string + }{ + { + name: "should name a standard library package after its path", + importPath: "strings", + expected: "strings", + }, + { + name: "should name a package after the last element", + importPath: "github.com/go-openapi/strfmt", + expected: "strfmt", + }, + { + name: "should look past a module major version", + importPath: "github.com/go-openapi/testify/v2", + expected: "testify", + }, + { + name: "should look past a version element that is a real directory", + importPath: "go.mongodb.org/mongo-driver/internal/aws/signer/v4", + expected: "signer", + }, + { + name: "should look past an api version directory", + importPath: "k8s.io/api/apps/v1", + expected: "apps", + }, + { + name: "should drop a gopkg.in version suffix", + importPath: "gopkg.in/yaml.v3", + expected: "yaml", + }, + { + name: "should drop a go- prefix", + importPath: "github.com/jessevdk/go-flags", + expected: "flags", + }, + { + name: "should drop a -go suffix", + importPath: "github.com/googleapis/gax-go", + expected: "gax", + }, + { + name: "should take the last segment of a hyphenated name", + importPath: "example.com/my-pkg", + expected: "pkg", + }, + { + name: "should name nothing when no part of the path is an identifier", + importPath: "example.com/2fa", + expected: "", + }, + { + name: "should not name a keyword", + importPath: "example.com/range", + expected: "", + }, + } + + for _, toPin := range tests { + test := toPin + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, formatting.ImportedPackageName(test.importPath)) + }) + } +} diff --git a/formatting/options.go b/formatting/options.go index 2a93907..daadc23 100644 --- a/formatting/options.go +++ b/formatting/options.go @@ -1,185 +1,128 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package formatting provides the language-specific options used by the -// go-swagger code generator. The primary type is [Options], which describes -// formatting, naming, and import resolution rules for a target language. package formatting -import ( - "path/filepath" +// Option configures [Format]. +type Option func(*options) - "golang.org/x/tools/imports" - - "github.com/go-openapi/codegen/mangling" -) - -// DefaultIndent is the default tab width used for Go source formatting. -const DefaultIndent = 2 - -// FormatterFunc is a function that processes go code to reformat it, e.g. [golang.org/x/tools/imports.Process]). -// -// Formatting options allow for injecting a custom formatter for the generated code. See [WithCustomFormatter]. -type FormatterFunc func(filename string, src []byte, opts ...FormatOption) ([]byte, error) - -// MangleFunc is a function that transforms a name string. -type MangleFunc func(string) string - -// FormatOption allows for more flexible code formatting settings. -type FormatOption func(*FormatOpts) - -// FormatOpts holds options for code formatting. -type FormatOpts struct { - imports.Options - - LocalPrefixes []string +type options struct { + groups []string + goFumpt bool + forcePruning bool + simplifyAliases bool + resolved map[string]string } -// WithFormatLocalPrefixes adds local prefixes to group imports. -func WithFormatLocalPrefixes(prefixes ...string) FormatOption { - return func(o *FormatOpts) { - o.LocalPrefixes = append(o.LocalPrefixes, prefixes...) - } -} - -// WithFormatOnly tells the formatter to skip imports processing. -func WithFormatOnly(enabled bool) FormatOption { - return func(o *FormatOpts) { - o.FormatOnly = enabled - } -} - -// DefaultFormatOpts is the default set of formatting options. -var DefaultFormatOpts = FormatOpts{ - Options: imports.Options{ - TabIndent: true, - TabWidth: DefaultIndent, - Fragment: true, - Comments: true, - }, - LocalPrefixes: []string{"github.com/go-openapi"}, -} - -// FormatOptsWithDefault applies the given options on top of [DefaultFormatOpts]. -func FormatOptsWithDefault(opts []FormatOption) FormatOpts { - o := DefaultFormatOpts - - for _, apply := range opts { - apply(&o) - } - - return o -} - -// Options describes a target language to the code generator. -type Options struct { - BaseImportFunc MangleFunc `json:"-"` - ImportsFunc func(map[string]string) string `json:"-"` - ArrayInitializerFunc func(any) (string, error) `json:"-"` - FormatOnly bool - ExtraInitialisms []string - Mangler mangling.GoMangler - - initialized bool - formatFunc FormatterFunc -} - -// SetFormatFunc sets the formatting function for this language. -func (l *Options) SetFormatFunc(fn FormatterFunc) { - l.formatFunc = fn -} - -// Init the language option. -func (l *Options) Init() { - if l.initialized { - return - } - - l.Mangler = mangling.MakeGoMangler( - mangling.WithGoInitialisms(l.ExtraInitialisms...), - ) - - l.initialized = true -} - -// MangleName makes sure a string becomes a safe go identifier. -func (l *Options) MangleName(name, suffix string) string { - if name == "" { - return suffix +// WithImportGroups adds one import group per prefix, between the standard library and the rest. +// +// An import belongs to the first prefix it starts with, so pass the more specific prefix first. +// Without this option the output has two groups: the standard library, then everything else. +func WithImportGroups(prefixes ...string) Option { + return func(o *options) { + for _, prefix := range prefixes { + if prefix == "" { + continue + } + o.groups = append(o.groups, prefix) + } } - - return l.Mangler.IdentExported(name) -} - -// MangleVarName makes sure a reserved word gets a safe name. -func (l *Options) MangleVarName(name string) string { - return l.Mangler.IdentUnexported(name) -} - -// MangleFileName makes sure a file name gets a safe name. -func (l *Options) MangleFileName(name string) string { - return l.Mangler.File(name) } -// ManglePackageName makes sure a package gets a safe name. -// In case of a file system path (e.g. name contains "/" or "\" on Windows), this return only the last element. -func (l *Options) ManglePackageName(name, suffix string) string { - if name == "" { - return suffix +// WithGoFumpt applies the gofumpt rules before printing. +// +// Blank-import github.com/go-openapi/codegen/formatting/enable/gofumpt to make the rules available. +// Without it [Format] returns [ErrNoGoFumpt] rather than printing without them. +func WithGoFumpt() Option { + return func(o *options) { + o.goFumpt = true } - - target := filepath.ToSlash(filepath.Clean(name)) // preserve path - short, _ := l.Mangler.Package(target) - - return short } -// ManglePackagePath makes sure a full package path gets a safe name. -// Only the last part of the path is altered. -func (l *Options) ManglePackagePath(name string, suffix string) string { - if name == "" { - return suffix +// WithForceImportsPruning prunes an unused import even when its name was only guessed. +// +// Passing it is a promise: every import in the source either carries an alias, or declares the name +// [ImportedPackageName] gives for its path — the last path element, with a /v2 or later suffix +// dropped and the last segment taken from a hyphenated element. Idiomatic packages keep that promise. +// "github.com/json-iterator/go" declares jsoniter and breaks it, and such an import is then pruned +// although the file uses it. +// +// Without this option the formatter keeps a bare third-party import it cannot name, and reports it as +// in doubt. +// +// Pass [WithResolvedImports] alongside to cover the imports the promise does not. A name given there +// is used instead of the guess, so one awkward dependency does not cost the promise: +// +// formatting.Format(out, src, +// formatting.WithForceImportsPruning(), +// formatting.WithResolvedImports(map[string]string{ +// "github.com/json-iterator/go": "jsoniter", +// }), +// ) +func WithForceImportsPruning() Option { + return func(o *options) { + o.forcePruning = true } - - target := filepath.ToSlash(filepath.Clean(name)) // preserve path - _, fqn := l.Mangler.Package(target) - - return fqn } -// FormatContent formats a file with a language specific formatter. -func (l *Options) FormatContent(name string, content []byte, opts ...FormatOption) ([]byte, error) { - if l.formatFunc != nil { - return l.formatFunc(name, content, opts...) +// WithResolvedImports states the name each import path declares, for the paths no rule can guess. +// +// "github.com/json-iterator/go" declares jsoniter and "github.com/prometheus/client_model/go" +// declares io_prometheus_client; nothing in either path says so. A name given here is treated as +// certain, so the import is pruned when unused and never reported as in doubt. +// +// A path appears in at most one place, and the first of these wins: an alias written in the source, +// this map, then the generated standard library table, then the guesses. +// +// It combines with [WithForceImportsPruning], which settles every path the map leaves out. +// +// The map is read, not kept: pass the same map to as many concurrent calls as you like. Build it with +// github.com/go-openapi/codegen/formatting/resolve, which answers from the packages themselves rather +// than from the machine, so one map serves every build. +func WithResolvedImports(names map[string]string) Option { + return func(o *options) { + if len(names) == 0 { + return + } + + if o.resolved == nil { + o.resolved = make(map[string]string, len(names)) + } + + for importPath, name := range names { + o.resolved[importPath] = name + } } - - // unformatted content - return content, nil } -// Imports generates the code to import some external packages, possibly aliased. -func (l *Options) Imports(imports map[string]string) string { - if l.ImportsFunc != nil { - return l.ImportsFunc(imports) +// WithSimplifiedImportAliases drops an alias that repeats the name its package declares. +// +// import fmt "fmt" -> import "fmt" +// import strfmt "github.com/go-openapi/strfmt" -> import "github.com/go-openapi/strfmt" +// +// A template that writes the alias even where Go would leave it out gets exact pruning without +// promising anything, because an alias states the name. This takes those aliases back out once the +// name is proven, so the output reads as ordinary Go. The second line above needs +// [WithResolvedImports] to name that package; the first is proven by the standard library table. +// +// An alias survives when dropping it would lose something. jsoniter "github.com/json-iterator/go" +// keeps its alias even with the name proven, because the path does not say jsoniter and the bare +// import would leave nothing that does. So does an alias that renames a package, as sql "database/sql +// /driver", and so do _ and . imports. +// +// Nothing is dropped on a guess. Without evidence from the table or the map, every alias stays. +func WithSimplifiedImportAliases() Option { + return func(o *options) { + o.simplifyAliases = true } - - return "" } -// ArrayInitializer builds a literal array. -func (l *Options) ArrayInitializer(data any) (string, error) { - if l.ArrayInitializerFunc != nil { - return l.ArrayInitializerFunc(data) - } - - return "", nil -} +func optionsWithDefaults(opts []Option) options { + var o options -// BaseImport figures out the base path to generate import statements. -func (l *Options) BaseImport(tgt string) string { - if l.BaseImportFunc != nil { - return l.BaseImportFunc(tgt) + for _, apply := range opts { + apply(&o) } - return "" + return o } diff --git a/formatting/options_test.go b/formatting/options_test.go deleted file mode 100644 index f371b7a..0000000 --- a/formatting/options_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers -// SPDX-License-Identifier: Apache-2.0 - -package formatting - -import ( - "testing" - - "github.com/go-openapi/testify/v2/assert" - "github.com/go-openapi/testify/v2/require" -) - -func TestOptions_Init(t *testing.T) { - opts := &Options{} - assert.Empty(t, opts.BaseImport("x")) - res, err := opts.FormatContent("x", []byte("y")) - require.NoError(t, err) - assert.Equal(t, []byte("y"), res) - opts = GolangOpts() - o := opts - o.Init() - assert.Equal(t, opts, o) -} - -func TestOptions_MangleVarName(t *testing.T) { - o := GolangOpts() - - // non-reserved word: returned as-is (after ToVarName) - assert.EqualT(t, "myVar", o.MangleVarName("myVar")) - - // reserved word: gets "Var" suffix - assert.EqualT(t, "breakVar", o.MangleVarName("break")) - assert.EqualT(t, "selectVar", o.MangleVarName("select")) -} - -func TestOptions_SetFormatFunc(t *testing.T) { - o := &Options{} - o.Init() - - // without formatFunc: returns content as-is - res, err := o.FormatContent("test.go", []byte("hello")) - require.NoError(t, err) - assert.Equal(t, []byte("hello"), res) - - // with formatFunc: delegates - o.SetFormatFunc(func(_ string, src []byte, _ ...FormatOption) ([]byte, error) { - return []byte("formatted:" + string(src)), nil - }) - res, err = o.FormatContent("test.go", []byte("hello")) - require.NoError(t, err) - assert.Equal(t, []byte("formatted:hello"), res) -} - -func TestOptions_Imports_Nil(t *testing.T) { - // nil ImportsFunc: returns "" - o := &Options{} - o.Init() - assert.Empty(t, o.Imports(map[string]string{"fmt": "fmt"})) -} - -func TestOptions_ArrayInitializer_Nil(t *testing.T) { - // nil func: returns "", nil - o := &Options{} - o.Init() - res, err := o.ArrayInitializer([]string{"a"}) - require.NoError(t, err) - assert.Empty(t, res) -} - -func TestOptions_BaseImport(t *testing.T) { - // nil func: returns "" - o := &Options{} - o.Init() - assert.Empty(t, o.BaseImport("anything")) - - // with custom func: delegates - o.BaseImportFunc = func(s string) string { return "custom/" + s } - assert.EqualT(t, "custom/target", o.BaseImport("target")) -} - -func TestFormatOptions(t *testing.T) { - // WithFormatLocalPrefixes - opts := FormatOptsWithDefault([]FormatOption{ - WithFormatLocalPrefixes("github.com/myorg"), - }) - assert.Equal(t, []string{"github.com/go-openapi", "github.com/myorg"}, opts.LocalPrefixes) - - // WithFormatOnly - opts = FormatOptsWithDefault([]FormatOption{ - WithFormatOnly(true), - }) - assert.TrueT(t, opts.FormatOnly) - - // defaults preserved when no options - opts = FormatOptsWithDefault(nil) - assert.EqualT(t, DefaultIndent, opts.TabWidth) - assert.TrueT(t, opts.TabIndent) - assert.TrueT(t, opts.Fragment) - assert.TrueT(t, opts.Comments) -} diff --git a/formatting/parse.go b/formatting/parse.go new file mode 100644 index 0000000..81b13ef --- /dev/null +++ b/formatting/parse.go @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 +// +// The three-tier fragment parse, cutSpace and matchSpace follow +// golang.org/x/tools/internal/imports/imports.go, which carries: +// +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license. + +package formatting + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// adjustFunc rewrites printed output to undo the wrapping a fragment needed to parse. +// +// It takes the original source and the printed bytes. A whole file needs none, and [Format] streams +// when it gets nil. +type adjustFunc func(orig, printed []byte) []byte + +// The two parse modes. +// +// [prune] reads Ident.Obj to tell a package qualifier from a name a declaration shadows, and only +// [resolvedMode] fills Obj in. Building those scopes costs about a sixth of everything Format +// allocates, so [fastMode] runs first and [needsResolution] says whether the answer can differ. +// +// [go/ast.Object] is deprecated and points at [go/types] instead, which is no use here: the type +// checker needs an importer and imports that resolve, and this package formats generated files +// whose imports may name packages no module holds yet. The warning on Object is about composite +// literal keys, where T{K: 0} gives K a meaning only a type decides; prune asks about a selector +// base in expression position, which the parser settles on syntax alone. Replacing it means walking +// the scopes ourselves, not type checking. +const ( + fastMode = parser.ParseComments | parser.AllErrors | parser.SkipObjectResolution + resolvedMode = parser.ParseComments | parser.AllErrors +) + +// parseFile parses src as a whole file, a declaration list or a statement list, in that order. +// +// It returns the file, an [adjustFunc] to run over the printed output, and an error. Only a source +// that fails to parse under all three readings returns an error, and it reports what the whole file +// attempt found, since that is the reading the caller meant. +// +// The parser is given no file name. Nothing here reads the file system, so a name would only label +// the positions a parse error reports, and the caller already has the path. +func parseFile(fset *token.FileSet, src []byte, mode parser.Mode) (*ast.File, adjustFunc, error) { + file, err := parser.ParseFile(fset, "", src, mode) + if err == nil { + return file, nil, nil + } + + if !strings.Contains(err.Error(), "expected 'package'") { + return nil, nil, err + } + + if file, adjust, ok := parseDeclList(fset, src, mode); ok { + return file, adjust, nil + } + + if file, adjust, ok := parseStmtList(fset, src, mode); ok { + return file, adjust, nil + } + + return nil, nil, err +} + +// declPrefix opens a declaration list. The semicolon rather than a newline keeps every parse error +// on its original line. +const declPrefix = "package main;" + +// parseDeclList reads src as a list of declarations by prefixing a package clause. +func parseDeclList(fset *token.FileSet, src []byte, mode parser.Mode) (*ast.File, adjustFunc, bool) { + prefixed := append([]byte(declPrefix), src...) + + file, err := parser.ParseFile(fset, "", prefixed, mode) + if err != nil { + return nil, nil, false + } + + // the printer turns the semicolon into a newline, so do it here and re-line the file, keeping + // every position and line number below in step with what will be printed. + prefixed[len(declPrefix)-1] = '\n' + fset.File(file.Package).SetLinesForContent(prefixed) + + // a fragment declaring func main() is a package of its own, and keeping the clause is right. + if declaresMain(file) { + return file, nil, true + } + + adjust := func(orig, printed []byte) []byte { + return matchSpace(orig, printed[len(declPrefix):]) + } + + return file, adjust, true +} + +// stmtPrefix and stmtSuffix wrap a statement list, an expression included, in a function body. +const ( + stmtPrefix = "package p; func _() {" + stmtSuffix = "}" + + // printedStmtPrefix is stmtPrefix once the printer has laid it out. + printedStmtPrefix = "package p\n\nfunc _() {" + printedStmtSuffix = "}\n" +) + +// parseStmtList reads src as a list of statements by wrapping it in a function. +func parseStmtList(fset *token.FileSet, src []byte, mode parser.Mode) (*ast.File, adjustFunc, bool) { + wrapped := make([]byte, 0, len(stmtPrefix)+len(src)+len(stmtSuffix)) + wrapped = append(wrapped, stmtPrefix...) + wrapped = append(wrapped, src...) + wrapped = append(wrapped, stmtSuffix...) + + file, err := parser.ParseFile(fset, "", wrapped, mode) + if err != nil { + return nil, nil, false + } + + adjust := func(orig, printed []byte) []byte { + body := printed[len(printedStmtPrefix) : len(printed)-len(printedStmtSuffix)] + // the printer indented the body one level; take that level back out + body = bytes.ReplaceAll(body, []byte("\n\t"), []byte("\n")) + + return matchSpace(orig, body) + } + + return file, adjust, true +} + +// declaresMain reports whether file declares func main(), taking no arguments and returning nothing. +func declaresMain(file *ast.File) bool { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "main" || fn.Recv != nil { + continue + } + + if len(fn.Type.Params.List) != 0 { + continue + } + + if fn.Type.Results != nil && len(fn.Type.Results.List) != 0 { + continue + } + + return true + } + + return false +} + +// matchSpace gives printed the white space that surrounded orig. +// +// Leading blank lines come back, the indentation of the first non-blank line of orig is applied to +// every non-blank line of printed, and the trailing space of orig replaces the trailing space of +// printed. A fragment rendered by a template sits inside a file, and matchSpace puts it back where +// it sat. +func matchSpace(orig, printed []byte) []byte { + before, _, after := cutSpace(orig) + lineStart := bytes.LastIndexByte(before, '\n') + before, indent := before[:lineStart+1], before[lineStart+1:] + + _, printed, _ = cutSpace(printed) + + var out bytes.Buffer + out.Write(before) + + for len(printed) > 0 { + line := printed + if end := bytes.IndexByte(line, '\n'); end >= 0 { + line, printed = line[:end+1], line[end+1:] + } else { + printed = nil + } + + if len(line) > 0 && line[0] != '\n' { // a blank line takes no indent + out.Write(indent) + } + out.Write(line) + } + + out.Write(after) + + return out.Bytes() +} + +// cutSpace splits b into its leading space, its content and its trailing space. +func cutSpace(b []byte) (before, middle, after []byte) { + start := 0 + for start < len(b) && isSpaceByte(b[start]) { + start++ + } + + end := len(b) + for end > 0 && isSpaceByte(b[end-1]) { + end-- + } + + if start > end { // all space + return nil, nil, b[end:] + } + + return b[:start], b[start:end], b[end:] +} + +// isSpaceByte reports whether c is one of the four bytes go/format counts as space. +// +// \r belongs here: a fragment written on Windows separates its lines with \r\n, and leaving \r out +// makes [cutSpace] read it as content, so [matchSpace] restores none of the space around the +// fragment. go/format/internal.go carries the same list. +func isSpaceByte(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} diff --git a/formatting/parse_internal_test.go b/formatting/parse_internal_test.go new file mode 100644 index 0000000..0498f92 --- /dev/null +++ b/formatting/parse_internal_test.go @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "bytes" + "go/parser" + "go/token" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestNeedsResolution states which files pay for the parser's scopes. +func TestNeedsResolution(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + resolved bool + }{ + { + name: "should trust the cheap parse when no name is both qualified and declared", + src: `package p + +import "bytes" + +func F() { + var buf bytes.Buffer + _ = buf +} +`, + }, + { + name: "should trust the cheap parse for an aliased import", + src: `package p + +import buf "bytes" + +var _ buf.Buffer +`, + }, + { + name: "should ask for scopes when a local goes by the name of a qualifier", + src: `package p + +import "bytes" + +func F() { + bytes := "shadow" + _ = bytes + var _ bytes.Buffer +} +`, + resolved: true, + }, + { + name: "should ask for scopes when a package level name matches a qualifier", + src: `package p + +import "bytes" + +var bytes = 1 + +var _ = bytes.Buffer +`, + resolved: true, + }, + { + name: "should not count the field of a selector", + src: `package p + +import "bytes" + +type T struct{ Buffer int } + +var _ bytes.Buffer +`, + }, + } + + for _, toPin := range tests { + test := toPin + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + file, _, err := parseFile(token.NewFileSet(), []byte(test.src), fastMode) + require.NoError(t, err) + + assert.Equal(t, test.resolved, needsResolution(file, nil)) + }) + } +} + +// TestParsePathsAgree checks the claim [needsResolution] makes. +// +// The claim is not that the two parses always agree — on testdata/sources/prune/shadowed-wholly they +// must not, since only the resolved one sees that every use of bytes is a local and the import is +// dead. The claim is that they agree on every file needsResolution passes through, which is what +// makes skipping the scopes safe. So this formats each fixture both ways and compares only where the +// cheap parse would have been trusted. +func TestParsePathsAgree(t *testing.T) { + t.Parallel() + + fixtures, err := filepath.Glob(filepath.Join("testdata", "sources", "*", "*.input")) + require.NoError(t, err) + + more, err := filepath.Glob(filepath.Join("testdata", "sources", "*.input")) + require.NoError(t, err) + fixtures = append(fixtures, more...) + + corpus, err := filepath.Glob(filepath.Join("testdata", "corpus", "*", "*.input")) + require.NoError(t, err) + fixtures = append(fixtures, corpus...) + + require.NotEmpty(t, fixtures) + + var slowPath atomic.Int64 + t.Cleanup(func() { + assert.Positive(t, slowPath.Load(), "no fixture exercises the resolved parse, so it is untested") + }) + + for _, toPin := range fixtures { + fixture := toPin + t.Run(filepath.ToSlash(fixture), func(t *testing.T) { + t.Parallel() + + src, err := os.ReadFile(fixture) + require.NoError(t, err) + + file, _, err := parseFile(token.NewFileSet(), src, fastMode) + if err != nil { + t.Skip("fixture does not parse; the error paths are covered elsewhere") + } + + if needsResolution(file, nil) { + slowPath.Add(1) + + return // Format parses this one again, so the paths are free to differ + } + + fast, fastErr := formatWith(src, fastMode) + require.NoError(t, fastErr) + + resolved, resolvedErr := formatWith(src, resolvedMode) + require.NoError(t, resolvedErr) + + assert.Equal(t, resolved, fast, "the cheap parse must format as the resolved one does") + }) + } +} + +// formatWith runs the pipeline with the parse mode pinned, bypassing the choice Format makes. +func formatWith(src []byte, mode parser.Mode) (string, error) { + fset := token.NewFileSet() + + file, adjust, err := parseFile(fset, src, mode) + if err != nil { + return "", err + } + + prune(fset, file, options{forcePruning: true}) + mergeImports(file) + sortImports(fset.File(file.FileStart), file, nil) + breaks := groupBreaks(fset, file, nil) + + var out bytes.Buffer + if adjust != nil { + var printed bytes.Buffer + spaced := newSpacer(&printed, breaks) + if err := fprint(spaced, fset, file); err != nil { + return "", err + } + if err := spaced.Flush(); err != nil { + return "", err + } + out.Write(adjust(src, printed.Bytes())) + + return out.String(), nil + } + + spaced := newSpacer(&out, breaks) + if err := fprint(spaced, fset, file); err != nil { + return "", err + } + if err := spaced.Flush(); err != nil { + return "", err + } + + return out.String(), nil +} diff --git a/formatting/prune.go b/formatting/prune.go new file mode 100644 index 0000000..7a14ff4 --- /dev/null +++ b/formatting/prune.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "go/ast" + "go/token" + "strconv" + + "golang.org/x/tools/go/ast/astutil" +) + +// cgoImport names the pseudo-package C, which carries a cgo preamble. Nothing may touch it. +const cgoImport = "C" + +// prune deletes the imports the file does not use and can be shown not to use. +// +// It never adds one, and it deletes only an import whose name it knows: an alias states the name, the +// generated table states it for the standard library, and [WithResolvedImports] lets the caller state +// it for anything else. A bare third-party import is a guess, and a guess keeps an import rather than +// delete it — see [binding.inDoubt]. [WithForceImportsPruning] turns the guesses into decisions. +// +// It returns the bindings, so the caller can report on what was pruned and what was left in doubt. +func prune(fset *token.FileSet, file *ast.File, o options) ([]binding, map[string]bool) { + used := usedQualifiers(file) + bindings := describeImports(file, o.resolved) + + type deletion struct{ name, path string } + var unused []deletion + + for i := range bindings { + described := &bindings[i] + + if !described.prunable(o.forcePruning) || described.isUsed(used) { + continue + } + + unused = append(unused, deletion{name: described.alias, path: described.path}) + described.pruned = true + } + + for _, spec := range unused { + astutil.DeleteNamedImport(fset, file, spec.name, spec.path) + } + + return bindings, used +} + +// usedQualifiers collects every identifier the file uses to qualify a selector. +// +// An identifier the parser resolved to a declaration carries a non-nil Obj, which separates the +// package fmt in fmt.Println from a local variable named fmt. Since one shadowed use does not hide +// another, a package used anywhere in the file lands in the set. +// +// A file parsed in [fastMode] carries no Obj at all, so every selector base lands in the set. That +// is the same answer whenever no declaration in the file shares a name with a selector's qualifier. +// [needsResolution] checks that before the cheap parse is trusted. +func usedQualifiers(file *ast.File) map[string]bool { + used := make(map[string]bool) + + ast.Inspect(file, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + + if ident, ok := selector.X.(*ast.Ident); ok && ident.Obj == nil { + used[ident.Name] = true + } + + return true + }) + + return used +} + +// needsResolution reports whether telling a package qualifier from a shadowed name needs the +// parser's scopes. +// +// Only the names an import could declare are worth asking about, and there are a handful of those. +// It needs the scopes when the file writes one of them somewhere that is not a selector base — as a +// parameter, a range variable, the left of a :=, any declaration at all — because only scopes then +// say whether bytes.Buffer means the package or the local. When no imported name is written that +// way, an unresolved parse already separates them: a selector base the file never declares is a +// package, or a name a sibling file declares, and the parser resolves neither. +// +// Asking about every selector base instead would be useless. A method receiver is a selector base: +// m.ID is the same shape as fmt.Println, so m would collide with itself and no file would ever take +// the cheap parse. +func needsResolution(file *ast.File, resolved map[string]string) bool { + names := importedNames(file, resolved) + if len(names) == 0 { + return false + } + + shadowed := false + + var walk func(ast.Node) bool + walk = func(node ast.Node) bool { + if shadowed { + return false + } + + switch typed := node.(type) { + case *ast.ImportSpec: + return false // the alias declares the name; nothing here shadows it + + case *ast.SelectorExpr: + if _, ok := typed.X.(*ast.Ident); ok { + return false // a qualifier or a value with a field, and neither declares a name + } + + ast.Inspect(typed.X, walk) // m.Count.Value: walk the left, skip the field + + return false + + case *ast.Ident: + if _, imported := names[typed.Name]; imported { + shadowed = true + } + } + + return true + } + + ast.Inspect(file, walk) + + return shadowed +} + +// importedNames returns every name the file's imports could declare. +// +// An alias declares its name outright, and [WithResolvedImports] states the names a caller supplied. +// Without either the package name is guessed, and [importedPackageNames] returns every guess, so a +// name that could belong to an import is in the set. +func importedNames(file *ast.File, resolved map[string]string) map[string]struct{} { + names := make(map[string]struct{}, len(file.Imports)) + + for _, spec := range file.Imports { + if spec.Name != nil { + if spec.Name.Name != "_" && spec.Name.Name != "." { + names[spec.Name.Name] = struct{}{} + } + + continue + } + + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + + if name, ok := resolved[importPath]; ok { + names[name] = struct{}{} + + continue + } + + for _, name := range importedPackageNames(importPath) { + names[name] = struct{}{} + } + } + + return names +} diff --git a/formatting/prune_test.go b/formatting/prune_test.go new file mode 100644 index 0000000..5832600 --- /dev/null +++ b/formatting/prune_test.go @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" +) + +func TestPrune(t *testing.T) { + t.Parallel() + + t.Run("should drop an import nothing uses", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/unused")) + + assert.NotContains(t, out, `"strings"`) + assert.Contains(t, out, `"bytes"`) + }) + + t.Run("should keep a blank and a dot import", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/blank-and-dot")) + + assert.Contains(t, out, `_ "embed"`) + assert.Contains(t, out, `. "strings"`) + }) + + t.Run("should trust an alias rather than guess", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/aliased")) + + assert.Contains(t, out, `buf "bytes"`) + assert.NotContains(t, out, `unused "strings"`) + }) + + t.Run("should keep an import whose package it cannot name", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/unnameable")) + + assert.Contains(t, out, `"example.com/2fa"`) + }) + + t.Run("should keep the cgo preamble", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/cgo")) + + assert.Contains(t, out, `import "C"`) + }) + + t.Run("should keep an import a shadowed name does not hide", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/shadowed-partly")) + + assert.Contains(t, out, `"bytes"`) + }) + + t.Run("should drop an import every use of which is shadowed", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/shadowed-wholly")) + + assert.NotContains(t, out, `"bytes"`) + }) + + t.Run("should never add an import", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/missing")) + + assert.NotContains(t, out, `"fmt"`, "a missing import is the template's business, not ours") + }) +} + +// TestPruneConfidence pins which imports may be deleted, and on what evidence. +// +// An import is deleted only when its name is known: written as an alias, held by the generated +// standard library table, or supplied through [formatting.WithResolvedImports]. A bare third-party +// import is a guess, and a guess keeps an import rather than delete it. +func TestPruneConfidence(t *testing.T) { + t.Parallel() + + strfmtResolved := formatting.WithResolvedImports(map[string]string{ + "github.com/go-openapi/strfmt": "strfmt", + }) + + t.Run("should delete an unused import whose name is certain", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/unused")) + assert.NotContains(t, out, `"strings"`, "the standard library table names it") + + out = format2(t, source(t, "prune/aliased")) + assert.NotContains(t, out, `unused "strings"`, "the alias names it") + }) + + t.Run("should keep an unused bare third-party import", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/bare-third-party")) + + assert.Contains(t, out, `"github.com/go-openapi/strfmt"`, + "strfmt is a guess, and the package may declare something else") + }) + + t.Run("should delete it once the caller names it", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/bare-third-party"), strfmtResolved) + + assert.NotContains(t, out, "strfmt") + }) + + t.Run("should delete it once the caller promises the naming convention", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/bare-third-party"), formatting.WithForceImportsPruning()) + + assert.NotContains(t, out, "strfmt") + }) + + t.Run("should keep an import whose package no rule can name", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/unnameable-third-party")) + + assert.Contains(t, out, `"github.com/json-iterator/go"`, "the path never says jsoniter") + }) + + t.Run("should delete that one under a promise it breaks", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/unnameable-third-party"), formatting.WithForceImportsPruning()) + + assert.NotContains(t, out, "json-iterator", + "jsoniter does not follow the convention, so the promise was wrong and the build will say so") + }) + + t.Run("should keep a version directory the file uses under its own name", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/version-directory"), formatting.WithForceImportsPruning()) + + assert.Contains(t, out, `"k8s.io/api/apps/v1"`, + "forced pruning checks every candidate, so v1 counts as much as apps") + }) + + t.Run("should never delete a blank or a dot import", func(t *testing.T) { + t.Parallel() + + out := format2(t, source(t, "prune/blank-and-dot-unused"), formatting.WithForceImportsPruning()) + + assert.Contains(t, out, `_ "embed"`, "a blank import runs an init and binds no qualifier") + assert.Contains(t, out, `. "strings"`, "a dot import spills its names, so nothing can be checked") + }) +} + +// TestPromiseWithExceptions pins that the two options compose. +// +// [formatting.WithForceImportsPruning] promises the naming convention holds, and +// [formatting.WithResolvedImports] states the names where it does not. A caller with one awkward +// dependency does not have to choose between them. +func TestPromiseWithExceptions(t *testing.T) { + t.Parallel() + + const ( + jsoniterPath = "github.com/json-iterator/go" + swagPath = "github.com/go-openapi/swag" + ) + + src := source(t, "prune/promise-with-exceptions") + hints := formatting.WithResolvedImports(map[string]string{jsoniterPath: "jsoniter"}) + force := formatting.WithForceImportsPruning() + + t.Run("should prune a used import when only the promise is given", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, force) + + assert.NotContains(t, out, jsoniterPath, "jsoniter breaks the convention, so the promise was wrong") + }) + + t.Run("should keep it once the map names it, and still prune the rest", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, force, hints) + + assert.Contains(t, out, jsoniterPath, "the map states what the promise could not cover") + assert.NotContains(t, out, swagPath, "the promise still settles everything the map leaves out") + }) + + t.Run("should not care which option comes first", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, format2(t, src, force, hints), format2(t, src, hints, force)) + }) + + t.Run("should leave nothing in doubt", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + report, err := formatting.Format(&out, []byte(src), force, hints) + require.NoError(t, err) + + assert.False(t, report.HasImportsInDoubt(), "every import was decided:\n%s", report) + }) +} diff --git a/formatting/reference_test.go b/formatting/reference_test.go new file mode 100644 index 0000000..56da8ca --- /dev/null +++ b/formatting/reference_test.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + "golang.org/x/tools/imports" + + "github.com/go-openapi/codegen/formatting" +) + +// TestAgainstReference compares Format with goimports on sources where the two cannot disagree. +// +// This package was written against x/tools/internal/imports. Format parts company with it in four +// places, none of them exercised here: +// +// - Format never adds an import; +// - [formatting.WithImportGroups] opens groups goimports has no way to express; +// - Format sorts and dedups the whole import block, where goimports sorts each blank-line-separated +// run on its own, and no fixture below writes a blank line inside its import block; +// - Format keeps an unused bare third-party import, because it cannot know the name that package +// declares, and every fixture below imports the standard library or uses what it imports. +// +// Any other difference is a bug in one of them. +func TestAgainstReference(t *testing.T) { + t.Parallel() + + for fixture, toPin := range sourceSet(t, "reference") { + src := toPin + + t.Run("should agree with goimports on "+caseName(fixture), func(t *testing.T) { + t.Parallel() + + expected, err := imports.Process("p.go", []byte(src), &imports.Options{ + Comments: true, + TabIndent: true, + TabWidth: 8, + }) + require.NoError(t, err) + + var out bytes.Buffer + _, err = formatting.Format(&out, []byte(src)) + require.NoError(t, err) + + assert.Equal(t, string(expected), out.String()) + }) + } +} + +// TestNeverAdds states the one difference on purpose. +func TestNeverAdds(t *testing.T) { + t.Parallel() + + src := source(t, "prune/missing") + + resolved, err := imports.Process("p.go", []byte(src), &imports.Options{Comments: true, TabIndent: true, TabWidth: 8}) + require.NoError(t, err) + require.Contains(t, string(resolved), `"fmt"`, "goimports resolves the missing import") + + var out bytes.Buffer + _, err = formatting.Format(&out, []byte(src)) + require.NoError(t, err) + assert.NotContains(t, out.String(), `"fmt"`, "Format leaves it to the compiler to complain") +} + +func BenchmarkFormat(b *testing.B) { + src, err := sources.ReadFile(sourceRoot + "/reference/third-party-apart-from-std.input") + if err != nil { + b.Fatal(err) + } + + groups := formatting.WithImportGroups("github.com/go-openapi") + + b.Run("formatting.Format", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + var out bytes.Buffer + if _, err := formatting.Format(&out, src, groups); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("imports.Process", func(b *testing.B) { + b.ReportAllocs() + opts := &imports.Options{Comments: true, TabIndent: true, TabWidth: 8} + + for b.Loop() { + if _, err := imports.Process("p.go", src, opts); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("imports.Process format only", func(b *testing.B) { + b.ReportAllocs() + opts := &imports.Options{Comments: true, TabIndent: true, TabWidth: 8, FormatOnly: true} + + for b.Loop() { + if _, err := imports.Process("p.go", src, opts); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/formatting/report.go b/formatting/report.go new file mode 100644 index 0000000..6a6ced0 --- /dev/null +++ b/formatting/report.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "fmt" + "go/ast" + "go/token" + "slices" + "strings" +) + +// ImportStatus says what became of one import. +type ImportStatus int + +const ( + // ImportUsed marks an import the file writes as a qualifier. It stays. + ImportUsed ImportStatus = iota + + // ImportPruned marks an import [Format] deleted, because its name is known and nothing used it. + ImportPruned + + // ImportInDoubt marks a bare import whose package [Format] cannot name, so it cannot say whether + // the file uses it. It stays. Resolve it with [WithResolvedImports], or accept the guess with + // [WithForceImportsPruning]. + ImportInDoubt + + // ImportCollision marks an import whose guessed name is claimed by another import as well. It + // stays, and neither of the two is pruned. A collision between names [Format] knows is an error + // rather than a status: see [ErrInconsistentImports]. + ImportCollision + + // ImportBlank marks a _ import. It runs an init, binds no qualifier, and is never pruned. + ImportBlank + + // ImportDot marks a . import. Its names land in the file scope and no qualifier appears, so + // nothing about it can be checked. It is never pruned and always in doubt. + ImportDot + + // ImportCgo marks the pseudo-package C, which carries a cgo preamble. Nothing may touch it. + ImportCgo +) + +func (s ImportStatus) String() string { + switch s { + case ImportUsed: + return "used" + case ImportPruned: + return "pruned" + case ImportInDoubt: + return "in doubt" + case ImportCollision: + return "collision" + case ImportBlank: + return "blank" + case ImportDot: + return "dot" + case ImportCgo: + return "cgo" + default: + return "unknown" + } +} + +// ImportRecord is what [Format] made of one import. +type ImportRecord struct { + // Path is the import path, unquoted. + Path string + + // Alias is the name written before the path, empty for a bare import. "_" and "." appear here as + // they were written. + Alias string + + // Name is the qualifier the import binds, empty when [Format] could not tell. Under + // [ImportCollision], the records that clash share a Name. + Name string + + // Certain reports whether Name was stated rather than guessed: written as an alias, held in the + // standard library table, or supplied through [WithResolvedImports]. + Certain bool + + Status ImportStatus +} + +func (r ImportRecord) String() string { + name := r.Name + if name == "" { + name = "?" + } + + return fmt.Sprintf("%s (%s) %s", r.Path, name, r.Status) +} + +// ImportsReport accounts for every import [Format] read. +// +// [Format] returns one whenever it parsed the source, including when it then failed, so a caller can +// see the imports behind an [ErrInconsistentImports]. +// +// The report is the input to the resolving loop: format once, ask [ImportsReport.HasImportsInDoubt], +// resolve [ImportsReport.PathsInDoubt] with +// github.com/go-openapi/codegen/formatting/resolve, and format again with [WithResolvedImports] until +// nothing is left in doubt. +type ImportsReport struct { + records []ImportRecord +} + +// Imports returns every import, ordered by path. +func (r *ImportsReport) Imports() []ImportRecord { + if r == nil { + return nil + } + + return slices.Clone(r.records) +} + +// Used returns the imports the file writes as a qualifier. +func (r *ImportsReport) Used() []ImportRecord { return r.withStatus(ImportUsed) } + +// Pruned returns the imports [Format] deleted. +func (r *ImportsReport) Pruned() []ImportRecord { return r.withStatus(ImportPruned) } + +// InDoubt returns the imports [Format] kept because it could not name them. +// +// That covers [ImportInDoubt], [ImportCollision] and [ImportDot]: a name guessed and unmatched, a name +// guessed and claimed twice, and a dot import, which no qualifier ever reveals. +func (r *ImportsReport) InDoubt() []ImportRecord { + if r == nil { + return nil + } + + var doubtful []ImportRecord + + for _, record := range r.records { + switch record.Status { + case ImportInDoubt, ImportCollision, ImportDot: + doubtful = append(doubtful, record) + case ImportUsed, ImportPruned, ImportBlank, ImportCgo: + } + } + + return doubtful +} + +// PathsInDoubt returns the import paths of [ImportsReport.InDoubt], ready for a resolver. +func (r *ImportsReport) PathsInDoubt() []string { + doubtful := r.InDoubt() + + paths := make([]string, 0, len(doubtful)) + for _, record := range doubtful { + paths = append(paths, record.Path) + } + + return paths +} + +// HasImportsInDoubt reports whether any import was kept because [Format] could not name it. +// +// A false answer means every import was decided: pruning was exact, and the output has no import the +// file does not use. +func (r *ImportsReport) HasImportsInDoubt() bool { return len(r.InDoubt()) > 0 } + +// String summarises the report, one import per line. +func (r *ImportsReport) String() string { + if r == nil || len(r.records) == 0 { + return "no imports" + } + + lines := make([]string, 0, len(r.records)) + for _, record := range r.records { + lines = append(lines, record.String()) + } + + return strings.Join(lines, "\n") +} + +func (r *ImportsReport) withStatus(status ImportStatus) []ImportRecord { + if r == nil { + return nil + } + + var matching []ImportRecord + + for _, record := range r.records { + if record.Status == status { + matching = append(matching, record) + } + } + + return matching +} + +// newImportsReport turns the bindings into the report, once pruning and sorting are done. +// +// A binding whose spec left the tree without being pruned was an exact duplicate that [sortImports] +// collapsed, and the spec it collapsed into carries the same path and name, so dropping it here loses +// nothing. +func newImportsReport(bindings []binding, file *ast.File, used map[string]bool) *ImportsReport { + surviving := survivingSpecs(file) + report := &ImportsReport{records: make([]ImportRecord, 0, len(bindings))} + + for _, described := range bindings { + if !described.pruned && !surviving[described.spec] { + continue + } + + report.records = append(report.records, ImportRecord{ + Path: described.path, + Alias: described.alias, + Name: described.effectiveName(used), + Certain: described.certain, + Status: described.status(used), + }) + } + + slices.SortFunc(report.records, func(a, b ImportRecord) int { return strings.Compare(a.Path, b.Path) }) + + return report +} + +func survivingSpecs(file *ast.File) map[*ast.ImportSpec]bool { + surviving := make(map[*ast.ImportSpec]bool, len(file.Imports)) + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT { + break // imports come first, so the first other declaration ends the search + } + + for _, spec := range gen.Specs { + surviving[spec.(*ast.ImportSpec)] = true + } + } + + return surviving +} diff --git a/formatting/report_test.go b/formatting/report_test.go new file mode 100644 index 0000000..0c4b277 --- /dev/null +++ b/formatting/report_test.go @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "bytes" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" +) + +// reportOf formats and returns the report, failing the test if the source does not format. +func reportOf(t *testing.T, src string, opts ...formatting.Option) *formatting.ImportsReport { + t.Helper() + + var out bytes.Buffer + + report, err := formatting.Format(&out, []byte(src), opts...) + require.NoError(t, err) + + return report +} + +// statusOf finds one import in the report. +func statusOf(t *testing.T, report *formatting.ImportsReport, importPath string) formatting.ImportRecord { + t.Helper() + + for _, record := range report.Imports() { + if record.Path == importPath { + return record + } + } + + t.Fatalf("%s is not in the report:\n%s", importPath, report) + + return formatting.ImportRecord{} +} + +func TestImportsReport(t *testing.T) { + t.Parallel() + + t.Run("should account for every import that reached the output", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/blank-and-dot-unused")) + + assert.Equal(t, formatting.ImportUsed, statusOf(t, report, "bytes").Status) + assert.Equal(t, formatting.ImportBlank, statusOf(t, report, "embed").Status) + assert.Equal(t, formatting.ImportDot, statusOf(t, report, "strings").Status) + }) + + t.Run("should name what it pruned", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/unused")) + + pruned := report.Pruned() + require.Len(t, pruned, 1) + assert.Equal(t, "strings", pruned[0].Path) + assert.True(t, pruned[0].Certain, "the standard library table named it") + }) + + t.Run("should report a bare third-party import it cannot decide", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/bare-third-party")) + record := statusOf(t, report, "github.com/go-openapi/strfmt") + + assert.Equal(t, formatting.ImportInDoubt, record.Status) + assert.False(t, record.Certain) + assert.True(t, report.HasImportsInDoubt()) + assert.Equal(t, []string{"github.com/go-openapi/strfmt"}, report.PathsInDoubt()) + }) + + t.Run("should settle that import once the caller names it", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/bare-third-party"), + formatting.WithResolvedImports(map[string]string{"github.com/go-openapi/strfmt": "strfmt"}), + ) + + assert.False(t, report.HasImportsInDoubt()) + assert.Equal(t, formatting.ImportPruned, statusOf(t, report, "github.com/go-openapi/strfmt").Status) + }) + + t.Run("should hold a dot import in doubt, whatever the options say", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/blank-and-dot-unused"), formatting.WithForceImportsPruning()) + + assert.True(t, report.HasImportsInDoubt(), "nothing reveals what a dot import declares") + assert.Equal(t, []string{"strings"}, report.PathsInDoubt()) + }) + + t.Run("should settle every import of a well-formed file", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/unused")) + + assert.False(t, report.HasImportsInDoubt()) + assert.Empty(t, report.InDoubt()) + }) + + t.Run("should name the qualifier a guess settled on", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/version-directory")) + record := statusOf(t, report, "k8s.io/api/apps/v1") + + assert.Equal(t, "v1", record.Name, "the file writes v1, not apps") + assert.False(t, record.Certain) + assert.Equal(t, formatting.ImportUsed, record.Status) + }) + + t.Run("should read as a summary", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, source(t, "prune/bare-third-party")) + + assert.Contains(t, report.String(), "github.com/go-openapi/strfmt (?) in doubt") + assert.Contains(t, report.String(), "bytes (bytes) used") + }) + + t.Run("should survive being nil", func(t *testing.T) { + t.Parallel() + + var absent *formatting.ImportsReport + + assert.False(t, absent.HasImportsInDoubt()) + assert.Empty(t, absent.Imports()) + assert.Equal(t, "no imports", absent.String()) + }) + + t.Run("should come back even when the imports contradict each other", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + report, err := formatting.Format(&out, []byte(source(t, "inconsistent/one-alias-two-packages"))) + + require.Error(t, err) + assert.NotNil(t, report, "the caller can still see what the imports were") + assert.NotEmpty(t, report.Imports()) + }) +} + +// TestGuessedCollision pins the difference between a clash we know about and one we inferred. +func TestGuessedCollision(t *testing.T) { + t.Parallel() + + clash := source(t, "collision/guessed-names-clash") + + t.Run("should report a clash between guessed names rather than fail", func(t *testing.T) { + t.Parallel() + + report := reportOf(t, clash) + + assert.Equal(t, formatting.ImportCollision, statusOf(t, report, "github.com/go-openapi/core").Status) + assert.Equal(t, formatting.ImportCollision, statusOf(t, report, "k8s.io/api/core/v1").Status) + assert.True(t, report.HasImportsInDoubt()) + }) + + t.Run("should keep both, since either package may declare something else", func(t *testing.T) { + t.Parallel() + + out := format2(t, clash) + + assert.Contains(t, out, `"github.com/go-openapi/core"`) + assert.Contains(t, out, `"k8s.io/api/core/v1"`) + }) + + t.Run("should fail once the caller promises the guesses are right", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + _, err := formatting.Format(&out, []byte(clash), formatting.WithForceImportsPruning()) + + require.Error(t, err) + assert.ErrorIs(t, err, formatting.ErrInconsistentImports) + assert.Contains(t, err.Error(), `the name "core" is bound to 2 packages`) + }) + + t.Run("should still fail on a clash between names it knows", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + + _, err := formatting.Format(&out, []byte(source(t, "inconsistent/two-packages-same-base"))) + + require.Error(t, err) + assert.ErrorIs(t, err, formatting.ErrInconsistentImports, "crypto/rand and math/rand are both in the table") + }) +} diff --git a/formatting/resolve/doc.go b/formatting/resolve/doc.go new file mode 100644 index 0000000..25aaff8 --- /dev/null +++ b/formatting/resolve/doc.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package resolve reads the name each imported package declares. +// +// [github.com/go-openapi/codegen/formatting.Format] never loads a package, so it cannot name one +// whose package clause does not follow its import path: "github.com/json-iterator/go" declares +// jsoniter and "github.com/prometheus/client_model/go" declares io_prometheus_client. It keeps such +// an import rather than delete one the code may be using, and lists it in the report. +// +// [Names] loads those packages and answers outright. Feed it what the report could not settle, and +// pass the map back: +// +// report, err := formatting.Format(out, rendered) +// if err != nil { +// return err +// } +// +// if report.HasImportsInDoubt() { +// names, err := resolve.Names(ctx, report.PathsInDoubt(), resolve.WithDir(moduleDir)) +// if err != nil { +// return err +// } +// // format again with formatting.WithResolvedImports(names) +// } +// +// # Resolve once, not on every run +// +// The answer depends on the dependencies and on nothing else — not on the machine, the module cache +// or the build list. So run this once, commit the map, and every generator run anywhere agrees. That +// is the whole reason [github.com/go-openapi/codegen/formatting] does not resolve imports itself: +// a generator that searched the build list would produce different files on different machines. +// +// Rerun it when a dependency is added, or when one renames its package. Nothing detects that for you. +// +// # What it does not check +// +// [Names] answers for any path go list can find, an internal package of another module included. +// Nothing here asks whether the importing file may legally use it: go build rejects such an import +// and names the file and the line. +// +// # What it costs +// +// [Names] runs "go list" through golang.org/x/tools/go/packages, so it needs the go toolchain and a +// module that requires the paths being asked about. It is far slower than formatting, which is why it +// belongs in a separate step rather than inside [github.com/go-openapi/codegen/formatting.Format]. +package resolve diff --git a/formatting/resolve/example_test.go b/formatting/resolve/example_test.go new file mode 100644 index 0000000..6ca3618 --- /dev/null +++ b/formatting/resolve/example_test.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolve_test + +import ( + "context" + "fmt" + "io" + "log" + "os" + + "github.com/go-openapi/codegen/formatting" + "github.com/go-openapi/codegen/formatting/resolve" +) + +// ExampleNames reads the name a package declares, which its import path does not give. +func ExampleNames() { + names, err := resolve.Names(context.Background(), []string{ + "net/http", + "math/rand/v2", + "golang.org/x/tools/go/ast/astutil", + }) + if err != nil { + log.Fatal(err) + } + + for _, importPath := range []string{"net/http", "math/rand/v2", "golang.org/x/tools/go/ast/astutil"} { + fmt.Printf("%-34s %s\n", importPath, names[importPath]) + } + + // Output: + // net/http http + // math/rand/v2 rand + // golang.org/x/tools/go/ast/astutil astutil +} + +// Example runs the whole loop: format, resolve what the report could not settle, format again. +// +// One pass over the generated tree produces the map. Commit it, and every later run needs only +// [formatting.WithResolvedImports], with no toolchain and no module cache. +func Example() { + const rendered = `package p + +import ( + "bytes" + "golang.org/x/tools/go/ast/astutil" +) + +var _ bytes.Buffer +` + + report, err := formatting.Format(io.Discard, []byte(rendered)) + if err != nil { + log.Fatal(err) + } + + fmt.Println("first pass leaves in doubt:", report.PathsInDoubt()) + + names, err := resolve.Names(context.Background(), report.PathsInDoubt()) + if err != nil { + log.Fatal(err) + } + + settled, err := formatting.Format(os.Stdout, []byte(rendered), formatting.WithResolvedImports(names)) + if err != nil { + log.Fatal(err) + } + + fmt.Println("still in doubt:", settled.HasImportsInDoubt()) + + // Output: + // first pass leaves in doubt: [golang.org/x/tools/go/ast/astutil] + // package p + // + // import ( + // "bytes" + // ) + // + // var _ bytes.Buffer + // still in doubt: false +} diff --git a/formatting/resolve/resolve.go b/formatting/resolve/resolve.go new file mode 100644 index 0000000..4fa1408 --- /dev/null +++ b/formatting/resolve/resolve.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolve + +import ( + "context" + "fmt" + "slices" + "strings" + + "golang.org/x/tools/go/packages" +) + +// Error is a string that implements error, so a sentinel below can be a constant. +type Error string + +func (e Error) Error() string { return string(e) } + +const ( + // ErrResolve matches every error [Names] returns. + ErrResolve Error = "cannot resolve import names" + + // ErrUnresolved is returned when a path did not come back with a name. The map still holds every + // path that did, so a caller may use what resolved and act on the rest. + ErrUnresolved Error = "some import paths did not resolve" +) + +// Option configures [Names]. +type Option func(*options) + +type options struct { + dir string + env []string + buildFlags []string +} + +// WithDir loads the packages as if from dir. +// +// "go list" runs there, so dir has to sit in a module requiring the paths being asked about. Without +// it the current working directory is used, which is right only when the process already runs inside +// that module. +func WithDir(dir string) Option { + return func(o *options) { + o.dir = dir + } +} + +// WithEnv replaces the environment "go list" runs with, in the form os.Environ returns. +// +// Use it to pin GOFLAGS, GOPATH or GOMODCACHE. An empty slice leaves the process environment alone. +func WithEnv(env []string) Option { + return func(o *options) { + o.env = slices.Clone(env) + } +} + +// WithBuildFlags passes flags to "go list", as in -tags or -mod=mod. +func WithBuildFlags(flags ...string) Option { + return func(o *options) { + o.buildFlags = append(o.buildFlags, flags...) + } +} + +// Names returns the name each import path declares, ready for +// [github.com/go-openapi/codegen/formatting.WithResolvedImports]. +// +// It loads the packages, so it needs the go toolchain and a module requiring them: see [WithDir]. +// An empty or nil paths leaves it returning an empty map and no error, so a caller may hand it +// [github.com/go-openapi/codegen/formatting.ImportsReport.PathsInDoubt] without checking first. +// +// A path that does not resolve is left out of the map, and the error wraps [ErrUnresolved] and names +// every one of them with what go list said, as in "no required module provides package X". The map +// still holds what did resolve, so use it and report the rest: +// +// names, err := resolve.Names(ctx, paths) +// if err != nil && !errors.Is(err, resolve.ErrUnresolved) { +// return err +// } +// +// Duplicate paths are asked once. The map has one entry per distinct path. +func Names(ctx context.Context, paths []string, opts ...Option) (map[string]string, error) { + wanted := distinct(paths) + if len(wanted) == 0 { + return map[string]string{}, nil + } + + var o options + for _, apply := range opts { + apply(&o) + } + + loaded, err := packages.Load(&packages.Config{ + Context: ctx, + Mode: packages.NeedName, + Dir: o.dir, + Env: o.env, + BuildFlags: o.buildFlags, + }, wanted...) + if err != nil { + return nil, fmt.Errorf("cannot load %d import paths: %w: %w", len(wanted), err, ErrResolve) + } + + names := make(map[string]string, len(loaded)) + reasons := make(map[string]string, len(loaded)) + + for _, pkg := range loaded { + if pkg.PkgPath == "" { + continue + } + + if len(pkg.Errors) > 0 { + reasons[pkg.PkgPath] = oneLine(pkg.Errors[0].Msg) + + continue + } + + if pkg.Name == "" { + continue + } + + names[pkg.PkgPath] = pkg.Name + } + + if missing := missingFrom(wanted, names, reasons); len(missing) > 0 { + return names, fmt.Errorf("%s: %w: %w", strings.Join(missing, "; "), ErrUnresolved, ErrResolve) + } + + return names, nil +} + +// distinct returns the paths worth asking about, in the order they were given, without repeats. +func distinct(paths []string) []string { + wanted := make([]string, 0, len(paths)) + + for _, importPath := range paths { + if importPath == "" || slices.Contains(wanted, importPath) { + continue + } + + wanted = append(wanted, importPath) + } + + return wanted +} + +// missingFrom lists the paths that came back without a name, each with what go list said about it. +// +// go list explains itself well - "no required module provides package X; to add it: go get X" - and +// that sentence is the whole of what a caller needs, so it travels in the error rather than being +// dropped for a tidier message. A path go list did not mention at all gets a stand-in. +func missingFrom(wanted []string, names, reasons map[string]string) []string { + var missing []string + + for _, importPath := range wanted { + if _, ok := names[importPath]; ok { + continue + } + + reason, ok := reasons[importPath] + if !ok { + reason = "go list returned no package for it" + } + + missing = append(missing, importPath+" ("+reason+")") + } + + return missing +} + +// oneLine flattens a go list message, which wraps its "to add it" hint onto a second line. +func oneLine(message string) string { + return strings.Join(strings.Fields(message), " ") +} diff --git a/formatting/resolve/resolve_test.go b/formatting/resolve/resolve_test.go new file mode 100644 index 0000000..f050709 --- /dev/null +++ b/formatting/resolve/resolve_test.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolve_test + +import ( + "bytes" + "context" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" + "github.com/go-openapi/codegen/formatting/resolve" +) + +func TestNames(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("loads packages through go list") + } + + t.Run("should read the name from the package, not from the path", func(t *testing.T) { + t.Parallel() + + names, err := resolve.Names(t.Context(), []string{ + "net/http", + "math/rand/v2", + "golang.org/x/tools/go/ast/astutil", + }) + + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "net/http": "http", + "math/rand/v2": "rand", + "golang.org/x/tools/go/ast/astutil": "astutil", + }, names) + }) + + t.Run("should answer where the path cannot", func(t *testing.T) { + t.Parallel() + + const importPath = "golang.org/x/tools/go/packages" + + names, err := resolve.Names(t.Context(), []string{importPath}) + require.NoError(t, err) + + assert.Equal(t, "packages", names[importPath]) + }) + + t.Run("should take an empty list without loading anything", func(t *testing.T) { + t.Parallel() + + names, err := resolve.Names(t.Context(), nil) + + require.NoError(t, err) + assert.Empty(t, names) + }) + + t.Run("should ask about a repeated path once", func(t *testing.T) { + t.Parallel() + + names, err := resolve.Names(t.Context(), []string{"bytes", "bytes", "bytes"}) + + require.NoError(t, err) + assert.Len(t, names, 1) + }) + + t.Run("should name the paths it could not resolve, and keep the rest", func(t *testing.T) { + t.Parallel() + + names, err := resolve.Names(t.Context(), []string{"bytes", "example.invalid/nope"}) + + require.Error(t, err) + assert.ErrorIs(t, err, resolve.ErrUnresolved) + assert.ErrorIs(t, err, resolve.ErrResolve) + assert.Contains(t, err.Error(), "example.invalid/nope") + assert.Contains(t, err.Error(), "no required module provides package", + "go list says why, and the caller needs that sentence") + + assert.Equal(t, "bytes", names["bytes"], "what resolved is still usable") + }) + + t.Run("should say why a path outside the build list did not resolve", func(t *testing.T) { + t.Parallel() + + // a real package, but nothing in this module requires it + _, err := resolve.Names(t.Context(), []string{"github.com/json-iterator/go"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "go get github.com/json-iterator/go", + "the message carries the fix go list suggests") + }) + + t.Run("should stop when the context is done", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := resolve.Names(ctx, []string{"bytes"}) + + require.Error(t, err) + }) +} + +// TestFeedsTheFormatter closes the loop the report opens. +func TestFeedsTheFormatter(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("loads packages through go list") + } + + // astutil is bare and third-party, so the formatter cannot be sure of its name + const src = `package p + +import ( + "golang.org/x/tools/go/ast/astutil" + "bytes" +) + +var _ bytes.Buffer +` + + var first bytes.Buffer + + report, err := formatting.Format(&first, []byte(src)) + require.NoError(t, err) + require.True(t, report.HasImportsInDoubt()) + require.Equal(t, []string{"golang.org/x/tools/go/ast/astutil"}, report.PathsInDoubt()) + + names, err := resolve.Names(t.Context(), report.PathsInDoubt()) + require.NoError(t, err) + + var second bytes.Buffer + + settled, err := formatting.Format(&second, []byte(src), formatting.WithResolvedImports(names)) + require.NoError(t, err) + + assert.False(t, settled.HasImportsInDoubt(), "the map settled it:\n%s", settled) + assert.NotContains(t, second.String(), "astutil", "and nothing used it, so it went") +} diff --git a/formatting/simplify_test.go b/formatting/simplify_test.go new file mode 100644 index 0000000..ffac043 --- /dev/null +++ b/formatting/simplify_test.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + + "github.com/go-openapi/codegen/formatting" +) + +// TestSimplifiedImportAliases pins which aliases may be taken back out. +// +// An alias states the name an import binds, which is the cheapest way for a template to get exact +// pruning. Dropping it needs proof that the package declares that same name, and that the path says +// so too, or the bare import left behind would bind something else. +func TestSimplifiedImportAliases(t *testing.T) { + t.Parallel() + + src := source(t, "redundant-aliases") + + simplify := formatting.WithSimplifiedImportAliases() + hints := formatting.WithResolvedImports(map[string]string{ + "github.com/go-openapi/strfmt": "strfmt", + "github.com/json-iterator/go": "jsoniter", + }) + + t.Run("should leave every alias alone without the option", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, hints) + + assert.Contains(t, out, `fmt "fmt"`) + assert.Contains(t, out, `strfmt "github.com/go-openapi/strfmt"`) + }) + + t.Run("should drop an alias the standard library table proves redundant", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, simplify) + + assert.Contains(t, out, "\t\"fmt\"\n", "the table names fmt, and so does the path") + assert.NotContains(t, out, `fmt "fmt"`) + }) + + t.Run("should keep a third-party alias until the caller proves the name", func(t *testing.T) { + t.Parallel() + + assert.Contains(t, format2(t, src, simplify), `strfmt "github.com/go-openapi/strfmt"`, + "strfmt is only a guess, and a guess is not evidence") + + assert.Contains(t, format2(t, src, simplify, hints), "\t\"github.com/go-openapi/strfmt\"\n", + "the map states it, and the path agrees") + }) + + t.Run("should keep an alias the path cannot replace", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, simplify, hints) + + assert.Contains(t, out, `jsoniter "github.com/json-iterator/go"`, + "the name is proven, but nothing in the path says jsoniter") + }) + + t.Run("should keep an alias that renames a package", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, simplify, hints) + + assert.Contains(t, out, `sql "database/sql/driver"`, "the package is driver, so sql is not redundant") + }) + + t.Run("should not touch a blank or a dot import", func(t *testing.T) { + t.Parallel() + + out := format2(t, src, simplify, hints) + + assert.Contains(t, out, `_ "embed"`) + assert.Contains(t, out, `. "strings"`) + }) + + t.Run("should not change its own output on a second pass", func(t *testing.T) { + t.Parallel() + + once := format2(t, src, simplify, hints) + twice := format2(t, once, simplify, hints) + + assert.Equal(t, once, twice) + }) + + t.Run("should leave the output nameable by the path alone", func(t *testing.T) { + t.Parallel() + + // what a later run without the map makes of the simplified output + simplified := format2(t, src, simplify, hints) + report := reportOf(t, simplified) + + for _, record := range report.Imports() { + if record.Path == "github.com/go-openapi/strfmt" { + assert.Equal(t, "strfmt", formatting.ImportedPackageName(record.Path), + "dropping the alias lost nothing the path does not say") + } + } + }) +} diff --git a/formatting/sort.go b/formatting/sort.go new file mode 100644 index 0000000..03f3b1c --- /dev/null +++ b/formatting/sort.go @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 +// +// The spec sorting and its position bookkeeping follow +// golang.org/x/tools/internal/imports/sortimports.go, itself a copy of go/ast/import.go, which +// carries: +// +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license. + +package formatting + +import ( + "go/ast" + "go/token" + "reflect" + "slices" + "sort" + "strconv" + "strings" + + "golang.org/x/tools/go/ast/astutil" +) + +// sortImports orders the imports of every import block and removes the duplicates it safely can. +// +// The whole block is one run: a blank line the source left between two imports is not a boundary +// here, so "bytes" written in two groups is one import in the output. gofmt and goimports sort each +// blank-line-separated run on its own and leave that duplicate behind, which the compiler then +// rejects with "bytes redeclared in this block". [groupBreaks] and [spacer] put the blank lines +// back, from the prefixes [WithImportGroups] was given. +// +// It mutates the file and the token.File: a spec keeps the position of whichever spec used to sit +// where it lands, so the printer lays the block out on consecutive lines. +func sortImports(tokFile *token.File, file *ast.File, groups []string) { + for i, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT { + break // imports come first, so the first other declaration ends the search + } + + if len(gen.Specs) == 0 { + file.Decls = slices.Delete(file.Decls, i, i+1) + + continue + } + + if !gen.Lparen.IsValid() { + continue // a single import needs no sorting + } + + gen.Specs = sortSpecs(tokFile, file, gen.Specs, groups) + closeGap(tokFile, gen) + } +} + +// closeGap removes the blank line a dedup may have left before the closing parenthesis. +func closeGap(tokFile *token.File, gen *ast.GenDecl) { + if len(gen.Specs) == 0 { + return + } + + last := gen.Specs[len(gen.Specs)-1] + lastLine := tokFile.PositionFor(last.Pos(), false).Line + + if rparen := tokFile.PositionFor(gen.Rparen, false).Line; rparen > lastLine+1 { + tokFile.MergeLine(rparen - 1) + } +} + +// mergeImports moves every import declaration into the first one. +// +// A cgo preamble is attached to the import that carries it, so a declaration importing "C" is left +// where it is. +func mergeImports(file *ast.File) { + if len(file.Decls) <= 1 { + return + } + + var first *ast.GenDecl + + for i := 0; i < len(file.Decls); i++ { + gen, ok := file.Decls[i].(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT || declaresCgo(gen) { + continue + } + + if first == nil { + first = gen + + continue + } + + first.Lparen = first.Pos() // more than one import, so the block needs parentheses + + for _, spec := range gen.Specs { + updateBasicLitPos(spec.(*ast.ImportSpec).Path, first.Pos()) + first.Specs = append(first.Specs, spec) + } + + file.Decls = slices.Delete(file.Decls, i, i+1) + i-- + } +} + +func declaresCgo(gen *ast.GenDecl) bool { + for _, spec := range gen.Specs { + if specPath(spec) == cgoImport { + return true + } + } + + return false +} + +// importGroup reports which group an import path belongs to. +// +// Group 0 is the standard library, group len(groups)+1 is everything the prefixes do not claim, and +// a prefix claims the group at its own index plus one. An import belongs to the first prefix it +// starts with, so a caller passing overlapping prefixes gets the more specific one by passing it +// first. +func importGroup(groups []string, importPath string) int { + if !isThirdParty(importPath) { + return 0 + } + + for i, prefix := range groups { + if strings.HasPrefix(importPath, prefix) || strings.TrimSuffix(prefix, "/") == importPath { + return i + 1 + } + } + + return len(groups) + 1 +} + +// isThirdParty reports whether an import path names something outside the standard library. +// +// The standard library owns every path whose first element holds no dot, which is the same test +// gofmt and goimports apply. +func isThirdParty(importPath string) bool { + first, _, _ := strings.Cut(importPath, "/") + + return strings.Contains(first, ".") +} + +// groupBreaks lists the import paths that open a group, past the first. +// +// The printer lays the sorted block out on consecutive lines, so these are the paths a blank line +// has to precede. [spacer] inserts them while the output is written. +func groupBreaks(fset *token.FileSet, file *ast.File, groups []string) []string { + var breaks []string + + for _, block := range astutil.Imports(fset, file) { + previous := -1 + + for _, spec := range block { + importPath := specPath(spec) + group := importGroup(groups, importPath) + + if previous != -1 && group != previous { + breaks = append(breaks, importPath) + } + + previous = group + } + } + + return breaks +} + +// sortSpecs sorts an import block and reassigns positions so it prints on consecutive lines. +func sortSpecs(tokFile *token.File, file *ast.File, specs []ast.Spec, groups []string) []ast.Spec { + if len(specs) <= 1 { + return specs // a lone import is sorted, and has nothing to collapse against + } + + positions := make([]posSpan, len(specs)) + for i, spec := range specs { + positions[i] = posSpan{Start: spec.Pos(), End: spec.End()} + } + + comments := commentsInRun(tokFile, file, positions) + attached := attachComments(comments, specs, positions) + + sort.Sort(byImportSpec{groups: groups, specs: specs}) + specs = dedup(tokFile, specs) + + replaceSpecPositions(specs, positions, attached) + sort.Sort(byCommentPos(comments)) + closeRunGaps(tokFile, specs) + + return specs +} + +type posSpan struct { + Start token.Pos + End token.Pos +} + +// commentsInRun returns the comment groups written inside the span the specs cover. +func commentsInRun(tokFile *token.File, file *ast.File, positions []posSpan) []*ast.CommentGroup { + lastLine := tokFile.Line(positions[len(positions)-1].End) + start, end := len(file.Comments), len(file.Comments) + + for i, group := range file.Comments { + if group.Pos() < positions[0].Start { + continue + } + + if i < start { + start = i + } + + if tokFile.Line(group.End()) > lastLine { + end = i + + break + } + } + + return file.Comments[start:end] +} + +// attachComments assigns each comment group to the spec it follows. +func attachComments( + comments []*ast.CommentGroup, + specs []ast.Spec, + positions []posSpan, +) map[*ast.ImportSpec][]*ast.CommentGroup { + attached := make(map[*ast.ImportSpec][]*ast.CommentGroup, len(specs)) + current := 0 + + for _, group := range comments { + for current+1 < len(specs) && positions[current+1].Start <= group.Pos() { + current++ + } + + spec := specs[current].(*ast.ImportSpec) + attached[spec] = append(attached[spec], group) + } + + return attached +} + +// dedup drops a spec that repeats the one before it, now that sorting has made them adjacent. +func dedup(tokFile *token.File, specs []ast.Spec) []ast.Spec { + deduped := specs[:0] + + for i, spec := range specs { + if i == len(specs)-1 || !collapses(spec, specs[i+1]) { + deduped = append(deduped, spec) + + continue + } + + tokFile.MergeLine(tokFile.Line(spec.Pos())) + } + + return deduped +} + +// collapses reports whether previous may be dropped in favour of next, losing nothing. +func collapses(previous, next ast.Spec) bool { + if specPath(next) != specPath(previous) || specName(next) != specName(previous) { + return false + } + + return previous.(*ast.ImportSpec).Comment == nil +} + +// replaceSpecPositions gives the sorted specs the positions the block occupied before sorting. +func replaceSpecPositions( + specs []ast.Spec, + positions []posSpan, + attached map[*ast.ImportSpec][]*ast.CommentGroup, +) { + for i, s := range specs { + spec := s.(*ast.ImportSpec) + + if spec.Name != nil { + spec.Name.NamePos = positions[i].Start + } + + updateBasicLitPos(spec.Path, positions[i].Start) + spec.EndPos = positions[i].End + + next := positions[i].End + for _, group := range attached[spec] { + for _, comment := range group.List { + comment.Slash = positions[i].End + next = comment.End() + } + } + + if i < len(specs)-1 { + positions[i+1].Start = next + positions[i+1].End = next + } + } +} + +// closeRunGaps merges away the blank lines, both the ones the source wrote between its own groups +// and the ones moving the comments opened. +func closeRunGaps(tokFile *token.File, specs []ast.Spec) { + firstLine := tokFile.Line(specs[0].Pos()) + + for _, spec := range specs[1:] { + for line := tokFile.Line(spec.Pos()) - 1; line >= firstLine; line-- { + // MergeLine panics outside the line range, and a comment can put a spec there. + // golang/go#50329 + if line <= 0 || line >= tokFile.LineCount() { + break + } + + tokFile.MergeLine(line) + } + } +} + +// updateBasicLitPos moves a literal, keeping its end in step with its start. +// +// ast.BasicLit.ValueEnd arrived in go1.26 and the module still builds on go1.25, so the field is +// reached by reflection. Assign it directly once the go directive moves. +func updateBasicLitPos(lit *ast.BasicLit, pos token.Pos) { + length := lit.End() - lit.Pos() + lit.ValuePos = pos + + if end := reflect.ValueOf(lit).Elem().FieldByName("ValueEnd"); end.IsValid() && end.Int() != 0 { + end.SetInt(int64(pos + length)) + } +} + +func specPath(spec ast.Spec) string { + unquoted, err := strconv.Unquote(spec.(*ast.ImportSpec).Path.Value) + if err != nil { + return "" + } + + return unquoted +} + +func specName(spec ast.Spec) string { + if name := spec.(*ast.ImportSpec).Name; name != nil { + return name.Name + } + + return "" +} + +func specComment(spec ast.Spec) string { + if comment := spec.(*ast.ImportSpec).Comment; comment != nil { + return comment.Text() + } + + return "" +} + +type byImportSpec struct { + groups []string + specs []ast.Spec +} + +func (x byImportSpec) Len() int { return len(x.specs) } +func (x byImportSpec) Swap(i, j int) { x.specs[i], x.specs[j] = x.specs[j], x.specs[i] } + +func (x byImportSpec) Less(i, j int) bool { + ipath, jpath := specPath(x.specs[i]), specPath(x.specs[j]) + + igroup, jgroup := importGroup(x.groups, ipath), importGroup(x.groups, jpath) + if igroup != jgroup { + return igroup < jgroup + } + + if ipath != jpath { + return ipath < jpath + } + + iname, jname := specName(x.specs[i]), specName(x.specs[j]) + if iname != jname { + return iname < jname + } + + return specComment(x.specs[i]) < specComment(x.specs[j]) +} + +type byCommentPos []*ast.CommentGroup + +func (x byCommentPos) Len() int { return len(x) } +func (x byCommentPos) Swap(i, j int) { x[i], x[j] = x[j], x[i] } +func (x byCommentPos) Less(i, j int) bool { return x[i].Pos() < x[j].Pos() } diff --git a/formatting/sources_test.go b/formatting/sources_test.go new file mode 100644 index 0000000..858f0d2 --- /dev/null +++ b/formatting/sources_test.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting_test + +import ( + "embed" + "path" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/require" +) + +// sources holds the Go source every test formats. +// +// A fixture is a file rather than a string in a test, so that it reads as the Go it is, an editor +// indents it, and adding a case to a table means adding a file. The .input extension keeps gofmt +// away from sources that are deliberately misformatted. +// +//go:embed testdata/sources +var sources embed.FS + +const sourceRoot = "testdata/sources" + +// source returns one fixture, named without its extension: source(t, "prune/unused"). +func source(t *testing.T, name string) string { + t.Helper() + + content, err := sources.ReadFile(path.Join(sourceRoot, name+".input")) + require.NoError(t, err, "no such fixture, add %s.input under %s", name, sourceRoot) + + return string(content) +} + +// sourceSet returns every fixture in a directory, keyed by file name without its extension. +// +// A table built from it grows by dropping a file in, which is the point. +func sourceSet(t *testing.T, dir string) map[string]string { + t.Helper() + + entries, err := sources.ReadDir(path.Join(sourceRoot, dir)) + require.NoError(t, err) + require.NotEmpty(t, entries, "no fixtures under %s", path.Join(sourceRoot, dir)) + + set := make(map[string]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".input") { + continue + } + + name := strings.TrimSuffix(entry.Name(), ".input") + set[name] = source(t, path.Join(dir, name)) + } + + require.NotEmpty(t, set, "no .input fixtures under %s", path.Join(sourceRoot, dir)) + + return set +} + +// caseName turns a fixture file name into the sentence a subtest reads as. +func caseName(fixture string) string { + return strings.ReplaceAll(fixture, "-", " ") +} diff --git a/formatting/spacer.go b/formatting/spacer.go new file mode 100644 index 0000000..647fb76 --- /dev/null +++ b/formatting/spacer.go @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 +// +// The import-line matching follows golang.org/x/tools/internal/imports/imports.go, which carries: +// +// Copyright 2013 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license. + +package formatting + +import ( + "bytes" + "io" + "regexp" +) + +// importLine matches a line inside an import block and captures the path it imports. +var importLine = regexp.MustCompile(`^\s+(?:[\w.]+\s+)?"(.+?)"`) + +// importKeyword opens the import block; declarationKeywords close it. +// +// Lines are handed about as bytes rather than strings: the printer emits one line per output line +// and turning each into a string allocated once per line of every file formatted. +var ( + importKeyword = []byte("import") + declarationKeywords = [...][]byte{[]byte("var"), []byte("func"), []byte("const"), []byte("type")} +) + +// spacer writes a blank line before each import that opens a group. +// +// The printer lays a sorted import block out on consecutive lines and offers no way to ask for a +// blank line between two specs, so the separation is written here, as the output goes past. Holding +// one line at a time keeps [Format] from buffering the whole file. +type spacer struct { + out io.Writer + + breaks []string // import paths still waiting for a blank line, in the order they appear + line bytes.Buffer + inBlock bool // inside the import block + past bool // the import block is behind us + err error +} + +func newSpacer(out io.Writer, breaks []string) *spacer { + return &spacer{out: out, breaks: breaks} +} + +func (s *spacer) Write(p []byte) (int, error) { + if s.err != nil { + return 0, s.err + } + + written := len(p) + + for len(p) > 0 { + end := bytes.IndexByte(p, '\n') + if end < 0 { + s.line.Write(p) + + break + } + + s.line.Write(p[:end+1]) + p = p[end+1:] + + if err := s.emit(s.line.Bytes()); err != nil { + s.err = err + + return 0, err + } + s.line.Reset() + } + + return written, nil +} + +// Flush writes the last line, when the output did not end with a newline. +func (s *spacer) Flush() error { + if s.err != nil { + return s.err + } + + if s.line.Len() == 0 { + return nil + } + + err := s.emit(s.line.Bytes()) + s.line.Reset() + + return err +} + +// emit writes one line, preceded by a blank line when it opens a group. +func (s *spacer) emit(line []byte) error { + s.track(line) + + if s.inBlock && len(s.breaks) > 0 { + if match := importLine.FindSubmatch(line); match != nil && string(match[1]) == s.breaks[0] { + s.breaks = s.breaks[1:] + + if _, err := io.WriteString(s.out, "\n"); err != nil { + return err + } + } + } + + _, err := s.out.Write(line) + + return err +} + +// track follows the output into and out of the import block. +func (s *spacer) track(line []byte) { + if s.past { + return + } + + if !s.inBlock && bytes.HasPrefix(line, importKeyword) { + s.inBlock = true + + return + } + + if s.inBlock && opensDeclaration(line) { + s.inBlock = false + s.past = true + } +} + +// opensDeclaration reports whether a line starts a top-level declaration, which puts the import +// block behind us. +func opensDeclaration(line []byte) bool { + for _, keyword := range declarationKeywords { + if bytes.HasPrefix(line, keyword) { + return true + } + } + + return false +} diff --git a/formatting/testdata/corpus/aliased/aliased.go b/formatting/testdata/corpus/aliased/aliased.go new file mode 100644 index 0000000..6b457c8 --- /dev/null +++ b/formatting/testdata/corpus/aliased/aliased.go @@ -0,0 +1,14 @@ +package aliased + +import ( + "crypto/rand" + _ "embed" + mrand "math/rand" +) + +// Fill shows two packages that both declare rand, kept apart by an alias. +func Fill(b []byte) int { + _, _ = rand.Read(b) + + return mrand.Int() +} diff --git a/formatting/testdata/corpus/aliased/aliased.input b/formatting/testdata/corpus/aliased/aliased.input new file mode 100644 index 0000000..5404923 --- /dev/null +++ b/formatting/testdata/corpus/aliased/aliased.input @@ -0,0 +1,15 @@ +package aliased + +import ( + _ "embed" + + "crypto/rand" + mrand "math/rand" +) + +// Fill shows two packages that both declare rand, kept apart by an alias. +func Fill(b []byte) int { + _, _ = rand.Read(b) + + return mrand.Int() +} diff --git a/formatting/testdata/corpus/deduped/deduped.go b/formatting/testdata/corpus/deduped/deduped.go new file mode 100644 index 0000000..568f96f --- /dev/null +++ b/formatting/testdata/corpus/deduped/deduped.go @@ -0,0 +1,17 @@ +package deduped + +import ( + "bytes" + "context" + + "github.com/go-openapi/swag/conv" +) + +// Encode names each package once, although the imports declare two of them twice. +func Encode(ctx context.Context, in *int) *bytes.Buffer { + _ = ctx + var buf bytes.Buffer + buf.WriteString(conv.FormatInteger(conv.Value(in))) + + return &buf +} diff --git a/formatting/testdata/corpus/deduped/deduped.input b/formatting/testdata/corpus/deduped/deduped.input new file mode 100644 index 0000000..f7e169b --- /dev/null +++ b/formatting/testdata/corpus/deduped/deduped.input @@ -0,0 +1,20 @@ +package deduped + +import ( + "bytes" + "github.com/go-openapi/swag/conv" + + "context" + "bytes" + + "github.com/go-openapi/swag/conv" +) + +// Encode names each package once, although the imports declare two of them twice. +func Encode(ctx context.Context, in *int) *bytes.Buffer { + _ = ctx + var buf bytes.Buffer + buf.WriteString(conv.FormatInteger(conv.Value(in))) + + return &buf +} diff --git a/formatting/testdata/corpus/go.mod b/formatting/testdata/corpus/go.mod new file mode 100644 index 0000000..06d51ca --- /dev/null +++ b/formatting/testdata/corpus/go.mod @@ -0,0 +1,15 @@ +// The corpus is a module of its own so that the import paths a fixture needs are not requirements +// of github.com/go-openapi/codegen. The go tool ignores a directory named testdata, so nothing here +// joins the parent module, and go.work leaves it out. +// +// Every .go file here is a golden: `go build ./...` from this directory proves that what the +// formatter produced still compiles, which is what catches an import dropped by mistake. The .input +// files beside them are what the formatter is given. +module github.com/go-openapi/codegen/formatting/testdata/corpus + +go 1.25.0 + +require ( + github.com/go-openapi/swag/conv v0.29.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/formatting/testdata/corpus/go.sum b/formatting/testdata/corpus/go.sum new file mode 100644 index 0000000..8e89b02 --- /dev/null +++ b/formatting/testdata/corpus/go.sum @@ -0,0 +1,10 @@ +github.com/go-openapi/swag/conv v0.29.0 h1:4+1TogWpOIzMPzVKrvx1BfqBYlApB7D7DW3EAWpwmp4= +github.com/go-openapi/swag/conv v0.29.0/go.mod h1:ch1l7V87F6zQXuLs5s0RFvrro6aFvrVcfVXn2PTZnu8= +github.com/go-openapi/swag/typeutils v0.29.0 h1:HrWCYZeXVVNDo/7QQPRaYk33XeIDxksbxpalID3bWR8= +github.com/go-openapi/swag/typeutils v0.29.0/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= +github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q= +github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/formatting/testdata/corpus/grouped/grouped.go b/formatting/testdata/corpus/grouped/grouped.go new file mode 100644 index 0000000..67d03ec --- /dev/null +++ b/formatting/testdata/corpus/grouped/grouped.go @@ -0,0 +1,19 @@ +package grouped + +import ( + "bytes" + "context" + + "github.com/go-openapi/swag/conv" + + "gopkg.in/yaml.v3" +) + +// Marshal reaches one import from every group. +func Marshal(ctx context.Context, in any) ([]byte, error) { + _ = ctx + _ = conv.Pointer(1) + var buf bytes.Buffer + _ = buf + return yaml.Marshal(in) +} diff --git a/formatting/testdata/corpus/grouped/grouped.input b/formatting/testdata/corpus/grouped/grouped.input new file mode 100644 index 0000000..af442cc --- /dev/null +++ b/formatting/testdata/corpus/grouped/grouped.input @@ -0,0 +1,17 @@ +package grouped + +import ( +"gopkg.in/yaml.v3" + "context" +"github.com/go-openapi/swag/conv" + "bytes" +) + +// Marshal reaches one import from every group. +func Marshal(ctx context.Context, in any) ([]byte, error) { + _ = ctx + _ = conv.Pointer(1) + var buf bytes.Buffer + _ = buf + return yaml.Marshal(in) +} diff --git a/formatting/testdata/corpus/pruned/pruned.go b/formatting/testdata/corpus/pruned/pruned.go new file mode 100644 index 0000000..d86a0b3 --- /dev/null +++ b/formatting/testdata/corpus/pruned/pruned.go @@ -0,0 +1,19 @@ +package pruned + +import ( + buf "bufio" + "bytes" + "context" + _ "embed" + + "gopkg.in/yaml.v3" +) + +// Keep uses only some of what the template imported. +func Keep() { + var b bytes.Buffer + _ = b + _ = context.TODO + _, _ = yaml.Marshal(nil) + _ = buf.NewReader +} diff --git a/formatting/testdata/corpus/pruned/pruned.input b/formatting/testdata/corpus/pruned/pruned.input new file mode 100644 index 0000000..909cc89 --- /dev/null +++ b/formatting/testdata/corpus/pruned/pruned.input @@ -0,0 +1,20 @@ +package pruned + +import ( + "bytes" + "context" + "strings" + _ "embed" + unused "net/http" + buf "bufio" + "gopkg.in/yaml.v3" +) + +// Keep uses only some of what the template imported. +func Keep() { + var b bytes.Buffer + _ = b + _ = context.TODO + _, _ = yaml.Marshal(nil) + _ = buf.NewReader +} diff --git a/formatting/testdata/sources/broken-decl.input b/formatting/testdata/sources/broken-decl.input new file mode 100644 index 0000000..cd9f28c --- /dev/null +++ b/formatting/testdata/sources/broken-decl.input @@ -0,0 +1,3 @@ +package p + +func F( { diff --git a/formatting/testdata/sources/broken-expr.input b/formatting/testdata/sources/broken-expr.input new file mode 100644 index 0000000..c5b7259 --- /dev/null +++ b/formatting/testdata/sources/broken-expr.input @@ -0,0 +1,3 @@ +package p + +var x = diff --git a/formatting/testdata/sources/collision/guessed-names-clash.input b/formatting/testdata/sources/collision/guessed-names-clash.input new file mode 100644 index 0000000..b196fef --- /dev/null +++ b/formatting/testdata/sources/collision/guessed-names-clash.input @@ -0,0 +1,8 @@ +package p + +import ( + "github.com/go-openapi/core" + "k8s.io/api/core/v1" +) + +var _ = core.String diff --git a/formatting/testdata/sources/consistent/blank-and-dot-beside-a-plain-import.input b/formatting/testdata/sources/consistent/blank-and-dot-beside-a-plain-import.input new file mode 100644 index 0000000..f06bfdd --- /dev/null +++ b/formatting/testdata/sources/consistent/blank-and-dot-beside-a-plain-import.input @@ -0,0 +1,9 @@ +package p + +import ( + "bytes" + _ "bytes" + . "strings" +) + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/consistent/both-candidates-used.input b/formatting/testdata/sources/consistent/both-candidates-used.input new file mode 100644 index 0000000..958122b --- /dev/null +++ b/formatting/testdata/sources/consistent/both-candidates-used.input @@ -0,0 +1,10 @@ +package p + +import ( + "k8s.io/api/apps/v1" +) + +var ( + _ = v1.Deployment{} + _ = apps.Name +) diff --git a/formatting/testdata/sources/consistent/collision-pruned-away.input b/formatting/testdata/sources/consistent/collision-pruned-away.input new file mode 100644 index 0000000..6453a79 --- /dev/null +++ b/formatting/testdata/sources/consistent/collision-pruned-away.input @@ -0,0 +1,8 @@ +package p + +import ( + "crypto/rand" + "math/rand" +) + +var _ = 1 diff --git a/formatting/testdata/sources/consistent/same-base-different-names-used.input b/formatting/testdata/sources/consistent/same-base-different-names-used.input new file mode 100644 index 0000000..09eafcf --- /dev/null +++ b/formatting/testdata/sources/consistent/same-base-different-names-used.input @@ -0,0 +1,11 @@ +package p + +import ( + "github.com/go-openapi/core" + "k8s.io/api/core/v1" +) + +var ( + _ = core.String + _ = v1.Pod{} +) diff --git a/formatting/testdata/sources/consistent/unnameable-package.input b/formatting/testdata/sources/consistent/unnameable-package.input new file mode 100644 index 0000000..ccc3514 --- /dev/null +++ b/formatting/testdata/sources/consistent/unnameable-package.input @@ -0,0 +1,9 @@ +package p + +import ( + "example.com/2fa" + + twofa "example.com/2fa" +) + +var _ = twofa.Value diff --git a/formatting/testdata/sources/duplicate-across-groups.input b/formatting/testdata/sources/duplicate-across-groups.input new file mode 100644 index 0000000..82f1b71 --- /dev/null +++ b/formatting/testdata/sources/duplicate-across-groups.input @@ -0,0 +1,15 @@ +package p + +import ( + "bytes" + "github.com/go-openapi/strfmt" + + "bytes" + "context" +) + +var ( + _ bytes.Buffer + _ strfmt.Date + _ = context.TODO +) diff --git a/formatting/testdata/sources/empty-package.input b/formatting/testdata/sources/empty-package.input new file mode 100644 index 0000000..c89cd18 --- /dev/null +++ b/formatting/testdata/sources/empty-package.input @@ -0,0 +1 @@ +package p diff --git a/formatting/testdata/sources/fragment-decls.input b/formatting/testdata/sources/fragment-decls.input new file mode 100644 index 0000000..ff19b0d --- /dev/null +++ b/formatting/testdata/sources/fragment-decls.input @@ -0,0 +1,3 @@ +func F( ) int { +return 1 +} diff --git a/formatting/testdata/sources/fragment-main.input b/formatting/testdata/sources/fragment-main.input new file mode 100644 index 0000000..0c9285a --- /dev/null +++ b/formatting/testdata/sources/fragment-main.input @@ -0,0 +1,2 @@ +func main( ) { +} diff --git a/formatting/testdata/sources/fragment-spaced.input b/formatting/testdata/sources/fragment-spaced.input new file mode 100644 index 0000000..e68fc03 --- /dev/null +++ b/formatting/testdata/sources/fragment-spaced.input @@ -0,0 +1,5 @@ + + + x := 1 + _ = x + diff --git a/formatting/testdata/sources/fragment-stmts.input b/formatting/testdata/sources/fragment-stmts.input new file mode 100644 index 0000000..b9ad1fd --- /dev/null +++ b/formatting/testdata/sources/fragment-stmts.input @@ -0,0 +1,2 @@ +x := 1 +_ = x diff --git a/formatting/testdata/sources/gofmt/alignment.input b/formatting/testdata/sources/gofmt/alignment.input new file mode 100644 index 0000000..188a248 --- /dev/null +++ b/formatting/testdata/sources/gofmt/alignment.input @@ -0,0 +1,6 @@ +package p + +type T struct { + A int `json:"a"` + BB string `json:"bb"` +} diff --git a/formatting/testdata/sources/gofmt/number-literals.input b/formatting/testdata/sources/gofmt/number-literals.input new file mode 100644 index 0000000..5ff6142 --- /dev/null +++ b/formatting/testdata/sources/gofmt/number-literals.input @@ -0,0 +1,8 @@ +package p + +var ( + _ = 0XFF + _ = 1E6 + _ = 0b1010 + _ = 0O17 +) diff --git a/formatting/testdata/sources/gofmt/standard-library-only.input b/formatting/testdata/sources/gofmt/standard-library-only.input new file mode 100644 index 0000000..41958fc --- /dev/null +++ b/formatting/testdata/sources/gofmt/standard-library-only.input @@ -0,0 +1,11 @@ +package p + +import ( + "context" + "bytes" +) + +var ( + _ = context.TODO + _ bytes.Buffer +) diff --git a/formatting/testdata/sources/grouped.input b/formatting/testdata/sources/grouped.input new file mode 100644 index 0000000..22a1c6b --- /dev/null +++ b/formatting/testdata/sources/grouped.input @@ -0,0 +1,15 @@ +package p + +import ( + "context" + "example.com/petstore/models" + "github.com/go-openapi/strfmt" + "github.com/google/uuid" +) + +var ( + _ = context.TODO + _ = models.Pet{} + _ = strfmt.Default + _ = uuid.New +) diff --git a/formatting/testdata/sources/idempotent/aliased.input b/formatting/testdata/sources/idempotent/aliased.input new file mode 100644 index 0000000..08d3b41 --- /dev/null +++ b/formatting/testdata/sources/idempotent/aliased.input @@ -0,0 +1,5 @@ +package p + +import buf "bytes" + +var _ buf.Buffer diff --git a/formatting/testdata/sources/idempotent/blank-lines-in-the-source.input b/formatting/testdata/sources/idempotent/blank-lines-in-the-source.input new file mode 100644 index 0000000..6b2f8a7 --- /dev/null +++ b/formatting/testdata/sources/idempotent/blank-lines-in-the-source.input @@ -0,0 +1,17 @@ +package p + +import ( + "strings" + + "bytes" + "github.com/go-openapi/strfmt" + + "context" +) + +var ( + _ = strings.NewReader + _ bytes.Buffer + _ strfmt.Date + _ = context.TODO +) diff --git a/formatting/testdata/sources/idempotent/blank-only.input b/formatting/testdata/sources/idempotent/blank-only.input new file mode 100644 index 0000000..4089847 --- /dev/null +++ b/formatting/testdata/sources/idempotent/blank-only.input @@ -0,0 +1,3 @@ +package p + +import _ "embed" diff --git a/formatting/testdata/sources/idempotent/cgo.input b/formatting/testdata/sources/idempotent/cgo.input new file mode 100644 index 0000000..45a936f --- /dev/null +++ b/formatting/testdata/sources/idempotent/cgo.input @@ -0,0 +1,4 @@ +package p + +// #include +import "C" diff --git a/formatting/testdata/sources/idempotent/comment-kept.input b/formatting/testdata/sources/idempotent/comment-kept.input new file mode 100644 index 0000000..e28451d --- /dev/null +++ b/formatting/testdata/sources/idempotent/comment-kept.input @@ -0,0 +1,11 @@ +package p + +import ( + "bytes" // a comment on the import + "context" +) + +var ( + _ bytes.Buffer + _ = context.TODO +) diff --git a/formatting/testdata/sources/idempotent/fragment.input b/formatting/testdata/sources/idempotent/fragment.input new file mode 100644 index 0000000..e68fc03 --- /dev/null +++ b/formatting/testdata/sources/idempotent/fragment.input @@ -0,0 +1,5 @@ + + + x := 1 + _ = x + diff --git a/formatting/testdata/sources/idempotent/grouped.input b/formatting/testdata/sources/idempotent/grouped.input new file mode 100644 index 0000000..22a1c6b --- /dev/null +++ b/formatting/testdata/sources/idempotent/grouped.input @@ -0,0 +1,15 @@ +package p + +import ( + "context" + "example.com/petstore/models" + "github.com/go-openapi/strfmt" + "github.com/google/uuid" +) + +var ( + _ = context.TODO + _ = models.Pet{} + _ = strfmt.Default + _ = uuid.New +) diff --git a/formatting/testdata/sources/idempotent/no-imports.input b/formatting/testdata/sources/idempotent/no-imports.input new file mode 100644 index 0000000..d8a7f8a --- /dev/null +++ b/formatting/testdata/sources/idempotent/no-imports.input @@ -0,0 +1,3 @@ +package p + +var _ = 1 diff --git a/formatting/testdata/sources/idempotent/single.input b/formatting/testdata/sources/idempotent/single.input new file mode 100644 index 0000000..e929c38 --- /dev/null +++ b/formatting/testdata/sources/idempotent/single.input @@ -0,0 +1,5 @@ +package p + +import "bytes" + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/inconsistent/alias-shadows-another-base.input b/formatting/testdata/sources/inconsistent/alias-shadows-another-base.input new file mode 100644 index 0000000..a7a9d8b --- /dev/null +++ b/formatting/testdata/sources/inconsistent/alias-shadows-another-base.input @@ -0,0 +1,9 @@ +package p + +import ( + rand "math/rand" + + "crypto/rand" +) + +var _ = rand.Read diff --git a/formatting/testdata/sources/inconsistent/one-alias-two-packages.input b/formatting/testdata/sources/inconsistent/one-alias-two-packages.input new file mode 100644 index 0000000..055745c --- /dev/null +++ b/formatting/testdata/sources/inconsistent/one-alias-two-packages.input @@ -0,0 +1,9 @@ +package p + +import ( + x "bytes" + + x "strings" +) + +var _ = x.NewReader diff --git a/formatting/testdata/sources/inconsistent/one-package-two-names.input b/formatting/testdata/sources/inconsistent/one-package-two-names.input new file mode 100644 index 0000000..47e2cef --- /dev/null +++ b/formatting/testdata/sources/inconsistent/one-package-two-names.input @@ -0,0 +1,12 @@ +package p + +import ( + "bytes" + + b "bytes" +) + +var ( + _ bytes.Buffer + _ b.Reader +) diff --git a/formatting/testdata/sources/inconsistent/several-mismatches.input b/formatting/testdata/sources/inconsistent/several-mismatches.input new file mode 100644 index 0000000..7a29c4c --- /dev/null +++ b/formatting/testdata/sources/inconsistent/several-mismatches.input @@ -0,0 +1,17 @@ +package p + +import ( + "bytes" + b "bytes" + x "strings" + x "errors" + "crypto/rand" + "math/rand" +) + +var ( + _ = bytes.NewReader + _ b.Buffer + _ = x.NewReader + _ = rand.Read +) diff --git a/formatting/testdata/sources/inconsistent/two-packages-same-base.input b/formatting/testdata/sources/inconsistent/two-packages-same-base.input new file mode 100644 index 0000000..7a98a2d --- /dev/null +++ b/formatting/testdata/sources/inconsistent/two-packages-same-base.input @@ -0,0 +1,8 @@ +package p + +import ( + "crypto/rand" + "math/rand" +) + +var _ = rand.Read diff --git a/formatting/testdata/sources/prune/aliased.input b/formatting/testdata/sources/prune/aliased.input new file mode 100644 index 0000000..2cad806 --- /dev/null +++ b/formatting/testdata/sources/prune/aliased.input @@ -0,0 +1,8 @@ +package p + +import ( + buf "bytes" + unused "strings" +) + +var _ buf.Buffer diff --git a/formatting/testdata/sources/prune/bare-third-party.input b/formatting/testdata/sources/prune/bare-third-party.input new file mode 100644 index 0000000..94f0e8e --- /dev/null +++ b/formatting/testdata/sources/prune/bare-third-party.input @@ -0,0 +1,8 @@ +package p + +import ( + "bytes" + "github.com/go-openapi/strfmt" +) + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/prune/blank-and-dot-unused.input b/formatting/testdata/sources/prune/blank-and-dot-unused.input new file mode 100644 index 0000000..bc0adde --- /dev/null +++ b/formatting/testdata/sources/prune/blank-and-dot-unused.input @@ -0,0 +1,9 @@ +package p + +import ( + "bytes" + _ "embed" + . "strings" +) + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/prune/blank-and-dot.input b/formatting/testdata/sources/prune/blank-and-dot.input new file mode 100644 index 0000000..fe59489 --- /dev/null +++ b/formatting/testdata/sources/prune/blank-and-dot.input @@ -0,0 +1,6 @@ +package p + +import ( + _ "embed" + . "strings" +) diff --git a/formatting/testdata/sources/prune/cgo.input b/formatting/testdata/sources/prune/cgo.input new file mode 100644 index 0000000..45a936f --- /dev/null +++ b/formatting/testdata/sources/prune/cgo.input @@ -0,0 +1,4 @@ +package p + +// #include +import "C" diff --git a/formatting/testdata/sources/prune/missing.input b/formatting/testdata/sources/prune/missing.input new file mode 100644 index 0000000..adbc58c --- /dev/null +++ b/formatting/testdata/sources/prune/missing.input @@ -0,0 +1,5 @@ +package p + +func F() string { + return fmt.Sprintf("%d", 1) +} diff --git a/formatting/testdata/sources/prune/promise-with-exceptions.input b/formatting/testdata/sources/prune/promise-with-exceptions.input new file mode 100644 index 0000000..da513bf --- /dev/null +++ b/formatting/testdata/sources/prune/promise-with-exceptions.input @@ -0,0 +1,14 @@ +package p + +import ( + "github.com/json-iterator/go" + "github.com/go-openapi/strfmt" + "k8s.io/api/apps/v1" + "github.com/go-openapi/swag" +) + +var ( + _ = jsoniter.Marshal + _ = strfmt.Date{} + _ = v1.Deployment{} +) diff --git a/formatting/testdata/sources/prune/shadowed-partly.input b/formatting/testdata/sources/prune/shadowed-partly.input new file mode 100644 index 0000000..2e9b387 --- /dev/null +++ b/formatting/testdata/sources/prune/shadowed-partly.input @@ -0,0 +1,12 @@ +package p + +import "bytes" + +func shadowed() { + bytes := "not the package" + _ = bytes +} + +func genuine() { + var _ bytes.Buffer +} diff --git a/formatting/testdata/sources/prune/shadowed-wholly.input b/formatting/testdata/sources/prune/shadowed-wholly.input new file mode 100644 index 0000000..23d802b --- /dev/null +++ b/formatting/testdata/sources/prune/shadowed-wholly.input @@ -0,0 +1,8 @@ +package p + +import "bytes" + +func shadowed() { + bytes := "not the package" + _ = bytes.Size +} diff --git a/formatting/testdata/sources/prune/unnameable-third-party.input b/formatting/testdata/sources/prune/unnameable-third-party.input new file mode 100644 index 0000000..02c85f5 --- /dev/null +++ b/formatting/testdata/sources/prune/unnameable-third-party.input @@ -0,0 +1,7 @@ +package p + +import ( + "github.com/json-iterator/go" +) + +var _ = jsoniter.Marshal diff --git a/formatting/testdata/sources/prune/unnameable.input b/formatting/testdata/sources/prune/unnameable.input new file mode 100644 index 0000000..ccb826b --- /dev/null +++ b/formatting/testdata/sources/prune/unnameable.input @@ -0,0 +1,3 @@ +package p + +import "example.com/2fa" diff --git a/formatting/testdata/sources/prune/unused.input b/formatting/testdata/sources/prune/unused.input new file mode 100644 index 0000000..ccb0d5a --- /dev/null +++ b/formatting/testdata/sources/prune/unused.input @@ -0,0 +1,8 @@ +package p + +import ( + "bytes" + "strings" +) + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/prune/version-directory.input b/formatting/testdata/sources/prune/version-directory.input new file mode 100644 index 0000000..1b671de --- /dev/null +++ b/formatting/testdata/sources/prune/version-directory.input @@ -0,0 +1,7 @@ +package p + +import ( + "k8s.io/api/apps/v1" +) + +var _ = v1.Deployment{} diff --git a/formatting/testdata/sources/redundant-aliases.input b/formatting/testdata/sources/redundant-aliases.input new file mode 100644 index 0000000..c43f656 --- /dev/null +++ b/formatting/testdata/sources/redundant-aliases.input @@ -0,0 +1,17 @@ +package p + +import ( + fmt "fmt" + sql "database/sql/driver" + strfmt "github.com/go-openapi/strfmt" + jsoniter "github.com/json-iterator/go" + _ "embed" + . "strings" +) + +var ( + _ = fmt.Sprint + _ sql.Valuer + _ = strfmt.Date{} + _ = jsoniter.Marshal +) diff --git a/formatting/testdata/sources/reference/aliased.input b/formatting/testdata/sources/reference/aliased.input new file mode 100644 index 0000000..6b5c1af --- /dev/null +++ b/formatting/testdata/sources/reference/aliased.input @@ -0,0 +1,11 @@ +package p + +import ( + buf "bufio" + "bytes" +) + +var ( + _ = buf.NewReader + _ bytes.Buffer +) diff --git a/formatting/testdata/sources/reference/blank-and-dot-imports.input b/formatting/testdata/sources/reference/blank-and-dot-imports.input new file mode 100644 index 0000000..dbd5308 --- /dev/null +++ b/formatting/testdata/sources/reference/blank-and-dot-imports.input @@ -0,0 +1,12 @@ +package p + +import ( + _ "embed" + . "strings" + "bytes" +) + +var ( + _ bytes.Buffer + _ = Title +) diff --git a/formatting/testdata/sources/reference/cgo.input b/formatting/testdata/sources/reference/cgo.input new file mode 100644 index 0000000..f016ac4 --- /dev/null +++ b/formatting/testdata/sources/reference/cgo.input @@ -0,0 +1,8 @@ +package p + +// #include +import "C" + +import "bytes" + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/reference/comment-on-an-import.input b/formatting/testdata/sources/reference/comment-on-an-import.input new file mode 100644 index 0000000..5192628 --- /dev/null +++ b/formatting/testdata/sources/reference/comment-on-an-import.input @@ -0,0 +1,11 @@ +package p + +import ( + "context" // the context + "bytes" +) + +var ( + _ = context.TODO + _ bytes.Buffer +) diff --git a/formatting/testdata/sources/reference/duplicate-path.input b/formatting/testdata/sources/reference/duplicate-path.input new file mode 100644 index 0000000..30b0f36 --- /dev/null +++ b/formatting/testdata/sources/reference/duplicate-path.input @@ -0,0 +1,8 @@ +package p + +import ( + "bytes" + "bytes" +) + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/reference/empty-block.input b/formatting/testdata/sources/reference/empty-block.input new file mode 100644 index 0000000..e52b2d2 --- /dev/null +++ b/formatting/testdata/sources/reference/empty-block.input @@ -0,0 +1,5 @@ +package p + +import () + +var _ = 1 diff --git a/formatting/testdata/sources/reference/no-imports.input b/formatting/testdata/sources/reference/no-imports.input new file mode 100644 index 0000000..d8a7f8a --- /dev/null +++ b/formatting/testdata/sources/reference/no-imports.input @@ -0,0 +1,3 @@ +package p + +var _ = 1 diff --git a/formatting/testdata/sources/reference/separate-declarations.input b/formatting/testdata/sources/reference/separate-declarations.input new file mode 100644 index 0000000..dd10aee --- /dev/null +++ b/formatting/testdata/sources/reference/separate-declarations.input @@ -0,0 +1,9 @@ +package p + +import "context" +import "bytes" + +var ( + _ = context.TODO + _ bytes.Buffer +) diff --git a/formatting/testdata/sources/reference/third-party-apart-from-std.input b/formatting/testdata/sources/reference/third-party-apart-from-std.input new file mode 100644 index 0000000..4f7eb15 --- /dev/null +++ b/formatting/testdata/sources/reference/third-party-apart-from-std.input @@ -0,0 +1,13 @@ +package p + +import ( + "context" + "github.com/go-openapi/swag/conv" + "bytes" +) + +var ( + _ = context.TODO + _ bytes.Buffer + _ = conv.Pointer[int] +) diff --git a/formatting/testdata/sources/reference/unsorted.input b/formatting/testdata/sources/reference/unsorted.input new file mode 100644 index 0000000..126802a --- /dev/null +++ b/formatting/testdata/sources/reference/unsorted.input @@ -0,0 +1,13 @@ +package p + +import ( + "strings" + "bytes" + "context" +) + +var ( + _ = strings.Title + _ bytes.Buffer + _ = context.TODO +) diff --git a/formatting/testdata/sources/reference/unused-import.input b/formatting/testdata/sources/reference/unused-import.input new file mode 100644 index 0000000..ccb0d5a --- /dev/null +++ b/formatting/testdata/sources/reference/unused-import.input @@ -0,0 +1,8 @@ +package p + +import ( + "bytes" + "strings" +) + +var _ bytes.Buffer diff --git a/formatting/testdata/sources/separate-decls.input b/formatting/testdata/sources/separate-decls.input new file mode 100644 index 0000000..dd10aee --- /dev/null +++ b/formatting/testdata/sources/separate-decls.input @@ -0,0 +1,9 @@ +package p + +import "context" +import "bytes" + +var ( + _ = context.TODO + _ bytes.Buffer +) diff --git a/formatting/testdata/sources/unsorted-across-groups.input b/formatting/testdata/sources/unsorted-across-groups.input new file mode 100644 index 0000000..8fde116 --- /dev/null +++ b/formatting/testdata/sources/unsorted-across-groups.input @@ -0,0 +1,16 @@ +package p + +import ( + "strings" + "bytes" + + "errors" + "context" +) + +var ( + _ = strings.NewReader + _ = bytes.NewReader + _ = errors.New + _ = context.TODO +) diff --git a/genapp/.gitkeep b/genapp/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/genapp/concurrent_test.go b/genapp/concurrent_test.go new file mode 100644 index 0000000..8310ee2 --- /dev/null +++ b/genapp/concurrent_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/genapp" +) + +// TestConcurrentRender renders from many goroutines at once. +// +// A [genapp.GoGenApp] keeps no buffer of its own: each render borrows one from the shared pool and +// gives it back. Run under -race, this is what says the buffers never overlap, and comparing every +// result against a serial render says none of them was handed a buffer another goroutine was still +// filling. +func TestConcurrentRender(t *testing.T) { + t.Parallel() + + const goroutines = 24 + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + expected := make([]string, goroutines) + for i := range expected { + var out bytes.Buffer + require.NoError(t, app.Render(&out, "model", model{Package: "models", Name: fmt.Sprintf("thing_%d", i)})) + expected[i] = out.String() + } + + var wg sync.WaitGroup + rendered := make([]string, goroutines) + + wg.Add(goroutines) + for i := range goroutines { + go func() { + defer wg.Done() + + data := model{Package: "models", Name: fmt.Sprintf("thing_%d", i)} + + var out bytes.Buffer + if err := app.Render(&out, "model", data); err != nil { + t.Error(err) + + return + } + rendered[i] = out.String() + + if err := app.RenderFile(fmt.Sprintf("thing_%d.go", i), "model", data); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + + for i := range goroutines { + assert.Equal(t, expected[i], rendered[i], "goroutine %d rendered what a serial render does", i) + + written, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("thing_%d.go", i))) + require.NoError(t, err) + assert.Equal(t, expected[i], string(written)) + } +} + +func BenchmarkRender(b *testing.B) { + app, err := genapp.New(genapp.WithTemplates(newRepo(b))) + if err != nil { + b.Fatal(err) + } + + data := model{Package: "models", Name: "pet_owner"} + + b.Run("Render", func(b *testing.B) { + b.ReportAllocs() + + var out bytes.Buffer + for b.Loop() { + out.Reset() + if err := app.Render(&out, "model", data); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/genapp/doc.go b/genapp/doc.go index 7ae73ce..9b009cb 100644 --- a/genapp/doc.go +++ b/genapp/doc.go @@ -1,2 +1,88 @@ -// Package genapp exposes a composable application generator for go code. +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package genapp renders templates into formatted Go files. +// +// A code generator holds a set of templates and, for each file it produces, executes one of them and +// formats the result. [GoGenApp] is that loop: +// +// templates, err := repo.New( +// repo.FromFS(assets, ""), +// repo.WithFuncMap(golang.FuncMap(mangling.MakeGoMangler())), +// ) +// if err != nil { +// return err +// } +// +// app, err := genapp.New( +// genapp.WithTemplates(templates), +// genapp.WithOutputPath("./generated"), +// genapp.WithFormatOptions( +// formatting.WithImportGroups("github.com/go-openapi", baseImport), +// ), +// ) +// if err != nil { +// return err +// } +// +// if err := app.RenderFile("models/pet.go", "modelValidator", pet); err != nil { +// return err +// } +// +// [GoGenApp.Render] writes to an [io.Writer] and [GoGenApp.RenderFile] writes a file under the +// output path, creating the directories it needs. +// +// Where the templates come from, what funcmap they run with and which of them a run reaches are +// settled by [github.com/go-openapi/codegen/templates-repo], and this package re-exports none of +// it: build the repository, then hand it over with [WithTemplates]. +// +// # Formatting +// +// Rendered Go goes through [github.com/go-openapi/codegen/formatting], which prunes the imports +// nothing uses, groups the rest and prints in gofmt style. It never resolves a missing import and +// never runs the go command, so a template that forgets an import produces a file that does not +// compile rather than one that differs from machine to machine. +// +// [GoGenApp.RenderFile] formats a target ending in ".go" and copies anything else through. Pass +// [WithSkipFormatFunc] to decide differently, or [WithSkipFormat] to write every target unformatted, +// which is worth doing when a template is misbehaving and the parse error hides the output. +// +// # Where the code lands +// +// A generator has to write the imports that reach the code it produces, and that means knowing the +// import path of the tree it is writing into. [GoGenApp.PackagePath] answers it by reading the +// go.mod above the output path: +// +// module example.com/petstore declared in /src/petstore/go.mod +// output path /src/petstore/gen/models +// PackagePath example.com/petstore/gen/models +// +// [GoGenApp.ModuleRequired] answers the other half: whether the output path sits outside every +// module, and so needs a go.mod of its own before anything there can be built. +// +// # Modules +// +// [GoGenApp.InitModule] writes a go.mod for the generated tree, as "go mod init" would, without +// running it: +// +// err := app.InitModule( +// genapp.WithModulePath("example.com/petstore/gen"), +// genapp.WithRequire("github.com/go-openapi/strfmt", "v0.24.0", false), +// ) +// +// [GoGenApp.TidyModule] runs "go mod tidy", and is the one thing here that needs a Go toolchain: +// +// err := app.TidyModule(ctx, genapp.WithTidyGoVersion("1.25.0")) +// +// Everything else runs the go command never and reads the environment never, so a generated tree +// can be laid down and formatted on a machine with no Go installed. Resolving the versions a module +// ends up with is the exception, because it means walking the module graph and the checksum +// database, and reproducing that would mean reproducing the go command. +// +// # Concurrency +// +// A [GoGenApp] holds no state between calls, so [GoGenApp.Render] and [GoGenApp.RenderFile] may run +// concurrently. Each render borrows its buffer from +// [github.com/go-openapi/swag/pools/shared] and gives it back before returning, so a generator +// writing a few hundred files recycles a handful of buffers rather than allocating one per file. package genapp diff --git a/genapp/errors.go b/genapp/errors.go new file mode 100644 index 0000000..69aa897 --- /dev/null +++ b/genapp/errors.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +// Error is a string that implements error, so a sentinel below can be a constant. +type Error string + +func (e Error) Error() string { return string(e) } + +// ErrGenApp matches every error this package returns. +// +// It is attached where an error from elsewhere crosses into this package — from the templates +// repository, from text/template, from the formatter, from os — and a call that already wrapped one +// passes it back untouched. Attaching it twice would say "code generation error" twice in one +// message and add nothing the first said; TestErrorsWrapOnce counts it on every path. +const ErrGenApp Error = "code generation error" diff --git a/genapp/errors_test.go b/genapp/errors_test.go new file mode 100644 index 0000000..51ee4f2 --- /dev/null +++ b/genapp/errors_test.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/genapp" +) + +// TestErrorsWrapOnce walks every error this package returns and counts the sentinel. +// +// A wrapper that attaches ErrGenApp to an error already carrying it says "code generation error" +// twice in one message and tells the reader nothing the first one did not. The sentinel is attached +// where a foreign error crosses into this package — from the repository, from text/template, from +// the formatter, from os — and a call that already wrapped one returns it as it is. This test is +// what keeps that true. +func TestErrorsWrapOnce(t *testing.T) { + t.Parallel() + + sentinel := string(genapp.ErrGenApp) + + tests := []struct { + name string + call func(t *testing.T) error + }{ + { + name: "no repository", + call: func(t *testing.T) error { + _, err := genapp.New(genapp.WithOutputPath(t.TempDir())) + + return err + }, + }, + { + name: "no such template", + call: func(t *testing.T) error { + return newApp(t).Render(&bytes.Buffer{}, "noSuchTemplate", pet) + }, + }, + { + name: "template execution fails", + call: func(t *testing.T) error { + return newApp(t).Render(&bytes.Buffer{}, "model", struct{ Package string }{}) + }, + }, + { + name: "rendered Go does not format", + call: func(t *testing.T) error { + return newApp(t).Render(&bytes.Buffer{}, "broken", pet) + }, + }, + { + name: "the writer refuses, formatted", + call: func(t *testing.T) error { + return newApp(t).Render(refusingWriter{}, "model", pet) + }, + }, + { + name: "the writer refuses, unformatted", + call: func(t *testing.T) error { + return newApp(t, genapp.WithSkipFormat(true)).Render(refusingWriter{}, "model", pet) + }, + }, + { + name: "the target directory cannot be created", + call: func(t *testing.T) error { + blocker := filepath.Join(t.TempDir(), "blocker") + require.NoError(t, os.WriteFile(blocker, []byte("not a directory"), 0o600)) + + return newApp(t, genapp.WithOutputPath(blocker)).RenderFile("sub/pet.go", "model", pet) + }, + }, + { + name: "a file target does not format", + call: func(t *testing.T) error { + return newApp(t, genapp.WithOutputPath(t.TempDir())).RenderFile("broken.go", "broken", pet) + }, + }, + { + name: "a file target names no template", + call: func(t *testing.T) error { + return newApp(t, genapp.WithOutputPath(t.TempDir())).RenderFile("pet.go", "noSuchTemplate", pet) + }, + }, + } + + for _, toPin := range tests { + test := toPin + + t.Run("should wrap "+test.name+" once", func(t *testing.T) { + t.Parallel() + + err := test.call(t) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Equal(t, 1, strings.Count(err.Error(), sentinel), "%q", err) + }) + } +} + +var errRefused = errors.New("writer refused") + +type refusingWriter struct{} + +func (refusingWriter) Write([]byte) (int, error) { return 0, errRefused } diff --git a/genapp/genapp.go b/genapp/genapp.go new file mode 100644 index 0000000..90db06b --- /dev/null +++ b/genapp/genapp.go @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/go-openapi/swag/pools/shared" + + "github.com/go-openapi/codegen/formatting" + repo "github.com/go-openapi/codegen/templates-repo" +) + +// dirPerm and filePerm set the mode of a generated tree. +const ( + dirPerm = 0o750 + filePerm = 0o640 +) + +// GoGenApp renders templates into formatted Go. +// +// It holds a templates repository and the formatting settings, and does the same three things for +// every file a generator produces: execute a template, format what it produced, write it. +type GoGenApp struct { + options +} + +// New builds a [GoGenApp] rendering from the repository [WithTemplates] gives it. +// +// It returns an error when no repository was given. Everything a repository is made of — its +// sources, its funcmap, the roots it is scoped to — is settled by +// [github.com/go-openapi/codegen/templates-repo.New], and a repository that would not build has +// already reported why by the time it reaches here. +func New(opts ...Option) (*GoGenApp, error) { + o := optionsWithDefaults(opts) + + if o.templates == nil { + return nil, fmt.Errorf("a templates repository is required, see WithTemplates: %w", ErrGenApp) + } + + return &GoGenApp{options: o}, nil +} + +// Templates returns the repository the app renders from, for a caller wanting to document, audit or +// derive it. +func (g *GoGenApp) Templates() *repo.Repository { + return g.templates +} + +// Render executes a template and writes the formatted result to w. +// +// The repository knows each template by a name derived from its asset path; see +// [github.com/go-openapi/codegen/templates-repo]. +// +// Render formats unless [WithSkipFormat] is set, so it is the entry point for Go. Use +// [GoGenApp.RenderFile], which decides by target name, for a generator writing Go and other things +// side by side. +// +// A template rendering Go that does not parse leaves w untouched: the formatter reads the whole +// source before it writes anything. +func (g *GoGenApp) Render(w io.Writer, name string, data any) error { + rendered := shared.BorrowBuffer() + defer shared.RedeemBuffer(rendered) + + if err := g.execute(rendered, name, data); err != nil { + return err + } + + if g.skipFormat { + if _, err := w.Write(rendered.Bytes()); err != nil { + return fmt.Errorf("cannot write rendered %q: %w: %w", name, err, ErrGenApp) + } + + return nil + } + + return g.format(w, name, rendered) +} + +// RenderFile executes a template and writes the result to target, under the output path. +// +// It creates the directories the target needs. A target ending in ".go" is formatted; anything else +// is written as rendered. See [WithSkipFormatFunc] and [WithSkipFormat]. +// +// The file appears whole or not at all: RenderFile writes beside the target and renames over it, so +// a template that fails to render or to format leaves whatever was there untouched, and a write +// that fails halfway leaves no half-written target. +// +// When the formatter rejects what a template rendered, the unformatted output is kept beside the +// target, named for it with a ".unformatted" suffix, and the error names the file. A parse error +// reports a line and a column, and reading them means reading the source they came from; that +// source would otherwise be gone. +func (g *GoGenApp) RenderFile(target, name string, data any) error { + rendered := shared.BorrowBuffer() + defer shared.RedeemBuffer(rendered) + + if err := g.execute(rendered, name, data); err != nil { + return err + } + + path := filepath.Join(g.outputPath, filepath.FromSlash(target)) + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return fmt.Errorf("cannot create the directory for %q: %w: %w", target, err, ErrGenApp) + } + + return g.writeFile(path, target, name, rendered) +} + +// execute renders one template into the buffer it is given. +// +// The buffer comes from the shared pool rather than the [GoGenApp], which holds no state so that +// [GoGenApp.Render] and [GoGenApp.RenderFile] may run concurrently. +// +// [github.com/go-openapi/swag/pools/shared.RedeemBuffer] drops a buffer grown past 64 KiB instead +// of recycling it, so a generator that emits one enormous file does not park an enormous buffer in +// a pool the whole process shares. A file that size costs an allocation; the ones a generator +// usually writes run a couple of kilobytes and are recycled. +func (g *GoGenApp) execute(into *bytes.Buffer, name string, data any) error { + tpl, err := g.templates.Get(name) + if err != nil { + return fmt.Errorf("no template %q: %w: %w", name, err, ErrGenApp) + } + + if err := tpl.Execute(into, data); err != nil { + return fmt.Errorf("cannot render template %q: %w: %w", name, err, ErrGenApp) + } + + return nil +} + +// format runs the formatter over what a template rendered. +// unformattedSuffix names the file a failed format leaves behind. +const unformattedSuffix = ".unformatted" + +// dumpUnformatted keeps what a template rendered, so a parse error can be read against its source. +// +// The formatter may have printed part of a file before it failed, so the file is truncated and +// written again from the rendered bytes, then moved off the temporary name to path plus +// [unformattedSuffix]. The name is visible and predictable on purpose: the file sits beside the +// target it failed to become, and the next run over that target replaces it rather than adding +// another. +// +// The returned error carries the formatting failure and adds the path. It attaches no second +// [ErrGenApp]: the cause already carries one. +func dumpUnformatted(file *os.File, path string, rendered *bytes.Buffer, cause error) error { + dumped := path + unformattedSuffix + + written := func() error { + if err := file.Truncate(0); err != nil { + return err + } + + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + + if _, err := file.Write(rendered.Bytes()); err != nil { + return err + } + + if err := file.Chmod(filePerm); err != nil { + return err + } + + if err := file.Close(); err != nil { + return err + } + + return os.Rename(file.Name(), dumped) + }() + + if written != nil { + return fmt.Errorf("could not keep the unformatted output at %q (%w): %w", dumped, written, cause) + } + + return fmt.Errorf("the unformatted output is kept at %q: %w", dumped, cause) +} + +// format writes the formatted render, and hands the imports report to the caller's sink. +// +// An import the formatter could not name is kept rather than pruned, so a report holding doubts is +// worth seeing: it lists the paths to feed [github.com/go-openapi/codegen/formatting/resolve]. See +// [WithImportsReporter]. +func (g *GoGenApp) format(w io.Writer, name string, rendered *bytes.Buffer) error { + report, err := formatting.Format(w, rendered, g.formatOptions...) + if err != nil { + return fmt.Errorf("template %q rendered Go that does not format: %w: %w", name, err, ErrGenApp) + } + + if g.importsReporter != nil { + g.importsReporter(name, report) + } + + return nil +} + +// writeFile writes the target through a temporary file in the same directory, then renames. +func (g *GoGenApp) writeFile(path, target, name string, rendered *bytes.Buffer) (err error) { + temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return fmt.Errorf("cannot create a temporary file for %q: %w: %w", target, err, ErrGenApp) + } + + keep := false + + defer func() { + if err == nil || keep { + return + } + + _ = temporary.Close() + _ = os.Remove(temporary.Name()) + }() + + if g.skipsFormat(target) { + if _, err = temporary.Write(rendered.Bytes()); err != nil { + return fmt.Errorf("cannot write %q: %w: %w", target, err, ErrGenApp) + } + } else if formatErr := g.format(temporary, name, rendered); formatErr != nil { + keep = true + err = dumpUnformatted(temporary, path, rendered, formatErr) + + return err + } + + if err = temporary.Chmod(filePerm); err != nil { + return fmt.Errorf("cannot set the mode of %q: %w: %w", target, err, ErrGenApp) + } + + if err = temporary.Close(); err != nil { + return fmt.Errorf("cannot close %q: %w: %w", target, err, ErrGenApp) + } + + if err = os.Rename(temporary.Name(), path); err != nil { + return fmt.Errorf("cannot write %q: %w: %w", target, err, ErrGenApp) + } + + return nil +} diff --git a/genapp/genapp_test.go b/genapp/genapp_test.go new file mode 100644 index 0000000..f0a96c0 --- /dev/null +++ b/genapp/genapp_test.go @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "bytes" + "embed" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" + "github.com/go-openapi/codegen/funcmaps/golang" + "github.com/go-openapi/codegen/genapp" + "github.com/go-openapi/codegen/mangling" + repo "github.com/go-openapi/codegen/templates-repo" +) + +//go:embed testdata/templates +var templates embed.FS + +type model struct { + Package string + Name string +} + +var pet = model{Package: "models", Name: "pet_owner"} + +// newRepo builds the fixture repository the way a generator would. +func newRepo(t testing.TB, opts ...repo.Option) *repo.Repository { + t.Helper() + + assets, err := fs.Sub(templates, "testdata/templates") + require.NoError(t, err) + + templates, err := repo.New(append([]repo.Option{ + repo.FromFS(assets, ""), + repo.WithFuncMap(golang.FuncMap(mangling.MakeGoMangler())), + }, opts...)...) + require.NoError(t, err) + + return templates +} + +func newApp(t testing.TB, opts ...genapp.Option) *genapp.GoGenApp { + t.Helper() + + app, err := genapp.New(append([]genapp.Option{ + genapp.WithTemplates(newRepo(t)), + }, opts...)...) + require.NoError(t, err) + + return app +} + +func TestRender(t *testing.T) { + t.Parallel() + + t.Run("should render and format a template", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + require.NoError(t, newApp(t).Render(&out, "model", pet)) + + rendered := out.String() + assert.Contains(t, rendered, "type PetOwner struct {", "the funcmap mangles the name") + assert.Contains(t, rendered, "func (m *PetOwner) Validate(ctx context.Context) error {") + assert.NotContains(t, rendered, `"strings"`, "the formatter prunes what the template did not use") + assert.Contains(t, rendered, "\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/go-openapi/strfmt\"\n", + "and groups what is left") + }) + + t.Run("should group imports by the prefixes given", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + app := newApp(t, genapp.WithFormatOptions(formatting.WithImportGroups("github.com/go-openapi"))) + require.NoError(t, app.Render(&out, "model", pet)) + + assert.Contains(t, out.String(), "\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/go-openapi/strfmt\"\n") + }) + + t.Run("should reach a template under a directory by its name", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + require.NoError(t, newApp(t).Render(&out, "modelsNested", pet)) + + assert.Contains(t, out.String(), "type PetOwnerNested struct{ Value int }") + }) + + t.Run("should report a template it does not hold", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + err := newApp(t).Render(&out, "noSuchTemplate", pet) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "noSuchTemplate") + }) + + t.Run("should report Go that does not format", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + err := newApp(t).Render(&out, "broken", pet) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "broken") + }) + + t.Run("should leave the writer untouched when the Go does not parse", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + require.Error(t, newApp(t).Render(&out, "broken", pet)) + + assert.Zero(t, out.Len(), "the formatter reads the whole source before it writes") + }) + + t.Run("should write what the template rendered when format is skipped", func(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + app := newApp(t, genapp.WithSkipFormat(true)) + require.NoError(t, app.Render(&out, "broken", pet), "unformattable Go still lands") + + assert.Contains(t, out.String(), "func Broken( {") + }) +} + +func TestRenderFile(t *testing.T) { + t.Parallel() + + t.Run("should write a formatted file under the output path", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.RenderFile("models/pet.go", "model", pet)) + + written, err := os.ReadFile(filepath.Join(dir, "models", "pet.go")) + require.NoError(t, err) + assert.Contains(t, string(written), "type PetOwner struct {") + assert.NotContains(t, string(written), `"strings"`) + }) + + t.Run("should create the directories a target needs", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.RenderFile("a/b/c/pet.go", "model", pet)) + assert.FileExists(t, filepath.Join(dir, "a", "b", "c", "pet.go")) + }) + + t.Run("should copy a target that is not Go through unformatted", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.RenderFile("README.md", "readme", pet)) + + written, err := os.ReadFile(filepath.Join(dir, "README.md")) + require.NoError(t, err) + assert.Equal(t, "# pet_owner\n\nGenerated, and not Go.\n", string(written)) + }) + + t.Run("should keep the unformatted output when formatting fails", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + err := app.RenderFile("broken.go", "broken", pet) + require.Error(t, err) + + dumped := dumpedPath(t, err) + assert.Equal(t, filepath.Join(dir, "broken.go.unformatted"), dumped, + "named for the target, beside it, and the same name on the next run") + + kept, readErr := os.ReadFile(dumped) + require.NoError(t, readErr, "the error names a file that is there") + assert.Contains(t, string(kept), "func Broken( {", "and it holds what the template rendered") + + _, statErr := os.Stat(filepath.Join(dir, "broken.go")) + assert.ErrorIs(t, statErr, os.ErrNotExist, "the target itself was never written") + }) + + t.Run("should keep nothing when the template fails before the formatter", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.Error(t, app.RenderFile("pet.go", "noSuchTemplate", pet)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries, "nothing was rendered, so there is nothing to inspect") + }) + + t.Run("should leave an existing file untouched when the render fails", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + target := filepath.Join(dir, "broken.go") + require.NoError(t, os.WriteFile(target, []byte("package kept\n"), 0o600)) + + app := newApp(t, genapp.WithOutputPath(dir)) + require.Error(t, app.RenderFile("broken.go", "broken", pet)) + + kept, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "package kept\n", string(kept)) + }) + + t.Run("should honour a caller's own skip rule", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, + genapp.WithOutputPath(dir), + genapp.WithSkipFormatFunc(func(string) bool { return true }), + ) + + require.NoError(t, app.RenderFile("broken.go", "broken", pet), "nothing is formatted") + assert.FileExists(t, filepath.Join(dir, "broken.go")) + }) +} + +// dumpedPath pulls the kept file out of a formatting error. +// +// The message writes the path with %q, so what follows the marker is a Go quoted string, not the path +// itself. On Windows every separator arrives escaped - "D:\\a\\codegen" - so the quotes are cut with +// [strconv.QuotedPrefix] and the escapes undone with [strconv.Unquote] rather than by hand. +func dumpedPath(t *testing.T, err error) string { + t.Helper() + + const marker = "the unformatted output is kept at " + + message := err.Error() + start := strings.Index(message, marker) + require.GreaterOrEqual(t, start, 0, "the error names the file it kept: %v", err) + + quoted, quoteErr := strconv.QuotedPrefix(message[start+len(marker):]) + require.NoError(t, quoteErr, "the path is quoted: %v", err) + + path, unquoteErr := strconv.Unquote(quoted) + require.NoError(t, unquoteErr) + + return path +} + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("should refuse to build without a repository", func(t *testing.T) { + t.Parallel() + + _, err := genapp.New(genapp.WithOutputPath(t.TempDir())) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "WithTemplates") + }) + + t.Run("should expose the repository it built", func(t *testing.T) { + t.Parallel() + + names := newApp(t).Templates().Names() + require.NotNil(t, names) + + var found []string + for name := range names { + found = append(found, name) + } + + assert.Contains(t, found, "model") + assert.Contains(t, found, "modelsNested") + }) +} diff --git a/genapp/go.mod b/genapp/go.mod deleted file mode 100644 index 576cdcd..0000000 --- a/genapp/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/go-openapi/codegen/genapp - -go 1.25.0 diff --git a/genapp/gopath.go b/genapp/gopath.go new file mode 100644 index 0000000..cb3f204 --- /dev/null +++ b/genapp/gopath.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// gopathPackage returns the import path the output path has under GOPATH. +// +// A tree under GOPATH/src builds without a go.mod when GO111MODULE is off, and the way down from +// src is its import path. Modules came later and win where both apply, so this is reached only +// after the walk for a go.mod has found none. +// +// Every entry of GOPATH is tried, and each of them twice: once as written, once with its symlinks +// resolved, since a GOPATH reached through a link does not match a target that was not. +func gopathPackage(target string) (string, bool) { + for _, entry := range filepath.SplitList(goPath()) { + if entry == "" { + continue + } + + src := filepath.Join(entry, "src") + + if within, ok := descendantOf(src, target); ok { + return filepath.ToSlash(within), true + } + + resolved, err := filepath.EvalSymlinks(src) + if err != nil { + continue + } + + if within, ok := descendantOf(resolved, target); ok { + return filepath.ToSlash(within), true + } + } + + return "", false +} + +// goPath returns GOPATH, or the directory the go command falls back to when it is unset. +func goPath() string { + if fromEnv := os.Getenv("GOPATH"); fromEnv != "" { + return fromEnv + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, "go") +} + +// descendantOf reports whether target sits below parent, and the way down to it. +// +// [filepath.Rel] does the comparing, which is what separates this from a prefix test: it reads ".." +// and "." rather than matching text, and it reports an error where no relative path exists at all — +// a GOPATH on one Windows volume and an output path on another. Such an entry is one this target +// does not belong to, not a failure. +// +// The way down is cut from the target rather than taken from Rel, so a case-insensitive match on +// Windows still yields the import path with the case the directories actually have. +func descendantOf(parent, target string) (string, bool) { + parent, target = filepath.Clean(parent), filepath.Clean(target) + + comparedParent, comparedTarget := parent, target + if runtime.GOOS == "windows" { + comparedParent, comparedTarget = strings.ToLower(parent), strings.ToLower(target) + } + + within, err := filepath.Rel(comparedParent, comparedTarget) + if err != nil { + return "", false + } + + if within == "." || within == ".." || strings.HasPrefix(within, ".."+string(filepath.Separator)) { + return "", false + } + + return target[len(parent)+1:], true +} diff --git a/genapp/gopath_test.go b/genapp/gopath_test.go new file mode 100644 index 0000000..c1dc900 --- /dev/null +++ b/genapp/gopath_test.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +// TestDescendantOf covers the comparison a prefix test gets wrong. +func TestDescendantOf(t *testing.T) { + t.Parallel() + + sep := string(filepath.Separator) + + tests := []struct { + name string + parent string + target string + within string + below bool + platform string + }{ + { + name: "should find the way down", + parent: filepath.Join(sep, "gopath", "src"), + target: filepath.Join(sep, "gopath", "src", "example.com", "legacy", "pkg"), + within: filepath.Join("example.com", "legacy", "pkg"), + below: true, + }, + { + name: "should refuse the parent itself, which names no package", + parent: filepath.Join(sep, "gopath", "src"), + target: filepath.Join(sep, "gopath", "src"), + }, + { + name: "should refuse a sibling", + parent: filepath.Join(sep, "gopath", "src"), + target: filepath.Join(sep, "gopath", "pkg", "mod"), + }, + { + name: "should refuse a name that merely starts the same", + parent: filepath.Join(sep, "gopath", "src"), + target: filepath.Join(sep, "gopath", "srcery", "pkg"), + }, + { + name: "should refuse a path above", + parent: filepath.Join(sep, "gopath", "src"), + target: filepath.Join(sep, "gopath"), + }, + { + name: "should refuse a target on another volume", + parent: `C:\gopath\src`, + target: `D:\code\pkg`, + platform: "windows", + }, + } + + for _, toPin := range tests { + test := toPin + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + if test.platform != "" && test.platform != runtime.GOOS { + t.Skipf("%s only", test.platform) + } + + within, below := descendantOf(test.parent, test.target) + + assert.Equal(t, test.below, below) + assert.Equal(t, test.within, within) + }) + } +} + +// TestDescendantOfCrossVolume states what a prefix test would get wrong on Windows. +// +// filepath.Rel reports an error when no relative path exists, which is a target belonging to +// another GOPATH entry rather than a failure. go-swagger's resolver reached the same conclusion by +// a different route and reported it as "target must reside inside a location within $GOPATH/src". +func TestDescendantOfCrossVolume(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "windows" { + // the same shape, on a platform where Rel can express it: nothing relates these + within, below := descendantOf(filepath.Join("relative", "src"), filepath.Join("/absolute", "pkg")) + + assert.False(t, below) + assert.Empty(t, within) + + return + } + + within, below := descendantOf(`C:\gopath\src`, `D:\code\pkg`) + assert.False(t, below) + assert.Empty(t, within) +} diff --git a/genapp/module.go b/genapp/module.go new file mode 100644 index 0000000..059094b --- /dev/null +++ b/genapp/module.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "golang.org/x/mod/modfile" +) + +// goModFile is the name the go command gives a module definition. +const goModFile = "go.mod" + +// InitModule writes a go.mod in the output path, as "go mod init" would. +// +// It writes the module path, the go directive, the toolchain directive when [WithToolchain] asks +// for one, and whatever [WithRequire] declared, formatted the way the go command formats a go.mod. It runs no command and reads no environment, so a generator can +// lay down a buildable module on a machine with no Go toolchain installed, and the file it produces +// does not depend on the one that is installed. +// +// What it does not do is resolve anything. "go mod init" fills nothing in either; the versions a +// module ends up with come from "go mod tidy", which needs the toolchain and the network. Declare +// what the templates import with [WithRequire] and tidy has somewhere to start. +// +// A go.mod already in the output path is left alone and reported as [fs.ErrExist], unless +// [WithReplaceExisting] says otherwise. +func (g *GoGenApp) InitModule(opts ...ModOption) error { + o, err := modOptionsWithDefaults(opts) + if err != nil { + return err + } + + path := filepath.Join(g.outputPath, goModFile) + + if err := g.checkModuleAbsent(path, o); err != nil { + return err + } + + content, err := buildModFile(o) + if err != nil { + return err + } + + if err := os.MkdirAll(g.outputPath, dirPerm); err != nil { + return fmt.Errorf("cannot create the module directory %q: %w: %w", g.outputPath, err, ErrGenApp) + } + + if err := os.WriteFile(path, content, filePerm); err != nil { + return fmt.Errorf("cannot write %q: %w: %w", path, err, ErrGenApp) + } + + return nil +} + +// checkModuleAbsent reports an existing go.mod, unless the caller asked to replace it. +func (g *GoGenApp) checkModuleAbsent(path string, o modOptions) error { + if o.replace { + return nil + } + + switch _, err := os.Stat(path); { + case err == nil: + return fmt.Errorf("%q exists, see WithReplaceExisting: %w: %w", path, fs.ErrExist, ErrGenApp) + case errors.Is(err, fs.ErrNotExist): + return nil + default: + return fmt.Errorf("cannot read %q: %w: %w", path, err, ErrGenApp) + } +} + +// buildModFile renders the go.mod content. +func buildModFile(o modOptions) ([]byte, error) { + file := new(modfile.File) + + if err := file.AddModuleStmt(o.modulePath); err != nil { + return nil, fmt.Errorf("cannot declare module %q: %w: %w", o.modulePath, err, ErrGenApp) + } + + if err := file.AddGoStmt(o.goVersion); err != nil { + return nil, fmt.Errorf("cannot declare go %q: %w: %w", o.goVersion, err, ErrGenApp) + } + + if o.toolchain != "" { + if err := file.AddToolchainStmt(o.toolchain); err != nil { + return nil, fmt.Errorf("cannot declare toolchain %q: %w: %w", o.toolchain, err, ErrGenApp) + } + } + + for _, required := range o.requires { + if err := file.AddRequire(required.path, required.version); err != nil { + return nil, fmt.Errorf( + "cannot require %q %q: %w: %w", required.path, required.version, err, ErrGenApp, + ) + } + } + + markIndirect(file, o.requires) + + file.Cleanup() + + return modfile.Format(file.Syntax), nil +} + +// markIndirect puts the "// indirect" comment on the requirements the caller declared as such. +// +// modfile carries the flag on the parsed rule rather than taking it when a requirement is added, so +// the requirements are matched back by path. +func markIndirect(file *modfile.File, declared []requirement) { + indirect := make(map[string]bool, len(declared)) + + for _, required := range declared { + if required.indirect { + indirect[required.path] = true + } + } + + for _, required := range file.Require { + if indirect[required.Mod.Path] { + required.Indirect = true + file.SetRequire(file.Require) + + break + } + } +} diff --git a/genapp/module_options.go b/genapp/module_options.go new file mode 100644 index 0000000..14c382c --- /dev/null +++ b/genapp/module_options.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "fmt" + "path" + "path/filepath" + "regexp" + "runtime" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// ModOption configures the go.mod [GoGenApp.InitModule] writes. +type ModOption func(*modOptions) + +type modOptions struct { + modulePath string + goVersion string + toolchain string + requires []requirement + replace bool +} + +type requirement struct { + path string + version string + indirect bool +} + +// WithModulePath names the module, as the argument to "go mod init" does. +// +// The path is cleaned and slash-separated, and it is checked the way the go command checks it, so a +// path no module could have is reported here rather than by the first build. +func WithModulePath(pth string) ModOption { + return func(o *modOptions) { + o.modulePath = path.Clean(filepath.ToSlash(pth)) + } +} + +// WithGoVersion sets the go directive, as in "1.25.0". +// +// It defaults to the version of Go this program was built with, which is what "go mod init" writes. +func WithGoVersion(version string) ModOption { + return func(o *modOptions) { + o.goVersion = version + } +} + +// WithToolchain sets the toolchain directive, as in "go1.25.0", or "default" to pin the module to +// whatever toolchain is installed. +// +// The two directives are spelled differently — "go 1.25.0" carries no prefix, "toolchain go1.25.0" +// does — so a bare version is accepted here and written in the form the directive takes. +// +// There is no default: "go mod init" writes no toolchain line, and the go command adds one when it +// needs a toolchain newer than the one installed. Set it to say which toolchain a generated module +// is meant to build with, whatever is on the machine that generated it. +func WithToolchain(name string) ModOption { + return func(o *modOptions) { + if name != "" && name != toolchainDefault && !strings.HasPrefix(name, "go") { + name = "go" + name + } + + o.toolchain = name + } +} + +// WithRequire adds a require directive, as in ("github.com/go-openapi/strfmt", "v0.24.0"). +// +// A generated module knows what its templates import, and saying so here means "go mod tidy" has +// versions to start from rather than resolving every import from scratch. Mark a requirement +// indirect when nothing the module itself holds imports it. +func WithRequire(pth, version string, indirect bool) ModOption { + return func(o *modOptions) { + o.requires = append(o.requires, requirement{path: pth, version: version, indirect: indirect}) + } +} + +// WithReplaceExisting overwrites a go.mod that is already there. +// +// Without it [GoGenApp.InitModule] leaves an existing file alone and reports [fs.ErrExist], as +// "go mod init" does. +func WithReplaceExisting(replace bool) ModOption { + return func(o *modOptions) { + o.replace = replace + } +} + +// toolchainDefault pins a module to the installed toolchain, whatever it is. +const toolchainDefault = "default" + +// buildVersion is the language version of the toolchain that built this program. +// +// [runtime.Version] reports things like "go1.25.0", and a development build reports something the go +// directive would reject, so what it returns is matched rather than trusted. +var buildVersion = regexp.MustCompile(`^go(\d+\.\d+(\.\d+)?)`) + +// defaultGoVersion returns the go directive to write when a caller names none. +func defaultGoVersion() string { + const fallback = "1.24" + + if matched := buildVersion.FindStringSubmatch(runtime.Version()); matched != nil { + return matched[1] + } + + return fallback +} + +func modOptionsWithDefaults(opts []ModOption) (modOptions, error) { + o := modOptions{goVersion: defaultGoVersion()} + + for _, apply := range opts { + apply(&o) + } + + if o.modulePath == "" || o.modulePath == "." { + return o, fmt.Errorf("a module path is required, see WithModulePath: %w", ErrGenApp) + } + + if err := module.CheckPath(o.modulePath); err != nil { + return o, fmt.Errorf("%q is not a module path: %w: %w", o.modulePath, err, ErrGenApp) + } + + if o.toolchain != "" && !modfile.ToolchainRE.MatchString(o.toolchain) { + return o, fmt.Errorf( + "%q is not a toolchain name, want something like go1.25.0 or %q: %w", + o.toolchain, toolchainDefault, ErrGenApp, + ) + } + + if !modfile.GoVersionRE.MatchString(o.goVersion) { + return o, fmt.Errorf("%q is not a go version, want something like 1.25.0: %w", o.goVersion, ErrGenApp) + } + + // modfile takes a requirement without looking at it, and a version it would not have written + // reaches the go command as a broken go.mod rather than as an error here. + for _, required := range o.requires { + if err := module.Check(required.path, required.version); err != nil { + return o, fmt.Errorf( + "cannot require %q at %q: %w: %w", required.path, required.version, err, ErrGenApp, + ) + } + } + + return o, nil +} diff --git a/genapp/module_test.go b/genapp/module_test.go new file mode 100644 index 0000000..59d973a --- /dev/null +++ b/genapp/module_test.go @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + "golang.org/x/mod/modfile" + + "github.com/go-openapi/codegen/genapp" +) + +func TestInitModule(t *testing.T) { + t.Parallel() + + t.Run("should write a go.mod the go command would have written", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithGoVersion("1.25.0"), + )) + + assert.Equal(t, "module example.com/petstore/gen\n\ngo 1.25.0\n", readMod(t, dir)) + }) + + t.Run("should default the go directive to the toolchain that built this", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule(genapp.WithModulePath("example.com/petstore/gen"))) + + parsed := parseMod(t, dir) + require.NotNil(t, parsed.Go) + assert.Contains(t, runtime.Version(), parsed.Go.Version) + }) + + t.Run("should declare the requirements it was given", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithGoVersion("1.25.0"), + genapp.WithRequire("github.com/go-openapi/strfmt", "v0.24.0", false), + genapp.WithRequire("github.com/go-openapi/errors", "v0.22.8", true), + )) + + parsed := parseMod(t, dir) + require.Len(t, parsed.Require, 2) + + byPath := map[string]*modfile.Require{} + for _, required := range parsed.Require { + byPath[required.Mod.Path] = required + } + + require.Contains(t, byPath, "github.com/go-openapi/strfmt") + assert.Equal(t, "v0.24.0", byPath["github.com/go-openapi/strfmt"].Mod.Version) + assert.False(t, byPath["github.com/go-openapi/strfmt"].Indirect) + assert.True(t, byPath["github.com/go-openapi/errors"].Indirect, "and says which are indirect") + }) + + t.Run("should write a toolchain directive when asked for one", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithGoVersion("1.25.0"), + genapp.WithToolchain("go1.26.0"), + )) + + assert.Equal(t, + "module example.com/petstore/gen\n\ngo 1.25.0\n\ntoolchain go1.26.0\n", + readMod(t, dir)) + }) + + t.Run("should take a toolchain spelled as a bare version", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithGoVersion("1.25.0"), + genapp.WithToolchain("1.26.0"), + )) + + parsed := parseMod(t, dir) + require.NotNil(t, parsed.Toolchain) + assert.Equal(t, "go1.26.0", parsed.Toolchain.Name, "written in the form the directive takes") + }) + + t.Run("should take default as a toolchain", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithToolchain("default"), + )) + + parsed := parseMod(t, dir) + require.NotNil(t, parsed.Toolchain) + assert.Equal(t, "default", parsed.Toolchain.Name) + }) + + t.Run("should write no toolchain directive by default", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule(genapp.WithModulePath("example.com/petstore/gen"))) + + assert.Nil(t, parseMod(t, dir).Toolchain, "as go mod init writes none") + }) + + t.Run("should create the module directory", func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "a", "b") + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule(genapp.WithModulePath("example.com/deep"))) + assert.FileExists(t, filepath.Join(dir, "go.mod")) + }) + + t.Run("should leave an existing go.mod alone", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + existing := "module example.com/already/there\n\ngo 1.24.0\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(existing), 0o600)) + + err := app.InitModule(genapp.WithModulePath("example.com/petstore/gen")) + + require.Error(t, err) + assert.ErrorIs(t, err, fs.ErrExist) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Equal(t, existing, readMod(t, dir), "and does not touch it") + }) + + t.Run("should replace an existing go.mod when told to", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module gone\n"), 0o600)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithGoVersion("1.25.0"), + genapp.WithReplaceExisting(true), + )) + + assert.Equal(t, "module example.com/petstore/gen\n\ngo 1.25.0\n", readMod(t, dir)) + }) +} + +func TestInitModuleErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []genapp.ModOption + says string + }{ + { + name: "no module path", + opts: nil, + says: "WithModulePath", + }, + { + name: "a path no module could have", + opts: []genapp.ModOption{genapp.WithModulePath("not a module path!")}, + says: "not a module path", + }, + { + name: "a version no go directive could have", + opts: []genapp.ModOption{ + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithGoVersion("go1.25"), + }, + says: "go version", + }, + { + name: "a toolchain name the directive would reject", + opts: []genapp.ModOption{ + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithToolchain("tip"), + }, + says: "toolchain name", + }, + { + name: "a requirement with no version", + opts: []genapp.ModOption{ + genapp.WithModulePath("example.com/petstore/gen"), + genapp.WithRequire("github.com/go-openapi/strfmt", "not-a-version", false), + }, + says: "cannot require", + }, + } + + for _, toPin := range tests { + test := toPin + + t.Run("should report "+test.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := newApp(t, genapp.WithOutputPath(dir)).InitModule(test.opts...) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), test.says) + + _, statErr := os.Stat(filepath.Join(dir, "go.mod")) + assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing is written when the options are wrong") + }) + } +} + +func readMod(t *testing.T, dir string) string { + t.Helper() + + content, err := os.ReadFile(filepath.Join(dir, "go.mod")) + require.NoError(t, err) + + return string(content) +} + +func parseMod(t *testing.T, dir string) *modfile.File { + t.Helper() + + parsed, err := modfile.Parse("go.mod", []byte(readMod(t, dir)), nil) + require.NoError(t, err) + + return parsed +} diff --git a/genapp/options.go b/genapp/options.go new file mode 100644 index 0000000..439ba23 --- /dev/null +++ b/genapp/options.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "path/filepath" + "strings" + + "github.com/go-openapi/codegen/formatting" + repo "github.com/go-openapi/codegen/templates-repo" +) + +// Option configures a [GoGenApp]. +type Option func(*options) + +type options struct { + templates *repo.Repository + outputPath string + formatOptions []formatting.Option + importsReporter func(string, *formatting.ImportsReport) + skipFormat bool + skipFormatFunc func(target string) bool +} + +// WithTemplates sets the repository to render from. [New] needs it. +// +// The repository is built by [github.com/go-openapi/codegen/templates-repo.New], which is where the +// sources, the funcmap and the scoping are decided: +// +// templates, err := repo.New( +// repo.FromFS(assets, ""), +// repo.WithFuncMap(golang.FuncMap(mangling.MakeGoMangler())), +// ) +// if err != nil { +// return err +// } +// +// app, err := genapp.New(genapp.WithTemplates(templates)) +func WithTemplates(templates *repo.Repository) Option { + return func(o *options) { + o.templates = templates + } +} + +// WithOutputPath sets where [GoGenApp.RenderFile] writes. Targets are relative to that directory. +func WithOutputPath(path string) Option { + return func(o *options) { + o.outputPath = path + } +} + +// WithFormatOptions configures the formatter. +// +// Grouping, gofumpt and the rest are settled by +// [github.com/go-openapi/codegen/formatting], and this package re-exports none of it: +// +// genapp.WithFormatOptions( +// formatting.WithImportGroups("github.com/go-openapi", baseImport), +// ) +func WithFormatOptions(opts ...formatting.Option) Option { + return func(o *options) { + o.formatOptions = append(o.formatOptions, opts...) + } +} + +// WithImportsReporter calls report for every file rendered, with the name of the template that +// rendered it. +// +// [formatting.Format] keeps an import whose package it cannot name, rather than delete one the code +// may be using, and says so in the report. Use this to see those: +// +// genapp.WithImportsReporter(func(template string, report *formatting.ImportsReport) { +// if report.HasImportsInDoubt() { +// log.Printf("%s: %v", template, report.PathsInDoubt()) +// } +// }) +// +// Resolve the paths it lists once, then pass the names through +// [formatting.WithResolvedImports] with [WithFormatOptions]. +func WithImportsReporter(report func(template string, report *formatting.ImportsReport)) Option { + return func(o *options) { + o.importsReporter = report + } +} + +// WithSkipFormat writes every target as the template rendered it. +// +// A template that produces Go which does not parse makes [GoGenApp.RenderFile] fail with nothing +// written, and finding out why means reading the output. Turn this on and the file lands +// unformatted. +func WithSkipFormat(skipped bool) Option { + return func(o *options) { + o.skipFormat = skipped + } +} + +// WithSkipFormatFunc decides which targets [GoGenApp.RenderFile] formats. +// +// The default formats a target whose name ends in ".go" and copies anything else through. +func WithSkipFormatFunc(skip func(target string) bool) Option { + return func(o *options) { + o.skipFormatFunc = skip + } +} + +// skipsFormat reports whether a target is written as rendered. +func (o options) skipsFormat(target string) bool { + return o.skipFormat || o.skipFormatFunc(target) +} + +func optionsWithDefaults(opts []Option) options { + var o options + + for _, apply := range opts { + apply(&o) + } + + if o.skipFormatFunc == nil { + o.skipFormatFunc = func(target string) bool { + return !strings.EqualFold(filepath.Ext(target), ".go") + } + } + + return o +} diff --git a/genapp/options_test.go b/genapp/options_test.go new file mode 100644 index 0000000..558716e --- /dev/null +++ b/genapp/options_test.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/formatting" + "github.com/go-openapi/codegen/genapp" + repo "github.com/go-openapi/codegen/templates-repo" +) + +func TestOptions(t *testing.T) { + t.Parallel() + + t.Run("should render from a repository built by the caller", func(t *testing.T) { + t.Parallel() + + templates, err := repo.New(repo.FromTemplate("mine", []byte("package {{ .Package }}\n"))) + require.NoError(t, err) + + app, err := genapp.New(genapp.WithTemplates(templates)) + require.NoError(t, err) + + var out bytes.Buffer + require.NoError(t, app.Render(&out, "mine", pet)) + assert.Equal(t, "package models\n", out.String()) + }) + + t.Run("should render with whatever funcmap the repository carries", func(t *testing.T) { + t.Parallel() + + app, err := genapp.New(genapp.WithTemplates(newRepo(t, repo.WithFuncMap(template.FuncMap{ + "pascalize": func(string) string { return "Overridden" }, + })))) + require.NoError(t, err) + + var out bytes.Buffer + require.NoError(t, app.Render(&out, "model", pet)) + assert.Contains(t, out.String(), "type Overridden struct {", "the later funcmap wins") + }) + + t.Run("should pass options through to the formatter", func(t *testing.T) { + t.Parallel() + + app, err := genapp.New( + genapp.WithTemplates(newRepo(t)), + genapp.WithFormatOptions(formatting.WithGoFumpt()), + ) + require.NoError(t, err) + + var out bytes.Buffer + err = app.Render(&out, "model", pet) + + require.Error(t, err, "the gofumpt enable module is not linked into this test") + assert.ErrorIs(t, err, formatting.ErrNoGoFumpt) + }) + +} + +func TestExecuteError(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + err := newApp(t).Render(&out, "model", struct{ Package string }{Package: "models"}) + + require.Error(t, err, "the template reaches a field the data does not carry") + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "model") +} diff --git a/genapp/package.go b/genapp/package.go new file mode 100644 index 0000000..0923a2d --- /dev/null +++ b/genapp/package.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// ErrNoModule is returned when nothing above the output path declares a module, and the path is +// under no GOPATH either. +// +// Generated code lands where the caller says, and that may be outside any module: a fresh directory +// in /tmp, a tree beside a repository rather than inside it. [GoGenApp.ModuleRequired] asks the same +// question without treating the answer as a failure. +const ErrNoModule Error = "no module and no GOPATH covers the output path" + +// EnclosingModule finds the module the output path belongs to. +// +// It returns the module path as go.mod declares it, and the directory that go.mod sits in. The +// search walks up from the output path and stops at the first go.mod, so a module nested inside +// another wins, which is how the go command reads the same tree. +// +// It reads go.mod files and nothing else: no go command, no environment, no GOPATH. A directory +// that does not exist yet is not an obstacle, since only its name takes part in the answer. +// +// It returns [ErrNoModule] when the walk reaches the root of the file system without finding one. +func (g *GoGenApp) EnclosingModule() (modulePath, moduleDir string, err error) { + start, err := filepath.Abs(g.outputPath) + if err != nil { + return "", "", fmt.Errorf("cannot resolve the output path %q: %w: %w", g.outputPath, err, ErrGenApp) + } + + for dir := start; ; { + declared, found, err := readModulePath(filepath.Join(dir, goModFile)) + if err != nil { + return "", "", err + } + + if found { + return declared, dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", "", fmt.Errorf("%w above %q: %w", ErrNoModule, start, ErrGenApp) + } + + dir = parent + } +} + +// PackagePath returns the import path of the output path. +// +// It is the module path of the enclosing module followed by the way down to the output path, so a +// generator can write the import statements that reach the code it is about to produce: +// +// module example.com/petstore declared in /src/petstore/go.mod +// output path /src/petstore/gen/models +// PackagePath example.com/petstore/gen/models +// +// A caller generating into a tree that has no module yet calls [GoGenApp.InitModule] first, and +// PackagePath then returns what that declared. +// +// Failing a module, a path under GOPATH/src answers too: the way down from src is the import path +// such a tree has when GO111MODULE is off. Modules are looked for first, since they win wherever +// both apply. +// +// It returns [ErrNoModule] when neither names the path. See [GoGenApp.ModuleRequired]. +func (g *GoGenApp) PackagePath() (string, error) { + start, err := filepath.Abs(g.outputPath) + if err != nil { + return "", fmt.Errorf("cannot resolve the output path %q: %w: %w", g.outputPath, err, ErrGenApp) + } + + modulePath, moduleDir, err := g.EnclosingModule() + if err != nil { + if !errors.Is(err, ErrNoModule) { + return "", err + } + + within, found := gopathPackage(start) + if !found { + return "", err + } + + return checkedImportPath(within) + } + + within, err := filepath.Rel(moduleDir, start) + if err != nil { + return "", fmt.Errorf("%q is not under %q: %w: %w", start, moduleDir, err, ErrGenApp) + } + + importPath := modulePath + if within != "." { + importPath = path.Join(modulePath, filepath.ToSlash(within)) + } + + return checkedImportPath(importPath) +} + +// checkedImportPath reports a path the go command would not import. +func checkedImportPath(importPath string) (string, error) { + if err := module.CheckImportPath(importPath); err != nil { + return "", fmt.Errorf( + "%q does not name a package, the output path has a directory go would not import: %w: %w", + importPath, err, ErrGenApp, + ) + } + + return importPath, nil +} + +// ModuleRequired reports whether the output path needs a go.mod of its own. +// +// It is true when nothing above the output path declares a module, which is when generated code +// there could not be built until [GoGenApp.InitModule] gives it one. +// +// It is false when a module already covers the path. That module may be the one the generator is +// running from, so a caller generating into its own repository gets false and needs no go.mod. +// +// GOPATH does not enter into it, though [GoGenApp.PackagePath] falls back to it. A tree under +// GOPATH/src builds only with GO111MODULE off, and go has defaulted the other way since 1.16, so +// such a tree does need a go.mod for the go a caller will be running. +func (g *GoGenApp) ModuleRequired() (bool, error) { + _, _, err := g.EnclosingModule() + + switch { + case err == nil: + return false, nil + case errors.Is(err, ErrNoModule): + return true, nil + default: + return false, err + } +} + +// readModulePath reads the module path a go.mod declares. +// +// It reports found as false when there is no file there, and an error when the file is unreadable +// or declares no module, since a go.mod without a module line stops the go command too. +func readModulePath(path string) (modulePath string, found bool, err error) { + content, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + + return "", false, fmt.Errorf("cannot read %q: %w: %w", path, err, ErrGenApp) + } + + declared := modfile.ModulePath(content) + if strings.TrimSpace(declared) == "" { + return "", false, fmt.Errorf("%q declares no module: %w", path, ErrGenApp) + } + + return declared, true, nil +} diff --git a/genapp/package_test.go b/genapp/package_test.go new file mode 100644 index 0000000..1a8beb6 --- /dev/null +++ b/genapp/package_test.go @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + + "github.com/go-openapi/codegen/genapp" +) + +// module writes a go.mod declaring path in dir. +func writeModule(t *testing.T, dir, path string) { + t.Helper() + + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "go.mod"), + []byte("module "+path+"\n\ngo 1.25.0\n"), + 0o600, + )) +} + +func TestPackagePath(t *testing.T) { + t.Parallel() + + t.Run("should name the module itself at its root", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeModule(t, root, "example.com/petstore") + + app := newApp(t, genapp.WithOutputPath(root)) + + pkg, err := app.PackagePath() + require.NoError(t, err) + assert.Equal(t, "example.com/petstore", pkg) + }) + + t.Run("should follow the way down to the output path", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeModule(t, root, "example.com/petstore") + + out := filepath.Join(root, "gen", "models") + require.NoError(t, os.MkdirAll(out, 0o750)) + + pkg, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + require.NoError(t, err) + assert.Equal(t, "example.com/petstore/gen/models", pkg) + }) + + t.Run("should answer for a directory that does not exist yet", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeModule(t, root, "example.com/petstore") + + out := filepath.Join(root, "not", "created", "yet") + + pkg, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + require.NoError(t, err) + assert.Equal(t, "example.com/petstore/not/created/yet", pkg) + + _, statErr := os.Stat(out) + assert.ErrorIs(t, statErr, os.ErrNotExist, "and creates nothing to find out") + }) + + t.Run("should take the nearest module when they nest", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeModule(t, root, "example.com/outer") + + inner := filepath.Join(root, "tools") + writeModule(t, inner, "example.com/outer/tools") + + out := filepath.Join(inner, "gen") + require.NoError(t, os.MkdirAll(out, 0o750)) + + pkg, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + require.NoError(t, err) + assert.Equal(t, "example.com/outer/tools/gen", pkg) + }) + + t.Run("should report a path no module covers", func(t *testing.T) { + t.Parallel() + + out := filepath.Join(t.TempDir(), "orphan") + + _, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrNoModule) + assert.ErrorIs(t, err, genapp.ErrGenApp) + }) + + t.Run("should report a go.mod that declares no module", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("go 1.25.0\n"), 0o600)) + + _, err := newApp(t, genapp.WithOutputPath(root)).PackagePath() + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "declares no module") + }) + + t.Run("should answer what InitModule just declared", func(t *testing.T) { + t.Parallel() + + out := filepath.Join(t.TempDir(), "generated") + app := newApp(t, genapp.WithOutputPath(out)) + + required, err := app.ModuleRequired() + require.NoError(t, err) + require.True(t, required, "nothing covers it yet") + + require.NoError(t, app.InitModule(genapp.WithModulePath("example.com/fresh/gen"))) + + pkg, err := app.PackagePath() + require.NoError(t, err) + assert.Equal(t, "example.com/fresh/gen", pkg) + }) +} + +func TestModuleRequired(t *testing.T) { + t.Parallel() + + t.Run("should be false inside a module", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeModule(t, root, "example.com/petstore") + + required, err := newApp(t, genapp.WithOutputPath(filepath.Join(root, "gen"))).ModuleRequired() + + require.NoError(t, err) + assert.False(t, required) + }) + + t.Run("should be true outside every module", func(t *testing.T) { + t.Parallel() + + required, err := newApp(t, genapp.WithOutputPath(t.TempDir())).ModuleRequired() + + require.NoError(t, err) + assert.True(t, required) + }) + + t.Run("should report a go.mod it cannot read rather than answer", func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("go 1.25.0\n"), 0o600)) + + _, err := newApp(t, genapp.WithOutputPath(root)).ModuleRequired() + + require.Error(t, err, "a broken go.mod is not the same as no go.mod") + assert.ErrorIs(t, err, genapp.ErrGenApp) + }) +} + +func TestEnclosingModule(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeModule(t, root, "example.com/petstore") + + out := filepath.Join(root, "gen", "models") + require.NoError(t, os.MkdirAll(out, 0o750)) + + modulePath, moduleDir, err := newApp(t, genapp.WithOutputPath(out)).EnclosingModule() + require.NoError(t, err) + + assert.Equal(t, "example.com/petstore", modulePath) + + resolved, err := filepath.EvalSymlinks(moduleDir) + require.NoError(t, err) + expected, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + assert.Equal(t, expected, resolved, "and says where the go.mod sits") +} + +func TestPackagePathUnderGopath(t *testing.T) { + t.Run("should name a package under GOPATH/src", func(t *testing.T) { + gopath := t.TempDir() + t.Setenv("GOPATH", gopath) + + out := filepath.Join(gopath, "src", "example.com", "legacy", "pkg") + require.NoError(t, os.MkdirAll(out, 0o750)) + + pkg, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + + require.NoError(t, err) + assert.Equal(t, "example.com/legacy/pkg", pkg) + }) + + t.Run("should try every entry of GOPATH", func(t *testing.T) { + first, second := t.TempDir(), t.TempDir() + t.Setenv("GOPATH", first+string(os.PathListSeparator)+second) + + out := filepath.Join(second, "src", "example.com", "second", "pkg") + require.NoError(t, os.MkdirAll(out, 0o750)) + + pkg, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + + require.NoError(t, err) + assert.Equal(t, "example.com/second/pkg", pkg) + }) + + t.Run("should let a module win where both apply", func(t *testing.T) { + gopath := t.TempDir() + t.Setenv("GOPATH", gopath) + + out := filepath.Join(gopath, "src", "example.com", "legacy", "pkg") + writeModule(t, out, "example.com/modernised") + + pkg, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + + require.NoError(t, err) + assert.Equal(t, "example.com/modernised", pkg, "the go.mod decides, as it does for the go command") + }) + + t.Run("should not name GOPATH/src itself", func(t *testing.T) { + gopath := t.TempDir() + t.Setenv("GOPATH", gopath) + + out := filepath.Join(gopath, "src") + require.NoError(t, os.MkdirAll(out, 0o750)) + + _, err := newApp(t, genapp.WithOutputPath(out)).PackagePath() + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrNoModule) + }) + + t.Run("should report a path under neither", func(t *testing.T) { + t.Setenv("GOPATH", t.TempDir()) + + _, err := newApp(t, genapp.WithOutputPath(t.TempDir())).PackagePath() + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrNoModule) + }) + + t.Run("should still say a module is required, since go defaults to module mode", func(t *testing.T) { + gopath := t.TempDir() + t.Setenv("GOPATH", gopath) + + out := filepath.Join(gopath, "src", "example.com", "legacy", "pkg") + require.NoError(t, os.MkdirAll(out, 0o750)) + + required, err := newApp(t, genapp.WithOutputPath(out)).ModuleRequired() + + require.NoError(t, err) + assert.True(t, required, "the path has a name, and still needs a go.mod to build") + }) +} diff --git a/genapp/testdata/templates/broken.gotmpl b/genapp/testdata/templates/broken.gotmpl new file mode 100644 index 0000000..6735af3 --- /dev/null +++ b/genapp/testdata/templates/broken.gotmpl @@ -0,0 +1,3 @@ +package {{ .Package }} + +func Broken( { diff --git a/genapp/testdata/templates/model.gotmpl b/genapp/testdata/templates/model.gotmpl new file mode 100644 index 0000000..3fb0ece --- /dev/null +++ b/genapp/testdata/templates/model.gotmpl @@ -0,0 +1,25 @@ +{{- /* a model, deliberately misformatted and over-imported */ -}} +// Code generated by codegen; DO NOT EDIT. + +package {{ .Package }} + +import ( +"context" + "strings" +"github.com/go-openapi/strfmt" + "fmt" +) + +// {{ pascalize .Name }} is a generated model. +type {{ pascalize .Name }} struct { +ID strfmt.UUID +Name string +} + +func ( m * {{ pascalize .Name }} ) Validate(ctx context.Context) error { +_ = ctx +if m.Name == "" { +return fmt.Errorf("name is required") +} +return nil +} diff --git a/genapp/testdata/templates/models/nested.gotmpl b/genapp/testdata/templates/models/nested.gotmpl new file mode 100644 index 0000000..0aaa82b --- /dev/null +++ b/genapp/testdata/templates/models/nested.gotmpl @@ -0,0 +1,4 @@ +{{- /* a template addressed under a directory */ -}} +package {{ .Package }} + +type {{ pascalize .Name }}Nested struct{ Value int } diff --git a/genapp/testdata/templates/readme.gotmpl b/genapp/testdata/templates/readme.gotmpl new file mode 100644 index 0000000..7855a24 --- /dev/null +++ b/genapp/testdata/templates/readme.gotmpl @@ -0,0 +1,3 @@ +# {{ .Name }} + +Generated, and not Go. diff --git a/genapp/tidy.go b/genapp/tidy.go new file mode 100644 index 0000000..8796feb --- /dev/null +++ b/genapp/tidy.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// maxTidyOutput bounds how much of a failing command's output goes into the error. +const maxTidyOutput = 4096 + +// TidyModule runs "go mod tidy" in the output path. +// +// This is the one thing in this package that needs a Go toolchain. Tidying resolves every import +// the generated code makes against the module graph and the checksum database, downloading what it +// must, and reproducing that here would mean reproducing the go command. So it is shelled out, and +// a generator that never calls it never needs go on the machine. +// +// The context bounds the run: cancelling it kills the command, and +// [WithTidyWaitDelay] bounds how long the command may hold the output pipes open after that. +// +// A go.work in a parent directory that does not list the generated module makes the go command +// refuse to work in it, so the command runs with GOWORK off unless [WithWorkspace] says otherwise. +// [WithTidyEnv] sets anything else the command needs, such as GOPROXY or GOPRIVATE. +// +// What the command wrote is reported when it fails; pass [WithTidyOutput] to watch it as it runs. +func (g *GoGenApp) TidyModule(ctx context.Context, opts ...TidyOption) error { + o, err := tidyOptionsWithDefaults(opts) + if err != nil { + return err + } + + if err := g.checkModulePresent(); err != nil { + return err + } + + var captured bytes.Buffer + + cmd := g.tidyCommand(ctx, o, &captured) + + if err := cmd.Run(); err != nil { + return g.tidyError(o, err, captured.Bytes()) + } + + return nil +} + +// checkModulePresent reports a missing go.mod, which tidy needs and does not create. +func (g *GoGenApp) checkModulePresent() error { + path := filepath.Join(g.outputPath, goModFile) + + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("no %q to tidy, see InitModule: %w: %w", path, err, ErrGenApp) + } + + return nil +} + +// tidyCommand assembles the command to run. +func (g *GoGenApp) tidyCommand(ctx context.Context, o tidyOptions, captured *bytes.Buffer) *exec.Cmd { + args := []string{"mod", "tidy"} + + if o.goVersion != "" { + args = append(args, "-go="+o.goVersion) + } + + if o.compat != "" { + args = append(args, "-compat="+o.compat) + } + + cmd := exec.CommandContext(ctx, o.goCommand, args...) //nolint:gosec // the caller names the toolchain + cmd.Dir = g.outputPath + cmd.WaitDelay = o.waitDelay + + cmd.Env = os.Environ() + if !o.workspace { + cmd.Env = append(cmd.Env, "GOWORK=off") + } + + cmd.Env = append(cmd.Env, o.env...) + + output := io.Writer(captured) + if o.output != nil { + output = io.MultiWriter(captured, o.output) + } + + cmd.Stdout = output + cmd.Stderr = output + + return cmd +} + +// tidyError explains a command that did not run, or ran and failed. +func (g *GoGenApp) tidyError(o tidyOptions, cause error, output []byte) error { + var missing *exec.Error + if errors.As(cause, &missing) { + return fmt.Errorf( + "%q is needed to tidy %q and was not found, a Go toolchain is required for this step alone: %w: %w", + o.goCommand, g.outputPath, cause, ErrGenApp, + ) + } + + said := strings.TrimSpace(string(output)) + if len(said) > maxTidyOutput { + said = said[:maxTidyOutput] + "..." + } + + if said == "" { + return fmt.Errorf("go mod tidy failed in %q: %w: %w", g.outputPath, cause, ErrGenApp) + } + + return fmt.Errorf("go mod tidy failed in %q: %w: %s: %w", g.outputPath, cause, said, ErrGenApp) +} diff --git a/genapp/tidy_options.go b/genapp/tidy_options.go new file mode 100644 index 0000000..7e5c616 --- /dev/null +++ b/genapp/tidy_options.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp + +import ( + "fmt" + "io" + "time" + + "golang.org/x/mod/modfile" +) + +// TidyOption configures [GoGenApp.TidyModule]. +type TidyOption func(*tidyOptions) + +type tidyOptions struct { + goCommand string + goVersion string + compat string + output io.Writer + env []string + workspace bool + waitDelay time.Duration +} + +// WithGoCommand names the go binary to run. It defaults to "go", found on PATH. +// +// Pass an absolute path to run a toolchain the PATH does not point at. +func WithGoCommand(command string) TidyOption { + return func(o *tidyOptions) { + if command != "" { + o.goCommand = command + } + } +} + +// WithTidyGoVersion passes -go to the command, as in "1.25.0", which sets the go directive while +// tidying. +func WithTidyGoVersion(version string) TidyOption { + return func(o *tidyOptions) { + o.goVersion = version + } +} + +// WithTidyCompat passes -compat to the command, as in "1.24", which keeps the checksums an older +// go needs to load the module. +func WithTidyCompat(version string) TidyOption { + return func(o *tidyOptions) { + o.compat = version + } +} + +// WithTidyOutput copies what the command writes to w, as it writes it. +// +// The output is kept either way and reported when the command fails. Pass a writer to watch a tidy +// that takes a while, since it downloads what the module requires. +func WithTidyOutput(w io.Writer) TidyOption { + return func(o *tidyOptions) { + o.output = w + } +} + +// WithTidyEnv sets environment variables for the command, as "GOPROXY=off" or "GOPRIVATE=example.com". +// +// They are added to the environment this process runs in, so a later setting replaces an earlier +// one. Tidying reaches the module proxy and the checksum database, and a generated module often +// wants different settings for those than the generator itself. +func WithTidyEnv(vars ...string) TidyOption { + return func(o *tidyOptions) { + o.env = append(o.env, vars...) + } +} + +// WithWorkspace lets the go workspace apply to the command. +// +// A generated module is usually a module of its own, and a go.work in a parent directory that does +// not list it makes the go command refuse to work in it, so [GoGenApp.TidyModule] runs with GOWORK +// off. Turn this on for a module the surrounding workspace is meant to cover. +func WithWorkspace(enabled bool) TidyOption { + return func(o *tidyOptions) { + o.workspace = enabled + } +} + +// WithTidyWaitDelay bounds how long the command may hold the output pipes open after its context is +// done, before it is killed. It defaults to five seconds. +func WithTidyWaitDelay(delay time.Duration) TidyOption { + return func(o *tidyOptions) { + o.waitDelay = delay + } +} + +func tidyOptionsWithDefaults(opts []TidyOption) (tidyOptions, error) { + const defaultWaitDelay = 5 * time.Second + + o := tidyOptions{goCommand: "go", waitDelay: defaultWaitDelay} + + for _, apply := range opts { + apply(&o) + } + + for _, version := range [...]struct{ flag, value string }{ + {flag: "-go", value: o.goVersion}, + {flag: "-compat", value: o.compat}, + } { + if version.value == "" { + continue + } + + if !modfile.GoVersionRE.MatchString(version.value) { + return o, fmt.Errorf( + "%s=%q is not a go version, want something like 1.25.0: %w", version.flag, version.value, ErrGenApp, + ) + } + } + + return o, nil +} diff --git a/genapp/tidy_test.go b/genapp/tidy_test.go new file mode 100644 index 0000000..bd7ffaa --- /dev/null +++ b/genapp/tidy_test.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package genapp_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + "golang.org/x/mod/modfile" + + "github.com/go-openapi/codegen/genapp" +) + +// tidyable lays down a module with one file that imports only the standard library, so tidying it +// resolves nothing and needs no network. +func tidyable(t *testing.T) (*genapp.GoGenApp, string) { + t.Helper() + + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go mod tidy needs a toolchain on PATH") + } + + dir := t.TempDir() + app := newApp(t, genapp.WithOutputPath(dir)) + + require.NoError(t, app.InitModule( + genapp.WithModulePath("example.com/tidyme"), + genapp.WithRequire("github.com/go-openapi/strfmt", "v0.24.0", false), + )) + + const source = "package tidyme\n\nimport \"strings\"\n\nfunc F() string { return strings.TrimSpace(\" x \") }\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "tidyme.go"), []byte(source), 0o600)) + + return app, dir +} + +func TestTidyModule(t *testing.T) { + t.Parallel() + + t.Run("should tidy a module that resolves offline", func(t *testing.T) { + t.Parallel() + + app, dir := tidyable(t) + + require.NoError(t, app.TidyModule(t.Context())) + + parsed, err := modfile.Parse("go.mod", []byte(readMod(t, dir)), nil) + require.NoError(t, err) + assert.Empty(t, parsed.Require, "tidy drops the requirement nothing imports") + }) + + t.Run("should set the go directive when asked", func(t *testing.T) { + t.Parallel() + + app, dir := tidyable(t) + + require.NoError(t, app.TidyModule(t.Context(), genapp.WithTidyGoVersion("1.24.0"))) + + parsed, err := modfile.Parse("go.mod", []byte(readMod(t, dir)), nil) + require.NoError(t, err) + require.NotNil(t, parsed.Go) + assert.Equal(t, "1.24.0", parsed.Go.Version) + }) + + t.Run("should copy what the command writes", func(t *testing.T) { + t.Parallel() + + app, _ := tidyable(t) + + var watched bytes.Buffer + require.NoError(t, app.TidyModule(t.Context(), + genapp.WithTidyOutput(&watched), + genapp.WithGoCommand("go"), + )) + }) + + t.Run("should report a module that is not there", func(t *testing.T) { + t.Parallel() + + app := newApp(t, genapp.WithOutputPath(t.TempDir())) + + err := app.TidyModule(t.Context()) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "InitModule") + }) + + t.Run("should say a toolchain is needed when the command is not there", func(t *testing.T) { + t.Parallel() + + app, _ := tidyable(t) + + err := app.TidyModule(t.Context(), genapp.WithGoCommand("no-such-go-command")) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.ErrorIs(t, err, exec.ErrNotFound) + assert.Contains(t, err.Error(), "toolchain is required") + }) + + t.Run("should stop when the context is done", func(t *testing.T) { + t.Parallel() + + app, _ := tidyable(t) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := app.TidyModule(ctx) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + }) + + t.Run("should refuse a version the go directive would reject", func(t *testing.T) { + t.Parallel() + + app, _ := tidyable(t) + + err := app.TidyModule(t.Context(), genapp.WithTidyCompat("go1.24")) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "-compat") + }) + + t.Run("should report what the command said when it fails", func(t *testing.T) { + t.Parallel() + + app, dir := tidyable(t) + unresolvable := "package tidyme\n\nimport _ \"example.invalid/nope/v9\"\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "unresolvable.go"), []byte(unresolvable), 0o600)) + + // GOPROXY=off makes the failure immediate rather than a trip to the module proxy + err := app.TidyModule(t.Context(), + genapp.WithTidyEnv("GOPROXY=off"), + genapp.WithTidyWaitDelay(time.Second), + ) + + require.Error(t, err) + assert.ErrorIs(t, err, genapp.ErrGenApp) + assert.Contains(t, err.Error(), "example.invalid/nope/v9", "the command's own words reach the caller") + }) +} diff --git a/go.mod b/go.mod index 6657419..8369828 100644 --- a/go.mod +++ b/go.mod @@ -6,15 +6,15 @@ require ( github.com/go-openapi/codegen/mangling v0.0.0 github.com/go-openapi/inflect v1.0.0 github.com/go-openapi/swag/conv v0.29.0 + github.com/go-openapi/swag/pools v0.29.0 github.com/go-openapi/testify/v2 v2.6.1 + golang.org/x/mod v0.40.0 golang.org/x/tools v0.49.0 ) replace github.com/go-openapi/codegen/mangling => ./mangling require ( - github.com/go-openapi/swag/pools v0.29.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - golang.org/x/mod v0.40.0 // indirect golang.org/x/sync v0.22.0 // indirect ) diff --git a/go.work b/go.work index 9b14baf..ef3cb81 100644 --- a/go.work +++ b/go.work @@ -2,7 +2,8 @@ go 1.25.0 use ( . - ./genapp + ./formatting/enable/gofumpt + ./formatting/testdata/corpus ./mangling ./mangling/ucd ) diff --git a/templates-repo/reports/documentation.go b/templates-repo/reports/documentation.go index fc6b9fc..84e3f82 100644 --- a/templates-repo/reports/documentation.go +++ b/templates-repo/reports/documentation.go @@ -24,7 +24,7 @@ type Asset struct { // Template is the documentation of a single template. type Template struct { - // Name is the name the template is registered under. + // Name is what [github.com/go-openapi/codegen/templates-repo.Repository.Get] answers to. Name string // Doc holds the comments documenting the template, one entry per comment. diff --git a/templates-repo/repository.go b/templates-repo/repository.go index eb539fd..d8eff50 100644 --- a/templates-repo/repository.go +++ b/templates-repo/repository.go @@ -239,7 +239,7 @@ func (r *Repository) Roots() []string { // AssetOf returns the path of the asset that declares a name, and whether it is declared at all. // -// The path is the one the asset has once mounted, and the name was derived from it. +// The path is the asset's, once mounted, and the name was derived from it. func (r *Repository) AssetOf(name string) (string, bool) { declared, found := r.declarations[name] diff --git a/templates-repo/resolve.go b/templates-repo/resolve.go index aa3c3bc..7c1012a 100644 --- a/templates-repo/resolve.go +++ b/templates-repo/resolve.go @@ -12,7 +12,7 @@ import ( "text/template/parse" ) -// declared is a template the repository holds, at the address it was declared under. +// declared holds one template of the repository, together with the address declaring it. type declared struct { // address is the path the template was declared at, never mangled. // diff --git a/templates-repo/sources.go b/templates-repo/sources.go index fc6f197..a6c7af1 100644 --- a/templates-repo/sources.go +++ b/templates-repo/sources.go @@ -14,9 +14,9 @@ import ( // asset is a template file read from a source, held for as long as the repository lives. // -// The path is the one the asset has once mounted, slash-separated and cleaned. The name of the -// template is derived from it, so the path is retained rather than the name: a [Clone] that -// changes the recognized extensions renames the templates accordingly. +// The path is the asset's, once mounted, slash-separated and cleaned. The name of the template is +// derived from it, so the path is retained rather than the name: a [Clone] that changes the +// recognized extensions renames the templates accordingly. // // The layer records which source read the asset. Layers are numbered in the order the sources are // declared, and a [Clone] carries on where the repository it derives from left off, so two assets