diff --git a/go/loader/loader.go b/go/loader/loader.go index 95bc95624..4242e4533 100644 --- a/go/loader/loader.go +++ b/go/loader/loader.go @@ -140,6 +140,7 @@ func Graph(c *cache.Cache, cfg *packages.Config, patterns ...string) ([]*Package type program struct { fset *token.FileSet packages map[string]*types.Package + options *Options } type Stats struct { @@ -147,6 +148,14 @@ type Stats struct { Export map[*PackageSpec]time.Duration } +type Options struct { + // The Go language version to use for the type checker. If unset, or if set + // to "module", it will default to the Go version specified in the module; + // if there is no module, it will default to the version of Go the + // executable was built with. + GoVersion string +} + // Load loads the package described in spec. Imports will be loaded // from export data, while the package itself will be loaded from // source. @@ -154,10 +163,17 @@ type Stats struct { // An error will only be returned for system failures, such as failure // to read export data from disk. Syntax and type errors, among // others, will only populate the returned package's Errors field. -func Load(spec *PackageSpec) (*Package, Stats, error) { +func Load(spec *PackageSpec, opts *Options) (*Package, Stats, error) { + if opts == nil { + opts = &Options{} + } + if opts.GoVersion == "" { + opts.GoVersion = "module" + } prog := &program{ fset: token.NewFileSet(), packages: map[string]*types.Package{}, + options: opts, } stats := Stats{ @@ -292,54 +308,62 @@ func (prog *program) loadFromSource(spec *PackageSpec) (*Package, error) { pkg.Errors = append(pkg.Errors, convertError(err)...) }, } - if spec.Module != nil && spec.Module.GoVersion != "" { - var our string - if version.IsValid(runtime.Version()) { - // Staticcheck was built with a released version of Go. - // runtime.Version() returns something like "go1.22.4" or - // "go1.23rc1". - our = runtime.Version() + if prog.options.GoVersion == "module" { + if spec.Module != nil && spec.Module.GoVersion != "" { + var our string + if version.IsValid(runtime.Version()) { + // Staticcheck was built with a released version of Go. + // runtime.Version() returns something like "go1.22.4" or + // "go1.23rc1". + our = runtime.Version() + } else { + // Staticcheck was built with a development version of Go. + // runtime.Version() returns something like "devel go1.23-e8ee1dc4f9 + // Sun Jun 23 00:52:20 2024 +0000". Fall back to using ReleaseTags, + // where the last one will contain the language version of the + // development version of Go. + tags := build.Default.ReleaseTags + our = tags[len(tags)-1] + } + if version.Compare("go"+spec.Module.GoVersion, our) == 1 { + // We don't need this check for correctness, as go/types rejects + // a GoVersion that's too new. But we can produce a better error + // message. In Go 1.22, go/types simply says "package requires + // newer Go version go1.23", without any information about the + // file, or what version Staticcheck was built with. Starting + // with Go 1.23, the error seems to be better: + // "/home/dominikh/prj/src/example.com/foo.go:3:1: package + // requires newer Go version go1.24 (application built with + // go1.23)" and we may be able to remove this custom logic once + // we depend on Go 1.23. + // + // Note that if Staticcheck was built with a development version of + // Go, e.g. "devel go1.23-82c371a307", then we'll say that + // Staticcheck was built with go1.23, which is the language version + // of the development build. This matches the behavior of the Go + // toolchain, which says "go.mod requires go >= 1.23rc1 (running go + // 1.23; GOTOOLCHAIN=local)". + // + // Note that this prevents Go master from working with go1.23rc1, + // even if master is further ahead. This is currently unavoidable, + // and matches the behavior of the Go toolchain (see above.) + return nil, fmt.Errorf( + "module requires at least go%s, but Staticcheck was built with %s", + spec.Module.GoVersion, our, + ) + } + tc.GoVersion = "go" + spec.Module.GoVersion } else { - // Staticcheck was built with a development version of Go. - // runtime.Version() returns something like "devel go1.23-e8ee1dc4f9 - // Sun Jun 23 00:52:20 2024 +0000". Fall back to using ReleaseTags, - // where the last one will contain the language version of the - // development version of Go. tags := build.Default.ReleaseTags - our = tags[len(tags)-1] - } - if version.Compare("go"+spec.Module.GoVersion, our) == 1 { - // We don't need this check for correctness, as go/types rejects a - // GoVersion that's too new. But we can produce a better error - // message. - // - // Note that if Staticcheck was built with a development version of - // Go, e.g. "devel go1.23-82c371a307", then we'll say that - // Staticcheck was built with go1.23, which is the language version - // of the development build. This matches the behavior of the Go - // toolchain, which says "go.mod requires go >= 1.23rc1 (running go - // 1.23; GOTOOLCHAIN=local)". - // - // Note that this prevents Go master from working with go1.23rc1, - // even if master is further ahead. This is currently unavoidable, - // and matches the behavior of the Go toolchain (see above.) - return nil, fmt.Errorf( - "module requires at least go%s, but Staticcheck was built with %s", - spec.Module.GoVersion, our, - ) + tc.GoVersion = tags[len(tags)-1] } - tc.GoVersion = "go" + spec.Module.GoVersion } else { - tags := build.Default.ReleaseTags - tc.GoVersion = tags[len(tags)-1] + tc.GoVersion = prog.options.GoVersion } // Note that the type-checker can return a non-nil error even though the Go // compiler has already successfully built this package (which is an - // invariant of getting to this point.) For example, for a module that - // requires Go 1.23, if Staticcheck was built with Go 1.22, but the user's - // toolchain is Go 1.23, then 'go list' (as invoked by go/packages) will - // build the package fine, but go/types will complain that the version is - // too new. + // invariant of getting to this point), for example because of the Go + // version passed to the type checker. err := types.NewChecker(tc, pkg.Fset, pkg.Types, pkg.TypesInfo).Files(pkg.Syntax) return pkg, err } diff --git a/internal/cmd/unused/unused.go b/internal/cmd/unused/unused.go index 6151e6660..3372b0d46 100644 --- a/internal/cmd/unused/unused.go +++ b/internal/cmd/unused/unused.go @@ -56,7 +56,7 @@ func main() { // XXX priunt errors continue } - lpkg, _, err := loader.Load(spec) + lpkg, _, err := loader.Load(spec, nil) if err != nil { continue } diff --git a/lintcmd/cmd.go b/lintcmd/cmd.go index 8bd53cf9e..eace8bc0d 100644 --- a/lintcmd/cmd.go +++ b/lintcmd/cmd.go @@ -8,6 +8,7 @@ import ( "flag" "fmt" "go/token" + stdversion "go/version" "io" "log" "os" @@ -66,8 +67,9 @@ type Command struct { debugMeasureAnalyzers string debugTrace string - checks list - fail list + checks list + fail list + goVersion versionFlag } } @@ -156,8 +158,10 @@ func (cmd *Command) initFlagSet(name string) { cmd.flags.checks = list{"inherit"} cmd.flags.fail = list{"all"} + cmd.flags.goVersion = versionFlag("module") flags.Var(&cmd.flags.checks, "checks", "Comma-separated list of `checks` to enable.") flags.Var(&cmd.flags.fail, "fail", "Comma-separated list of `checks` that can cause a non-zero exit status.") + flags.Var(&cmd.flags.goVersion, "go", "Target Go `version` in the format '1.x', or the literal 'module' to use the module's Go version") } type list []string @@ -180,6 +184,29 @@ func (list *list) Set(s string) error { return nil } +type versionFlag string + +func (v *versionFlag) String() string { + return fmt.Sprintf("%q", string(*v)) +} + +func (v *versionFlag) Set(s string) error { + if s == "module" { + *v = "module" + } else { + orig := s + if !strings.HasPrefix(s, "go") { + s = "go" + s + } + if stdversion.IsValid(s) { + *v = versionFlag(s) + } else { + return fmt.Errorf("%q is not a valid Go version", orig) + } + } + return nil +} + // ParseFlags parses command line flags. // It must be called before calling Run. // After calling ParseFlags, the values of flags can be accessed. @@ -457,6 +484,7 @@ func (cmd *Command) lint() int { analyzers: cs, patterns: cmd.flags.fs.Args(), lintTests: cmd.flags.tests, + goVersion: string(cmd.flags.goVersion), config: config.Config{ Checks: cmd.flags.checks, }, diff --git a/lintcmd/lint.go b/lintcmd/lint.go index 3168389e4..0b588a97c 100644 --- a/lintcmd/lint.go +++ b/lintcmd/lint.go @@ -93,6 +93,7 @@ type options struct { analyzers []*lint.Analyzer patterns []string lintTests bool + goVersion string printAnalyzerMeasurement func(analysis *analysis.Analyzer, pkg *loader.PackageSpec, d time.Duration) } @@ -109,6 +110,7 @@ func (l *linter) run(bconf buildConfig) (lintResult, error) { if err != nil { return lintResult{}, err } + r.GoVersion = l.opts.goVersion r.Stats.PrintAnalyzerMeasurement = l.opts.printAnalyzerMeasurement printStats := func() { diff --git a/lintcmd/runner/runner.go b/lintcmd/runner/runner.go index 0990ca8a7..d3461703b 100644 --- a/lintcmd/runner/runner.go +++ b/lintcmd/runner/runner.go @@ -377,7 +377,8 @@ func (act *analyzerAction) String() string { // A Runner executes analyzers on packages. type Runner struct { - Stats Stats + Stats Stats + GoVersion string // If set to true, Runner will populate results with data relevant to testing analyzers TestMode bool @@ -543,6 +544,7 @@ func (r *subrunner) do(act action) error { fmt.Fprintf(h, "cfg %#v\n", hashCfg) fmt.Fprintf(h, "pkg %x\n", a.Package.Hash) fmt.Fprintf(h, "analyzers %s\n", r.analyzerNames) + fmt.Fprintf(h, "go %s\n", r.GoVersion) fmt.Fprintf(h, "env godebug %q\n", os.Getenv("GODEBUG")) // OPT(dh): do we actually need to hash vetx? can we not assume @@ -685,7 +687,7 @@ func (r *subrunner) doUncached(a *packageAction) (packageActionResult, error) { // processed concurrently, we shouldn't load b's export data // twice. - pkg, _, err := loader.Load(a.Package) + pkg, _, err := loader.Load(a.Package, &loader.Options{GoVersion: r.GoVersion}) if err != nil { return packageActionResult{}, err } diff --git a/website/content/docs/faq.md b/website/content/docs/faq.md index 2bc61b911..7081183f3 100644 --- a/website/content/docs/faq.md +++ b/website/content/docs/faq.md @@ -19,3 +19,8 @@ Some checks, particularly those in the `ST` (stylecheck) category, may not be ap [`checks` option]({{< relref "/docs/configuration/options#checks" >}}) in your [configuration]({{< relref "/docs/configuration/#configuration-files" >}}). {{% /faq/question %}} + +{{% faq/question id="go-version" question="Staticcheck's suggestions don't apply to my version of Go" %}} +You can [specify the version of Go your code should work with.]({{< relref "/docs/configuration/#targeting-go-versions" >}}) +{{% /faq/question %}} +{{% /faq/list %}} diff --git a/website/content/docs/running-staticcheck/cli/_index.md b/website/content/docs/running-staticcheck/cli/_index.md index 2a87ff3ca..a814e5688 100644 --- a/website/content/docs/running-staticcheck/cli/_index.md +++ b/website/content/docs/running-staticcheck/cli/_index.md @@ -50,6 +50,21 @@ See this [list of formatters]({{< relref "/docs/running-staticcheck/cli/formatte +## Targeting Go versions {#go} + +Some of Staticcheck's analyses adjust their behavior based on the targeted Go version. +For example, the suggestion that one use `for range xs` instead of `for _ = range xs` only applies to Go 1.4 and later, as it won't compile with versions of Go older than that. + +By default, Staticcheck targets the Go version declared in `go.mod` via the `go` directive. +For Go 1.21 and never, that directive specifies the minimum required version of Go. + +For older versions of Go, the directive technically specifies the maximum version of language features that the module +can use, which means it might be higher than the minimum required version. In those cases, you can manually overwrite +the targeted Go version by using the `-go` command line flag. For example, `staticcheck -go 1.0 ./...` will only make +suggestions that work with Go 1.0. + +The targeted Go version limits both language features and parts of the standard library that will be recommended. + ## Excluding tests {#tests} By default, Staticcheck analyses packages as well as their tests.