github.com/vanstinator/golangci-lint@v0.0.0-20240223191551-cc572f00d9d1/pkg/lint/load.go (about)

     1  package lint
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"go/build"
     7  	"go/token"
     8  	"os"
     9  	"path/filepath"
    10  	"regexp"
    11  	"strings"
    12  	"time"
    13  
    14  	"golang.org/x/tools/go/packages"
    15  
    16  	"github.com/vanstinator/golangci-lint/internal/pkgcache"
    17  	"github.com/vanstinator/golangci-lint/pkg/config"
    18  	"github.com/vanstinator/golangci-lint/pkg/exitcodes"
    19  	"github.com/vanstinator/golangci-lint/pkg/fsutils"
    20  	"github.com/vanstinator/golangci-lint/pkg/golinters/goanalysis/load"
    21  	"github.com/vanstinator/golangci-lint/pkg/goutil"
    22  	"github.com/vanstinator/golangci-lint/pkg/lint/linter"
    23  	"github.com/vanstinator/golangci-lint/pkg/logutils"
    24  )
    25  
    26  type ContextLoader struct {
    27  	cfg         *config.Config
    28  	log         logutils.Log
    29  	debugf      logutils.DebugFunc
    30  	goenv       *goutil.Env
    31  	pkgTestIDRe *regexp.Regexp
    32  	lineCache   *fsutils.LineCache
    33  	fileCache   *fsutils.FileCache
    34  	pkgCache    *pkgcache.Cache
    35  	loadGuard   *load.Guard
    36  }
    37  
    38  func NewContextLoader(cfg *config.Config, log logutils.Log, goenv *goutil.Env,
    39  	lineCache *fsutils.LineCache, fileCache *fsutils.FileCache, pkgCache *pkgcache.Cache, loadGuard *load.Guard) *ContextLoader {
    40  	return &ContextLoader{
    41  		cfg:         cfg,
    42  		log:         log,
    43  		debugf:      logutils.Debug(logutils.DebugKeyLoader),
    44  		goenv:       goenv,
    45  		pkgTestIDRe: regexp.MustCompile(`^(.*) \[(.*)\.test\]`),
    46  		lineCache:   lineCache,
    47  		fileCache:   fileCache,
    48  		pkgCache:    pkgCache,
    49  		loadGuard:   loadGuard,
    50  	}
    51  }
    52  
    53  func (cl *ContextLoader) prepareBuildContext() {
    54  	// Set GOROOT to have working cross-compilation: cross-compiled binaries
    55  	// have invalid GOROOT. XXX: can't use runtime.GOROOT().
    56  	goroot := cl.goenv.Get(goutil.EnvGoRoot)
    57  	if goroot == "" {
    58  		return
    59  	}
    60  
    61  	os.Setenv(string(goutil.EnvGoRoot), goroot)
    62  	build.Default.GOROOT = goroot
    63  	build.Default.BuildTags = cl.cfg.Run.BuildTags
    64  }
    65  
    66  func (cl *ContextLoader) findLoadMode(linters []*linter.Config) packages.LoadMode {
    67  	loadMode := packages.LoadMode(0)
    68  	for _, lc := range linters {
    69  		loadMode |= lc.LoadMode
    70  	}
    71  
    72  	return loadMode
    73  }
    74  
    75  func (cl *ContextLoader) buildArgs() []string {
    76  	args := cl.cfg.Run.Args
    77  	if len(args) == 0 {
    78  		return []string{"./..."}
    79  	}
    80  
    81  	var retArgs []string
    82  	for _, arg := range args {
    83  		if strings.HasPrefix(arg, ".") || filepath.IsAbs(arg) {
    84  			retArgs = append(retArgs, arg)
    85  		} else {
    86  			// go/packages doesn't work well if we don't have the prefix ./ for local packages
    87  			retArgs = append(retArgs, fmt.Sprintf(".%c%s", filepath.Separator, arg))
    88  		}
    89  	}
    90  
    91  	return retArgs
    92  }
    93  
    94  func (cl *ContextLoader) makeBuildFlags() ([]string, error) {
    95  	var buildFlags []string
    96  
    97  	if len(cl.cfg.Run.BuildTags) != 0 {
    98  		// go help build
    99  		buildFlags = append(buildFlags, "-tags", strings.Join(cl.cfg.Run.BuildTags, " "))
   100  		cl.log.Infof("Using build tags: %v", cl.cfg.Run.BuildTags)
   101  	}
   102  
   103  	mod := cl.cfg.Run.ModulesDownloadMode
   104  	if mod != "" {
   105  		// go help modules
   106  		allowedMods := []string{"mod", "readonly", "vendor"}
   107  		var ok bool
   108  		for _, am := range allowedMods {
   109  			if am == mod {
   110  				ok = true
   111  				break
   112  			}
   113  		}
   114  		if !ok {
   115  			return nil, fmt.Errorf("invalid modules download path %s, only (%s) allowed", mod, strings.Join(allowedMods, "|"))
   116  		}
   117  
   118  		buildFlags = append(buildFlags, fmt.Sprintf("-mod=%s", cl.cfg.Run.ModulesDownloadMode))
   119  	}
   120  
   121  	return buildFlags, nil
   122  }
   123  
   124  func stringifyLoadMode(mode packages.LoadMode) string {
   125  	m := map[packages.LoadMode]string{
   126  		packages.NeedCompiledGoFiles: "compiled_files",
   127  		packages.NeedDeps:            "deps",
   128  		packages.NeedExportFile:      "exports_file",
   129  		packages.NeedFiles:           "files",
   130  		packages.NeedImports:         "imports",
   131  		packages.NeedName:            "name",
   132  		packages.NeedSyntax:          "syntax",
   133  		packages.NeedTypes:           "types",
   134  		packages.NeedTypesInfo:       "types_info",
   135  		packages.NeedTypesSizes:      "types_sizes",
   136  	}
   137  
   138  	var flags []string
   139  	for flag, flagStr := range m {
   140  		if mode&flag != 0 {
   141  			flags = append(flags, flagStr)
   142  		}
   143  	}
   144  
   145  	return fmt.Sprintf("%d (%s)", mode, strings.Join(flags, "|"))
   146  }
   147  
   148  func (cl *ContextLoader) debugPrintLoadedPackages(pkgs []*packages.Package) {
   149  	cl.debugf("loaded %d pkgs", len(pkgs))
   150  	for i, pkg := range pkgs {
   151  		var syntaxFiles []string
   152  		for _, sf := range pkg.Syntax {
   153  			syntaxFiles = append(syntaxFiles, pkg.Fset.Position(sf.Pos()).Filename)
   154  		}
   155  		cl.debugf("Loaded pkg #%d: ID=%s GoFiles=%s CompiledGoFiles=%s Syntax=%s",
   156  			i, pkg.ID, pkg.GoFiles, pkg.CompiledGoFiles, syntaxFiles)
   157  	}
   158  }
   159  
   160  func (cl *ContextLoader) parseLoadedPackagesErrors(pkgs []*packages.Package) error {
   161  	for _, pkg := range pkgs {
   162  		var errs []packages.Error
   163  		for _, err := range pkg.Errors {
   164  			// quick fix: skip error related to `go list` invocation by packages.Load()
   165  			// The behavior has been changed between go1.19 and go1.20, the error is now inside the JSON content.
   166  			// https://github.com/vanstinator/golangci-lint/pull/3414#issuecomment-1364756303
   167  			if strings.Contains(err.Msg, "# command-line-arguments") {
   168  				continue
   169  			}
   170  
   171  			errs = append(errs, err)
   172  
   173  			if strings.Contains(err.Msg, "no Go files") {
   174  				return fmt.Errorf("package %s: %w", pkg.PkgPath, exitcodes.ErrNoGoFiles)
   175  			}
   176  			if strings.Contains(err.Msg, "cannot find package") {
   177  				// when analyzing not existing directory
   178  				return fmt.Errorf("%v: %w", err.Msg, exitcodes.ErrFailure)
   179  			}
   180  		}
   181  
   182  		pkg.Errors = errs
   183  	}
   184  
   185  	return nil
   186  }
   187  
   188  func (cl *ContextLoader) loadPackages(ctx context.Context, loadMode packages.LoadMode) ([]*packages.Package, error) {
   189  	defer func(startedAt time.Time) {
   190  		cl.log.Infof("Go packages loading at mode %s took %s", stringifyLoadMode(loadMode), time.Since(startedAt))
   191  	}(time.Now())
   192  
   193  	cl.prepareBuildContext()
   194  
   195  	buildFlags, err := cl.makeBuildFlags()
   196  	if err != nil {
   197  		return nil, fmt.Errorf("failed to make build flags for go list: %w", err)
   198  	}
   199  
   200  	conf := &packages.Config{
   201  		Mode:       loadMode,
   202  		Tests:      cl.cfg.Run.AnalyzeTests,
   203  		Context:    ctx,
   204  		BuildFlags: buildFlags,
   205  		Logf:       cl.debugf,
   206  		// TODO: use fset, parsefile, overlay
   207  	}
   208  
   209  	args := cl.buildArgs()
   210  	cl.debugf("Built loader args are %s", args)
   211  	pkgs, err := packages.Load(conf, args...)
   212  	if err != nil {
   213  		return nil, fmt.Errorf("failed to load with go/packages: %w", err)
   214  	}
   215  
   216  	// Currently, go/packages doesn't guarantee that error will be returned
   217  	// if context was canceled. See
   218  	// https://github.com/golang/tools/commit/c5cec6710e927457c3c29d6c156415e8539a5111#r39261855
   219  	if ctx.Err() != nil {
   220  		return nil, fmt.Errorf("timed out to load packages: %w", ctx.Err())
   221  	}
   222  
   223  	if loadMode&packages.NeedSyntax == 0 {
   224  		// Needed e.g. for go/analysis loading.
   225  		fset := token.NewFileSet()
   226  		packages.Visit(pkgs, nil, func(pkg *packages.Package) {
   227  			pkg.Fset = fset
   228  			cl.loadGuard.AddMutexForPkg(pkg)
   229  		})
   230  	}
   231  
   232  	cl.debugPrintLoadedPackages(pkgs)
   233  
   234  	if err := cl.parseLoadedPackagesErrors(pkgs); err != nil {
   235  		return nil, err
   236  	}
   237  
   238  	return cl.filterTestMainPackages(pkgs), nil
   239  }
   240  
   241  func (cl *ContextLoader) tryParseTestPackage(pkg *packages.Package) (name string, isTest bool) {
   242  	matches := cl.pkgTestIDRe.FindStringSubmatch(pkg.ID)
   243  	if matches == nil {
   244  		return "", false
   245  	}
   246  
   247  	return matches[1], true
   248  }
   249  
   250  func (cl *ContextLoader) filterTestMainPackages(pkgs []*packages.Package) []*packages.Package {
   251  	var retPkgs []*packages.Package
   252  	for _, pkg := range pkgs {
   253  		if pkg.Name == "main" && strings.HasSuffix(pkg.PkgPath, ".test") {
   254  			// it's an implicit testmain package
   255  			cl.debugf("skip pkg ID=%s", pkg.ID)
   256  			continue
   257  		}
   258  
   259  		retPkgs = append(retPkgs, pkg)
   260  	}
   261  
   262  	return retPkgs
   263  }
   264  
   265  func (cl *ContextLoader) filterDuplicatePackages(pkgs []*packages.Package) []*packages.Package {
   266  	packagesWithTests := map[string]bool{}
   267  	for _, pkg := range pkgs {
   268  		name, isTest := cl.tryParseTestPackage(pkg)
   269  		if !isTest {
   270  			continue
   271  		}
   272  		packagesWithTests[name] = true
   273  	}
   274  
   275  	cl.debugf("package with tests: %#v", packagesWithTests)
   276  
   277  	var retPkgs []*packages.Package
   278  	for _, pkg := range pkgs {
   279  		_, isTest := cl.tryParseTestPackage(pkg)
   280  		if !isTest && packagesWithTests[pkg.PkgPath] {
   281  			// If tests loading is enabled,
   282  			// for package with files a.go and a_test.go go/packages loads two packages:
   283  			// 1. ID=".../a" GoFiles=[a.go]
   284  			// 2. ID=".../a [.../a.test]" GoFiles=[a.go a_test.go]
   285  			// We need only the second package, otherwise we can get warnings about unused variables/fields/functions
   286  			// in a.go if they are used only in a_test.go.
   287  			cl.debugf("skip pkg ID=%s because we load it with test package", pkg.ID)
   288  			continue
   289  		}
   290  
   291  		retPkgs = append(retPkgs, pkg)
   292  	}
   293  
   294  	return retPkgs
   295  }
   296  
   297  func (cl *ContextLoader) Load(ctx context.Context, linters []*linter.Config) (*linter.Context, error) {
   298  	loadMode := cl.findLoadMode(linters)
   299  	pkgs, err := cl.loadPackages(ctx, loadMode)
   300  	if err != nil {
   301  		return nil, fmt.Errorf("failed to load packages: %w", err)
   302  	}
   303  
   304  	deduplicatedPkgs := cl.filterDuplicatePackages(pkgs)
   305  
   306  	if len(deduplicatedPkgs) == 0 {
   307  		return nil, exitcodes.ErrNoGoFiles
   308  	}
   309  
   310  	ret := &linter.Context{
   311  		Packages: deduplicatedPkgs,
   312  
   313  		// At least `unused` linters works properly only on original (not deduplicated) packages,
   314  		// see https://github.com/vanstinator/golangci-lint/pull/585.
   315  		OriginalPackages: pkgs,
   316  
   317  		Cfg:       cl.cfg,
   318  		Log:       cl.log,
   319  		FileCache: cl.fileCache,
   320  		LineCache: cl.lineCache,
   321  		PkgCache:  cl.pkgCache,
   322  		LoadGuard: cl.loadGuard,
   323  	}
   324  
   325  	return ret, nil
   326  }