github.com/blend/go-sdk@v1.20220411.3/cmd/coverage/main.go (about) 1 /* 2 3 Copyright (c) 2022 - Present. Blend Labs, Inc. All rights reserved 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file. 5 6 */ 7 8 package main 9 10 import ( 11 "bufio" 12 "bytes" 13 "flag" 14 "fmt" 15 "go/build" 16 "os" 17 "os/exec" 18 "path/filepath" 19 "regexp" 20 "strconv" 21 "strings" 22 23 "golang.org/x/tools/cover" 24 ) 25 26 const ( 27 star = "*" 28 defaultFileFlags = 0644 29 expand = "/..." 30 ) 31 32 var reportOutputPath = flag.String("output", "coverage.html", "the path to write the full html coverage report") 33 var update = flag.Bool("update", false, "if we should write the current coverage to `COVERAGE` files") 34 var enforce = flag.Bool("enforce", false, "if we should enforce coverage minimums defined in `COVERAGE` files") 35 var timeout = flag.String("timeout", "", "the timeout to pass to the package tests.") 36 var race = flag.Bool("race", false, "if we should add -race to test invocations") 37 var covermode = flag.String("covermode", "atomic", "the go test covermode.") 38 var coverprofile = flag.String("coverprofile", "coverage.cov", "the intermediate cover profile.") 39 var keepCoverageOut = flag.Bool("keep-coverage-out", false, "if we should keep coverage.out") 40 var v = flag.Bool("v", false, "show verbose output") 41 var exitFirst = flag.Bool("exit-first", true, "exit on first coverage failure; when disabled this will produce full coverage reports even on coverage failures") 42 43 var ( 44 includes Paths 45 excludes Paths 46 ) 47 48 func main() { 49 flag.Var(&includes, "include", "glob patterns to include explicitly") 50 flag.Var(&excludes, "exclude", "glob patterns to exclude explicitly") 51 flag.Parse() 52 53 pwd, err := os.Getwd() 54 maybeFatal(err) 55 56 fmt.Fprintln(os.Stdout, "coverage starting") 57 fmt.Fprintf(os.Stdout, "using covermode: %s\n", *covermode) 58 fmt.Fprintf(os.Stdout, "using coverprofile: %s\n", *coverprofile) 59 if *timeout != "" { 60 fmt.Fprintf(os.Stdout, "using timeout: %s\n", *timeout) 61 } 62 if len(includes) > 0 { 63 fmt.Fprintf(os.Stdout, "using includes: %s\n", strings.Join(includes, ", ")) 64 } 65 if len(excludes) > 0 { 66 fmt.Fprintf(os.Stdout, "using excludes: %s\n", strings.Join(excludes, ", ")) 67 } 68 if *race { 69 fmt.Fprintln(os.Stdout, "using race detection") 70 } 71 72 // 73 // start 74 // 75 76 fullCoverageData, err := removeAndOpen(*coverprofile) 77 if err != nil { 78 maybeFatal(err) 79 } 80 fmt.Fprintf(fullCoverageData, "mode: %s\n", *covermode) 81 82 paths := flag.Args() 83 84 if len(paths) == 0 { 85 paths = []string{"./..."} 86 } 87 88 var allPathCoverageErrors []error 89 for _, path := range paths { 90 fmt.Fprintf(os.Stdout, "walking path: %s\n", path) 91 if coverageErrors := walkPath(path, fullCoverageData); len(coverageErrors) > 0 { 92 allPathCoverageErrors = append(allPathCoverageErrors, coverageErrors...) 93 } 94 } 95 96 // close the coverage data handle 97 maybeFatal(fullCoverageData.Close()) 98 99 // complete summary steps 100 covered, total, err := parseFullCoverProfile(pwd, *coverprofile) 101 maybeFatal(err) 102 finalCoverage := (float64(covered) / float64(total)) * 100 103 maybeFatal(writeCoverage(pwd, formatCoverage(finalCoverage))) 104 105 fmt.Fprintf(os.Stdout, "final coverage: %s%%\n", colorCoverage(finalCoverage)) 106 fmt.Fprintf(os.Stdout, "compiling coverage report: %s\n", *reportOutputPath) 107 108 // compile coverage.html 109 maybeFatal(execCoverageReportCompile()) 110 111 if !*keepCoverageOut { 112 maybeFatal(removeIfExists(*coverprofile)) 113 } 114 115 if len(allPathCoverageErrors) > 0 { 116 fmt.Fprintln(os.Stderr, "coverage thresholds not met") 117 for _, coverageError := range allPathCoverageErrors { 118 fmt.Fprintf(os.Stderr, "%+v\n", coverageError) 119 } 120 os.Exit(1) 121 } 122 123 fmt.Fprintln(os.Stdout, "coverage complete") 124 } 125 126 func walkPath(walkedPath string, fullCoverageData *os.File) []error { 127 recursive := strings.HasSuffix(walkedPath, expand) 128 rootPath := filepath.Dir(walkedPath) 129 var coverageErrors []error 130 131 maybeFatal(filepath.Walk(rootPath, func(currentPath string, info os.FileInfo, fileErr error) error { 132 packageCoverReport, err := getPackageCoverage(currentPath, info, fileErr) 133 if err != nil { 134 if (exitFirst != nil && *exitFirst) || len(packageCoverReport) == 0 { 135 return err 136 } 137 coverageErrors = append(coverageErrors, err) 138 } 139 140 if len(packageCoverReport) == 0 { 141 return nil 142 } 143 144 err = mergeCoverageOutput(packageCoverReport, fullCoverageData) 145 if err != nil { 146 return err 147 } 148 149 err = removeIfExists(packageCoverReport) 150 if err != nil { 151 return err 152 } 153 154 if !recursive && info.IsDir() { 155 return filepath.SkipDir 156 } 157 return nil 158 })) 159 return coverageErrors 160 } 161 162 // gets coverage for a directory and returns the path to the coverage file for that directory 163 func getPackageCoverage(currentPath string, info os.FileInfo, err error) (string, error) { 164 if os.IsNotExist(err) { 165 return "", nil 166 } 167 if err != nil { 168 return "", err 169 } 170 fileName := info.Name() 171 172 if fileName == ".git" { 173 vf("%q skipping dir; .git", currentPath) 174 return "", filepath.SkipDir 175 } 176 if strings.HasPrefix(fileName, "_") { 177 vf("%q skipping dir; '_' prefix", currentPath) 178 return "", filepath.SkipDir 179 } 180 if fileName == "vendor" { 181 vf("%q skipping dir; vendor", currentPath) 182 return "", filepath.SkipDir 183 } 184 185 if !dirHasGlob(currentPath, "*.go") { 186 vf("%q skipping dir; no *.go files", currentPath) 187 return "", nil 188 } 189 190 for _, include := range includes { 191 if matches := glob(include, currentPath); !matches { // note the ! 192 vf("%q skipping dir; include no match: %s", currentPath, include) 193 return "", nil 194 } 195 } 196 197 for _, exclude := range excludes { 198 if matches := glob(exclude, currentPath); matches { 199 vf("%q skipping dir; exclude match: %s", currentPath, exclude) 200 return "", nil 201 } 202 } 203 204 packageCoverReport := filepath.Join(currentPath, "profile.cov") 205 err = removeIfExists(packageCoverReport) 206 if err != nil { 207 return "", err 208 } 209 210 var output []byte 211 output, err = execCoverage(currentPath) 212 if err != nil { 213 verrf("error running coverage") 214 fmt.Fprintln(os.Stderr, string(output)) 215 return "", err 216 } 217 218 coverage := extractCoverage(string(output)) 219 fmt.Fprintf(os.Stdout, "%s: %v%%\n", currentPath, colorCoverage(parseCoverage(coverage))) 220 221 if enforce != nil && *enforce { 222 vf("enforcing coverage minimums") 223 err = enforceCoverage(currentPath, coverage) 224 if err != nil { 225 return packageCoverReport, err 226 } 227 } 228 229 if update != nil && *update { 230 fmt.Fprintf(os.Stdout, "%q updating coverage\n", currentPath) 231 err = writeCoverage(currentPath, coverage) 232 if err != nil { 233 return "", err 234 } 235 } 236 237 return packageCoverReport, nil 238 } 239 240 // -------------------------------------------------------------------------------- 241 // utilities 242 // -------------------------------------------------------------------------------- 243 244 func verbose() bool { 245 if v != nil && *v { 246 return true 247 } 248 return false 249 } 250 251 func vf(format string, args ...interface{}) { 252 if verbose() { 253 fmt.Fprintf(os.Stdout, "coverage :: "+format+"\n", args...) 254 } 255 } 256 257 func verrf(format string, args ...interface{}) { 258 if verbose() { 259 fmt.Fprintf(os.Stderr, "coverage :: err :: "+format+"\n", args...) 260 } 261 } 262 263 func gopath() string { 264 gopath := os.Getenv("GOPATH") 265 if gopath != "" { 266 return gopath 267 } 268 return build.Default.GOPATH 269 } 270 271 func glob(pattern, subj string) bool { 272 // Empty pattern can only match empty subject 273 if pattern == "" { 274 return subj == pattern 275 } 276 277 // If the pattern _is_ a glob, it matches everything 278 if pattern == star { 279 return true 280 } 281 282 parts := strings.Split(pattern, star) 283 284 if len(parts) == 1 { 285 // No globs in pattern, so test for equality 286 return subj == pattern 287 } 288 289 leadingGlob := strings.HasPrefix(pattern, star) 290 trailingGlob := strings.HasSuffix(pattern, star) 291 end := len(parts) - 1 292 293 // Go over the leading parts and ensure they match. 294 for i := 0; i < end; i++ { 295 idx := strings.Index(subj, parts[i]) 296 297 switch i { 298 case 0: 299 // Check the first section. Requires special handling. 300 if !leadingGlob && idx != 0 { 301 return false 302 } 303 default: 304 // Check that the middle parts match. 305 if idx < 0 { 306 return false 307 } 308 } 309 310 // Trim evaluated text from subj as we loop over the pattern. 311 subj = subj[idx+len(parts[i]):] 312 } 313 314 // Reached the last section. Requires special handling. 315 return trailingGlob || strings.HasSuffix(subj, parts[end]) 316 } 317 318 func enforceCoverage(path, actualCoverage string) error { 319 actual, err := strconv.ParseFloat(actualCoverage, 64) 320 if err != nil { 321 return err 322 } 323 324 contents, err := os.ReadFile(filepath.Join(path, "COVERAGE")) 325 if err != nil { 326 return err 327 } 328 expected, err := strconv.ParseFloat(strings.TrimSpace(string(contents)), 64) 329 if err != nil { 330 return err 331 } 332 333 if expected == 0 { 334 return nil 335 } 336 337 if actual < expected { 338 return fmt.Errorf( 339 "%s %s coverage: %0.2f%% vs. %0.2f%%", 340 path, colorRed.Apply("fails"), expected, actual, 341 ) 342 } 343 return nil 344 } 345 346 func extractCoverage(corpus string) string { 347 regex := `coverage: ([0-9,.]+)% of statements` 348 expr := regexp.MustCompile(regex) 349 350 results := expr.FindStringSubmatch(corpus) 351 if len(results) > 1 { 352 return results[1] 353 } 354 return "0" 355 } 356 357 func writeCoverage(path, coverage string) error { 358 return os.WriteFile(filepath.Join(path, "COVERAGE"), []byte(strings.TrimSpace(coverage)), defaultFileFlags) 359 } 360 361 func dirHasGlob(path, glob string) bool { 362 files, _ := filepath.Glob(filepath.Join(path, glob)) 363 return len(files) > 0 364 } 365 366 func gobin() string { 367 gobin, err := exec.LookPath("go") 368 maybeFatal(err) 369 return gobin 370 } 371 372 func execCoverage(path string) ([]byte, error) { 373 args := []string{ 374 "test", 375 "-short", 376 fmt.Sprintf("-covermode=%s", *covermode), 377 "-coverprofile=profile.cov", 378 } 379 if *timeout != "" { 380 args = append(args, fmt.Sprintf("-timeout=%s", *timeout)) 381 } 382 if *race { 383 args = append(args, "-race") 384 } 385 cmd := exec.Command(gobin(), args...) 386 cmd.Env = os.Environ() 387 cmd.Dir = path 388 return cmd.CombinedOutput() 389 } 390 391 func execCoverageReportCompile() error { 392 cmd := exec.Command(gobin(), "tool", "cover", fmt.Sprintf("-html=%s", *coverprofile), fmt.Sprintf("-o=%s", *reportOutputPath)) 393 cmd.Env = os.Environ() 394 return cmd.Run() 395 } 396 397 func mergeCoverageOutput(temp string, outFile *os.File) error { 398 contents, err := os.ReadFile(temp) 399 if err != nil { 400 return err 401 } 402 403 scanner := bufio.NewScanner(bytes.NewBuffer(contents)) 404 405 var skip int 406 for scanner.Scan() { 407 skip++ 408 if skip < 2 { 409 continue 410 } 411 _, err = fmt.Fprintln(outFile, scanner.Text()) 412 if err != nil { 413 return err 414 } 415 } 416 return nil 417 } 418 419 func removeIfExists(path string) error { 420 if _, err := os.Stat(path); err == nil { 421 return os.Remove(path) 422 } 423 return nil 424 } 425 426 func maybeFatal(err error) { 427 if err != nil { 428 fmt.Fprintf(os.Stderr, "%+v\n", err) 429 os.Exit(1) 430 } 431 } 432 433 func removeAndOpen(path string) (*os.File, error) { 434 if _, err := os.Stat(path); err == nil { 435 if err = os.Remove(path); err != nil { 436 return nil, err 437 } 438 } 439 return os.Create(path) 440 } 441 442 // joinCoverPath takes a pwd, and a filename, and joins them 443 // overlaying parts of the suffix of the pwd, and the prefix 444 // of the filename that match. 445 // ex: 446 // - pwd: /foo/bar/baz, filename: bar/baz/buzz.go => /foo/bar/baz/buzz.go 447 func joinCoverPath(pwd, fileName string) string { 448 pwdPath := lessEmpty(strings.Split(pwd, "/")) 449 fileDirPath := lessEmpty(strings.Split(filepath.Dir(fileName), "/")) 450 451 for index, dir := range pwdPath { 452 if dir == first(fileDirPath) { 453 pwdPath = pwdPath[:index] 454 break 455 } 456 } 457 458 return filepath.Join(maybePrefix(strings.Join(pwdPath, "/"), "/"), fileName) 459 } 460 461 // pacakgeFilename returns the github.com/foo/bar/baz.go form of the filename. 462 func packageFilename(pwd, relativePath string) string { 463 fullPath := filepath.Join(pwd, relativePath) 464 return strings.TrimPrefix(strings.TrimPrefix(fullPath, filepath.Join(gopath(), "src")), "/") 465 } 466 467 // parseFullCoverProfile parses the final / merged cover output. 468 func parseFullCoverProfile(pwd string, path string) (covered, total int, err error) { 469 vf("parsing coverage profile: %q", path) 470 files, err := cover.ParseProfiles(path) 471 if err != nil { 472 return 473 } 474 475 var fileCovered, numLines int 476 477 for _, file := range files { 478 fileCovered = 0 479 480 for _, block := range file.Blocks { 481 numLines = block.EndLine - block.StartLine 482 483 total += numLines 484 if block.Count != 0 { 485 fileCovered += numLines 486 } 487 } 488 489 vf("processing coverage profile: %q result: %s (%d/%d lines)", path, file.FileName, fileCovered, numLines) 490 covered += fileCovered 491 } 492 493 return 494 } 495 496 func lessEmpty(values []string) (output []string) { 497 for _, value := range values { 498 if len(value) > 0 { 499 output = append(output, value) 500 } 501 } 502 return 503 } 504 505 func first(values []string) (output string) { 506 if len(values) == 0 { 507 return 508 } 509 output = values[0] 510 return 511 } 512 513 func maybePrefix(root, prefix string) string { 514 if strings.HasPrefix(root, prefix) { 515 return root 516 } 517 return prefix + root 518 } 519 520 // AnsiColor represents an ansi color code fragment. 521 type ansiColor string 522 523 func (acc ansiColor) escaped() string { 524 return "\033[" + string(acc) 525 } 526 527 // Apply returns a string with the color code applied. 528 func (acc ansiColor) Apply(text string) string { 529 return acc.escaped() + text + colorReset.escaped() 530 } 531 532 const ( 533 // ColorGray is the posix escape code fragment for black. 534 colorGray ansiColor = "90m" 535 // ColorRed is the posix escape code fragment for red. 536 colorRed ansiColor = "31m" 537 // ColorYellow is the posix escape code fragment for yellow. 538 colorYellow ansiColor = "33m" 539 // ColorGreen is the posix escape code fragment for green. 540 colorGreen ansiColor = "32m" 541 // ColorReset is the posix escape code fragment to reset all formatting. 542 colorReset ansiColor = "0m" 543 ) 544 545 func parseCoverage(coverage string) float64 { 546 coverage = strings.TrimSpace(coverage) 547 coverage = strings.TrimSuffix(coverage, "%") 548 value, _ := strconv.ParseFloat(coverage, 64) 549 return value 550 } 551 552 func colorCoverage(coverage float64) string { 553 text := formatCoverage(coverage) 554 if coverage > 80.0 { 555 return colorGreen.Apply(text) 556 } else if coverage > 70 { 557 return colorYellow.Apply(text) 558 } else if coverage == 0 { 559 return colorGray.Apply(text) 560 } 561 return colorRed.Apply(text) 562 } 563 564 func formatCoverage(coverage float64) string { 565 return fmt.Sprintf("%.2f", coverage) 566 } 567 568 // Paths are cli flag input paths. 569 type Paths []string 570 571 // String returns the param as a string. 572 func (p *Paths) String() string { 573 return fmt.Sprint(*p) 574 } 575 576 // Set sets a value. 577 func (p *Paths) Set(value string) error { 578 for _, val := range strings.Split(value, ",") { 579 *p = append(*p, val) 580 } 581 return nil 582 }