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  }