github.com/amarpal/go-tools@v0.0.0-20240422043104-40142f59f616/lintcmd/lint.go (about)

     1  package lintcmd
     2  
     3  import (
     4  	"crypto/sha256"
     5  	"fmt"
     6  	"go/build"
     7  	"go/token"
     8  	"io"
     9  	"os"
    10  	"os/signal"
    11  	"path/filepath"
    12  	"regexp"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  	"unicode"
    17  
    18  	"github.com/amarpal/go-tools/analysis/lint"
    19  	"github.com/amarpal/go-tools/config"
    20  	"github.com/amarpal/go-tools/go/buildid"
    21  	"github.com/amarpal/go-tools/go/loader"
    22  	"github.com/amarpal/go-tools/lintcmd/cache"
    23  	"github.com/amarpal/go-tools/lintcmd/runner"
    24  	"github.com/amarpal/go-tools/unused"
    25  
    26  	"golang.org/x/tools/go/analysis"
    27  	"golang.org/x/tools/go/packages"
    28  )
    29  
    30  // A linter lints Go source code.
    31  type linter struct {
    32  	analyzers map[string]*lint.Analyzer
    33  	cache     *cache.Cache
    34  	opts      options
    35  }
    36  
    37  func computeSalt() ([]byte, error) {
    38  	p, err := os.Executable()
    39  	if err != nil {
    40  		return nil, err
    41  	}
    42  
    43  	if id, err := buildid.ReadFile(p); err == nil {
    44  		return []byte(id), nil
    45  	} else {
    46  		// For some reason we couldn't read the build id from the executable.
    47  		// Fall back to hashing the entire executable.
    48  		f, err := os.Open(p)
    49  		if err != nil {
    50  			return nil, err
    51  		}
    52  		defer f.Close()
    53  		h := sha256.New()
    54  		if _, err := io.Copy(h, f); err != nil {
    55  			return nil, err
    56  		}
    57  		return h.Sum(nil), nil
    58  	}
    59  }
    60  
    61  func newLinter(opts options) (*linter, error) {
    62  	c, err := cache.Default()
    63  	if err != nil {
    64  		return nil, err
    65  	}
    66  	salt, err := computeSalt()
    67  	if err != nil {
    68  		return nil, fmt.Errorf("could not compute salt for cache: %s", err)
    69  	}
    70  	c.SetSalt(salt)
    71  
    72  	analyzers := make(map[string]*lint.Analyzer, len(opts.analyzers))
    73  	for _, a := range opts.analyzers {
    74  		analyzers[a.Analyzer.Name] = a
    75  	}
    76  
    77  	return &linter{
    78  		cache:     c,
    79  		analyzers: analyzers,
    80  		opts:      opts,
    81  	}, nil
    82  }
    83  
    84  type lintResult struct {
    85  	// These fields are exported so that we can gob encode them.
    86  
    87  	CheckedFiles []string
    88  	Diagnostics  []diagnostic
    89  	Warnings     []string
    90  }
    91  
    92  type options struct {
    93  	config                   config.Config
    94  	analyzers                []*lint.Analyzer
    95  	patterns                 []string
    96  	lintTests                bool
    97  	goVersion                string
    98  	printAnalyzerMeasurement func(analysis *analysis.Analyzer, pkg *loader.PackageSpec, d time.Duration)
    99  }
   100  
   101  func (l *linter) run(bconf buildConfig) (lintResult, error) {
   102  	cfg := &packages.Config{}
   103  	if l.opts.lintTests {
   104  		cfg.Tests = true
   105  	}
   106  
   107  	cfg.BuildFlags = bconf.Flags
   108  	cfg.Env = append(os.Environ(), bconf.Envs...)
   109  
   110  	r, err := runner.New(l.opts.config, l.cache)
   111  	if err != nil {
   112  		return lintResult{}, err
   113  	}
   114  	r.FallbackGoVersion = defaultGoVersion()
   115  	r.GoVersion = l.opts.goVersion
   116  	r.Stats.PrintAnalyzerMeasurement = l.opts.printAnalyzerMeasurement
   117  
   118  	printStats := func() {
   119  		// Individual stats are read atomically, but overall there
   120  		// is no synchronisation. For printing rough progress
   121  		// information, this doesn't matter.
   122  		switch r.Stats.State() {
   123  		case runner.StateInitializing:
   124  			fmt.Fprintln(os.Stderr, "Status: initializing")
   125  		case runner.StateLoadPackageGraph:
   126  			fmt.Fprintln(os.Stderr, "Status: loading package graph")
   127  		case runner.StateBuildActionGraph:
   128  			fmt.Fprintln(os.Stderr, "Status: building action graph")
   129  		case runner.StateProcessing:
   130  			fmt.Fprintf(os.Stderr, "Packages: %d/%d initial, %d/%d total; Workers: %d/%d\n",
   131  				r.Stats.ProcessedInitialPackages(),
   132  				r.Stats.InitialPackages(),
   133  				r.Stats.ProcessedPackages(),
   134  				r.Stats.TotalPackages(),
   135  				r.ActiveWorkers(),
   136  				r.TotalWorkers(),
   137  			)
   138  		case runner.StateFinalizing:
   139  			fmt.Fprintln(os.Stderr, "Status: finalizing")
   140  		}
   141  	}
   142  	if len(infoSignals) > 0 {
   143  		ch := make(chan os.Signal, 1)
   144  		signal.Notify(ch, infoSignals...)
   145  		defer signal.Stop(ch)
   146  		go func() {
   147  			for range ch {
   148  				printStats()
   149  			}
   150  		}()
   151  	}
   152  	res, err := l.lint(r, cfg, l.opts.patterns)
   153  	for i := range res.Diagnostics {
   154  		res.Diagnostics[i].BuildName = bconf.Name
   155  	}
   156  	return res, err
   157  }
   158  
   159  func (l *linter) lint(r *runner.Runner, cfg *packages.Config, patterns []string) (lintResult, error) {
   160  	var out lintResult
   161  
   162  	as := make([]*analysis.Analyzer, 0, len(l.analyzers))
   163  	for _, a := range l.analyzers {
   164  		as = append(as, a.Analyzer)
   165  	}
   166  	results, err := r.Run(cfg, as, patterns)
   167  	if err != nil {
   168  		return out, err
   169  	}
   170  
   171  	if len(results) == 0 {
   172  		// TODO(dh): emulate Go's behavior more closely once we have
   173  		// access to go list's Match field.
   174  		for _, pattern := range patterns {
   175  			fmt.Fprintf(os.Stderr, "warning: %q matched no packages\n", pattern)
   176  		}
   177  	}
   178  
   179  	analyzerNames := make([]string, 0, len(l.analyzers))
   180  	for name := range l.analyzers {
   181  		analyzerNames = append(analyzerNames, name)
   182  	}
   183  	used := map[unusedKey]bool{}
   184  	var unuseds []unusedPair
   185  	for _, res := range results {
   186  		if len(res.Errors) > 0 && !res.Failed {
   187  			panic("package has errors but isn't marked as failed")
   188  		}
   189  		if res.Failed {
   190  			out.Diagnostics = append(out.Diagnostics, failed(res)...)
   191  		} else {
   192  			if res.Skipped {
   193  				out.Warnings = append(out.Warnings, fmt.Sprintf("skipped package %s because it is too large", res.Package))
   194  				continue
   195  			}
   196  
   197  			if !res.Initial {
   198  				continue
   199  			}
   200  
   201  			out.CheckedFiles = append(out.CheckedFiles, res.Package.GoFiles...)
   202  			allowedAnalyzers := filterAnalyzerNames(analyzerNames, res.Config.Checks)
   203  			resd, err := res.Load()
   204  			if err != nil {
   205  				return out, err
   206  			}
   207  			ps := success(allowedAnalyzers, resd)
   208  			filtered, err := filterIgnored(ps, resd, allowedAnalyzers)
   209  			if err != nil {
   210  				return out, err
   211  			}
   212  			// OPT move this code into the 'success' function.
   213  			for i, diag := range filtered {
   214  				a := l.analyzers[diag.Category]
   215  				// Some diag.Category don't map to analyzers, such as "staticcheck"
   216  				if a != nil {
   217  					filtered[i].MergeIf = a.Doc.MergeIf
   218  				}
   219  			}
   220  			out.Diagnostics = append(out.Diagnostics, filtered...)
   221  
   222  			for _, obj := range resd.Unused.Used {
   223  				// Note: a side-effect of this code is that fields in instantiated structs are handled correctly. Even
   224  				// if only an instantiated field is marked as used, we will not flag the generic field, because it has
   225  				// the same position as the instance. At some point this won't be necessary anymore because we'll be
   226  				// able to make use of the Go 1.19+ Origin methods.
   227  
   228  				// FIXME(dh): pick the object whose filename does not include $GOROOT
   229  				key := unusedKey{
   230  					pkgPath: res.Package.PkgPath,
   231  					base:    filepath.Base(obj.Position.Filename),
   232  					line:    obj.Position.Line,
   233  					name:    obj.Name,
   234  				}
   235  				used[key] = true
   236  			}
   237  
   238  			if allowedAnalyzers["U1000"] {
   239  				for _, obj := range resd.Unused.Unused {
   240  					key := unusedKey{
   241  						pkgPath: res.Package.PkgPath,
   242  						base:    filepath.Base(obj.Position.Filename),
   243  						line:    obj.Position.Line,
   244  						name:    obj.Name,
   245  					}
   246  					unuseds = append(unuseds, unusedPair{key, obj})
   247  					if _, ok := used[key]; !ok {
   248  						used[key] = false
   249  					}
   250  				}
   251  			}
   252  		}
   253  	}
   254  
   255  	for _, uo := range unuseds {
   256  		if used[uo.key] {
   257  			continue
   258  		}
   259  		out.Diagnostics = append(out.Diagnostics, diagnostic{
   260  			Diagnostic: runner.Diagnostic{
   261  				Position: uo.obj.DisplayPosition,
   262  				Message:  fmt.Sprintf("%s %s is unused", uo.obj.Kind, uo.obj.Name),
   263  				Category: "U1000",
   264  			},
   265  			MergeIf: lint.MergeIfAll,
   266  		})
   267  	}
   268  
   269  	return out, nil
   270  }
   271  
   272  func filterIgnored(diagnostics []diagnostic, res runner.ResultData, allowedAnalyzers map[string]bool) ([]diagnostic, error) {
   273  	couldHaveMatched := func(ig *lineIgnore) bool {
   274  		for _, c := range ig.Checks {
   275  			if c == "U1000" {
   276  				// We never want to flag ignores for U1000,
   277  				// because U1000 isn't local to a single
   278  				// package. For example, an identifier may
   279  				// only be used by tests, in which case an
   280  				// ignore would only fire when not analyzing
   281  				// tests. To avoid spurious "useless ignore"
   282  				// warnings, just never flag U1000.
   283  				return false
   284  			}
   285  
   286  			// Even though the runner always runs all analyzers, we
   287  			// still only flag unmatched ignores for the set of
   288  			// analyzers the user has expressed interest in. That way,
   289  			// `staticcheck -checks=SA1000` won't complain about an
   290  			// unmatched ignore for an unrelated check.
   291  			if allowedAnalyzers[c] {
   292  				return true
   293  			}
   294  		}
   295  
   296  		return false
   297  	}
   298  
   299  	ignores, moreDiagnostics := parseDirectives(res.Directives)
   300  
   301  	for _, ig := range ignores {
   302  		for i := range diagnostics {
   303  			diag := &diagnostics[i]
   304  			if ig.match(*diag) {
   305  				diag.Severity = severityIgnored
   306  			}
   307  		}
   308  
   309  		if ig, ok := ig.(*lineIgnore); ok && !ig.Matched && couldHaveMatched(ig) {
   310  			diag := diagnostic{
   311  				Diagnostic: runner.Diagnostic{
   312  					Position: ig.Pos,
   313  					Message:  "this linter directive didn't match anything; should it be removed?",
   314  					Category: "staticcheck",
   315  				},
   316  			}
   317  			moreDiagnostics = append(moreDiagnostics, diag)
   318  		}
   319  	}
   320  
   321  	return append(diagnostics, moreDiagnostics...), nil
   322  }
   323  
   324  type ignore interface {
   325  	match(diag diagnostic) bool
   326  }
   327  
   328  type lineIgnore struct {
   329  	File    string
   330  	Line    int
   331  	Checks  []string
   332  	Matched bool
   333  	Pos     token.Position
   334  }
   335  
   336  func (li *lineIgnore) match(p diagnostic) bool {
   337  	pos := p.Position
   338  	if pos.Filename != li.File || pos.Line != li.Line {
   339  		return false
   340  	}
   341  	for _, c := range li.Checks {
   342  		if m, _ := filepath.Match(c, p.Category); m {
   343  			li.Matched = true
   344  			return true
   345  		}
   346  	}
   347  	return false
   348  }
   349  
   350  func (li *lineIgnore) String() string {
   351  	matched := "not matched"
   352  	if li.Matched {
   353  		matched = "matched"
   354  	}
   355  	return fmt.Sprintf("%s:%d %s (%s)", li.File, li.Line, strings.Join(li.Checks, ", "), matched)
   356  }
   357  
   358  type fileIgnore struct {
   359  	File   string
   360  	Checks []string
   361  }
   362  
   363  func (fi *fileIgnore) match(p diagnostic) bool {
   364  	if p.Position.Filename != fi.File {
   365  		return false
   366  	}
   367  	for _, c := range fi.Checks {
   368  		if m, _ := filepath.Match(c, p.Category); m {
   369  			return true
   370  		}
   371  	}
   372  	return false
   373  }
   374  
   375  type severity uint8
   376  
   377  const (
   378  	severityError severity = iota
   379  	severityWarning
   380  	severityIgnored
   381  )
   382  
   383  func (s severity) String() string {
   384  	switch s {
   385  	case severityError:
   386  		return "error"
   387  	case severityWarning:
   388  		return "warning"
   389  	case severityIgnored:
   390  		return "ignored"
   391  	default:
   392  		return fmt.Sprintf("Severity(%d)", s)
   393  	}
   394  }
   395  
   396  // diagnostic represents a diagnostic in some source code.
   397  type diagnostic struct {
   398  	runner.Diagnostic
   399  
   400  	// These fields are exported so that we can gob encode them.
   401  	Severity  severity
   402  	MergeIf   lint.MergeStrategy
   403  	BuildName string
   404  }
   405  
   406  func (p diagnostic) equal(o diagnostic) bool {
   407  	return p.Position == o.Position &&
   408  		p.End == o.End &&
   409  		p.Message == o.Message &&
   410  		p.Category == o.Category &&
   411  		p.Severity == o.Severity &&
   412  		p.MergeIf == o.MergeIf &&
   413  		p.BuildName == o.BuildName
   414  }
   415  
   416  func (p *diagnostic) String() string {
   417  	if p.BuildName != "" {
   418  		return fmt.Sprintf("%s [%s] (%s)", p.Message, p.BuildName, p.Category)
   419  	} else {
   420  		return fmt.Sprintf("%s (%s)", p.Message, p.Category)
   421  	}
   422  }
   423  
   424  func failed(res runner.Result) []diagnostic {
   425  	var diagnostics []diagnostic
   426  
   427  	for _, e := range res.Errors {
   428  		switch e := e.(type) {
   429  		case packages.Error:
   430  			msg := e.Msg
   431  			if len(msg) != 0 && msg[0] == '\n' {
   432  				// TODO(dh): See https://github.com/golang/go/issues/32363
   433  				msg = msg[1:]
   434  			}
   435  
   436  			var posn token.Position
   437  			if e.Pos == "" {
   438  				// Under certain conditions (malformed package
   439  				// declarations, multiple packages in the same
   440  				// directory), go list emits an error on stderr
   441  				// instead of JSON. Those errors do not have
   442  				// associated position information in
   443  				// go/packages.Error, even though the output on
   444  				// stderr may contain it.
   445  				if p, n, err := parsePos(msg); err == nil {
   446  					if abs, err := filepath.Abs(p.Filename); err == nil {
   447  						p.Filename = abs
   448  					}
   449  					posn = p
   450  					msg = msg[n+2:]
   451  				}
   452  			} else {
   453  				var err error
   454  				posn, _, err = parsePos(e.Pos)
   455  				if err != nil {
   456  					panic(fmt.Sprintf("internal error: %s", e))
   457  				}
   458  			}
   459  			diag := diagnostic{
   460  				Diagnostic: runner.Diagnostic{
   461  					Position: posn,
   462  					Message:  msg,
   463  					Category: "compile",
   464  				},
   465  				Severity: severityError,
   466  			}
   467  			diagnostics = append(diagnostics, diag)
   468  		case error:
   469  			diag := diagnostic{
   470  				Diagnostic: runner.Diagnostic{
   471  					Position: token.Position{},
   472  					Message:  e.Error(),
   473  					Category: "compile",
   474  				},
   475  				Severity: severityError,
   476  			}
   477  			diagnostics = append(diagnostics, diag)
   478  		}
   479  	}
   480  
   481  	return diagnostics
   482  }
   483  
   484  type unusedKey struct {
   485  	pkgPath string
   486  	base    string
   487  	line    int
   488  	name    string
   489  }
   490  
   491  type unusedPair struct {
   492  	key unusedKey
   493  	obj unused.Object
   494  }
   495  
   496  func success(allowedAnalyzers map[string]bool, res runner.ResultData) []diagnostic {
   497  	diags := res.Diagnostics
   498  	var diagnostics []diagnostic
   499  	for _, diag := range diags {
   500  		if !allowedAnalyzers[diag.Category] {
   501  			continue
   502  		}
   503  		diagnostics = append(diagnostics, diagnostic{Diagnostic: diag})
   504  	}
   505  	return diagnostics
   506  }
   507  
   508  func defaultGoVersion() string {
   509  	tags := build.Default.ReleaseTags
   510  	v := tags[len(tags)-1][2:]
   511  	return v
   512  }
   513  
   514  func filterAnalyzerNames(analyzers []string, checks []string) map[string]bool {
   515  	allowedChecks := map[string]bool{}
   516  
   517  	for _, check := range checks {
   518  		b := true
   519  		if len(check) > 1 && check[0] == '-' {
   520  			b = false
   521  			check = check[1:]
   522  		}
   523  		if check == "*" || check == "all" {
   524  			// Match all
   525  			for _, c := range analyzers {
   526  				allowedChecks[c] = b
   527  			}
   528  		} else if strings.HasSuffix(check, "*") {
   529  			// Glob
   530  			prefix := check[:len(check)-1]
   531  			isCat := strings.IndexFunc(prefix, func(r rune) bool { return unicode.IsNumber(r) }) == -1
   532  
   533  			for _, a := range analyzers {
   534  				idx := strings.IndexFunc(a, func(r rune) bool { return unicode.IsNumber(r) })
   535  				if isCat {
   536  					// Glob is S*, which should match S1000 but not SA1000
   537  					cat := a[:idx]
   538  					if prefix == cat {
   539  						allowedChecks[a] = b
   540  					}
   541  				} else {
   542  					// Glob is S1*
   543  					if strings.HasPrefix(a, prefix) {
   544  						allowedChecks[a] = b
   545  					}
   546  				}
   547  			}
   548  		} else {
   549  			// Literal check name
   550  			allowedChecks[check] = b
   551  		}
   552  	}
   553  	return allowedChecks
   554  }
   555  
   556  var posRe = regexp.MustCompile(`^(.+?):(\d+)(?::(\d+)?)?`)
   557  
   558  func parsePos(pos string) (token.Position, int, error) {
   559  	if pos == "-" || pos == "" {
   560  		return token.Position{}, 0, nil
   561  	}
   562  	parts := posRe.FindStringSubmatch(pos)
   563  	if parts == nil {
   564  		return token.Position{}, 0, fmt.Errorf("internal error: malformed position %q", pos)
   565  	}
   566  	file := parts[1]
   567  	line, _ := strconv.Atoi(parts[2])
   568  	col, _ := strconv.Atoi(parts[3])
   569  	return token.Position{
   570  		Filename: file,
   571  		Line:     line,
   572  		Column:   col,
   573  	}, len(parts[0]), nil
   574  }