Skip to content

Commit

Permalink
Overhaul version handling
Browse files Browse the repository at this point in the history
Go 1.21 changed the meaning of the "go" statement in go.mod files to
enforce a minimum toolchain version, as opposed to a maximum language
version. This means we no longer need the '-go' CLI flag to be able to
specify the minimum targeted Go version.

Furthermore, go/types.Config gained a GoVersion field and its value
gets propagated to go/types.Package.GoVersion, which means that we no
longer need per-analyzer "go" flags to access the targeted Go version.

Go 1.22 expanded go/types to report per-file versions (as
//go:build lines in individual files can downgrade and upgrade to
different Go versions), which means that we no longer need to parse
these lines manually.

Go 1.22 also added the go/version package to allow comparing
string-based Go versions. This means we no longer need to parse strings
to turn them into integers.

We update our tests to be grouped by Go versions, to ensure that checks
work with the intended minimum versions of Go and to make it easier to
test version-specific behavior. Each tested Go version for a check is
treated as its own module. To avoid checking in actual go.mod files, we
use go/packages.Config.Overlay to synthesize them. This requires
updating to the latest commit of x/tools which includes a fix for
synthesizing go.mod files.

Closes: gh-105
Closes: gh-1464
Closes: gh-1463
  • Loading branch information
dominikh committed Jun 1, 2024
1 parent 80d98d7 commit bc9aaa8
Show file tree
Hide file tree
Showing 378 changed files with 562 additions and 729 deletions.
113 changes: 44 additions & 69 deletions analysis/code/code.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@
package code

