Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Go: Use toolchain directives for version selection if available, and add tests #16453

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 46 additions & 18 deletions go/extractor/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ type GoModule struct {
Module *modfile.File // The parsed contents of the `go.mod` file
}

// Tries to find the Go toolchain version required for this module.
func (module *GoModule) RequiredGoVersion() GoVersionInfo {
if module.Module != nil && module.Module.Toolchain != nil {
return VersionFound(toolchain.ToolchainVersionToSemVer(module.Module.Toolchain.Name))
}
if module.Module != nil && module.Module.Go != nil {
return VersionFound(toolchain.GoVersionToSemVer(module.Module.Go.Version))
} else {
return tryReadGoDirective(module.Path)
}
}

// Represents information about a Go project workspace: this may either be a folder containing
// a `go.work` file or a collection of `go.mod` files.
type GoWorkspace struct {
Expand All @@ -56,43 +68,59 @@ type GoVersionInfo struct {
Found bool
}

// Represents a `GoVersionInfo` indicating that no version was found.
var VersionNotFound GoVersionInfo = GoVersionInfo{"", false}

// Constructs a `GoVersionInfo` for a version we found.
func VersionFound(version string) GoVersionInfo {
// Add the "v" required by `semver` if there isn't one yet.
if !strings.HasPrefix(version, "v") {
version = "v" + version
}

return GoVersionInfo{
Version: version,
Found: true,
}
}

// Determines the version of Go that is required by this workspace. This is, in order of preference:
// 1. The Go version specified in the `go.work` file, if any.
// 2. The greatest Go version specified in any `go.mod` file, if any.
func (workspace *GoWorkspace) RequiredGoVersion() GoVersionInfo {
if workspace.WorkspaceFile != nil && workspace.WorkspaceFile.Go != nil {
// If we have parsed a `go.work` file, return the version number from it.
return GoVersionInfo{Version: workspace.WorkspaceFile.Go.Version, Found: true}
// If we have parsed a `go.work` file, we prioritise versions from it over those in individual `go.mod`
// files. We are interested in toolchain versions, so if there is an explicit toolchain declaration in
// a `go.work` file, we use that. Otherwise, we fall back to the language version in the `go.work` file
// and use that as toolchain version. If we didn't parse a `go.work` file, then we try to find the
// greatest version contained in `go.mod` files.
if workspace.WorkspaceFile != nil && workspace.WorkspaceFile.Toolchain != nil {
return VersionFound(toolchain.ToolchainVersionToSemVer(workspace.WorkspaceFile.Toolchain.Name))
} else if workspace.WorkspaceFile != nil && workspace.WorkspaceFile.Go != nil {
return VersionFound(toolchain.GoVersionToSemVer(workspace.WorkspaceFile.Go.Version))
} else if workspace.Modules != nil && len(workspace.Modules) > 0 {
// Otherwise, if we have `go.work` files, find the greatest Go version in those.
var greatestVersion string = ""
for _, module := range workspace.Modules {
if module.Module != nil && module.Module.Go != nil {
// If we have parsed the file, retrieve the version number we have already obtained.
if greatestVersion == "" || semver.Compare("v"+module.Module.Go.Version, "v"+greatestVersion) > 0 {
greatestVersion = module.Module.Go.Version
}
} else {
modVersion := tryReadGoDirective(module.Path)
if modVersion.Found && (greatestVersion == "" || semver.Compare("v"+modVersion.Version, "v"+greatestVersion) > 0) {
greatestVersion = modVersion.Version
}
moduleVersionInfo := module.RequiredGoVersion()

if greatestVersion == "" || semver.Compare(moduleVersionInfo.Version, greatestVersion) > 0 {
greatestVersion = moduleVersionInfo.Version
}
}

// If we have found some version, return it.
if greatestVersion != "" {
return GoVersionInfo{Version: greatestVersion, Found: true}
return VersionFound(greatestVersion)
}
}

return GoVersionInfo{Version: "", Found: false}
return VersionNotFound
}

// Finds the greatest Go version required by any of the given `workspaces`.
// Returns a `GoVersionInfo` value with `Found: false` if no version information is available.
func RequiredGoVersion(workspaces *[]GoWorkspace) GoVersionInfo {
greatestGoVersion := GoVersionInfo{Version: "", Found: false}
greatestGoVersion := VersionNotFound
for _, workspace := range *workspaces {
goVersionInfo := workspace.RequiredGoVersion()
if goVersionInfo.Found && (!greatestGoVersion.Found || semver.Compare("v"+goVersionInfo.Version, "v"+greatestGoVersion.Version) > 0) {
Expand Down Expand Up @@ -583,9 +611,9 @@ func tryReadGoDirective(path string) GoVersionInfo {
matches := versionRe.FindSubmatch(goMod)
if matches != nil {
if len(matches) > 1 {
return GoVersionInfo{string(matches[1]), true}
return VersionFound(toolchain.GoVersionToSemVer(string(matches[1])))
}
}
}
return GoVersionInfo{"", false}
return VersionNotFound
}
81 changes: 78 additions & 3 deletions go/extractor/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@ func TestStartsWithAnyOf(t *testing.T) {
testStartsWithAnyOf(t, filepath.Join("foo", "bar"), filepath.Join("foo", "baz"), false)
}

func testHasInvalidToolchainVersion(t *testing.T, contents string) bool {
modFile, err := modfile.Parse("test.go", []byte(contents), nil)
func parseModFile(t *testing.T, contents string) *modfile.File {
modFile, err := modfile.Parse("go.mod", []byte(contents), nil)

if err != nil {
t.Errorf("Unable to parse %s: %s.\n", contents, err.Error())
}

return hasInvalidToolchainVersion(modFile)
return modFile
}

func testHasInvalidToolchainVersion(t *testing.T, contents string) bool {
return hasInvalidToolchainVersion(parseModFile(t, contents))
}

func TestHasInvalidToolchainVersion(t *testing.T) {
Expand All @@ -62,3 +66,74 @@ func TestHasInvalidToolchainVersion(t *testing.T) {
}
}
}

func parseWorkFile(t *testing.T, contents string) *modfile.WorkFile {
workFile, err := modfile.ParseWork("go.work", []byte(contents), nil)

if err != nil {
t.Errorf("Unable to parse %s: %s.\n", contents, err.Error())
}

return workFile
}

func TestRequiredGoVersion(t *testing.T) {
type ModVersionPair struct {
FileContents string
ExpectedVersion string
}

modules := []ModVersionPair{
{"go 1.20", "v1.20.0"},
{"go 1.21.2", "v1.21.2"},
{"go 1.21rc1", "v1.21.0-rc1"},
{"go 1.21rc1\ntoolchain go1.22.0", "v1.22.0"},
{"go 1.21rc1\ntoolchain go1.22rc1", "v1.22.0-rc1"},
}

for _, testData := range modules {
// `go.mod` and `go.work` files have mostly the same format
modFile := parseModFile(t, testData.FileContents)
workFile := parseWorkFile(t, testData.FileContents)
mod := GoModule{
Path: "test", // irrelevant
Module: modFile,
}
work := GoWorkspace{
WorkspaceFile: workFile,
}

result := mod.RequiredGoVersion()
if !result.Found {
t.Errorf(
"Expected mod.RequiredGoVersion() to return %s for the below `go.mod` file, but got nothing:\n%s",
testData.ExpectedVersion,
testData.FileContents,
)
} else if result.Version != testData.ExpectedVersion {
t.Errorf(
"Expected mod.RequiredGoVersion() to return %s for the below `go.mod` file, but got %s:\n%s",
testData.ExpectedVersion,
result.Version,
testData.FileContents,
)
}

result = work.RequiredGoVersion()
if !result.Found {
t.Errorf(
"Expected mod.RequiredGoVersion() to return %s for the below `go.work` file, but got nothing:\n%s",
testData.ExpectedVersion,
testData.FileContents,
)
} else if result.Version != testData.ExpectedVersion {
t.Errorf(
"Expected mod.RequiredGoVersion() to return %s for the below `go.work` file, but got %s:\n%s",
testData.ExpectedVersion,
result.Version,
testData.FileContents,
)
}
}

}
28 changes: 19 additions & 9 deletions go/extractor/toolchain/toolchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,31 @@ func InstallVersion(workingDir string, version string) bool {
return true
}

// Returns the current Go version in semver format, e.g. v1.14.4
func GetEnvGoSemVer() string {
goVersion := GetEnvGoVersion()
if !strings.HasPrefix(goVersion, "go") {
log.Fatalf("Expected 'go version' output of the form 'go1.2.3'; got '%s'", goVersion)
}
// Converts a Go version to a semantic version.
mbg marked this conversation as resolved.
Show resolved Hide resolved
func GoVersionToSemVer(goVersion string) string {
// Go versions don't follow the SemVer format, but the only exception we normally care about
// is release candidates; so this is a horrible hack to convert e.g. `go1.22rc1` into `go1.22-rc1`
// is release candidates; so this is a horrible hack to convert e.g. `1.22rc1` into `1.22-rc1`
// which is compatible with the SemVer specification
rcIndex := strings.Index(goVersion, "rc")
if rcIndex != -1 {
return semver.Canonical("v"+goVersion[2:rcIndex]) + "-" + goVersion[rcIndex:]
return semver.Canonical("v"+goVersion[:rcIndex]) + "-" + goVersion[rcIndex:]
} else {
return semver.Canonical("v" + goVersion[2:])
return semver.Canonical("v" + goVersion)
}
}

// Converts a Go toolchain version of the form "go1.2.3" to a semantic version.
mbg marked this conversation as resolved.
Show resolved Hide resolved
func ToolchainVersionToSemVer(toolchainVersion string) string {
if !strings.HasPrefix(toolchainVersion, "go") {
log.Fatalf("Expected Go toolchain version of the form 'go1.2.3'; got '%s'", toolchainVersion)
}

return GoVersionToSemVer(toolchainVersion[2:])
}

// Returns the current Go version in semver format, e.g. v1.14.4
func GetEnvGoSemVer() string {
return ToolchainVersionToSemVer(GetEnvGoVersion())
}

// The 'go version' command may output warnings on separate lines before
Expand Down
26 changes: 26 additions & 0 deletions go/extractor/toolchain/toolchain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,29 @@ func TestHasGoVersion(t *testing.T) {
t.Error("Expected HasGoVersion(\"1.21\") to be false, but got true")
}
}

func testGoVersionToSemVer(t *testing.T, goVersion string, expectedSemVer string) {
result := GoVersionToSemVer(goVersion)
if result != expectedSemVer {
t.Errorf("Expected GoVersionToSemVer(\"%s\") to be %s, but got %s.", goVersion, expectedSemVer, result)
}
}

func TestGoVersionToSemVer(t *testing.T) {
testGoVersionToSemVer(t, "1.20", "v1.20.0")
testGoVersionToSemVer(t, "1.20.1", "v1.20.1")
testGoVersionToSemVer(t, "1.20rc1", "v1.20.0-rc1")
}

func testToolchainVersionToSemVer(t *testing.T, toolchainVersion string, expectedSemVer string) {
result := ToolchainVersionToSemVer(toolchainVersion)
if result != expectedSemVer {
t.Errorf("Expected ToolchainVersionToSemVer(\"%s\") to be %s, but got %s.", toolchainVersion, expectedSemVer, result)
}
}

func TestToolchainVersionToSemVer(t *testing.T) {
testToolchainVersionToSemVer(t, "go1.20", "v1.20.0")
testToolchainVersionToSemVer(t, "go1.20.1", "v1.20.1")
testToolchainVersionToSemVer(t, "go1.20rc1", "v1.20.0-rc1")
}