oss.indeed.com/go/go-opine@v1.3.0/internal/coverage/coverage.go (about)

     1  // Package coverage is for writing coverage reports.
     2  package coverage
     3  
     4  import (
     5  	"bufio"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"path"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strings"
    13  
    14  	"golang.org/x/tools/cover"
    15  
    16  	"oss.indeed.com/go/go-opine/internal/run"
    17  )
    18  
    19  // Generate testdata/cover.out by running the ./testdata tests.
    20  //go:generate go test -v -race -coverprofile=testdata/cover.out -covermode=atomic ./testdata
    21  
    22  var generatedFileRegexp = regexp.MustCompile(`(?m:^// Code generated .* DO NOT EDIT\.$)`)
    23  
    24  type Coverage struct {
    25  	profiles []*cover.Profile
    26  	modPaths map[string]string
    27  }
    28  
    29  // Load a Go coverprofile file. Generated files are excluded from the result.
    30  func Load(inPath string) (*Coverage, error) {
    31  	profiles, err := cover.ParseProfiles(inPath)
    32  	if err != nil {
    33  		return nil, err
    34  	}
    35  	paths, err := findModPaths(profiles)
    36  	if err != nil {
    37  		return nil, err
    38  	}
    39  	profiles, err = profilesWithoutGenerated(profiles, paths)
    40  	if err != nil {
    41  		return nil, err
    42  	}
    43  	return &Coverage{profiles: profiles, modPaths: paths}, nil
    44  }
    45  
    46  // CoverProfile writes the coverage to a file in the Go "coverprofile" format.
    47  func (cov *Coverage) CoverProfile(outPath string) error {
    48  	f, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
    49  	if err != nil {
    50  		return err
    51  	}
    52  	err = writeProfiles(cov.profiles, f)
    53  	if closeErr := f.Close(); err == nil {
    54  		err = closeErr
    55  	}
    56  	return err
    57  }
    58  
    59  // XML writes the coverage to a file in the Cobertura-style XML format.
    60  func (cov *Coverage) XML(outPath string) error {
    61  	// Work around an issue with gocover-cobertura and Jenkins where the files
    62  	// cannot be found.
    63  	profilesWithRealPaths := make([]*cover.Profile, len(cov.profiles))
    64  	for i, profile := range cov.profiles {
    65  		fileLoc, err := findFileRel(profile.FileName, cov.modPaths)
    66  		if err != nil {
    67  			return err
    68  		}
    69  		profileWithRealPath := *profile
    70  		profileWithRealPath.FileName = fileLoc
    71  		profilesWithRealPaths[i] = &profileWithRealPath
    72  	}
    73  	var modifiedGoCov strings.Builder
    74  	if err := writeProfiles(profilesWithRealPaths, &modifiedGoCov); err != nil {
    75  		return err
    76  	}
    77  	xmlCovOut, _, err := run.Cmd(
    78  		"go",
    79  		run.Args("run", "github.com/t-yuki/gocover-cobertura@v0.0.0-20180217150009-aaee18c8195c"),
    80  		run.Stdin(modifiedGoCov.String()),
    81  		run.SuppressStdout(),
    82  	)
    83  	if err != nil {
    84  		return err
    85  	}
    86  	return os.WriteFile(outPath, []byte(xmlCovOut), 0666) //nolint:gosec
    87  }
    88  
    89  // Ratio returns the ratio of covered statements over all statements. The
    90  // value returned will always be between 0 and 1. If there are no statements
    91  // then 1 is returned.
    92  func (cov *Coverage) Ratio() float64 {
    93  	statementCnt := 0
    94  	statementHit := 0
    95  	for _, profile := range cov.profiles {
    96  		for _, block := range profile.Blocks {
    97  			statementCnt += block.NumStmt
    98  			if block.Count > 0 {
    99  				statementHit += block.NumStmt
   100  			}
   101  		}
   102  	}
   103  	if statementCnt == 0 {
   104  		return 1
   105  	}
   106  	return float64(statementHit) / float64(statementCnt)
   107  }
   108  
   109  // isGenerated checks if the provided file was generated or not. The file
   110  // path should be a real filesystem path, not a Go module path (e.g.
   111  // "/home/you/github.com/proj/main.go", not "github.com/proj/main.go").
   112  func isGenerated(filePath string) (bool, error) {
   113  	file, err := os.Open(filePath)
   114  	if err != nil {
   115  		return false, err
   116  	}
   117  	defer file.Close() // ignore close error (we are not writing)
   118  
   119  	scanner := bufio.NewScanner(file)
   120  	for scanner.Scan() {
   121  		if generatedFileRegexp.MatchString(scanner.Text()) {
   122  			return true, nil
   123  		}
   124  	}
   125  	return isGeneratedReader(bufio.NewReader(file))
   126  }
   127  
   128  // isGeneratedReader checks if the file being read by the provided
   129  // io.RuneReader was generated or not.
   130  func isGeneratedReader(rr io.RuneReader) (bool, error) {
   131  	var err error
   132  	res := generatedFileRegexp.MatchReader(
   133  		// Snooping required due to https://github.com/golang/go/issues/40509.
   134  		runeReaderFunc(
   135  			func() (rune, int, error) {
   136  				var (
   137  					r rune
   138  					n int
   139  				)
   140  				r, n, err = rr.ReadRune()
   141  				return r, n, err
   142  			},
   143  		),
   144  	)
   145  	if err == io.EOF {
   146  		err = nil
   147  	}
   148  	return res, err
   149  }
   150  
   151  // profilesWithoutGenerated returns a new slice of profiles with all
   152  // generated files removed. The provided modPaths must be a map from each
   153  // known Go module to the absolute filesystem path of the module directory,
   154  // as returned by findModPaths.
   155  func profilesWithoutGenerated(profiles []*cover.Profile, modPaths map[string]string) ([]*cover.Profile, error) {
   156  	res := make([]*cover.Profile, 0, len(profiles))
   157  	for _, profile := range profiles {
   158  		filePath, err := findFile(profile.FileName, modPaths)
   159  		if err != nil {
   160  			return nil, err
   161  		}
   162  		if gen, err := isGenerated(filePath); err != nil {
   163  			return nil, err
   164  		} else if !gen {
   165  			res = append(res, profile)
   166  		}
   167  	}
   168  	return res, nil
   169  }
   170  
   171  // findFile finds the absolute filesystem path of a file name specified
   172  // relative to a Go module. The provided modPaths must be a map from each
   173  // known Go module to the absolute filesystem path of the module directory,
   174  // as returned by findModPaths.
   175  //
   176  // If the module of the provided file name is not in the paths an error is
   177  // returned.
   178  func findFile(fileName string, modPaths map[string]string) (string, error) {
   179  	pkg := path.Dir(fileName)
   180  	dir, ok := modPaths[pkg]
   181  	if !ok {
   182  		return "", fmt.Errorf("could not determine the filesystem path of %q", fileName)
   183  	}
   184  	return filepath.Join(dir, path.Base(fileName)), nil
   185  }
   186  
   187  // findFileRel finds the relative (to the current working directory)
   188  // filesystem path of a file name specified relative to a Go module.
   189  //
   190  // See findFile for more information.
   191  func findFileRel(fileName string, modPaths map[string]string) (string, error) {
   192  	filePath, err := findFile(fileName, modPaths)
   193  	if err != nil {
   194  		return "", err
   195  	}
   196  	cwd, err := os.Getwd()
   197  	if err != nil {
   198  		return "", err
   199  	}
   200  	return filepath.Rel(cwd, filePath)
   201  }
   202  
   203  // writeProfiles writes cover profiles to the provided io.Writer in the Go
   204  // "coverprofile" format.
   205  func writeProfiles(profiles []*cover.Profile, w io.Writer) error {
   206  	if len(profiles) == 0 {
   207  		return nil
   208  	}
   209  
   210  	if _, err := io.WriteString(w, "mode: "+profiles[0].Mode+"\n"); err != nil {
   211  		return err
   212  	}
   213  
   214  	for _, profile := range profiles {
   215  		for _, block := range profile.Blocks {
   216  			_, err := fmt.Fprintf(
   217  				w, "%s:%d.%d,%d.%d %d %d\n", profile.FileName,
   218  				block.StartLine, block.StartCol, block.EndLine, block.EndCol,
   219  				block.NumStmt, block.Count,
   220  			)
   221  			if err != nil {
   222  				return err
   223  			}
   224  		}
   225  	}
   226  
   227  	return nil
   228  }
   229  
   230  // findModPaths returns a map from module name to the filesystem path of
   231  // the module directory. The filesystem paths are absolute and do not include
   232  // a trailing "/".
   233  func findModPaths(profiles []*cover.Profile) (map[string]string, error) {
   234  	// Use %q to ensure the ImportPath and Dir boundaries can be easily
   235  	// distinguished. This also protects us against file names containing
   236  	// new line characters... you know you want to make them.
   237  	mods := modsInProfiles(profiles)
   238  	args := append(
   239  		[]string{"list", "-f", `{{ .ImportPath | printf "%q" }} {{ .Dir | printf "%q" }}`},
   240  		mods...,
   241  	)
   242  	stdout, stderr, err := run.Cmd("go", args, run.Log(io.Discard))
   243  	if err != nil {
   244  		return nil, fmt.Errorf("error running go list [stdout=%q stderr=%q]: %w", stdout, stderr, err)
   245  	}
   246  
   247  	modPaths := make(map[string]string, len(mods))
   248  	for _, line := range strings.Split(stdout, "\n") {
   249  		if line == "" {
   250  			continue
   251  		}
   252  		var (
   253  			mod string
   254  			dir string
   255  		)
   256  		_, err := fmt.Sscanf(line, "%q %q", &mod, &dir)
   257  		if err != nil {
   258  			return nil, fmt.Errorf("got unexpected line %q from go list: %w", line, err)
   259  		}
   260  		modPaths[mod] = dir
   261  	}
   262  	return modPaths, nil
   263  }
   264  
   265  // modsInProfiles returns a list of modules in the provided cover
   266  // profiles. Duplicate modules will show up only once in the result.
   267  func modsInProfiles(profiles []*cover.Profile) []string {
   268  	mods := make([]string, 0)
   269  	seen := make(map[string]bool)
   270  	for _, profile := range profiles {
   271  		mod, _ := filepath.Split(profile.FileName)
   272  		if !seen[mod] {
   273  			mods = append(mods, mod)
   274  			seen[mod] = true
   275  		}
   276  	}
   277  	return mods
   278  }
   279  
   280  type runeReaderFunc func() (rune, int, error)
   281  
   282  func (f runeReaderFunc) ReadRune() (rune, int, error) {
   283  	return f()
   284  }