import (
"flag"
"fmt"
"go/ast"
"go/build/constraint"
"go/constant"
"go/token"
"go/types"
"go/version"
"path/filepath"
"strconv"
"strings"

"honnef.co/go/tools/analysis/facts/generated"
"honnef.co/go/tools/analysis/facts/purity"
"honnef.co/go/tools/analysis/facts/tokenfile"
"honnef.co/go/tools/analysis/lint"
"honnef.co/go/tools/go/ast/astutil"
"honnef.co/go/tools/go/types/typeutil"
"honnef.co/go/tools/knowledge"
Expand All @@ -35,7 +33,7 @@ func IsOfStringConvertibleByteSlice(pass *analysis.Pass, expr ast.Expr) bool {
return false
}
elem := types.Unalias(typ.Elem())
if LanguageVersion(pass, expr) >= 18 {
if version.Compare(LanguageVersion(pass, expr), "go1.18") >= 0 {
// Before Go 1.18, one could not directly convert from []T (where 'type T byte')
// to string. See also https://github.com/golang/go/issues/23536.
elem = elem.Underlying()
Expand Down Expand Up @@ -436,7 +434,9 @@ func MayHaveSideEffects(pass *analysis.Pass, expr ast.Expr, purity purity.Result
}
}

func LanguageVersion(pass *analysis.Pass, node Positioner) int {
// LanguageVersion returns the version of the Go language that node has access to. This
// might differ from the version of the Go standard library.
func LanguageVersion(pass *analysis.Pass, node Positioner) string {
// As of Go 1.21, two places can specify the minimum Go version:
// - 'go' directives in go.mod and go.work files
// - individual files by using '//go:build'
Expand Down Expand Up @@ -478,89 +478,64 @@ func LanguageVersion(pass *analysis.Pass, node Positioner) int {
// relevant language changes before Go 1.22 will lead to type-checking failures and never reach
// us.
//
// It is not clear if per-file upgrading is possible in GOPATH mode. This needs clarification.
// Per-file upgrading is permitted in GOPATH mode.

f := File(pass, node)
var n int
if v := f.GoVersion; v != "" {
var ok bool
n, ok = lint.ParseGoVersion(v)
if !ok {
panic(fmt.Sprintf("unexpected failure parsing version %q", v))
}
} else if v := pass.Pkg.GoVersion(); v != "" {
var ok bool
n, ok = lint.ParseGoVersion(v)
if !ok {
panic(fmt.Sprintf("unexpected failure parsing version %q", v))
}
} else {
v, ok := pass.Analyzer.Flags.Lookup("go").Value.(flag.Getter)
if !ok {
panic("requested Go version, but analyzer has no version flag")
}
n = v.Get().(int)
}

return n
// If the file has its own Go version, we will return that. Otherwise, we default to
// the type checker's GoVersion, which is populated from either the Go module, or from
// our '-go' flag.
return pass.TypesInfo.FileVersions[File(pass, node)]
}

func StdlibVersion(pass *analysis.Pass, node Positioner) int {
var n int
if v := pass.Pkg.GoVersion(); v != "" {
var ok bool
n, ok = lint.ParseGoVersion(v)
if !ok {
panic(fmt.Sprintf("unexpected failure parsing version %q", v))
}
} else {
v, ok := pass.Analyzer.Flags.Lookup("go").Value.(flag.Getter)
if !ok {
panic("requested Go version, but analyzer has no version flag")
}
n = v.Get().(int)
}
// StdlibVersion returns the version of the Go standard library that node can expect to
// have access to. This might differ from the language version for versions of Go older
// than 1.21.
func StdlibVersion(pass *analysis.Pass, node Positioner) string {
// The Go version as specified in go.mod or via the '-go' flag
n := pass.Pkg.GoVersion()

f := File(pass, node)
if f == nil {
panic(fmt.Sprintf("no file found for node with position %s", pass.Fset.PositionFor(node.Pos(), false)))
}

if v := f.GoVersion; v != "" {
nf, err := strconv.Atoi(strings.TrimPrefix(v, "go1."))
if err != nil {
panic(fmt.Sprintf("unexpected error: %s", err))
}

if n < 21 {
// Before Go 1.21, the Go version set in go.mod specified the maximum language version
// available to the module. It wasn't uncommon to set the version to Go 1.20 but only
// use 1.20 functionality (both language and stdlib) in files tagged for 1.20, and
// supporting a lower version overall. As such, a file tagged lower than the module
// version couldn't expect to have access to the standard library of the version set in
// go.mod.
if nf := f.GoVersion; nf != "" {
if version.Compare(n, "go1.21") == -1 {
// Before Go 1.21, the Go version set in go.mod specified the maximum language
// version available to the module. It wasn't uncommon to set the version to
// Go 1.20 but restrict usage of 1.20 functionality (both language and stdlib)
// to files tagged for 1.20, and supporting a lower version overall. As such,
// a file tagged lower than the module version couldn't expect to have access
// to the standard library of the version set in go.mod.
//
// At the same time, a file tagged higher than the module version, while not
// able to use newer language features, would still have been able to use a
// newer standard library.
//
// While Go 1.21's behavior has been backported to 1.19.11 and 1.20.6, users'
// expectations have not.
n = nf
return nf
} else {
// Go 1.21 and newer refuse to build modules that depend on versions newer than the Go
// version. This means that in a 1.22 module with a file tagged as 1.17, the file can
// expect to have access to 1.22's standard library.
// Go 1.21 and newer refuse to build modules that depend on versions newer
// than the used version of the Go toolchain. This means that in a 1.22 module
// with a file tagged as 1.17, the file can expect to have access to 1.22's
// standard library (but not to 1.22 language features). A file tagged with a
// version higher than the minimum version has access to the newer standard
// library (and language features.)
//
// Do note that strictly speaking we're conflating the Go version and the module version in
// our check. Nothing is stopping a user from using Go 1.17 to build a Go 1.22 module, in
// which case the 1.17 file will not have acces to the 1.22 standard library. However, we
// believe that if a module requires 1.21 or newer, then the author clearly expects the new
// behavior, and doesn't care for the old one. Otherwise they would've specified an older
// version.
// Do note that strictly speaking we're conflating the Go version and the
// module version in our check. Nothing is stopping a user from using Go 1.17
// (which didn't implement the new rules for versions in go.mod) to build a Go
// 1.22 module, in which case a file tagged with go1.17 will not have acces to the 1.22
// standard library. However, we believe that if a module requires 1.21 or
// newer, then the author clearly expects the new behavior, and doesn't care
// for the old one. Otherwise they would've specified an older version.
//
// In other words, the module version also specifies what it itself actually means, with
// >=1.21 being a minimum version for the toolchain, and <1.21 being a maximum version for
// the language.

if nf > n {
n = nf
if version.Compare(nf, n) == 1 {
return nf
}
}
}
Expand Down
59 changes: 0 additions & 59 deletions analysis/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@
package lint

import (
"flag"
"fmt"
"go/ast"
"go/build"
"go/token"
"regexp"
"strconv"
"strings"

"golang.org/x/tools/go/analysis"
Expand All @@ -27,22 +23,12 @@ type Analyzer struct {

func (a *Analyzer) initialize() {
a.Analyzer.Doc = a.Doc.String()
if a.Analyzer.Flags.Usage == nil {
fs := flag.NewFlagSet("", flag.PanicOnError)
fs.Var(newVersionFlag(), "go", "Target Go version")
a.Analyzer.Flags = *fs
}
a.Analyzer.Requires = append(a.Analyzer.Requires, tokenfile.Analyzer)
}

func InitializeAnalyzer(a *Analyzer) *Analyzer {
a.Analyzer.Doc = a.Doc.String()
a.Analyzer.URL = "https://staticcheck.dev/docs/checks/#" + a.Analyzer.Name
if a.Analyzer.Flags.Usage == nil {
fs := flag.NewFlagSet("", flag.PanicOnError)
fs.Var(newVersionFlag(), "go", "Target Go version")
a.Analyzer.Flags = *fs
}
a.Analyzer.Requires = append(a.Analyzer.Requires, tokenfile.Analyzer)
return a
}
Expand Down Expand Up @@ -206,51 +192,6 @@ func (doc *Documentation) String() string {
return doc.Format(true)
}

func newVersionFlag() flag.Getter {
tags := build.Default.ReleaseTags
v := tags[len(tags)-1][2:]
version := new(VersionFlag)
if err := version.Set(v); err != nil {
panic(fmt.Sprintf("internal error: %s", err))
}
return version
}

type VersionFlag int

func (v *VersionFlag) String() string {
return fmt.Sprintf("1.%d", *v)
}

var goVersionRE = regexp.MustCompile(`^(?:go)?1.(\d+).*$`)

// ParseGoVersion parses Go versions of the form 1.M, 1.M.N, or 1.M.NrcR, with an optional "go" prefix. It assumes that
// versions have already been verified and are valid.
func ParseGoVersion(s string) (int, bool) {
m := goVersionRE.FindStringSubmatch(s)
if m == nil {
return 0, false
}
n, err := strconv.Atoi(m[1])
if err != nil {
return 0, false
}
return n, true
}

func (v *VersionFlag) Set(s string) error {
n, ok := ParseGoVersion(s)
if !ok {
return fmt.Errorf("invalid Go version: %q", s)
}
*v = VersionFlag(n)
return nil
}

func (v *VersionFlag) Get() interface{} {
return int(*v)
}

// ExhaustiveTypeSwitch panics when called. It can be used to ensure
// that type switches are exhaustive.
func ExhaustiveTypeSwitch(v interface{}) {
Expand Down
Loading

0 comments on commit bc9aaa8

Please sign in to comment.