github.com/msoap/go-carpet@v1.10.1-0.20240316220419-b690da179708/go-carpet.go (about)

     1  package main
     2  
     3  import (
     4  	"flag"
     5  	"fmt"
     6  	"go/build"
     7  	"io"
     8  	"log"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"regexp"
    13  	"runtime"
    14  	"strings"
    15  
    16  	"github.com/mgutz/ansi"
    17  	"golang.org/x/tools/cover"
    18  )
    19  
    20  const (
    21  	usageMessage = `go-carpet - show test coverage for Go source files
    22  
    23  usage: go-carpet [options] [paths]`
    24  
    25  	version = "1.9.0"
    26  
    27  	// predefined go test options
    28  	goTestCoverProfile = "-coverprofile"
    29  	goTestCoverMode    = "-covermode"
    30  )
    31  
    32  var (
    33  	reNewLine        = regexp.MustCompile("\n")
    34  	reWindowsPathFix = regexp.MustCompile(`^_\\([A-Z])_`)
    35  
    36  	// vendors directories for skip
    37  	vendorDirs = []string{"Godeps", "vendor", ".vendor", "_vendor"}
    38  
    39  	// directories for skip
    40  	skipDirs = []string{"testdata"}
    41  
    42  	errIsNotInGoMod = fmt.Errorf("is not in go modules")
    43  )
    44  
    45  func getDirsWithTests(includeVendor bool, roots ...string) (result []string, err error) {
    46  	if len(roots) == 0 {
    47  		roots = []string{"."}
    48  	}
    49  
    50  	dirs := map[string]struct{}{}
    51  	for _, root := range roots {
    52  		err = filepath.Walk(root, func(path string, _ os.FileInfo, _ error) error {
    53  			if strings.HasSuffix(path, "_test.go") {
    54  				dirs[filepath.Dir(path)] = struct{}{}
    55  			}
    56  			return nil
    57  		})
    58  		if err != nil {
    59  			return result, err
    60  		}
    61  	}
    62  
    63  	result = make([]string, 0, len(dirs))
    64  	for dir := range dirs {
    65  		if !includeVendor && isSliceInStringPrefix(dir, vendorDirs) || isSliceInStringPrefix(dir, skipDirs) {
    66  			continue
    67  		}
    68  		result = append(result, "./"+dir)
    69  	}
    70  
    71  	return result, nil
    72  }
    73  
    74  func readFile(fileName string) (result []byte, err error) {
    75  	fileReader, err := os.Open(fileName)
    76  	if err != nil {
    77  		return result, err
    78  	}
    79  
    80  	result, err = io.ReadAll(fileReader)
    81  	if err == nil {
    82  		err = fileReader.Close()
    83  	}
    84  
    85  	return result, err
    86  }
    87  
    88  func getShadeOfGreen(normCover float64) string {
    89  	/*
    90  		Get all colors for 255-colors terminal:
    91  			gommand 'for i := 0; i < 256; i++ {fmt.Println(i, ansi.ColorCode(strconv.Itoa(i)) + "String" + ansi.ColorCode("reset"))}'
    92  	*/
    93  	var tenShadesOfGreen = [...]string{
    94  		"29",
    95  		"30",
    96  		"34",
    97  		"36",
    98  		"40",
    99  		"42",
   100  		"46",
   101  		"48",
   102  		"50",
   103  		"51",
   104  	}
   105  	if normCover < 0 {
   106  		normCover = 0
   107  	}
   108  	if normCover > 1 {
   109  		normCover = 1
   110  	}
   111  	index := int((normCover - 0.00001) * float64(len(tenShadesOfGreen)))
   112  	return tenShadesOfGreen[index]
   113  }
   114  
   115  func runGoTest(path string, coverFileName string, goTestArgs []string, hideStderr bool) error {
   116  	args := []string{"test", goTestCoverProfile + "=" + coverFileName, goTestCoverMode + "=count"}
   117  	args = append(args, goTestArgs...)
   118  	args = append(args, path)
   119  	osExec := exec.Command("go", args...) // #nosec
   120  	if !hideStderr {
   121  		osExec.Stderr = os.Stderr
   122  	}
   123  
   124  	if output, err := osExec.Output(); err != nil {
   125  		fmt.Print(string(output))
   126  		return err
   127  	}
   128  
   129  	return nil
   130  }
   131  
   132  func guessAbsPathInGOPATH(GOPATH, relPath string) (absPath string, err error) {
   133  	if GOPATH == "" {
   134  		GOPATH = build.Default.GOPATH
   135  		if GOPATH == "" {
   136  			return "", fmt.Errorf("GOPATH is not set")
   137  		}
   138  	}
   139  
   140  	gopathChunks := strings.Split(GOPATH, string(os.PathListSeparator))
   141  	for _, gopathChunk := range gopathChunks {
   142  		guessAbsPath := filepath.Join(gopathChunk, "src", relPath)
   143  		if _, err = os.Stat(guessAbsPath); err == nil {
   144  			absPath = guessAbsPath
   145  			break
   146  		}
   147  	}
   148  
   149  	if absPath == "" {
   150  		return "", fmt.Errorf("file '%s' not found in GOPATH", relPath)
   151  	}
   152  
   153  	return absPath, err
   154  }
   155  
   156  func getCoverForDir(coverFileName string, filesFilter []string, config Config) (result []byte, profileBlocks []cover.ProfileBlock, err error) {
   157  	coverProfile, err := cover.ParseProfiles(coverFileName)
   158  	if err != nil {
   159  		return result, profileBlocks, err
   160  	}
   161  
   162  	for _, fileProfile := range coverProfile {
   163  		// Skip files if minimal coverage is set and is covered more than minimal coverage
   164  		if config.minCoverage > 0 && config.minCoverage < 100.0 && getStatForProfileBlocks(fileProfile.Blocks) > config.minCoverage {
   165  			continue
   166  		}
   167  
   168  		var fileName string
   169  		if strings.HasPrefix(fileProfile.FileName, "/") {
   170  			// TODO: what about windows?
   171  			fileName = fileProfile.FileName
   172  		} else if strings.HasPrefix(fileProfile.FileName, "_") {
   173  			// absolute path (or relative in tests)
   174  			if runtime.GOOS != "windows" {
   175  				fileName = strings.TrimLeft(fileProfile.FileName, "_")
   176  			} else {
   177  				// "_\C_\Users\..." -> "C:\Users\..."
   178  				fileName = reWindowsPathFix.ReplaceAllString(fileProfile.FileName, "$1:")
   179  			}
   180  		} else if fileName, err = guessAbsPathInGoMod(fileProfile.FileName); err != errIsNotInGoMod {
   181  			if err != nil {
   182  				return result, profileBlocks, err
   183  			}
   184  		} else {
   185  			// file in one dir in GOPATH
   186  			fileName, err = guessAbsPathInGOPATH(os.Getenv("GOPATH"), fileProfile.FileName)
   187  			if err != nil {
   188  				return result, profileBlocks, err
   189  			}
   190  		}
   191  
   192  		if len(filesFilter) > 0 && !isSliceInString(fileName, filesFilter) {
   193  			continue
   194  		}
   195  
   196  		var fileBytes []byte
   197  		fileBytes, err = readFile(fileName)
   198  		if err != nil {
   199  			return result, profileBlocks, err
   200  		}
   201  
   202  		result = append(result, getCoverForFile(fileProfile, fileBytes, config)...)
   203  		profileBlocks = append(profileBlocks, fileProfile.Blocks...)
   204  	}
   205  
   206  	return result, profileBlocks, err
   207  }
   208  
   209  func getColorHeader(header string, addUnderiline bool) string {
   210  	result := ansi.ColorCode("yellow") +
   211  		header + ansi.ColorCode("reset") + "\n"
   212  
   213  	if addUnderiline {
   214  		result += ansi.ColorCode("black+h") +
   215  			strings.Repeat("~", len(header)) +
   216  			ansi.ColorCode("reset") + "\n"
   217  	}
   218  
   219  	return result
   220  }
   221  
   222  // algorithms from Go-sources:
   223  //
   224  //	src/cmd/cover/html.go::percentCovered()
   225  //	src/testing/cover.go::coverReport()
   226  func getStatForProfileBlocks(fileProfileBlocks []cover.ProfileBlock) (stat float64) {
   227  	var total, covered int64
   228  	for _, profileBlock := range fileProfileBlocks {
   229  		total += int64(profileBlock.NumStmt)
   230  		if profileBlock.Count > 0 {
   231  			covered += int64(profileBlock.NumStmt)
   232  		}
   233  	}
   234  	if total > 0 {
   235  		stat = float64(covered) / float64(total) * 100.0
   236  	}
   237  
   238  	return stat
   239  }
   240  
   241  func getCoverForFile(fileProfile *cover.Profile, fileBytes []byte, config Config) (result []byte) {
   242  	stat := getStatForProfileBlocks(fileProfile.Blocks)
   243  
   244  	textRanges, err := getFileFuncRanges(fileBytes, config.funcFilter)
   245  	if err != nil {
   246  		return result
   247  	}
   248  
   249  	var fileNameDisplay string
   250  	if len(config.funcFilter) == 0 {
   251  		fileNameDisplay = fmt.Sprintf("%s - %.1f%%", strings.TrimLeft(fileProfile.FileName, "_"), stat)
   252  	} else {
   253  		fileNameDisplay = strings.TrimLeft(fileProfile.FileName, "_")
   254  	}
   255  
   256  	if config.summary {
   257  		return []byte(fileNameDisplay + "\n")
   258  	}
   259  
   260  	result = append(result, []byte(getColorHeader(fileNameDisplay, true))...)
   261  
   262  	boundaries := fileProfile.Boundaries(fileBytes)
   263  
   264  	for _, textRange := range textRanges {
   265  		fileBytesPart := fileBytes[textRange.begin:textRange.end]
   266  		curOffset := 0
   267  		coverColor := ""
   268  
   269  		for _, boundary := range boundaries {
   270  			if boundary.Offset < textRange.begin || boundary.Offset > textRange.end {
   271  				// skip boundary which is not in filter function
   272  				continue
   273  			}
   274  
   275  			boundaryOffset := boundary.Offset - textRange.begin
   276  
   277  			if boundaryOffset > curOffset {
   278  				nextChunk := fileBytesPart[curOffset:boundaryOffset]
   279  				// Add ansi color code in begin of each line (this fixed view in "less -R")
   280  				if coverColor != "" && coverColor != ansi.ColorCode("reset") {
   281  					nextChunk = reNewLine.ReplaceAllLiteral(nextChunk, []byte(ansi.ColorCode("reset")+"\n"+coverColor))
   282  				}
   283  				result = append(result, nextChunk...)
   284  			}
   285  
   286  			switch {
   287  			case boundary.Start && boundary.Count > 0:
   288  				coverColor = ansi.ColorCode("green")
   289  				if config.colors256 {
   290  					coverColor = ansi.ColorCode(getShadeOfGreen(boundary.Norm))
   291  				}
   292  			case boundary.Start && boundary.Count == 0:
   293  				coverColor = ansi.ColorCode("red")
   294  			case !boundary.Start:
   295  				coverColor = ansi.ColorCode("reset")
   296  			}
   297  			result = append(result, []byte(coverColor)...)
   298  
   299  			curOffset = boundaryOffset
   300  		}
   301  		if curOffset < len(fileBytesPart) {
   302  			result = append(result, fileBytesPart[curOffset:]...)
   303  		}
   304  
   305  		result = append(result, []byte("\n")...)
   306  	}
   307  
   308  	return result
   309  }
   310  
   311  type textRange struct {
   312  	begin, end int
   313  }
   314  
   315  func getFileFuncRanges(fileBytes []byte, funcs []string) (result []textRange, err error) {
   316  	if len(funcs) == 0 {
   317  		return []textRange{{
   318  			begin: 0,
   319  			end:   len(fileBytes),
   320  		}}, nil
   321  	}
   322  
   323  	golangFuncs, err := getGolangFuncs(fileBytes)
   324  	if err != nil {
   325  		return nil, err
   326  	}
   327  
   328  	for _, existsFunc := range golangFuncs {
   329  		for _, filterFuncName := range funcs {
   330  			if existsFunc.Name == filterFuncName {
   331  				result = append(result, textRange{begin: existsFunc.Begin - 1, end: existsFunc.End - 1})
   332  			}
   333  		}
   334  	}
   335  
   336  	if len(result) == 0 {
   337  		return nil, fmt.Errorf("filter by functions: %v - not found", funcs)
   338  	}
   339  
   340  	return result, nil
   341  }
   342  
   343  func getTempFileName() (string, error) {
   344  	tmpFile, err := os.CreateTemp(".", "go-carpet-coverage-out-")
   345  	if err != nil {
   346  		return "", err
   347  	}
   348  	err = tmpFile.Close()
   349  	if err != nil {
   350  		return "", err
   351  	}
   352  
   353  	return tmpFile.Name(), nil
   354  }
   355  
   356  // Config - application config
   357  type Config struct {
   358  	filesFilterRaw string
   359  	filesFilter    []string
   360  	funcFilterRaw  string
   361  	funcFilter     []string
   362  	argsRaw        string
   363  	minCoverage    float64
   364  	colors256      bool
   365  	includeVendor  bool
   366  	summary        bool
   367  }
   368  
   369  var config Config
   370  
   371  func init() {
   372  	flag.StringVar(&config.filesFilterRaw, "file", "", "comma-separated list of `files` to test (default: all)")
   373  	flag.StringVar(&config.funcFilterRaw, "func", "", "comma-separated `functions` list (default: all functions)")
   374  	flag.BoolVar(&config.colors256, "256colors", false, "use more colors on 256-color terminal (indicate the level of coverage)")
   375  	flag.BoolVar(&config.summary, "summary", false, "only show summary for each file")
   376  	flag.BoolVar(&config.includeVendor, "include-vendor", false, "include vendor directories for show coverage (Godeps, vendor)")
   377  	flag.StringVar(&config.argsRaw, "args", "", "pass additional `arguments` for go test")
   378  	flag.Float64Var(&config.minCoverage, "mincov", 100.0, "coverage threshold of the file to be displayed (in percent)")
   379  	flag.Usage = func() {
   380  		fmt.Println(usageMessage)
   381  		flag.PrintDefaults()
   382  		os.Exit(0)
   383  	}
   384  }
   385  
   386  func main() {
   387  	versionFl := flag.Bool("version", false, "get version")
   388  	flag.Parse()
   389  
   390  	if *versionFl {
   391  		fmt.Println(version)
   392  		os.Exit(0)
   393  	}
   394  
   395  	config.filesFilter = grepEmptyStringSlice(strings.Split(config.filesFilterRaw, ","))
   396  	config.funcFilter = grepEmptyStringSlice(strings.Split(config.funcFilterRaw, ","))
   397  	additionalArgs, err := parseAdditionalArgs(config.argsRaw, []string{goTestCoverProfile, goTestCoverMode})
   398  	if err != nil {
   399  		log.Fatal(err)
   400  	}
   401  
   402  	testDirs := flag.Args()
   403  
   404  	coverFileName, err := getTempFileName()
   405  	if err != nil {
   406  		log.Fatal(err)
   407  	}
   408  	defer func() {
   409  		err = os.RemoveAll(coverFileName)
   410  		if err != nil {
   411  			log.Fatal(err)
   412  		}
   413  	}()
   414  
   415  	stdOut := getColorWriter()
   416  	allProfileBlocks := []cover.ProfileBlock{}
   417  
   418  	if len(testDirs) > 0 {
   419  		testDirs, err = getDirsWithTests(config.includeVendor, testDirs...)
   420  	} else {
   421  		testDirs, err = getDirsWithTests(config.includeVendor, ".")
   422  	}
   423  	if err != nil {
   424  		log.Fatal(err)
   425  	}
   426  
   427  	for _, path := range testDirs {
   428  		if err = runGoTest(path, coverFileName, additionalArgs, false); err != nil {
   429  			log.Print(err)
   430  			continue
   431  		}
   432  
   433  		coverInBytes, profileBlocks, errCover := getCoverForDir(coverFileName, config.filesFilter, config)
   434  		if errCover != nil {
   435  			log.Print(errCover)
   436  			continue
   437  		}
   438  		_, err = stdOut.Write(coverInBytes)
   439  		if err != nil {
   440  			log.Fatal(err)
   441  		}
   442  
   443  		allProfileBlocks = append(allProfileBlocks, profileBlocks...)
   444  	}
   445  
   446  	if len(allProfileBlocks) > 0 && len(config.funcFilter) == 0 {
   447  		stat := getStatForProfileBlocks(allProfileBlocks)
   448  		totalCoverage := fmt.Sprintf("Coverage: %.1f%% of statements", stat)
   449  		_, err = stdOut.Write([]byte(getColorHeader(totalCoverage, false)))
   450  		if err != nil {
   451  			log.Fatal(err)
   452  		}
   453  	}
   454  }