github.com/bazelbuild/bazel-gazelle@v0.36.1-0.20240520142334-61b277ba6fed/language/go/utils.go (about)

     1  /* Copyright 2022 The Bazel Authors. All rights reserved.
     2  
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7     http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package golang
    17  
    18  import (
    19  	"bytes"
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"go/build"
    24  	"log"
    25  	"os"
    26  	"os/exec"
    27  	"path/filepath"
    28  	"runtime"
    29  	"sort"
    30  	"strings"
    31  
    32  	"github.com/bazelbuild/bazel-gazelle/label"
    33  	"github.com/bazelbuild/bazel-gazelle/rule"
    34  )
    35  
    36  // goListModules invokes "go list" in a directory containing a go.mod file.
    37  var goListModules = func(dir string) ([]byte, error) {
    38  	return runGoCommandForOutput(dir, "list", "-mod=readonly", "-e", "-m", "-json", "all")
    39  }
    40  
    41  // goModDownload invokes "go mod download" in a directory containing a
    42  // go.mod file.
    43  var goModDownload = func(dir string, args []string) ([]byte, error) {
    44  	dlArgs := []string{"mod", "download", "-json"}
    45  	dlArgs = append(dlArgs, args...)
    46  	return runGoCommandForOutput(dir, dlArgs...)
    47  }
    48  
    49  // modulesFromList is an abstraction to preserve the output of `go list`.
    50  // The output schema is documented at https://go.dev/ref/mod#go-list-m
    51  type moduleFromList struct {
    52  	Path, Version, Sum string
    53  	Main               bool
    54  	Replace            *struct {
    55  		Path, Version string
    56  	}
    57  	Error *moduleError
    58  }
    59  
    60  type moduleError struct {
    61  	Err string
    62  }
    63  
    64  // moduleFromDownload is an abstraction to preserve the output of `go mod download`.
    65  // The output schema is documented at https://go.dev/ref/mod#go-mod-download
    66  type moduleFromDownload struct {
    67  	Path, Version, Sum string
    68  	Main               bool
    69  	Replace            *struct {
    70  		Path, Version string
    71  	}
    72  	Error string
    73  }
    74  
    75  // extractModules lists all modules except for the main module,
    76  // including implicit indirect dependencies.
    77  func extractModules(data []byte) (map[string]*moduleFromList, error) {
    78  	// path@version can be used as a unique identifier for looking up sums
    79  	pathToModule := map[string]*moduleFromList{}
    80  	dec := json.NewDecoder(bytes.NewReader(data))
    81  	for dec.More() {
    82  		mod := new(moduleFromList)
    83  		if err := dec.Decode(mod); err != nil {
    84  			return nil, err
    85  		}
    86  		if mod.Error != nil {
    87  			return nil, fmt.Errorf("error listing %s: %s", mod.Path, mod.Error.Err)
    88  		}
    89  		if mod.Main {
    90  			continue
    91  		}
    92  		if mod.Replace != nil {
    93  			if filepath.IsAbs(mod.Replace.Path) || build.IsLocalImport(mod.Replace.Path) {
    94  				log.Printf("go_repository does not support file path replacements for %s -> %s", mod.Path,
    95  					mod.Replace.Path)
    96  				continue
    97  			}
    98  			pathToModule[mod.Replace.Path+"@"+mod.Replace.Version] = mod
    99  		} else {
   100  			pathToModule[mod.Path+"@"+mod.Version] = mod
   101  		}
   102  	}
   103  	return pathToModule, nil
   104  }
   105  
   106  // fillMissingSums runs `go mod download` to get missing sums.
   107  // This must be done in a temporary directory because 'go mod download'
   108  // may modify go.mod and go.sum. It does not support -mod=readonly.
   109  func fillMissingSums(pathToModule map[string]*moduleFromList) (map[string]*moduleFromList, error) {
   110  	var missingSumArgs []string
   111  	for pathVer, mod := range pathToModule {
   112  		if mod.Sum == "" {
   113  			missingSumArgs = append(missingSumArgs, pathVer)
   114  		}
   115  	}
   116  
   117  	if len(missingSumArgs) > 0 {
   118  		tmpDir, err := os.MkdirTemp("", "")
   119  		if err != nil {
   120  			return nil, err
   121  		}
   122  		defer os.RemoveAll(tmpDir)
   123  		data, err := goModDownload(tmpDir, missingSumArgs)
   124  		dec := json.NewDecoder(bytes.NewReader(data))
   125  		if err != nil {
   126  			// Best-effort try to adorn specific error details from the JSON output.
   127  			for dec.More() {
   128  				var dl moduleFromDownload
   129  				if decodeErr := dec.Decode(&dl); decodeErr != nil {
   130  					// If we couldn't parse a possible error description, just return the raw error.
   131  					err = fmt.Errorf("%w\nError parsing module for more error information: %v", err, decodeErr)
   132  					break
   133  				}
   134  				if dl.Error != "" {
   135  					err = fmt.Errorf("%w\nError downloading %v: %v", err, dl.Path, dl.Error)
   136  				}
   137  			}
   138  			err = fmt.Errorf("error from go mod download: %w", err)
   139  
   140  			return nil, err
   141  		}
   142  		for dec.More() {
   143  			var dl moduleFromDownload
   144  			if err := dec.Decode(&dl); err != nil {
   145  				return nil, err
   146  			}
   147  			if mod, ok := pathToModule[dl.Path+"@"+dl.Version]; ok {
   148  				mod.Sum = dl.Sum
   149  			}
   150  		}
   151  	}
   152  
   153  	return pathToModule, nil
   154  }
   155  
   156  // toRepositoryRules transforms the input map into repository rules.
   157  func toRepositoryRules(pathToModule map[string]*moduleFromList) []*rule.Rule {
   158  	gen := make([]*rule.Rule, 0, len(pathToModule))
   159  	for pathVer, mod := range pathToModule {
   160  		if mod.Sum == "" {
   161  			log.Printf("could not determine sum for module %s", pathVer)
   162  			continue
   163  		}
   164  		r := rule.NewRule("go_repository", label.ImportPathToBazelRepoName(mod.Path))
   165  		r.SetAttr("importpath", mod.Path)
   166  		r.SetAttr("sum", mod.Sum)
   167  		if mod.Replace == nil {
   168  			r.SetAttr("version", mod.Version)
   169  		} else {
   170  			r.SetAttr("replace", mod.Replace.Path)
   171  			r.SetAttr("version", mod.Replace.Version)
   172  		}
   173  		gen = append(gen, r)
   174  	}
   175  	sort.Slice(gen, func(i, j int) bool {
   176  		return gen[i].Name() < gen[j].Name()
   177  	})
   178  
   179  	return gen
   180  }
   181  
   182  // processGoListError attempts a best-effort try to adorn specific error details from the JSON output of `go list`.
   183  func processGoListError(err error, data []byte) error {
   184  	dec := json.NewDecoder(bytes.NewReader(data))
   185  	for dec.More() {
   186  		var dl moduleFromList
   187  		if decodeErr := dec.Decode(&dl); decodeErr != nil {
   188  			// If we couldn't parse a possible error description, just return the raw error.
   189  			err = fmt.Errorf("%w\nError parsing module for more error information: %v", err, decodeErr)
   190  			break
   191  		}
   192  		if dl.Error != nil {
   193  			err = fmt.Errorf("%w\nError listing %v: %v", err, dl.Path, dl.Error.Err)
   194  		}
   195  	}
   196  	err = fmt.Errorf("error from go list: %w", err)
   197  
   198  	return err
   199  }
   200  
   201  // findGoTool attempts to locate the go executable. If GOROOT is set, we'll
   202  // prefer the one in there; otherwise, we'll rely on PATH. If the wrapper
   203  // script generated by the gazelle rule is invoked by Bazel, it will set
   204  // GOROOT to the configured SDK. We don't want to rely on the host SDK in
   205  // that situation.
   206  func findGoTool() string {
   207  	path := "go" // rely on PATH by default
   208  	if goroot, ok := os.LookupEnv("GOROOT"); ok {
   209  		path = filepath.Join(goroot, "bin", "go")
   210  	}
   211  	if runtime.GOOS == "windows" {
   212  		path += ".exe"
   213  	}
   214  	return path
   215  }
   216  
   217  func runGoCommandForOutput(dir string, args ...string) ([]byte, error) {
   218  	goTool := findGoTool()
   219  	env := os.Environ()
   220  	env = append(env, "GO111MODULE=on")
   221  	if os.Getenv("GOCACHE") == "" && os.Getenv("HOME") == "" {
   222  		gocache, err := os.MkdirTemp("", "")
   223  		if err != nil {
   224  			return nil, err
   225  		}
   226  		env = append(env, "GOCACHE="+gocache)
   227  		defer os.RemoveAll(gocache)
   228  	}
   229  	if os.Getenv("GOPATH") == "" && os.Getenv("HOME") == "" {
   230  		gopath, err := os.MkdirTemp("", "")
   231  		if err != nil {
   232  			return nil, err
   233  		}
   234  		env = append(env, "GOPATH="+gopath)
   235  		defer os.RemoveAll(gopath)
   236  	}
   237  	cmd := exec.Command(goTool, args...)
   238  	stderr := &bytes.Buffer{}
   239  	cmd.Stderr = stderr
   240  	cmd.Dir = dir
   241  	cmd.Env = env
   242  	out, err := cmd.Output()
   243  	if err != nil {
   244  		var errStr string
   245  		var xerr *exec.ExitError
   246  		if errors.As(err, &xerr) {
   247  			errStr = strings.TrimSpace(stderr.String())
   248  		} else {
   249  			errStr = err.Error()
   250  		}
   251  		return out, fmt.Errorf("running '%s %s': %s", cmd.Path, strings.Join(cmd.Args, " "), errStr)
   252  	}
   253  	return out, nil
   254  }