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 }