/* Copyright 2022 The Bazel Authors. All rights reserved. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package golang import ( "bytes" "encoding/json" "errors" "fmt" "go/build" "log" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "github.com/bazelbuild/bazel-gazelle/label" "github.com/bazelbuild/bazel-gazelle/rule" ) // goListModules invokes "go list" in a directory containing a go.mod file. var goListModules = func(dir string) ([]byte, error) { return runGoCommandForOutput(dir, "list", "-mod=readonly", "-e", "-m", "-json", "all") } // goModDownload invokes "go mod download" in a directory containing a // go.mod file. var goModDownload = func(dir string, args []string) ([]byte, error) { dlArgs := []string{"mod", "download", "-json"} dlArgs = append(dlArgs, args...) return runGoCommandForOutput(dir, dlArgs...) } // modulesFromList is an abstraction to preserve the output of `go list`. // The output schema is documented at https://go.dev/ref/mod#go-list-m type moduleFromList struct { Path, Version, Sum string Main bool Replace *struct { Path, Version string } Error *moduleError } type moduleError struct { Err string } // moduleFromDownload is an abstraction to preserve the output of `go mod download`. // The output schema is documented at https://go.dev/ref/mod#go-mod-download type moduleFromDownload struct { Path, Version, Sum string Main bool Replace *struct { Path, Version string } Error string } // extractModules lists all modules except for the main module, // including implicit indirect dependencies. func extractModules(data []byte) (map[string]*moduleFromList, error) { // path@version can be used as a unique identifier for looking up sums pathToModule := map[string]*moduleFromList{} dec := json.NewDecoder(bytes.NewReader(data)) for dec.More() { mod := new(moduleFromList) if err := dec.Decode(mod); err != nil { return nil, err } if mod.Error != nil { return nil, fmt.Errorf("error listing %s: %s", mod.Path, mod.Error.Err) } if mod.Main { continue } if mod.Replace != nil { if filepath.IsAbs(mod.Replace.Path) || build.IsLocalImport(mod.Replace.Path) { log.Printf("go_repository does not support file path replacements for %s -> %s", mod.Path, mod.Replace.Path) continue } pathToModule[mod.Replace.Path+"@"+mod.Replace.Version] = mod } else { pathToModule[mod.Path+"@"+mod.Version] = mod } } return pathToModule, nil } // fillMissingSums runs `go mod download` to get missing sums. // This must be done in a temporary directory because 'go mod download' // may modify go.mod and go.sum. It does not support -mod=readonly. func fillMissingSums(pathToModule map[string]*moduleFromList) (map[string]*moduleFromList, error) { var missingSumArgs []string for pathVer, mod := range pathToModule { if mod.Sum == "" { missingSumArgs = append(missingSumArgs, pathVer) } } if len(missingSumArgs) > 0 { tmpDir, err := os.MkdirTemp("", "") if err != nil { return nil, err } defer os.RemoveAll(tmpDir) data, err := goModDownload(tmpDir, missingSumArgs) dec := json.NewDecoder(bytes.NewReader(data)) if err != nil { // Best-effort try to adorn specific error details from the JSON output. for dec.More() { var dl moduleFromDownload if decodeErr := dec.Decode(&dl); decodeErr != nil { // If we couldn't parse a possible error description, just return the raw error. err = fmt.Errorf("%w\nError parsing module for more error information: %v", err, decodeErr) break } if dl.Error != "" { err = fmt.Errorf("%w\nError downloading %v: %v", err, dl.Path, dl.Error) } } err = fmt.Errorf("error from go mod download: %w", err) return nil, err } for dec.More() { var dl moduleFromDownload if err := dec.Decode(&dl); err != nil { return nil, err } if mod, ok := pathToModule[dl.Path+"@"+dl.Version]; ok { mod.Sum = dl.Sum } } } return pathToModule, nil } // toRepositoryRules transforms the input map into repository rules. func toRepositoryRules(pathToModule map[string]*moduleFromList) []*rule.Rule { gen := make([]*rule.Rule, 0, len(pathToModule)) for pathVer, mod := range pathToModule { if mod.Sum == "" { log.Printf("could not determine sum for module %s", pathVer) continue } r := rule.NewRule("go_repository", label.ImportPathToBazelRepoName(mod.Path)) r.SetAttr("importpath", mod.Path) r.SetAttr("sum", mod.Sum) if mod.Replace == nil { r.SetAttr("version", mod.Version) } else { r.SetAttr("replace", mod.Replace.Path) r.SetAttr("version", mod.Replace.Version) } gen = append(gen, r) } sort.Slice(gen, func(i, j int) bool { return gen[i].Name() < gen[j].Name() }) return gen } // processGoListError attempts a best-effort try to adorn specific error details from the JSON output of `go list`. func processGoListError(err error, data []byte) error { dec := json.NewDecoder(bytes.NewReader(data)) for dec.More() { var dl moduleFromList if decodeErr := dec.Decode(&dl); decodeErr != nil { // If we couldn't parse a possible error description, just return the raw error. err = fmt.Errorf("%w\nError parsing module for more error information: %v", err, decodeErr) break } if dl.Error != nil { err = fmt.Errorf("%w\nError listing %v: %v", err, dl.Path, dl.Error.Err) } } err = fmt.Errorf("error from go list: %w", err) return err } // findGoTool attempts to locate the go executable. If GOROOT is set, we'll // prefer the one in there; otherwise, we'll rely on PATH. If the wrapper // script generated by the gazelle rule is invoked by Bazel, it will set // GOROOT to the configured SDK. We don't want to rely on the host SDK in // that situation. func findGoTool() string { path := "go" // rely on PATH by default if goroot, ok := os.LookupEnv("GOROOT"); ok { path = filepath.Join(goroot, "bin", "go") } if runtime.GOOS == "windows" { path += ".exe" } return path } func runGoCommandForOutput(dir string, args ...string) ([]byte, error) { goTool := findGoTool() env := os.Environ() env = append(env, "GO111MODULE=on") if os.Getenv("GOCACHE") == "" && os.Getenv("HOME") == "" { gocache, err := os.MkdirTemp("", "") if err != nil { return nil, err } env = append(env, "GOCACHE="+gocache) defer os.RemoveAll(gocache) } if os.Getenv("GOPATH") == "" && os.Getenv("HOME") == "" { gopath, err := os.MkdirTemp("", "") if err != nil { return nil, err } env = append(env, "GOPATH="+gopath) defer os.RemoveAll(gopath) } cmd := exec.Command(goTool, args...) stderr := &bytes.Buffer{} cmd.Stderr = stderr cmd.Dir = dir cmd.Env = env out, err := cmd.Output() if err != nil { var errStr string var xerr *exec.ExitError if errors.As(err, &xerr) { errStr = strings.TrimSpace(stderr.String()) } else { errStr = err.Error() } return out, fmt.Errorf("running '%s %s': %s", cmd.Path, strings.Join(cmd.Args, " "), errStr) } return out, nil }