From 54b3796cba9f6f7607780e8a06e3f55e75697f06 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 21 Aug 2026 20:32:14 +0200 Subject: [PATCH 01/14] feat(formatting): replace goimports with a toolchain-free formatter func Format[T Source](w io.Writer, src T, opts ...Option) error Format parses once, drops the imports nothing uses, sorts and groups the rest, and prints in gofmt style to the writer. Nothing reads the file system or runs the go command, so a source formats the same way on every machine. Source unions []byte and *bytes.Buffer, both of which give up their bytes without copying, so a generator recycles one buffer. It never adds an import. goimports resolves a missing one by searching the module cache and the build list, so a spec and a template produce different files on different machines and a maintainer cannot reproduce what a user reports. A template writes the imports its code needs, and code naming a package it did not import fails to compile. Out go golang.org/x/tools/imports, its LocalPrefix global two goroutines could race on, the $GOPATH/src resolver and BaseImport. Pruning drops an import when no selector uses the name it declares. An alias names the package exactly, and "_" and "." are never touched. Otherwise the name is guessed, and one guess is not enough: goimports reads "gopkg.in/yaml.v3" as yaml.v3 and drops a used import, and stripping the major version reads "k8s.io/api/apps/v1" as apps and drops another. Every legal candidate counts, so a surplus one can only keep an import. Ident.Obj separates a qualifier from a shadowed name, and those scopes cost a sixth of the allocations, so the parse skips them and asks for a second only when an imported name is also declared in the file. WithImportGroups opens a group per prefix between the standard library and the rest, carried in the options value rather than a global. go/format.Node would flatten that, re-sorting every parenthesized block by path, so Format prints through a printer.Config carrying gofmt's own mode bits, and TestMatchesGofmt pins the one bit with no exported name. Group separators are written by a one-line io.Writer, so a whole file streams and only a fragment buffers. WithGoFumpt applies the gofumpt rules from formatting/enable/gofumpt, its own module reached by a blank import, so the root go.mod does not require mvdan.cc/gofumpt. Speed depends on file size: imports.Process spends around 2.6ms per call on environment work before reading anything, and FormatLite, offered by go-swagger behind --with-custom-formatter, parses and prints then hands off to imports.Process to do it again. Medians of three runs, per file: input Format FormatLite goimports us KB us KB us KB 1 KB 102 26 277 95 2811 111 4 KB 514 78 1532 336 3425 279 40 KB 3621 741 15123 3204 11936 2207 160 KB 20700 3432 57359 14093 41924 9481 FormatLite costs three times the time and four times the memory at every size, so that is throughput and not a fixed charge. goimports costs 28 times on a 1 KB file and twice on a 160 KB one; generated models run 1 to 2 KB. FormatLite and goimports emit the same bytes, so grouping is the only change a generated file shows: a group per prefix here, against one merged group after the third-party imports. Fixtures live under formatting/testdata, whose corpus is a module of its own so its goldens compile. TestAgainstReference formats eleven sources with both Format and goimports and compares bytes: all eleven agree. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/corpus_test.go | 86 ++++ formatting/doc.go | 83 ++++ formatting/enable/gofumpt/doc.go | 41 ++ formatting/enable/gofumpt/enable.go | 94 ++++ formatting/enable/gofumpt/enable_test.go | 98 +++++ formatting/enable/gofumpt/go.mod | 15 + formatting/enable/gofumpt/go.sum | 20 + .../enable/gofumpt/testdata/generated.input | 15 + .../enable/gofumpt/testdata/grouped.input | 12 + formatting/errors.go | 19 + formatting/format.go | 194 +++++++++ formatting/format_lite.go | 354 ---------------- formatting/format_lite_test.go | 242 ----------- formatting/format_test.go | 346 +++++++++++++++ formatting/golang.go | 250 ----------- formatting/golang_test.go | 127 ------ formatting/internal/rules/rules.go | 46 ++ formatting/internal/rules/rules_test.go | 52 +++ formatting/options.go | 190 ++------- formatting/options_test.go | 100 ----- formatting/parse.go | 213 ++++++++++ formatting/parse_internal_test.go | 197 +++++++++ formatting/prune.go | 252 +++++++++++ formatting/prune_internal_test.go | 70 +++ formatting/prune_test.go | 81 ++++ formatting/reference_test.go | 102 +++++ formatting/sort.go | 401 ++++++++++++++++++ formatting/sources_test.go | 64 +++ formatting/spacer.go | 140 ++++++ formatting/testdata/corpus/go.mod | 15 + formatting/testdata/corpus/go.sum | 10 + formatting/testdata/corpus/grouped/grouped.go | 19 + .../testdata/corpus/grouped/grouped.input | 17 + formatting/testdata/corpus/pruned/pruned.go | 19 + .../testdata/corpus/pruned/pruned.input | 20 + formatting/testdata/sources/broken-decl.input | 3 + formatting/testdata/sources/broken-expr.input | 3 + .../testdata/sources/empty-package.input | 1 + .../testdata/sources/fragment-decls.input | 3 + .../testdata/sources/fragment-main.input | 2 + .../testdata/sources/fragment-spaced.input | 5 + .../testdata/sources/fragment-stmts.input | 2 + .../testdata/sources/gofmt/alignment.input | 6 + .../sources/gofmt/number-literals.input | 8 + .../sources/gofmt/standard-library-only.input | 11 + formatting/testdata/sources/grouped.input | 15 + .../testdata/sources/idempotent/aliased.input | 5 + .../sources/idempotent/blank-only.input | 3 + .../testdata/sources/idempotent/cgo.input | 4 + .../sources/idempotent/comment-kept.input | 11 + .../sources/idempotent/fragment.input | 5 + .../testdata/sources/idempotent/grouped.input | 15 + .../sources/idempotent/no-imports.input | 3 + .../testdata/sources/idempotent/single.input | 5 + .../testdata/sources/prune/aliased.input | 8 + .../sources/prune/blank-and-dot.input | 6 + formatting/testdata/sources/prune/cgo.input | 4 + .../testdata/sources/prune/missing.input | 5 + .../sources/prune/shadowed-partly.input | 12 + .../sources/prune/shadowed-wholly.input | 8 + .../testdata/sources/prune/unnameable.input | 3 + .../testdata/sources/prune/unused.input | 8 + .../testdata/sources/reference/aliased.input | 11 + .../reference/blank-and-dot-imports.input | 12 + .../testdata/sources/reference/cgo.input | 8 + .../reference/comment-on-an-import.input | 11 + .../sources/reference/duplicate-path.input | 8 + .../sources/reference/empty-block.input | 5 + .../sources/reference/no-imports.input | 3 + .../reference/separate-declarations.input | 9 + .../third-party-apart-from-std.input | 13 + .../testdata/sources/reference/unsorted.input | 13 + .../sources/reference/unused-import.input | 8 + .../testdata/sources/separate-decls.input | 9 + go.work | 1 + 75 files changed, 3042 insertions(+), 1237 deletions(-) create mode 100644 formatting/corpus_test.go create mode 100644 formatting/doc.go create mode 100644 formatting/enable/gofumpt/doc.go create mode 100644 formatting/enable/gofumpt/enable.go create mode 100644 formatting/enable/gofumpt/enable_test.go create mode 100644 formatting/enable/gofumpt/go.mod create mode 100644 formatting/enable/gofumpt/go.sum create mode 100644 formatting/enable/gofumpt/testdata/generated.input create mode 100644 formatting/enable/gofumpt/testdata/grouped.input create mode 100644 formatting/errors.go create mode 100644 formatting/format.go delete mode 100644 formatting/format_lite.go delete mode 100644 formatting/format_lite_test.go create mode 100644 formatting/format_test.go delete mode 100644 formatting/golang.go delete mode 100644 formatting/golang_test.go create mode 100644 formatting/internal/rules/rules.go create mode 100644 formatting/internal/rules/rules_test.go delete mode 100644 formatting/options_test.go create mode 100644 formatting/parse.go create mode 100644 formatting/parse_internal_test.go create mode 100644 formatting/prune.go create mode 100644 formatting/prune_internal_test.go create mode 100644 formatting/prune_test.go create mode 100644 formatting/reference_test.go create mode 100644 formatting/sort.go create mode 100644 formatting/sources_test.go create mode 100644 formatting/spacer.go create mode 100644 formatting/testdata/corpus/go.mod create mode 100644 formatting/testdata/corpus/go.sum create mode 100644 formatting/testdata/corpus/grouped/grouped.go create mode 100644 formatting/testdata/corpus/grouped/grouped.input create mode 100644 formatting/testdata/corpus/pruned/pruned.go create mode 100644 formatting/testdata/corpus/pruned/pruned.input create mode 100644 formatting/testdata/sources/broken-decl.input create mode 100644 formatting/testdata/sources/broken-expr.input create mode 100644 formatting/testdata/sources/empty-package.input create mode 100644 formatting/testdata/sources/fragment-decls.input create mode 100644 formatting/testdata/sources/fragment-main.input create mode 100644 formatting/testdata/sources/fragment-spaced.input create mode 100644 formatting/testdata/sources/fragment-stmts.input create mode 100644 formatting/testdata/sources/gofmt/alignment.input create mode 100644 formatting/testdata/sources/gofmt/number-literals.input create mode 100644 formatting/testdata/sources/gofmt/standard-library-only.input create mode 100644 formatting/testdata/sources/grouped.input create mode 100644 formatting/testdata/sources/idempotent/aliased.input create mode 100644 formatting/testdata/sources/idempotent/blank-only.input create mode 100644 formatting/testdata/sources/idempotent/cgo.input create mode 100644 formatting/testdata/sources/idempotent/comment-kept.input create mode 100644 formatting/testdata/sources/idempotent/fragment.input create mode 100644 formatting/testdata/sources/idempotent/grouped.input create mode 100644 formatting/testdata/sources/idempotent/no-imports.input create mode 100644 formatting/testdata/sources/idempotent/single.input create mode 100644 formatting/testdata/sources/prune/aliased.input create mode 100644 formatting/testdata/sources/prune/blank-and-dot.input create mode 100644 formatting/testdata/sources/prune/cgo.input create mode 100644 formatting/testdata/sources/prune/missing.input create mode 100644 formatting/testdata/sources/prune/shadowed-partly.input create mode 100644 formatting/testdata/sources/prune/shadowed-wholly.input create mode 100644 formatting/testdata/sources/prune/unnameable.input create mode 100644 formatting/testdata/sources/prune/unused.input create mode 100644 formatting/testdata/sources/reference/aliased.input create mode 100644 formatting/testdata/sources/reference/blank-and-dot-imports.input create mode 100644 formatting/testdata/sources/reference/cgo.input create mode 100644 formatting/testdata/sources/reference/comment-on-an-import.input create mode 100644 formatting/testdata/sources/reference/duplicate-path.input create mode 100644 formatting/testdata/sources/reference/empty-block.input create mode 100644 formatting/testdata/sources/reference/no-imports.input create mode 100644 formatting/testdata/sources/reference/separate-declarations.input create mode 100644 formatting/testdata/sources/reference/third-party-apart-from-std.input create mode 100644 formatting/testdata/sources/reference/unsorted.input create mode 100644 formatting/testdata/sources/reference/unused-import.input create mode 100644 formatting/testdata/sources/separate-decls.input diff --git a/formatting/corpus_test.go b/formatting/corpus_test.go new file mode 100644 index 0000000..c5b9220 --- /dev/null +++ b/formatting/corpus_test.go @@ -0,0 +1,86 @@ +// 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 + require.NoError(t, formatting.Format(&out, src, corpusGroups)) + + 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/doc.go b/formatting/doc.go new file mode 100644 index 0000000..b04375c --- /dev/null +++ b/formatting/doc.go @@ -0,0 +1,83 @@ +// 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, 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. +// +// # 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]. +// +// # 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..b50b50b --- /dev/null +++ b/formatting/enable/gofumpt/enable_test.go @@ -0,0 +1,98 @@ +// 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 + require.NoError(t, formatting.Format(&out, []byte(src), opts...)) + + 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..10c55d8 --- /dev/null +++ b/formatting/errors.go @@ -0,0 +1,19 @@ +// 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" + + // 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/format.go b/formatting/format.go new file mode 100644 index 0000000..8dea1da --- /dev/null +++ b/formatting/format.go @@ -0,0 +1,194 @@ +// 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. +// +// 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 and grouped before a byte is written, so a source that does +// not parse 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) 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) error { + o := optionsWithDefaults(opts) + + var extraRules rules.Func + if o.goFumpt { + if extraRules = rules.Registered(); extraRules == nil { + return ErrNoGoFumpt + } + } + + fset, file, adjust, err := parse(src) + if err != nil { + return fmt.Errorf("cannot parse source: %w: %w", err, ErrFormat) + } + + prune(fset, file) + mergeImports(file) + sortImports(fset.File(file.FileStart), file, o.groups) + breaks := groupBreaks(fset, file, o.groups) + + if extraRules != nil { + extraRules(fset, file) + } + + if adjust != nil { + return printFragment(w, fset, file, src, breaks, adjust) + } + + return 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) (*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) { + 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..ffad01c --- /dev/null +++ b/formatting/format_test.go @@ -0,0 +1,346 @@ +// 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 + require.Error(t, formatting.Format(&out, []byte(source(t, fixture)))) + + 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 + require.NoError(t, formatting.Format(&out, []byte(src), opts...)) + + 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 + require.NoError(t, formatting.Format(&out, []byte(src))) + 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 + require.NoError(t, formatting.Format(&out, &rendered)) + + 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 + require.NoError(t, formatting.Format(&fromBuffer, &rendered)) + require.NoError(t, formatting.Format(&fromBytes, rendered.Bytes())) + + 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 + require.NoError(t, formatting.Format(&out, &rendered)) + 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/options.go b/formatting/options.go index 2a93907..1fc14ae 100644 --- a/formatting/options.go +++ b/formatting/options.go @@ -1,68 +1,43 @@ // 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 } -// WithFormatLocalPrefixes adds local prefixes to group imports. -func WithFormatLocalPrefixes(prefixes ...string) FormatOption { - return func(o *FormatOpts) { - o.LocalPrefixes = append(o.LocalPrefixes, prefixes...) +// 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) + } } } -// WithFormatOnly tells the formatter to skip imports processing. -func WithFormatOnly(enabled bool) FormatOption { - return func(o *FormatOpts) { - o.FormatOnly = enabled +// 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 } } -// 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 +func optionsWithDefaults(opts []Option) options { + var o options for _, apply := range opts { apply(&o) @@ -70,116 +45,3 @@ func FormatOptsWithDefault(opts []FormatOption) FormatOpts { 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 - } - - 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 - } - - 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 - } - - 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...) - } - - // 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) - } - - return "" -} - -// ArrayInitializer builds a literal array. -func (l *Options) ArrayInitializer(data any) (string, error) { - if l.ArrayInitializerFunc != nil { - return l.ArrayInitializerFunc(data) - } - - return "", nil -} - -// 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) - } - - return "" -} 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..2fd4bc4 --- /dev/null +++ b/formatting/parse.go @@ -0,0 +1,213 @@ +// 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:] +} + +func isSpaceByte(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' +} diff --git a/formatting/parse_internal_test.go b/formatting/parse_internal_test.go new file mode 100644 index 0000000..b13b531 --- /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)) + }) + } +} + +// 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) { + 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) + 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..5ee63ba --- /dev/null +++ b/formatting/prune.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package formatting + +import ( + "go/ast" + "go/token" + "path" + "strconv" + "strings" + + "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 every import the file does not use. +// +// It never adds one, and it drops an import only when it can name the package the import declares. +// See [importNames] for what happens when it cannot. +func prune(fset *token.FileSet, file *ast.File) { + used := usedQualifiers(file) + + type deletion struct{ name, path string } + var unused []deletion + + for _, spec := range file.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil || importPath == cgoImport { + continue + } + + if isUsed(spec, importPath, used) { + continue + } + + name := "" + if spec.Name != nil { + name = spec.Name.Name + } + unused = append(unused, deletion{name: name, path: importPath}) + } + + for _, spec := range unused { + astutil.DeleteNamedImport(fset, file, spec.name, spec.path) + } +} + +// isUsed reports whether any name the import could answer to appears as a package qualifier. +func isUsed(spec *ast.ImportSpec, importPath string, used map[string]bool) bool { + if spec.Name != nil { + switch spec.Name.Name { + case "_", ".": + // a blank import runs an init, a dot import spills its names into the file scope, and + // neither shows up as a qualifier. Keep both. + return true + default: + // an alias names the package exactly, so there is nothing to guess + return used[spec.Name.Name] + } + } + + names := importNames(importPath) + if len(names) == 0 { + return true // cannot name the package, so cannot call it unused + } + + for _, name := range names { + if used[name] { + return true + } + } + + return false +} + +// 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 +} + +// importNames lists every name an import without an alias could declare. +// +// The package name is not in the import path, so it has to be guessed, and a single guess drops +// imports that are in use: goimports reads "gopkg.in/yaml.v3" as yaml.v3 and misses yaml, while the +// version-stripping guess reads "k8s.io/api/apps/v1" as apps and misses v1. Returning both leaves +// the caller to keep the import if either name is used, and a surplus name only ever keeps an +// import. +// +// An empty result means no candidate is a legal identifier — "example.com/my-pkg" — and the caller +// keeps the import rather than delete one that may well be used. +func importNames(importPath string) []string { + var names []string + + add := func(candidate string) { + if !token.IsIdentifier(candidate) { + return + } + for _, seen := range names { + if seen == candidate { + return + } + } + names = append(names, candidate) + } + + base := path.Base(importPath) + + if token.IsIdentifier(base) { + add(base) + + // a major version suffix names no package: "github.com/go-openapi/testify/v2" declares + // testify. The directory above it is the better guess, and base stays a candidate because + // "k8s.io/api/apps/v1" really does declare v1. + if isMajorVersion(base) { + if dir := path.Dir(importPath); dir != "." { + add(path.Base(dir)) + } + } + + return names + } + + // "gopkg.in/yaml.v3" declares yaml + if dot := strings.IndexByte(base, '.'); dot >= 0 { + add(base[:dot]) + } + + // "github.com/jessevdk/go-flags" declares flags + add(strings.TrimPrefix(base, "go-")) + + return names +} + +// isMajorVersion reports whether a path element is 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 +} + +// 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) bool { + names := importedNames(file) + 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. Without one the package name is guessed, and [importNames] +// returns every guess, so a name that could belong to an import is in the set. +func importedNames(file *ast.File) 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 + } + + for _, name := range importNames(importPath) { + names[name] = struct{}{} + } + } + + return names +} diff --git a/formatting/prune_internal_test.go b/formatting/prune_internal_test.go new file mode 100644 index 0000000..1e334d0 --- /dev/null +++ b/formatting/prune_internal_test.go @@ -0,0 +1,70 @@ +// 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 TestImportNames(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"}, + }, + { + name: "should name nothing when no candidate is an identifier", + importPath: "example.com/my-pkg", + expected: nil, + }, + { + name: "should not name a keyword", + importPath: "example.com/range", + expected: nil, + }, + } + + for _, toPin := range tests { + test := toPin + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, importNames(test.importPath)) + }) + } +} diff --git a/formatting/prune_test.go b/formatting/prune_test.go new file mode 100644 index 0000000..22422c8 --- /dev/null +++ b/formatting/prune_test.go @@ -0,0 +1,81 @@ +// 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" +) + +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/my-pkg"`) + }) + + 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") + }) +} diff --git a/formatting/reference_test.go b/formatting/reference_test.go new file mode 100644 index 0000000..a2b9271 --- /dev/null +++ b/formatting/reference_test.go @@ -0,0 +1,102 @@ +// 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 exactly two places, and neither is exercised here: Format never adds an import, and +// [formatting.WithImportGroups] opens groups goimports has no way to express. 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 + require.NoError(t, formatting.Format(&out, []byte(src))) + + 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 + require.NoError(t, formatting.Format(&out, []byte(src))) + 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/sort.go b/formatting/sort.go new file mode 100644 index 0000000..004b46f --- /dev/null +++ b/formatting/sort.go @@ -0,0 +1,401 @@ +// 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. +// +// 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 = sortRuns(tokFile, file, gen.Specs, groups) + closeGap(tokFile, gen) + } +} + +// sortRuns sorts each run of specs written on consecutive lines, separately. +// +// A blank line the caller left between two imports separates two runs, and sorting them apart keeps +// a deliberate grouping in the source intact. +func sortRuns(tokFile *token.File, file *ast.File, specs []ast.Spec, groups []string) []ast.Spec { + sorted := specs[:0] + start := 0 + + for i, spec := range specs { + if i > start && tokFile.Line(spec.Pos()) > 1+tokFile.Line(specs[i-1].End()) { + sorted = append(sorted, sortSpecs(tokFile, file, specs[start:i], groups)...) + start = i + } + } + + return append(sorted, sortSpecs(tokFile, file, specs[start:], groups)...) +} + +// 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 one run of import specs and reassigns positions so the run prints unbroken. +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 a run covers. +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 run 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 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/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/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-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/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/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/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.input b/formatting/testdata/sources/prune/unnameable.input new file mode 100644 index 0000000..6b38191 --- /dev/null +++ b/formatting/testdata/sources/prune/unnameable.input @@ -0,0 +1,3 @@ +package p + +import "example.com/my-pkg" 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/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/go.work b/go.work index 9b14baf..f564635 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,7 @@ go 1.25.0 use ( . + ./formatting/enable/gofumpt ./genapp ./mangling ./mangling/ucd From e0dc25d82facfcd39a7befe852525acb6015032a Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 22 Aug 2026 00:04:32 +0200 Subject: [PATCH 02/14] feat(genapp): render, format and lay out a generated Go tree app, err := genapp.New( genapp.WithTemplates(templates), genapp.WithOutputPath("./generated"), ) app.RenderFile("models/pet.go", "modelValidator", pet) GoGenApp does the three things a generator repeats for every file: run a template, format what it produced, put it where it goes. Render writes to an io.Writer; RenderFile writes under the output path, creating the directories it needs. It is a port of github.com/fredbi/core/codegen/genapp onto this repository's templates-repo and formatting, and the swap removes most of what was there. The repository validates when built, so the sync.Once, the stored load error and the lazy branch inside Render go, and New reports a broken template instead of the first render doing so. The formatter carries its grouping per call, so imports.LocalPrefix and the mutex guarding it go too, with the tab width and the import-check switch, which no longer name anything. afero goes: splitting Render from RenderFile leaves the tests a buffer and a temp dir, which is what the file system abstraction was for. genapp re-exports nothing of its dependencies. A file appears whole or not at all: RenderFile writes beside the target and renames over it. When the formatter rejects what a template rendered, that file is kept as .unformatted and the error names it, since a parse error reports a line and a column and the source they came from otherwise existed only in a pooled buffer. Each render borrows that buffer from swag/pools/shared and returns it, so a generator writing a few hundred files recycles a handful rather than allocating one apiece. Modules, without a toolchain: - InitModule writes a go.mod through modfile, byte for byte what "go mod init" writes, its go directive taken from the version this program was built with. WithRequire, WithToolchain and WithGoVersion fill in the rest, each checked before anything is written, since modfile takes a require without looking at its version. - PackagePath names the tree from the go.mod above it, or failing that from the way down out of GOPATH/src. ModuleRequired reports whether the path needs a go.mod of its own. - TidyModule shells out, and is the only thing here needing go installed: resolving a module graph is the go command's job. It takes a context, runs with GOWORK off so a workspace that does not list the generated module cannot refuse the run, and reports what the command said rather than an exit status. Answering a question now costs nothing: PackagePath creates no directory and lays down no overlay file, where packages.Load needed both, and ModuleRequired matches a sentinel rather than the text "go.mod file not found". Containment under GOPATH goes through filepath.Rel, so a GOPATH on one Windows volume and an output path on another is an entry that does not match rather than a resolution that fails. genapp joins the root module, adding golang.org/x/mod, already in the graph. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- genapp/.gitkeep | 0 genapp/concurrent_test.go | 94 ++++++ genapp/doc.go | 88 +++++- genapp/errors.go | 17 ++ genapp/errors_test.go | 116 +++++++ genapp/genapp.go | 234 ++++++++++++++ genapp/genapp_test.go | 287 ++++++++++++++++++ genapp/go.mod | 3 - genapp/gopath.go | 87 ++++++ genapp/gopath_test.go | 102 +++++++ genapp/module.go | 130 ++++++++ genapp/module_options.go | 151 +++++++++ genapp/module_test.go | 260 ++++++++++++++++ genapp/options.go | 105 +++++++ genapp/options_test.go | 76 +++++ genapp/package.go | 168 ++++++++++ genapp/package_test.go | 267 ++++++++++++++++ genapp/testdata/templates/broken.gotmpl | 3 + genapp/testdata/templates/model.gotmpl | 25 ++ .../testdata/templates/models/nested.gotmpl | 4 + genapp/testdata/templates/readme.gotmpl | 3 + genapp/tidy.go | 122 ++++++++ genapp/tidy_options.go | 119 ++++++++ genapp/tidy_test.go | 153 ++++++++++ go.mod | 4 +- go.work | 1 - 26 files changed, 2612 insertions(+), 7 deletions(-) delete mode 100644 genapp/.gitkeep create mode 100644 genapp/concurrent_test.go create mode 100644 genapp/errors.go create mode 100644 genapp/errors_test.go create mode 100644 genapp/genapp.go create mode 100644 genapp/genapp_test.go delete mode 100644 genapp/go.mod create mode 100644 genapp/gopath.go create mode 100644 genapp/gopath_test.go create mode 100644 genapp/module.go create mode 100644 genapp/module_options.go create mode 100644 genapp/module_test.go create mode 100644 genapp/options.go create mode 100644 genapp/options_test.go create mode 100644 genapp/package.go create mode 100644 genapp/package_test.go create mode 100644 genapp/testdata/templates/broken.gotmpl create mode 100644 genapp/testdata/templates/model.gotmpl create mode 100644 genapp/testdata/templates/models/nested.gotmpl create mode 100644 genapp/testdata/templates/readme.gotmpl create mode 100644 genapp/tidy.go create mode 100644 genapp/tidy_options.go create mode 100644 genapp/tidy_test.go 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..0a54da2 --- /dev/null +++ b/genapp/genapp.go @@ -0,0 +1,234 @@ +// 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) +} + +func (g *GoGenApp) format(w io.Writer, name string, rendered *bytes.Buffer) error { + if err := formatting.Format(w, rendered, g.formatOptions...); err != nil { + return fmt.Errorf("template %q rendered Go that does not format: %w: %w", name, err, ErrGenApp) + } + + 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..c064483 --- /dev/null +++ b/genapp/genapp_test.go @@ -0,0 +1,287 @@ +// 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" + "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. +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) + + rest := message[start+len(marker):] + end := strings.IndexByte(rest, '"') + require.GreaterOrEqual(t, end, 0) + + return rest[:end] +} + +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..06e298e --- /dev/null +++ b/genapp/options.go @@ -0,0 +1,105 @@ +// 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 + 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...) + } +} + +// 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 f564635..7ef3980 100644 --- a/go.work +++ b/go.work @@ -3,7 +3,6 @@ go 1.25.0 use ( . ./formatting/enable/gofumpt - ./genapp ./mangling ./mangling/ucd ) From 7aa49b3151c690eda7f537da0c446ce7f9813999 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 22 Aug 2026 00:09:20 +0200 Subject: [PATCH 03/14] doc(templates-repo): reword four comments that read backwards Each of these buried its verb in a relative clause, so the sentence read backwards and defined the thing by what happens to it: Name is the name the template is registered under. The path is the one the asset has once mounted. declared is a template the repository holds, at the address it was declared under. They now name the thing and give it a verb. No code changes. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- templates-repo/reports/documentation.go | 2 +- templates-repo/repository.go | 2 +- templates-repo/resolve.go | 2 +- templates-repo/sources.go | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) 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 From fbb7bb1ae2aa7fee3b64c9c77a2456fe0477fe47 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 17:34:52 +0200 Subject: [PATCH 04/14] feat(formatting): sort and dedup the whole import block sortImports split the block at every blank line and sorted each run on its own, so "bytes" written in two groups survived twice and the output failed to compile with "bytes redeclared in this block". A template assembling its imports from several fragments hits that whenever two fragments contribute the same package. Drop sortRuns and sort the block as one run. The source's own blank lines no longer reach the output; groupBreaks and spacer put them back from the prefixes WithImportGroups was given, so the layout comes from the option alone. gofmt and goimports keep the run-per-group rule, which is a third place where Format parts company with them. The deduped corpus package is checked by the compiler, not just by a golden: TestCorpusCompiles builds it, and the old behaviour produced a file that does not build. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/blanklines_test.go | 42 +++++++++++++++++++ formatting/doc.go | 19 +++++++++ formatting/reference_test.go | 10 +++-- formatting/sort.go | 35 ++++++---------- formatting/testdata/corpus/deduped/deduped.go | 17 ++++++++ .../testdata/corpus/deduped/deduped.input | 20 +++++++++ .../sources/duplicate-across-groups.input | 15 +++++++ .../blank-lines-in-the-source.input | 17 ++++++++ .../sources/unsorted-across-groups.input | 16 +++++++ 9 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 formatting/blanklines_test.go create mode 100644 formatting/testdata/corpus/deduped/deduped.go create mode 100644 formatting/testdata/corpus/deduped/deduped.input create mode 100644 formatting/testdata/sources/duplicate-across-groups.input create mode 100644 formatting/testdata/sources/idempotent/blank-lines-in-the-source.input create mode 100644 formatting/testdata/sources/unsorted-across-groups.input 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/doc.go b/formatting/doc.go index b04375c..10e0705 100644 --- a/formatting/doc.go +++ b/formatting/doc.go @@ -41,6 +41,25 @@ // 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. // +// # 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. +// // # Grouping // // Without [WithImportGroups] the output has two groups, the standard library and everything else. diff --git a/formatting/reference_test.go b/formatting/reference_test.go index a2b9271..493a826 100644 --- a/formatting/reference_test.go +++ b/formatting/reference_test.go @@ -16,10 +16,12 @@ import ( // 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 exactly two places, and neither is exercised here: Format never adds an import, and -// [formatting.WithImportGroups] opens groups goimports has no way to express. Any other difference -// is a bug in one of them. +// This package was written against x/tools/internal/imports. Format parts company with it in three +// places, none of them exercised here: Format never adds an import, [formatting.WithImportGroups] +// opens groups goimports has no way to express, and Format sorts and dedups the whole import block +// where goimports sorts each blank-line-separated run on its own. No fixture below writes a blank +// line inside its import block, so the third difference cannot show. Any other difference is a bug +// in one of them. func TestAgainstReference(t *testing.T) { t.Parallel() diff --git a/formatting/sort.go b/formatting/sort.go index 004b46f..03f3b1c 100644 --- a/formatting/sort.go +++ b/formatting/sort.go @@ -24,6 +24,12 @@ import ( // 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) { @@ -43,29 +49,11 @@ func sortImports(tokFile *token.File, file *ast.File, groups []string) { continue // a single import needs no sorting } - gen.Specs = sortRuns(tokFile, file, gen.Specs, groups) + gen.Specs = sortSpecs(tokFile, file, gen.Specs, groups) closeGap(tokFile, gen) } } -// sortRuns sorts each run of specs written on consecutive lines, separately. -// -// A blank line the caller left between two imports separates two runs, and sorting them apart keeps -// a deliberate grouping in the source intact. -func sortRuns(tokFile *token.File, file *ast.File, specs []ast.Spec, groups []string) []ast.Spec { - sorted := specs[:0] - start := 0 - - for i, spec := range specs { - if i > start && tokFile.Line(spec.Pos()) > 1+tokFile.Line(specs[i-1].End()) { - sorted = append(sorted, sortSpecs(tokFile, file, specs[start:i], groups)...) - start = i - } - } - - return append(sorted, sortSpecs(tokFile, file, specs[start:], groups)...) -} - // 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 { @@ -180,7 +168,7 @@ func groupBreaks(fset *token.FileSet, file *ast.File, groups []string) []string return breaks } -// sortSpecs sorts one run of import specs and reassigns positions so the run prints unbroken. +// 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 @@ -209,7 +197,7 @@ type posSpan struct { End token.Pos } -// commentsInRun returns the comment groups written inside the span a run covers. +// 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) @@ -280,7 +268,7 @@ func collapses(previous, next ast.Spec) bool { return previous.(*ast.ImportSpec).Comment == nil } -// replaceSpecPositions gives the sorted specs the positions the run occupied before sorting. +// replaceSpecPositions gives the sorted specs the positions the block occupied before sorting. func replaceSpecPositions( specs []ast.Spec, positions []posSpan, @@ -311,7 +299,8 @@ func replaceSpecPositions( } } -// closeRunGaps merges away the blank lines moving the comments opened. +// 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()) 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/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/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/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 +) From 85fbc67df9ebe88ad8382a65ee5f0c2de6344836 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 17:40:16 +0200 Subject: [PATCH 05/14] feat(formatting): add ImportedPackageName for a path's canonical name The identifier an import binds is the imported package's own package clause, and the path only says where to find it. A generator writing an import still needs a name to qualify it with, and ImportedPackageName gives the one the naming convention implies: last path element, /v2 or later dropped, last segment of a hyphenated element, rejecting keywords and "_". Version elements are the reason it is worth having. "k8s.io/api/apps/v1" and "k8s.io/api/core/v1" both declare v1 and collide under the name they declare; they give apps and core here, so a generator can write the alias that keeps them apart. The guesses behind pruning move to names.go beside it as importedPackageNames, which returns a candidate list rather than one name: no path separates "k8s.io/api/apps/v1", declaring v1, from "github.com/go-openapi/testify/v2", declaring testify. The hyphen rule now offers pkg, mypkg and my for "example.com/my-pkg", where it used to offer nothing at all; measured over the hyphenated package directories in a module cache, that lifts the hit rate from 46% to 89%, with the right name first in 80% of them. x/tools/internal/imports carries the same rule as ImportPathToAssumedName, which returns one name and cuts at the first character an identifier may not hold, reading "example.com/my-pkg" as my. It is internal and cannot be imported. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/doc.go | 6 + formatting/example_names_test.go | 31 ++++ formatting/names.go | 162 ++++++++++++++++++ ...nternal_test.go => names_internal_test.go} | 33 +++- formatting/names_test.go | 87 ++++++++++ formatting/prune.go | 76 +------- formatting/prune_test.go | 2 +- .../testdata/sources/prune/unnameable.input | 2 +- 8 files changed, 322 insertions(+), 77 deletions(-) create mode 100644 formatting/example_names_test.go create mode 100644 formatting/names.go rename formatting/{prune_internal_test.go => names_internal_test.go} (62%) create mode 100644 formatting/names_test.go diff --git a/formatting/doc.go b/formatting/doc.go index 10e0705..2d4b46b 100644 --- a/formatting/doc.go +++ b/formatting/doc.go @@ -60,6 +60,12 @@ // compile with "bytes redeclared in this block". A template assembling its imports from several // fragments hits this whenever two fragments contribute the same package. // +// # 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. +// // # Grouping // // Without [WithImportGroups] the output has two groups, the standard library and everything else. 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/names.go b/formatting/names.go new file mode 100644 index 0000000..de96729 --- /dev/null +++ b/formatting/names.go @@ -0,0 +1,162 @@ +// 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. It needs 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 [prune] then keeps the import. +// +// This stays unexported: a caller naming an import wants [ImportedPackageName]. A list of guesses +// answers no question a caller asks. +// +// 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/prune_internal_test.go b/formatting/names_internal_test.go similarity index 62% rename from formatting/prune_internal_test.go rename to formatting/names_internal_test.go index 1e334d0..5d65629 100644 --- a/formatting/prune_internal_test.go +++ b/formatting/names_internal_test.go @@ -9,7 +9,7 @@ import ( "github.com/go-openapi/testify/v2/assert" ) -func TestImportNames(t *testing.T) { +func TestImportedPackageNames(t *testing.T) { t.Parallel() tests := []struct { @@ -45,18 +45,43 @@ func TestImportNames(t *testing.T) { { name: "should drop a go- prefix", importPath: "github.com/jessevdk/go-flags", - expected: []string{"flags"}, + expected: []string{"flags", "goflags"}, }, { - name: "should name nothing when no candidate is an identifier", + 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 { @@ -64,7 +89,7 @@ func TestImportNames(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, test.expected, importNames(test.importPath)) + 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/prune.go b/formatting/prune.go index 5ee63ba..75c7041 100644 --- a/formatting/prune.go +++ b/formatting/prune.go @@ -6,9 +6,7 @@ package formatting import ( "go/ast" "go/token" - "path" "strconv" - "strings" "golang.org/x/tools/go/ast/astutil" ) @@ -19,7 +17,7 @@ const cgoImport = "C" // prune deletes every import the file does not use. // // It never adds one, and it drops an import only when it can name the package the import declares. -// See [importNames] for what happens when it cannot. +// See [importedPackageNames] for what happens when it cannot. func prune(fset *token.FileSet, file *ast.File) { used := usedQualifiers(file) @@ -62,7 +60,7 @@ func isUsed(spec *ast.ImportSpec, importPath string, used map[string]bool) bool } } - names := importNames(importPath) + names := importedPackageNames(importPath) if len(names) == 0 { return true // cannot name the package, so cannot call it unused } @@ -104,70 +102,6 @@ func usedQualifiers(file *ast.File) map[string]bool { return used } -// importNames lists every name an import without an alias could declare. -// -// The package name is not in the import path, so it has to be guessed, and a single guess drops -// imports that are in use: goimports reads "gopkg.in/yaml.v3" as yaml.v3 and misses yaml, while the -// version-stripping guess reads "k8s.io/api/apps/v1" as apps and misses v1. Returning both leaves -// the caller to keep the import if either name is used, and a surplus name only ever keeps an -// import. -// -// An empty result means no candidate is a legal identifier — "example.com/my-pkg" — and the caller -// keeps the import rather than delete one that may well be used. -func importNames(importPath string) []string { - var names []string - - add := func(candidate string) { - if !token.IsIdentifier(candidate) { - return - } - for _, seen := range names { - if seen == candidate { - return - } - } - names = append(names, candidate) - } - - base := path.Base(importPath) - - if token.IsIdentifier(base) { - add(base) - - // a major version suffix names no package: "github.com/go-openapi/testify/v2" declares - // testify. The directory above it is the better guess, and base stays a candidate because - // "k8s.io/api/apps/v1" really does declare v1. - if isMajorVersion(base) { - if dir := path.Dir(importPath); dir != "." { - add(path.Base(dir)) - } - } - - return names - } - - // "gopkg.in/yaml.v3" declares yaml - if dot := strings.IndexByte(base, '.'); dot >= 0 { - add(base[:dot]) - } - - // "github.com/jessevdk/go-flags" declares flags - add(strings.TrimPrefix(base, "go-")) - - return names -} - -// isMajorVersion reports whether a path element is 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 -} - // needsResolution reports whether telling a package qualifier from a shadowed name needs the // parser's scopes. // @@ -224,8 +158,8 @@ func needsResolution(file *ast.File) bool { // importedNames returns every name the file's imports could declare. // -// An alias declares its name outright. Without one the package name is guessed, and [importNames] -// returns every guess, so a name that could belong to an import is in the set. +// An alias declares its name outright. Without one 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) map[string]struct{} { names := make(map[string]struct{}, len(file.Imports)) @@ -243,7 +177,7 @@ func importedNames(file *ast.File) map[string]struct{} { continue } - for _, name := range importNames(importPath) { + for _, name := range importedPackageNames(importPath) { names[name] = struct{}{} } } diff --git a/formatting/prune_test.go b/formatting/prune_test.go index 22422c8..aa5fb48 100644 --- a/formatting/prune_test.go +++ b/formatting/prune_test.go @@ -44,7 +44,7 @@ func TestPrune(t *testing.T) { out := format2(t, source(t, "prune/unnameable")) - assert.Contains(t, out, `"example.com/my-pkg"`) + assert.Contains(t, out, `"example.com/2fa"`) }) t.Run("should keep the cgo preamble", func(t *testing.T) { diff --git a/formatting/testdata/sources/prune/unnameable.input b/formatting/testdata/sources/prune/unnameable.input index 6b38191..ccb826b 100644 --- a/formatting/testdata/sources/prune/unnameable.input +++ b/formatting/testdata/sources/prune/unnameable.input @@ -1,3 +1,3 @@ package p -import "example.com/my-pkg" +import "example.com/2fa" From 0a7a842ea94eaf9bcbd8dd81960751aa784686a5 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 17:36:58 +0200 Subject: [PATCH 06/14] feat(formatting): prune only the imports whose package name is known Pruning read a bare import's name off its path, so it deleted imports the code was using. "github.com/json-iterator/go" declares jsoniter and "github.com/prometheus/client_model/go" declares io_prometheus_client; neither path says so, and no rule reading a path can. Over the 4269 distinct import paths written in a module cache, the shape recurs wherever a polyglot repository puts its Go under a "go" subdirectory. An import is now deleted only when its name is stated rather than inferred: - an alias, which states it in the source; - the standard library, from a table generated by "go list std", asserted against it in internal/std; - WithResolvedImports, where the caller states it. A guess may still save an import, by matching a qualifier the file writes, but it never condemns one. Everything left over is reported in doubt and kept. Keeping an unused import is a compile error too, but it is "imported and not used", which names the line to delete, rather than "undefined: io_prometheus_client" at the use site. WithForceImportsPruning takes the caller's promise that every bare import declares the name ImportedPackageName gives, and prunes on the guesses. It condemns on the whole candidate list rather than on the canonical name, so a file writing v1.Pod keeps its "k8s.io/api/apps/v1". The two options compose: the map covers the paths the promise does not. Format now returns an ImportsReport beside its error, holding one record per import with the name it binds, whether that name was stated or guessed, and what became of it. It comes back on ErrInconsistentImports too, and is nil only when parsing failed. ErrInconsistentImports rejects two shapes a template can write: one package under two names, and one name bound to two packages. The first compiles and reads as though the two were different packages; the second does not compile. A clash between guessed names may not be real, since either package may declare something the path does not show, so those records carry ImportCollision and neither import is pruned. The generated table also replaces "first path element holds no dot" as the test for standard library, so a local module such as "myapp/models" no longer passes for one. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/binding.go | 197 +++++++++++++++ formatting/consistency.go | 153 ++++++++++++ formatting/consistency_test.go | 84 +++++++ formatting/corpus_test.go | 3 +- formatting/enable/gofumpt/enable_test.go | 4 +- formatting/errors.go | 5 + formatting/format.go | 44 ++-- formatting/format_test.go | 32 ++- formatting/internal/std/gen.go | 79 ++++++ formatting/internal/std/packages.go | 195 +++++++++++++++ formatting/internal/std/std.go | 28 +++ formatting/internal/std/std_test.go | 80 ++++++ formatting/options.go | 61 ++++- formatting/parse_internal_test.go | 6 +- formatting/prune.go | 74 ++---- formatting/prune_test.go | 139 +++++++++++ formatting/reference_test.go | 25 +- formatting/report.go | 235 ++++++++++++++++++ formatting/report_test.go | 198 +++++++++++++++ formatting/testdata/corpus/aliased/aliased.go | 14 ++ .../testdata/corpus/aliased/aliased.input | 15 ++ .../collision/guessed-names-clash.input | 8 + .../blank-and-dot-beside-a-plain-import.input | 9 + .../consistent/both-candidates-used.input | 10 + .../consistent/collision-pruned-away.input | 8 + .../same-base-different-names-used.input | 11 + .../consistent/unnameable-package.input | 9 + .../alias-shadows-another-base.input | 9 + .../inconsistent/one-alias-two-packages.input | 9 + .../inconsistent/one-package-two-names.input | 12 + .../inconsistent/several-mismatches.input | 17 ++ .../inconsistent/two-packages-same-base.input | 8 + .../sources/prune/bare-third-party.input | 8 + .../sources/prune/blank-and-dot-unused.input | 9 + .../prune/promise-with-exceptions.input | 14 ++ .../prune/unnameable-third-party.input | 7 + .../sources/prune/version-directory.input | 7 + genapp/genapp.go | 12 +- genapp/options.go | 31 ++- 39 files changed, 1774 insertions(+), 95 deletions(-) create mode 100644 formatting/binding.go create mode 100644 formatting/consistency.go create mode 100644 formatting/consistency_test.go create mode 100644 formatting/internal/std/gen.go create mode 100644 formatting/internal/std/packages.go create mode 100644 formatting/internal/std/std.go create mode 100644 formatting/internal/std/std_test.go create mode 100644 formatting/report.go create mode 100644 formatting/report_test.go create mode 100644 formatting/testdata/corpus/aliased/aliased.go create mode 100644 formatting/testdata/corpus/aliased/aliased.input create mode 100644 formatting/testdata/sources/collision/guessed-names-clash.input create mode 100644 formatting/testdata/sources/consistent/blank-and-dot-beside-a-plain-import.input create mode 100644 formatting/testdata/sources/consistent/both-candidates-used.input create mode 100644 formatting/testdata/sources/consistent/collision-pruned-away.input create mode 100644 formatting/testdata/sources/consistent/same-base-different-names-used.input create mode 100644 formatting/testdata/sources/consistent/unnameable-package.input create mode 100644 formatting/testdata/sources/inconsistent/alias-shadows-another-base.input create mode 100644 formatting/testdata/sources/inconsistent/one-alias-two-packages.input create mode 100644 formatting/testdata/sources/inconsistent/one-package-two-names.input create mode 100644 formatting/testdata/sources/inconsistent/several-mismatches.input create mode 100644 formatting/testdata/sources/inconsistent/two-packages-same-base.input create mode 100644 formatting/testdata/sources/prune/bare-third-party.input create mode 100644 formatting/testdata/sources/prune/blank-and-dot-unused.input create mode 100644 formatting/testdata/sources/prune/promise-with-exceptions.input create mode 100644 formatting/testdata/sources/prune/unnameable-third-party.input create mode 100644 formatting/testdata/sources/prune/version-directory.input diff --git a/formatting/binding.go b/formatting/binding.go new file mode 100644 index 0000000..2e03668 --- /dev/null +++ b/formatting/binding.go @@ -0,0 +1,197 @@ +// 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 +} 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 index c5b9220..72a7713 100644 --- a/formatting/corpus_test.go +++ b/formatting/corpus_test.go @@ -40,7 +40,8 @@ func TestCorpus(t *testing.T) { require.NoError(t, err) var out bytes.Buffer - require.NoError(t, formatting.Format(&out, src, corpusGroups)) + _, err = formatting.Format(&out, src, corpusGroups) + require.NoError(t, err) if *update { require.NoError(t, os.WriteFile(golden, out.Bytes(), 0o600)) diff --git a/formatting/enable/gofumpt/enable_test.go b/formatting/enable/gofumpt/enable_test.go index b50b50b..8877159 100644 --- a/formatting/enable/gofumpt/enable_test.go +++ b/formatting/enable/gofumpt/enable_test.go @@ -92,7 +92,9 @@ func format(t *testing.T, src string, opts ...formatting.Option) string { t.Helper() var out bytes.Buffer - require.NoError(t, formatting.Format(&out, []byte(src), opts...)) + + _, err := formatting.Format(&out, []byte(src), opts...) + require.NoError(t, err) return out.String() } diff --git a/formatting/errors.go b/formatting/errors.go index 10c55d8..6bc2643 100644 --- a/formatting/errors.go +++ b/formatting/errors.go @@ -12,6 +12,11 @@ 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 " + diff --git a/formatting/format.go b/formatting/format.go index 8dea1da..dc1819a 100644 --- a/formatting/format.go +++ b/formatting/format.go @@ -49,15 +49,24 @@ type Source interface { // 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. +// 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 and grouped before a byte is written, so a source that does -// not parse 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) error { +// 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...) } @@ -77,24 +86,31 @@ func sourceBytes[T Source](src T) []byte { return any(src).([]byte) } -func format(w io.Writer, src []byte, opts ...Option) error { +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 ErrNoGoFumpt + return nil, ErrNoGoFumpt } } - fset, file, adjust, err := parse(src) + fset, file, adjust, err := parse(src, o.resolved) if err != nil { - return fmt.Errorf("cannot parse source: %w: %w", err, ErrFormat) + return nil, fmt.Errorf("cannot parse source: %w: %w", err, ErrFormat) } - prune(fset, file) + bindings, used := prune(fset, file, o) 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 { @@ -102,10 +118,10 @@ func format(w io.Writer, src []byte, opts ...Option) error { } if adjust != nil { - return printFragment(w, fset, file, src, breaks, adjust) + return report, printFragment(w, fset, file, src, breaks, adjust) } - return printFile(w, fset, file, breaks) + return report, printFile(w, fset, file, breaks) } // printFile prints a whole file straight to w, one line at a time. @@ -116,7 +132,7 @@ func format(w io.Writer, src []byte, opts ...Option) error { // 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) (*token.FileSet, *ast.File, adjustFunc, error) { +func parse(src []byte, resolved map[string]string) (*token.FileSet, *ast.File, adjustFunc, error) { fset := token.NewFileSet() file, adjust, err := parseFile(fset, src, fastMode) @@ -124,7 +140,7 @@ func parse(src []byte) (*token.FileSet, *ast.File, adjustFunc, error) { return nil, nil, nil, err } - if !needsResolution(file) { + if !needsResolution(file, resolved) { return fset, file, adjust, nil } diff --git a/formatting/format_test.go b/formatting/format_test.go index ffad01c..f6d2ad9 100644 --- a/formatting/format_test.go +++ b/formatting/format_test.go @@ -128,7 +128,7 @@ func TestFormatErrors(t *testing.T) { t.Parallel() var out bytes.Buffer - err := formatting.Format(&out, []byte(source(t, "broken-decl"))) + _, err := formatting.Format(&out, []byte(source(t, "broken-decl"))) require.Error(t, err) assert.ErrorIs(t, err, formatting.ErrFormat) @@ -139,7 +139,7 @@ func TestFormatErrors(t *testing.T) { t.Parallel() var out bytes.Buffer - err := formatting.Format(&out, []byte(source(t, "broken-expr"))) + _, err := formatting.Format(&out, []byte(source(t, "broken-expr"))) require.Error(t, err) assert.NotContains(t, err.Error(), "expected 'package'") @@ -148,7 +148,7 @@ func TestFormatErrors(t *testing.T) { 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()) + _, err := formatting.Format(failingWriter{}, []byte(source(t, "empty-package")), formatting.WithGoFumpt()) require.Error(t, err) assert.ErrorIs(t, err, formatting.ErrNoGoFumpt) @@ -160,7 +160,8 @@ func TestFormatErrors(t *testing.T) { for _, fixture := range []string{"broken-decl", "broken-expr"} { var out countingWriter - require.Error(t, formatting.Format(&out, []byte(source(t, fixture)))) + _, 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) @@ -170,7 +171,7 @@ func TestFormatErrors(t *testing.T) { t.Run("should report a writer that fails", func(t *testing.T) { t.Parallel() - err := formatting.Format(failingWriter{}, []byte(source(t, "grouped"))) + _, err := formatting.Format(failingWriter{}, []byte(source(t, "grouped"))) require.Error(t, err) assert.ErrorIs(t, err, errWriter) @@ -220,7 +221,8 @@ func format2(t *testing.T, src string, opts ...formatting.Option) string { t.Helper() var out bytes.Buffer - require.NoError(t, formatting.Format(&out, []byte(src), opts...)) + _, err := formatting.Format(&out, []byte(src), opts...) + require.NoError(t, err) return out.String() } @@ -287,7 +289,8 @@ func TestSource(t *testing.T) { t.Parallel() var out bytes.Buffer - require.NoError(t, formatting.Format(&out, []byte(src))) + _, err := formatting.Format(&out, []byte(src)) + require.NoError(t, err) assert.Contains(t, out.String(), "package p") }) @@ -298,7 +301,8 @@ func TestSource(t *testing.T) { rendered.WriteString(src) var out bytes.Buffer - require.NoError(t, formatting.Format(&out, &rendered)) + _, 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") @@ -311,8 +315,11 @@ func TestSource(t *testing.T) { rendered.WriteString(src) var fromBuffer, fromBytes bytes.Buffer - require.NoError(t, formatting.Format(&fromBuffer, &rendered)) - require.NoError(t, formatting.Format(&fromBytes, rendered.Bytes())) + _, 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()) }) @@ -323,7 +330,7 @@ func TestSource(t *testing.T) { var nothing *bytes.Buffer var out bytes.Buffer - err := formatting.Format(&out, nothing) + _, err := formatting.Format(&out, nothing) require.Error(t, err, "an empty source has no package clause") assert.ErrorIs(t, err, formatting.ErrFormat) @@ -339,7 +346,8 @@ func TestSource(t *testing.T) { rendered.WriteString(source(t, fixture)) var out bytes.Buffer - require.NoError(t, formatting.Format(&out, &rendered)) + _, err := formatting.Format(&out, &rendered) + require.NoError(t, err) assert.Equal(t, format2(t, source(t, fixture)), out.String()) } }) diff --git a/formatting/internal/std/gen.go b/formatting/internal/std/gen.go new file mode 100644 index 0000000..1314c2b --- /dev/null +++ b/formatting/internal/std/gen.go @@ -0,0 +1,79 @@ +// 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". +// +// 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" + "sort" + "strings" +) + +func main() { + out, err := exec.Command("go", "list", "-f", "{{.ImportPath}} {{.Name}}", "std").Output() + if err != nil { + log.Fatalf("go list std: %v", 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 + } + + paths := make([]string, 0, len(names)) + for importPath := range names { + paths = append(paths, importPath) + } + sort.Strings(paths) + + var buf bytes.Buffer + buf.WriteString(`// 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 + +// names maps every importable standard library path to the name its package clause declares. +var names = map[string]string{ +`) + + 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)) +} diff --git a/formatting/internal/std/packages.go b/formatting/internal/std/packages.go new file mode 100644 index 0000000..01b2e8f --- /dev/null +++ b/formatting/internal/std/packages.go @@ -0,0 +1,195 @@ +// 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 + +// 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", + "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..8ad0768 --- /dev/null +++ b/formatting/internal/std/std_test.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package std_test + +import ( + "os/exec" + "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) + } + }) +} + +// TestTableIsCurrent fails when a Go release has moved the standard library under us. +// +// Regenerate with "go generate ./internal/std". +func TestTableIsCurrent(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 checked int + + 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 + } + + checked++ + + got, found := std.Name(importPath) + if assert.True(t, found, "%s is missing from the table", importPath) { + assert.Equal(t, name, got, "%s", importPath) + } + } + + assert.Equal(t, checked, std.Len(), "the table holds packages go list std does not") +} diff --git a/formatting/options.go b/formatting/options.go index 1fc14ae..7eb938e 100644 --- a/formatting/options.go +++ b/formatting/options.go @@ -7,8 +7,10 @@ package formatting type Option func(*options) type options struct { - groups []string - goFumpt bool + groups []string + goFumpt bool + forcePruning bool + resolved map[string]string } // WithImportGroups adds one import group per prefix, between the standard library and the rest. @@ -36,6 +38,61 @@ func WithGoFumpt() Option { } } +// 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 + } +} + +// 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. Its answers +// come from the packages 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 + } + } +} + func optionsWithDefaults(opts []Option) options { var o options diff --git a/formatting/parse_internal_test.go b/formatting/parse_internal_test.go index b13b531..0498f92 100644 --- a/formatting/parse_internal_test.go +++ b/formatting/parse_internal_test.go @@ -93,7 +93,7 @@ var _ bytes.Buffer file, _, err := parseFile(token.NewFileSet(), []byte(test.src), fastMode) require.NoError(t, err) - assert.Equal(t, test.resolved, needsResolution(file)) + assert.Equal(t, test.resolved, needsResolution(file, nil)) }) } } @@ -139,7 +139,7 @@ func TestParsePathsAgree(t *testing.T) { t.Skip("fixture does not parse; the error paths are covered elsewhere") } - if needsResolution(file) { + if needsResolution(file, nil) { slowPath.Add(1) return // Format parses this one again, so the paths are free to differ @@ -165,7 +165,7 @@ func formatWith(src []byte, mode parser.Mode) (string, error) { return "", err } - prune(fset, file) + prune(fset, file, options{forcePruning: true}) mergeImports(file) sortImports(fset.File(file.FileStart), file, nil) breaks := groupBreaks(fset, file, nil) diff --git a/formatting/prune.go b/formatting/prune.go index 75c7041..7a14ff4 100644 --- a/formatting/prune.go +++ b/formatting/prune.go @@ -14,64 +14,37 @@ import ( // cgoImport names the pseudo-package C, which carries a cgo preamble. Nothing may touch it. const cgoImport = "C" -// prune deletes every import the file does not use. +// prune deletes the imports the file does not use and can be shown not to use. // -// It never adds one, and it drops an import only when it can name the package the import declares. -// See [importedPackageNames] for what happens when it cannot. -func prune(fset *token.FileSet, file *ast.File) { +// 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 _, spec := range file.Imports { - importPath, err := strconv.Unquote(spec.Path.Value) - if err != nil || importPath == cgoImport { - continue - } + for i := range bindings { + described := &bindings[i] - if isUsed(spec, importPath, used) { + if !described.prunable(o.forcePruning) || described.isUsed(used) { continue } - name := "" - if spec.Name != nil { - name = spec.Name.Name - } - unused = append(unused, deletion{name: name, path: importPath}) + 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) } -} - -// isUsed reports whether any name the import could answer to appears as a package qualifier. -func isUsed(spec *ast.ImportSpec, importPath string, used map[string]bool) bool { - if spec.Name != nil { - switch spec.Name.Name { - case "_", ".": - // a blank import runs an init, a dot import spills its names into the file scope, and - // neither shows up as a qualifier. Keep both. - return true - default: - // an alias names the package exactly, so there is nothing to guess - return used[spec.Name.Name] - } - } - names := importedPackageNames(importPath) - if len(names) == 0 { - return true // cannot name the package, so cannot call it unused - } - - for _, name := range names { - if used[name] { - return true - } - } - - return false + return bindings, used } // usedQualifiers collects every identifier the file uses to qualify a selector. @@ -115,8 +88,8 @@ func usedQualifiers(file *ast.File) map[string]bool { // 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) bool { - names := importedNames(file) +func needsResolution(file *ast.File, resolved map[string]string) bool { + names := importedNames(file, resolved) if len(names) == 0 { return false } @@ -158,9 +131,10 @@ func needsResolution(file *ast.File) bool { // importedNames returns every name the file's imports could declare. // -// An alias declares its name outright. Without one 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) map[string]struct{} { +// 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 { @@ -177,6 +151,12 @@ func importedNames(file *ast.File) map[string]struct{} { continue } + if name, ok := resolved[importPath]; ok { + names[name] = struct{}{} + + continue + } + for _, name := range importedPackageNames(importPath) { names[name] = struct{}{} } diff --git a/formatting/prune_test.go b/formatting/prune_test.go index aa5fb48..5832600 100644 --- a/formatting/prune_test.go +++ b/formatting/prune_test.go @@ -4,9 +4,13 @@ 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) { @@ -79,3 +83,138 @@ func TestPrune(t *testing.T) { 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 index 493a826..56da8ca 100644 --- a/formatting/reference_test.go +++ b/formatting/reference_test.go @@ -16,12 +16,17 @@ import ( // 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 three -// places, none of them exercised here: Format never adds an import, [formatting.WithImportGroups] -// opens groups goimports has no way to express, and Format sorts and dedups the whole import block -// where goimports sorts each blank-line-separated run on its own. No fixture below writes a blank -// line inside its import block, so the third difference cannot show. Any other difference is a bug -// in one of them. +// 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() @@ -39,7 +44,8 @@ func TestAgainstReference(t *testing.T) { require.NoError(t, err) var out bytes.Buffer - require.NoError(t, formatting.Format(&out, []byte(src))) + _, err = formatting.Format(&out, []byte(src)) + require.NoError(t, err) assert.Equal(t, string(expected), out.String()) }) @@ -57,7 +63,8 @@ func TestNeverAdds(t *testing.T) { require.Contains(t, string(resolved), `"fmt"`, "goimports resolves the missing import") var out bytes.Buffer - require.NoError(t, formatting.Format(&out, []byte(src))) + _, err = formatting.Format(&out, []byte(src)) + require.NoError(t, err) assert.NotContains(t, out.String(), `"fmt"`, "Format leaves it to the compiler to complain") } @@ -74,7 +81,7 @@ func BenchmarkFormat(b *testing.B) { for b.Loop() { var out bytes.Buffer - if err := formatting.Format(&out, src, groups); err != nil { + if _, err := formatting.Format(&out, src, groups); err != nil { b.Fatal(err) } } diff --git a/formatting/report.go b/formatting/report.go new file mode 100644 index 0000000..93bdf42 --- /dev/null +++ b/formatting/report.go @@ -0,0 +1,235 @@ +// 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], +// read the names of [ImportsReport.PathsInDoubt] from the packages themselves, 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/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/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/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/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/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/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/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/genapp/genapp.go b/genapp/genapp.go index 0a54da2..90db06b 100644 --- a/genapp/genapp.go +++ b/genapp/genapp.go @@ -181,11 +181,21 @@ func dumpUnformatted(file *os.File, path string, rendered *bytes.Buffer, cause e 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 { - if err := formatting.Format(w, rendered, g.formatOptions...); err != nil { + 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 } diff --git a/genapp/options.go b/genapp/options.go index 06e298e..28926ea 100644 --- a/genapp/options.go +++ b/genapp/options.go @@ -15,11 +15,12 @@ import ( type Option func(*options) type options struct { - templates *repo.Repository - outputPath string - formatOptions []formatting.Option - skipFormat bool - skipFormatFunc func(target string) bool + 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. @@ -63,6 +64,26 @@ func WithFormatOptions(opts ...formatting.Option) Option { } } +// 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()) +// } +// }) +// +// Read the names of the paths it lists once, then pass them 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 From f78ab0c1ca1029c57e97fee37ce3657e540fcf75 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 17:40:16 +0200 Subject: [PATCH 07/14] feat(formatting): add resolve.Names to read package names via go list Format never loads a package, so it cannot name one whose package clause does not follow its import path, and reports such an import in doubt rather than deleting it. resolve.Names loads them through x/tools/go/packages with NeedName and answers outright, returning the map WithResolvedImports takes: names, err := resolve.Names(ctx, report.PathsInDoubt()) The answer depends on the dependencies and on nothing else, so the map can be produced once and committed, and every generator run agrees wherever it runs. That is the difference from goimports resolving live: searching the build list makes the output depend on the machine. A path that does not resolve is named in the error, which wraps ErrUnresolved, and left out of the map. The map still holds what did resolve, so a caller may use that and act on the rest. It sits in the root module rather than one of its own: x/tools, x/mod and x/sync were required already, so it costs no new dependency. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/options.go | 5 +- formatting/report.go | 5 +- formatting/resolve/doc.go | 41 ++++++++ formatting/resolve/resolve.go | 146 +++++++++++++++++++++++++++++ formatting/resolve/resolve_test.go | 132 ++++++++++++++++++++++++++ genapp/options.go | 2 +- 6 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 formatting/resolve/doc.go create mode 100644 formatting/resolve/resolve.go create mode 100644 formatting/resolve/resolve_test.go diff --git a/formatting/options.go b/formatting/options.go index 7eb938e..e94c5e0 100644 --- a/formatting/options.go +++ b/formatting/options.go @@ -75,8 +75,9 @@ func WithForceImportsPruning() Option { // // 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. Its answers -// come from the packages rather than from the machine, so one map serves every build. +// 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 { diff --git a/formatting/report.go b/formatting/report.go index 93bdf42..6a6ced0 100644 --- a/formatting/report.go +++ b/formatting/report.go @@ -98,8 +98,9 @@ func (r ImportRecord) String() string { // see the imports behind an [ErrInconsistentImports]. // // The report is the input to the resolving loop: format once, ask [ImportsReport.HasImportsInDoubt], -// read the names of [ImportsReport.PathsInDoubt] from the packages themselves, and format again with -// [WithResolvedImports] until nothing is left in doubt. +// 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 } diff --git a/formatting/resolve/doc.go b/formatting/resolve/doc.go new file mode 100644 index 0000000..80f2661 --- /dev/null +++ b/formatting/resolve/doc.go @@ -0,0 +1,41 @@ +// 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 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/resolve.go b/formatting/resolve/resolve.go new file mode 100644 index 0000000..2b9fa86 --- /dev/null +++ b/formatting/resolve/resolve.go @@ -0,0 +1,146 @@ +// 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. 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)) + + for _, pkg := range loaded { + if pkg.Name == "" || pkg.PkgPath == "" || len(pkg.Errors) > 0 { + continue + } + + names[pkg.PkgPath] = pkg.Name + } + + if missing := missingFrom(wanted, names); 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. +func missingFrom(wanted []string, names map[string]string) []string { + var missing []string + + for _, importPath := range wanted { + if _, ok := names[importPath]; !ok { + missing = append(missing, importPath) + } + } + + return missing +} diff --git a/formatting/resolve/resolve_test.go b/formatting/resolve/resolve_test.go new file mode 100644 index 0000000..a8d6b3f --- /dev/null +++ b/formatting/resolve/resolve_test.go @@ -0,0 +1,132 @@ +// 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.Equal(t, "bytes", names["bytes"], "what resolved is still usable") + }) + + 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/genapp/options.go b/genapp/options.go index 28926ea..439ba23 100644 --- a/genapp/options.go +++ b/genapp/options.go @@ -76,7 +76,7 @@ func WithFormatOptions(opts ...formatting.Option) Option { // } // }) // -// Read the names of the paths it lists once, then pass them through +// 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) { From 58bfaed3b6672cffc72452c60db9e8dc5662cd11 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 17:40:17 +0200 Subject: [PATCH 08/14] feat(formatting): add WithSimplifiedImportAliases for redundant aliases An alias states the name an import binds, so a template that writes one everywhere gets exact pruning without promising anything and without a resolved map. The cost is source no Go author would write: import strfmt "github.com/go-openapi/strfmt" This option takes such an alias back out once the name is proven, leaving ordinary Go. Two things have to hold. The name must be stated by the standard library table or by WithResolvedImports, since dropping an alias on a guess breaks the build the moment the guess is wrong. And it must be the name 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 with the name proven: dropping it compiles, and throws away the only thing in the file that says which package that is. A later run without the map would put it straight back in doubt. sql "database/sql/driver" keeps its alias too, because it renames the package rather than repeating it. describeImport used to stop at the alias and never consult the table or the map for an aliased import. It now records proven, the name the package declares, beside name, the name the file binds. They differ exactly when an alias renames something. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/binding.go | 36 ++++++ formatting/doc.go | 85 +++++++++++++- formatting/format.go | 5 + formatting/names.go | 11 +- formatting/options.go | 31 ++++- formatting/simplify_test.go | 107 ++++++++++++++++++ .../testdata/sources/redundant-aliases.input | 17 +++ 7 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 formatting/simplify_test.go create mode 100644 formatting/testdata/sources/redundant-aliases.input diff --git a/formatting/binding.go b/formatting/binding.go index 2e03668..dbbbec9 100644 --- a/formatting/binding.go +++ b/formatting/binding.go @@ -195,3 +195,39 @@ func (b binding) status(used map[string]bool) ImportStatus { 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/doc.go b/formatting/doc.go index 2d4b46b..063f965 100644 --- a/formatting/doc.go +++ b/formatting/doc.go @@ -3,8 +3,8 @@ // Package formatting formats generated Go source. // -// [Format] parses the source, drops the imports nothing uses, sorts and groups the rest, and prints -// the result to an [io.Writer]: +// [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), @@ -41,6 +41,66 @@ // 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. +// +// # 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 @@ -60,11 +120,30 @@ // 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. +// — which name an existing import already binds — for itself, and reports what it could not settle. // // # Grouping // diff --git a/formatting/format.go b/formatting/format.go index dc1819a..71a4b7a 100644 --- a/formatting/format.go +++ b/formatting/format.go @@ -102,6 +102,11 @@ func format(w io.Writer, src []byte, opts ...Option) (*ImportsReport, error) { } bindings, used := prune(fset, file, o) + + if o.simplifyAliases { + simplifyAliases(bindings) + } + mergeImports(file) sortImports(fset.File(file.FileStart), file, o.groups) diff --git a/formatting/names.go b/formatting/names.go index de96729..e7b32e4 100644 --- a/formatting/names.go +++ b/formatting/names.go @@ -38,8 +38,9 @@ func ImportedPackageName(importPath string) string { // 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. It needs every candidate, because one -// guess does not cover a version element: "k8s.io/api/apps/v1" declares v1, while +// [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" @@ -47,10 +48,10 @@ func ImportedPackageName(importPath string) string { // 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 [prune] then keeps the import. +// "unknown", not "declares nothing", and [binding] then marks the import in doubt. // -// This stays unexported: a caller naming an import wants [ImportedPackageName]. A list of guesses -// answers no question a caller asks. +// 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 diff --git a/formatting/options.go b/formatting/options.go index e94c5e0..daadc23 100644 --- a/formatting/options.go +++ b/formatting/options.go @@ -7,10 +7,11 @@ package formatting type Option func(*options) type options struct { - groups []string - goFumpt bool - forcePruning bool - resolved map[string]string + groups []string + goFumpt bool + forcePruning bool + simplifyAliases bool + resolved map[string]string } // WithImportGroups adds one import group per prefix, between the standard library and the rest. @@ -94,6 +95,28 @@ func WithResolvedImports(names map[string]string) Option { } } +// 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 + } +} + func optionsWithDefaults(opts []Option) options { var o options 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/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 +) From b087372b05f0af3e16a136b8aab62b94e0bc7cfb Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 17:49:08 +0200 Subject: [PATCH 09/14] fix(formatting): carry the go list reason into a resolve.Names error An unresolved path came back as "some import paths did not resolve", which says nothing a caller can act on. go list explains itself well - "no required module provides package X; to add it: go get X" - and Names was discarding that for a tidier message. Each unresolved path now carries what go list said about it, flattened onto one line. A path go list did not mention at all gets a stand-in rather than an empty parenthesis. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/resolve/resolve.go | 44 ++++++++++++++++++++++++------ formatting/resolve/resolve_test.go | 13 +++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/formatting/resolve/resolve.go b/formatting/resolve/resolve.go index 2b9fa86..4fa1408 100644 --- a/formatting/resolve/resolve.go +++ b/formatting/resolve/resolve.go @@ -70,7 +70,8 @@ func WithBuildFlags(flags ...string) Option { // [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. The map still holds what did resolve, so use it and report the rest: +// 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) { @@ -101,17 +102,28 @@ func Names(ctx context.Context, paths []string, opts ...Option) (map[string]stri } names := make(map[string]string, len(loaded)) + reasons := make(map[string]string, len(loaded)) for _, pkg := range loaded { - if pkg.Name == "" || pkg.PkgPath == "" || len(pkg.Errors) > 0 { + 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); len(missing) > 0 { - return names, fmt.Errorf("%s: %w: %w", strings.Join(missing, ", "), ErrUnresolved, ErrResolve) + if missing := missingFrom(wanted, names, reasons); len(missing) > 0 { + return names, fmt.Errorf("%s: %w: %w", strings.Join(missing, "; "), ErrUnresolved, ErrResolve) } return names, nil @@ -132,15 +144,31 @@ func distinct(paths []string) []string { return wanted } -// missingFrom lists the paths that came back without a name. -func missingFrom(wanted []string, names map[string]string) []string { +// 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 { - missing = append(missing, importPath) + 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 index a8d6b3f..f050709 100644 --- a/formatting/resolve/resolve_test.go +++ b/formatting/resolve/resolve_test.go @@ -77,10 +77,23 @@ func TestNames(t *testing.T) { 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() From cb510d5aa653b7e91df441f318f488f80991bdfa Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 18:02:49 +0200 Subject: [PATCH 10/14] doc(formatting): state what Format and resolve.Names leave to the compiler Both packages read syntax, not builds. An import of another module's internal package formats and resolves like any other, a //go:build line is copied through, and config_linux.go is treated as any other file. Say so, so nobody adds a check the go compiler already makes and makes better. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/doc.go | 7 +++++++ formatting/resolve/doc.go | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/formatting/doc.go b/formatting/doc.go index 063f965..182a2f9 100644 --- a/formatting/doc.go +++ b/formatting/doc.go @@ -78,6 +78,13 @@ // 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 diff --git a/formatting/resolve/doc.go b/formatting/resolve/doc.go index 80f2661..25aaff8 100644 --- a/formatting/resolve/doc.go +++ b/formatting/resolve/doc.go @@ -33,6 +33,12 @@ // // 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 From 493d0c31648681ef2b61ce0858c006837a50c08f Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 18:24:03 +0200 Subject: [PATCH 11/14] doc(formatting): add runnable examples for every option The options are where a caller needs to see behaviour rather than read about it, and several of them only make sense against each other. Each example prints real formatted output, so godoc shows what the option does to a file: - WithImportGroups: one group per prefix, in the order given; - WithForceImportsPruning: an unused bare import kept, then pruned; - WithResolvedImports: the promise deleting an import the code uses, and the map putting it back while the promise still covers the rest; - WithSimplifiedImportAliases: fmt and strfmt losing their aliases while jsoniter keeps its own; - ImportsReport and HasImportsInDoubt: what Format decided, and what it did not. resolve gains ExampleNames and a package-level Example running the whole loop: format, resolve what the report leaves in doubt, format again with the map, nothing in doubt. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/example_test.go | 273 +++++++++++++++++++++++++++++ formatting/resolve/example_test.go | 82 +++++++++ go.work | 1 + 3 files changed, 356 insertions(+) create mode 100644 formatting/example_test.go create mode 100644 formatting/resolve/example_test.go 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/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/go.work b/go.work index 7ef3980..ef3cb81 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.25.0 use ( . ./formatting/enable/gofumpt + ./formatting/testdata/corpus ./mangling ./mangling/ucd ) From 2d68443740b90a7a49b3b015bac753338cca1936 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:13:41 +0200 Subject: [PATCH 12/14] fix(formatting): stop the standard library table test asserting a count TestTableIsCurrent compared the table's size with "go list std" and failed on oldstable, where go1.26 reports 179 packages against the 185 the table was built from. The count was never the claim worth checking, and it varies on two axes, not one: the standard library also differs by platform, so runtime/cgo is absent on windows and syscall/js exists only on js/wasm. The table is now the union of linux/amd64, darwin/arm64, windows/amd64 and js/wasm, which makes it a property of the Go release alone. Every package of each of those platforms is in it, and "go generate" on a Mac no longer drops runtime/cgo and churns the file. The generator rejects a path two platforms name differently; none does today. The test, renamed TestTableMatchesToolchain, asserts what matters for correctness: a path both sides hold declares the same name. A path only "go list std" holds means the table is behind, which leaves the formatter guessing rather than wrong, so it is reported instead of failed - except on GeneratedFor, the release recorded in the generated file, where the two must agree exactly. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/internal/std/gen.go | 88 +++++++++++++++++++++++------ formatting/internal/std/packages.go | 8 +++ formatting/internal/std/std_test.go | 48 +++++++++++++--- 3 files changed, 118 insertions(+), 26 deletions(-) diff --git a/formatting/internal/std/gen.go b/formatting/internal/std/gen.go index 1314c2b..c1c587e 100644 --- a/formatting/internal/std/gen.go +++ b/formatting/internal/std/gen.go @@ -5,6 +5,10 @@ // 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 @@ -15,31 +19,32 @@ import ( "log" "os" "os/exec" + "runtime" "sort" "strings" ) -func main() { - out, err := exec.Command("go", "list", "-f", "{{.ImportPath}} {{.Name}}", "std").Output() - if err != nil { - log.Fatalf("go list std: %v", err) - } +// 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 line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { - importPath, name, ok := strings.Cut(line, " ") - if !ok || name == "main" { - continue - } + 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) + } - // 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 } - - names[importPath] = name } paths := make([]string, 0, len(names)) @@ -49,16 +54,23 @@ func main() { sort.Strings(paths) var buf bytes.Buffer - buf.WriteString(`// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers + 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]) @@ -77,3 +89,43 @@ var names = map[string]string{ 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 index 01b2e8f..8ddb228 100644 --- a/formatting/internal/std/packages.go +++ b/formatting/internal/std/packages.go @@ -5,6 +5,13 @@ 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", @@ -172,6 +179,7 @@ var names = map[string]string{ "sync": "sync", "sync/atomic": "atomic", "syscall": "syscall", + "syscall/js": "js", "testing": "testing", "testing/cryptotest": "cryptotest", "testing/fstest": "fstest", diff --git a/formatting/internal/std/std_test.go b/formatting/internal/std/std_test.go index 8ad0768..cbda3bd 100644 --- a/formatting/internal/std/std_test.go +++ b/formatting/internal/std/std_test.go @@ -5,6 +5,7 @@ package std_test import ( "os/exec" + "runtime" "strings" "testing" @@ -47,10 +48,18 @@ func TestName(t *testing.T) { }) } -// TestTableIsCurrent fails when a Go release has moved the standard library under us. +// 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 TestTableIsCurrent(t *testing.T) { +func TestTableMatchesToolchain(t *testing.T) { t.Parallel() if testing.Short() { @@ -60,7 +69,7 @@ func TestTableIsCurrent(t *testing.T) { out, err := exec.CommandContext(t.Context(), "go", "list", "-f", "{{.ImportPath}} {{.Name}}", "std").Output() require.NoError(t, err) - var checked int + var absent []string for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { importPath, name, ok := strings.Cut(line, " ") @@ -68,13 +77,36 @@ func TestTableIsCurrent(t *testing.T) { continue } - checked++ - got, found := std.Name(importPath) - if assert.True(t, found, "%s is missing from the table", importPath) { - assert.Equal(t, name, got, "%s", 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() } - assert.Equal(t, checked, std.Len(), "the table holds packages go list std does not") + return parts[0] + "." + parts[1] } From e662731c0727117c5f5d742228f05879ffaba0ae Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:25:44 +0200 Subject: [PATCH 13/14] fix(ci): check text files out with LF on every platform The Windows runners check out with core.autocrlf=true, so every golden and fixture arrived with CRLF while the formatter emits LF, and every byte-for-byte comparison failed on the line ending alone: TestCorpus against its goldens, and the fragment tests against the space they restore. Nothing committed holds CRLF today, so this only pins how the files are checked out. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitattributes 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 From cfa022d559e8700290f9b953bbbf60c25f68b6ca Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 23 Aug 2026 19:34:44 +0200 Subject: [PATCH 14/14] fix: pass the test suites on a Windows runner Two failures, both on bytes that only differ there. isSpaceByte listed ' ', '\t' and '\n', dropping the '\r' that go/format/internal.go carries and documents. cutSpace then read the \r of a \r\n as content, matchSpace found no leading or trailing space to match, and a fragment written on Windows came back with the blank lines and indent around it silently gone. Format now answers go/format.Source byte for byte on \r\n input, for a whole file and for a fragment alike. The new test carries its sources inline: .gitattributes checks every fixture out with LF, so no file on disk can exercise this. dumpedPath scraped the kept file out of a RenderFile error by cutting at the next quote, but the message writes the path with %q, so on Windows every separator arrives escaped and the path came back as D:\\a\\codegen. It reads the quoted string with strconv.QuotedPrefix and strconv.Unquote instead. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- formatting/crlf_test.go | 62 +++++++++++++++++++++++++++++++++++++++++ formatting/doc.go | 7 +++++ formatting/parse.go | 7 ++++- genapp/genapp_test.go | 17 +++++++---- 4 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 formatting/crlf_test.go 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 index 182a2f9..f074de0 100644 --- a/formatting/doc.go +++ b/formatting/doc.go @@ -186,6 +186,13 @@ // // 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 diff --git a/formatting/parse.go b/formatting/parse.go index 2fd4bc4..81b13ef 100644 --- a/formatting/parse.go +++ b/formatting/parse.go @@ -208,6 +208,11 @@ func cutSpace(b []byte) (before, middle, after []byte) { 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' + return c == ' ' || c == '\t' || c == '\n' || c == '\r' } diff --git a/genapp/genapp_test.go b/genapp/genapp_test.go index c064483..f0a96c0 100644 --- a/genapp/genapp_test.go +++ b/genapp/genapp_test.go @@ -9,6 +9,7 @@ import ( "io/fs" "os" "path/filepath" + "strconv" "strings" "testing" @@ -241,20 +242,26 @@ func TestRenderFile(t *testing.T) { } // 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 "` + 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) - rest := message[start+len(marker):] - end := strings.IndexByte(rest, '"') - require.GreaterOrEqual(t, end, 0) + quoted, quoteErr := strconv.QuotedPrefix(message[start+len(marker):]) + require.NoError(t, quoteErr, "the path is quoted: %v", err) - return rest[:end] + path, unquoteErr := strconv.Unquote(quoted) + require.NoError(t, unquoteErr) + + return path } func TestNew(t *testing.T) {