golangci-lint/pkg/lint/package.go
2024-03-17 22:52:28 +01:00

282 lines
7.6 KiB
Go

package lint
import (
"context"
"fmt"
"go/build"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"golang.org/x/tools/go/packages"
"github.com/golangci/golangci-lint/pkg/config"
"github.com/golangci/golangci-lint/pkg/exitcodes"
"github.com/golangci/golangci-lint/pkg/golinters/goanalysis/load"
"github.com/golangci/golangci-lint/pkg/goutil"
"github.com/golangci/golangci-lint/pkg/lint/linter"
"github.com/golangci/golangci-lint/pkg/logutils"
)
// PackageLoader loads packages based on [golang.org/x/tools/go/packages.Load].
type PackageLoader struct {
log logutils.Log
debugf logutils.DebugFunc
cfg *config.Config
args []string
pkgTestIDRe *regexp.Regexp
goenv *goutil.Env
loadGuard *load.Guard
}
// NewPackageLoader creates a new PackageLoader.
func NewPackageLoader(log logutils.Log, cfg *config.Config, args []string, goenv *goutil.Env, loadGuard *load.Guard) *PackageLoader {
return &PackageLoader{
cfg: cfg,
args: args,
log: log,
debugf: logutils.Debug(logutils.DebugKeyLoader),
goenv: goenv,
pkgTestIDRe: regexp.MustCompile(`^(.*) \[(.*)\.test\]`),
loadGuard: loadGuard,
}
}
// Load loads packages.
func (l *PackageLoader) Load(ctx context.Context, linters []*linter.Config) (pkgs, deduplicatedPkgs []*packages.Package, err error) {
loadMode := findLoadMode(linters)
pkgs, err = l.loadPackages(ctx, loadMode)
if err != nil {
return nil, nil, fmt.Errorf("failed to load packages: %w", err)
}
return pkgs, l.filterDuplicatePackages(pkgs), nil
}
func (l *PackageLoader) loadPackages(ctx context.Context, loadMode packages.LoadMode) ([]*packages.Package, error) {
defer func(startedAt time.Time) {
l.log.Infof("Go packages loading at mode %s took %s", stringifyLoadMode(loadMode), time.Since(startedAt))
}(time.Now())
l.prepareBuildContext()
conf := &packages.Config{
Mode: loadMode,
Tests: l.cfg.Run.AnalyzeTests,
Context: ctx,
BuildFlags: l.makeBuildFlags(),
Logf: l.debugf,
// TODO: use fset, parsefile, overlay
}
args := l.buildArgs()
l.debugf("Built loader args are %s", args)
pkgs, err := packages.Load(conf, args...)
if err != nil {
return nil, fmt.Errorf("failed to load with go/packages: %w", err)
}
if loadMode&packages.NeedSyntax == 0 {
// Needed e.g. for go/analysis loading.
fset := token.NewFileSet()
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
pkg.Fset = fset
l.loadGuard.AddMutexForPkg(pkg)
})
}
l.debugPrintLoadedPackages(pkgs)
if err := l.parseLoadedPackagesErrors(pkgs); err != nil {
return nil, err
}
return l.filterTestMainPackages(pkgs), nil
}
func (l *PackageLoader) parseLoadedPackagesErrors(pkgs []*packages.Package) error {
for _, pkg := range pkgs {
var errs []packages.Error
for _, err := range pkg.Errors {
// quick fix: skip error related to `go list` invocation by packages.Load()
// The behavior has been changed between go1.19 and go1.20, the error is now inside the JSON content.
// https://github.com/golangci/golangci-lint/pull/3414#issuecomment-1364756303
if strings.Contains(err.Msg, "# command-line-arguments") {
continue
}
errs = append(errs, err)
if strings.Contains(err.Msg, "no Go files") {
return fmt.Errorf("package %s: %w", pkg.PkgPath, exitcodes.ErrNoGoFiles)
}
if strings.Contains(err.Msg, "cannot find package") {
// when analyzing not existing directory
return fmt.Errorf("%v: %w", err.Msg, exitcodes.ErrFailure)
}
}
pkg.Errors = errs
}
return nil
}
func (l *PackageLoader) tryParseTestPackage(pkg *packages.Package) (name string, isTest bool) {
matches := l.pkgTestIDRe.FindStringSubmatch(pkg.ID)
if matches == nil {
return "", false
}
return matches[1], true
}
func (l *PackageLoader) filterDuplicatePackages(pkgs []*packages.Package) []*packages.Package {
packagesWithTests := map[string]bool{}
for _, pkg := range pkgs {
name, isTest := l.tryParseTestPackage(pkg)
if !isTest {
continue
}
packagesWithTests[name] = true
}
l.debugf("package with tests: %#v", packagesWithTests)
var retPkgs []*packages.Package
for _, pkg := range pkgs {
_, isTest := l.tryParseTestPackage(pkg)
if !isTest && packagesWithTests[pkg.PkgPath] {
// If tests loading is enabled,
// for package with files a.go and a_test.go go/packages loads two packages:
// 1. ID=".../a" GoFiles=[a.go]
// 2. ID=".../a [.../a.test]" GoFiles=[a.go a_test.go]
// We need only the second package, otherwise we can get warnings about unused variables/fields/functions
// in a.go if they are used only in a_test.go.
l.debugf("skip pkg ID=%s because we load it with test package", pkg.ID)
continue
}
retPkgs = append(retPkgs, pkg)
}
return retPkgs
}
func (l *PackageLoader) filterTestMainPackages(pkgs []*packages.Package) []*packages.Package {
var retPkgs []*packages.Package
for _, pkg := range pkgs {
if pkg.Name == "main" && strings.HasSuffix(pkg.PkgPath, ".test") {
// it's an implicit testmain package
l.debugf("skip pkg ID=%s", pkg.ID)
continue
}
retPkgs = append(retPkgs, pkg)
}
return retPkgs
}
func (l *PackageLoader) debugPrintLoadedPackages(pkgs []*packages.Package) {
l.debugf("loaded %d pkgs", len(pkgs))
for i, pkg := range pkgs {
var syntaxFiles []string
for _, sf := range pkg.Syntax {
syntaxFiles = append(syntaxFiles, pkg.Fset.Position(sf.Pos()).Filename)
}
l.debugf("Loaded pkg #%d: ID=%s GoFiles=%s CompiledGoFiles=%s Syntax=%s",
i, pkg.ID, pkg.GoFiles, pkg.CompiledGoFiles, syntaxFiles)
}
}
func (l *PackageLoader) prepareBuildContext() {
// Set GOROOT to have working cross-compilation: cross-compiled binaries
// have invalid GOROOT. XXX: can't use runtime.GOROOT().
goroot := l.goenv.Get(goutil.EnvGoRoot)
if goroot == "" {
return
}
os.Setenv(string(goutil.EnvGoRoot), goroot)
build.Default.GOROOT = goroot
build.Default.BuildTags = l.cfg.Run.BuildTags
}
func (l *PackageLoader) buildArgs() []string {
if len(l.args) == 0 {
return []string{"./..."}
}
var retArgs []string
for _, arg := range l.args {
if strings.HasPrefix(arg, ".") || filepath.IsAbs(arg) {
retArgs = append(retArgs, arg)
} else {
// go/packages doesn't work well if we don't have the prefix ./ for local packages
retArgs = append(retArgs, fmt.Sprintf(".%c%s", filepath.Separator, arg))
}
}
return retArgs
}
func (l *PackageLoader) makeBuildFlags() []string {
var buildFlags []string
if len(l.cfg.Run.BuildTags) != 0 {
// go help build
buildFlags = append(buildFlags, "-tags", strings.Join(l.cfg.Run.BuildTags, " "))
l.log.Infof("Using build tags: %v", l.cfg.Run.BuildTags)
}
if l.cfg.Run.ModulesDownloadMode != "" {
// go help modules
buildFlags = append(buildFlags, fmt.Sprintf("-mod=%s", l.cfg.Run.ModulesDownloadMode))
}
return buildFlags
}
func findLoadMode(linters []*linter.Config) packages.LoadMode {
loadMode := packages.LoadMode(0)
for _, lc := range linters {
loadMode |= lc.LoadMode
}
return loadMode
}
func stringifyLoadMode(mode packages.LoadMode) string {
m := map[packages.LoadMode]string{
packages.NeedCompiledGoFiles: "compiled_files",
packages.NeedDeps: "deps",
packages.NeedExportFile: "exports_file",
packages.NeedFiles: "files",
packages.NeedImports: "imports",
packages.NeedName: "name",
packages.NeedSyntax: "syntax",
packages.NeedTypes: "types",
packages.NeedTypesInfo: "types_info",
packages.NeedTypesSizes: "types_sizes",
}
var flags []string
for flag, flagStr := range m {
if mode&flag != 0 {
flags = append(flags, flagStr)
}
}
return fmt.Sprintf("%d (%s)", mode, strings.Join(flags, "|"))
}