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 }