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 }