Coverage report
Module: github.com/itsubaki/gocov
Generated 2026-06-06 09:29:35 UTC
Tracked
655lines
Covered
500lines
Partial
22lines
Missed
133lines
Directories
80.3%
total
badge
100.0%
profile
80.0%
render
91.7%
render/coverage
100.0%
render/directory
96.5%
report
66.9%
Files
badge/badge.go
100.0%
badge/badge.go
100.0%
| 1 | 0 | package badge |
| 2 | 0 | |
| 3 | 0 | import "fmt" |
| 4 | 0 | |
| 5 | 2 | func SVG(coverage float64) string { |
| 6 | 2 | length := 260 |
| 7 | 3 | if int(coverage) == 100 { |
| 8 | 1 | length = 320 |
| 9 | 1 | } |
| 10 | 0 | |
| 11 | 2 | return fmt.Sprintf(` |
| 12 | 2 | <svg xmlns="http://www.w3.org/2000/svg" width="100" height="20" role="img"> |
| 13 | 2 | <g shape-rendering="crispEdges"> |
| 14 | 2 | <rect width="61" height="20" fill="#555"/> |
| 15 | 2 | <rect x="61" width="39" height="20" fill="#4b0"/> |
| 16 | 2 | </g> |
| 17 | 2 | <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"> |
| 18 | 2 | <text x="305" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text> |
| 19 | 2 | <text x="805" y="140" transform="scale(.1)" fill="#fff" textLength="%d">%d%%</text> |
| 20 | 2 | </g> |
| 21 | 2 | </svg> |
| 22 | 2 | `, |
| 23 | 2 | length, |
| 24 | 2 | int(coverage), |
| 25 | 2 | ) |
| 26 | 0 | } |
profile/block.go
85.3%
profile/block.go
85.3%
| 1 | 0 | package profile |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "path/filepath" |
| 6 | 0 | "strconv" |
| 7 | 0 | "strings" |
| 8 | 0 | ) |
| 9 | 0 | |
| 10 | 0 | type Block struct { |
| 11 | 0 | File string |
| 12 | 0 | StartLine int |
| 13 | 0 | StartCol int |
| 14 | 0 | EndLine int |
| 15 | 0 | EndCol int |
| 16 | 0 | Statements int |
| 17 | 0 | Count int64 |
| 18 | 0 | } |
| 19 | 0 | |
| 20 | 253 | func parseLine(line string) (*Block, error) { |
| 21 | 253 | fields := strings.Fields(line) |
| 22 | 254 | if len(fields) != 3 { |
| 23 | 1 | return nil, fmt.Errorf("invalid line: %q", line) |
| 24 | 1 | } |
| 25 | 0 | |
| 26 | 252 | location := fields[0] |
| 27 | 252 | colon := strings.LastIndex(location, ":") |
| 28 | 253 | if colon < 1 || colon == len(location)-1 { |
| 29 | 1 | return nil, fmt.Errorf("invalid location %q", location) |
| 30 | 1 | } |
| 31 | 0 | |
| 32 | 251 | startEnd := location[colon+1:] |
| 33 | 251 | points := strings.Split(startEnd, ",") |
| 34 | 251 | if len(points) != 2 { |
| 35 | 0 | return nil, fmt.Errorf("invalid range %q", startEnd) |
| 36 | 0 | } |
| 37 | 0 | |
| 38 | 251 | startLine, startCol, err := parsePoint(points[0]) |
| 39 | 251 | if err != nil { |
| 40 | 0 | return nil, fmt.Errorf("invalid start point: %w", err) |
| 41 | 0 | } |
| 42 | 0 | |
| 43 | 251 | endLine, endCol, err := parsePoint(points[1]) |
| 44 | 251 | if err != nil { |
| 45 | 0 | return nil, fmt.Errorf("invalid end point: %w", err) |
| 46 | 0 | } |
| 47 | 0 | |
| 48 | 251 | statements, err := strconv.Atoi(fields[1]) |
| 49 | 251 | if err != nil { |
| 50 | 0 | return nil, fmt.Errorf("invalid statement count %q", fields[1]) |
| 51 | 0 | } |
| 52 | 0 | |
| 53 | 251 | count, err := strconv.ParseInt(fields[2], 10, 64) |
| 54 | 251 | if err != nil { |
| 55 | 0 | return nil, fmt.Errorf("invalid execution count %q", fields[2]) |
| 56 | 0 | } |
| 57 | 0 | |
| 58 | 251 | return &Block{ |
| 59 | 251 | File: filepath.ToSlash(location[:colon]), |
| 60 | 251 | StartLine: startLine, |
| 61 | 251 | StartCol: startCol, |
| 62 | 251 | EndLine: endLine, |
| 63 | 251 | EndCol: endCol, |
| 64 | 251 | Statements: statements, |
| 65 | 251 | Count: count, |
| 66 | 251 | }, nil |
| 67 | 0 | } |
| 68 | 0 | |
| 69 | 507 | func parsePoint(point string) (int, int, error) { |
| 70 | 507 | lineCol := strings.Split(point, ".") |
| 71 | 508 | if len(lineCol) != 2 { |
| 72 | 1 | return 0, 0, fmt.Errorf("invalid point: %q", point) |
| 73 | 1 | } |
| 74 | 0 | |
| 75 | 506 | line, err := strconv.Atoi(lineCol[0]) |
| 76 | 507 | if err != nil { |
| 77 | 1 | return 0, 0, fmt.Errorf("invalid line: %q", lineCol[0]) |
| 78 | 1 | } |
| 79 | 0 | |
| 80 | 505 | col, err := strconv.Atoi(lineCol[1]) |
| 81 | 506 | if err != nil { |
| 82 | 1 | return 0, 0, fmt.Errorf("invalid column: %q", lineCol[1]) |
| 83 | 1 | } |
| 84 | 0 | |
| 85 | 504 | return line, col, nil |
| 86 | 0 | } |
profile/profile.go
74.2%
profile/profile.go
74.2%
| 1 | 0 | package profile |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "bufio" |
| 5 | 0 | "fmt" |
| 6 | 0 | "os" |
| 7 | 0 | "strings" |
| 8 | 0 | ) |
| 9 | 0 | |
| 10 | 0 | type Profile struct { |
| 11 | 0 | Mode string // coverage mode (e.g., "set", "count", "atomic") |
| 12 | 0 | Blocks []*Block // coverage blocks |
| 13 | 0 | } |
| 14 | 0 | |
| 15 | 1 | func Parse(path string) (*Profile, error) { |
| 16 | 1 | file, err := os.Open(path) |
| 17 | 1 | if err != nil { |
| 18 | 0 | return nil, fmt.Errorf("open coverage profile: %w", err) |
| 19 | 0 | } |
| 20 | 2 | defer func() { |
| 21 | 1 | if err := file.Close(); err != nil { |
| 22 | 0 | fmt.Fprintf(os.Stderr, "gocov: %v\n", err) |
| 23 | 0 | } |
| 24 | 0 | }() |
| 25 | 0 | |
| 26 | 1 | scanner := bufio.NewScanner(file) |
| 27 | 1 | scanner.Buffer(make([]byte, 1024), 1024*1024) |
| 28 | 1 | |
| 29 | 1 | // read mode header |
| 30 | 1 | var mode string |
| 31 | 2 | if scanner.Scan() { |
| 32 | 1 | line := strings.TrimSpace(scanner.Text()) |
| 33 | 1 | if line == "" { |
| 34 | 0 | return nil, fmt.Errorf("missing mode header %q", path) |
| 35 | 0 | } |
| 36 | 0 | |
| 37 | 1 | md, ok := strings.CutPrefix(line, "mode:") |
| 38 | 1 | if !ok { |
| 39 | 0 | return nil, fmt.Errorf("invalid mode header %q", line) |
| 40 | 0 | } |
| 41 | 0 | |
| 42 | 1 | mode = strings.TrimSpace(md) |
| 43 | 1 | if mode == "" { |
| 44 | 0 | return nil, fmt.Errorf("empty mode header %q", line) |
| 45 | 0 | } |
| 46 | 0 | } |
| 47 | 0 | |
| 48 | 0 | // read blocks |
| 49 | 1 | var blocks []*Block |
| 50 | 250 | for scanner.Scan() { |
| 51 | 249 | line := strings.TrimSpace(scanner.Text()) |
| 52 | 249 | if line == "" { |
| 53 | 0 | continue |
| 54 | 0 | } |
| 55 | 0 | |
| 56 | 249 | block, err := parseLine(line) |
| 57 | 249 | if err != nil { |
| 58 | 0 | return nil, fmt.Errorf("parse line %q: %w", line, err) |
| 59 | 0 | } |
| 60 | 0 | |
| 61 | 249 | blocks = append(blocks, block) |
| 62 | 0 | } |
| 63 | 0 | |
| 64 | 1 | if err := scanner.Err(); err != nil { |
| 65 | 0 | return nil, fmt.Errorf("read coverage profile: %w", err) |
| 66 | 0 | } |
| 67 | 0 | |
| 68 | 1 | return &Profile{ |
| 69 | 1 | Mode: mode, |
| 70 | 1 | Blocks: blocks, |
| 71 | 1 | }, nil |
| 72 | 0 | } |
render/coverage/color.go
100.0%
render/coverage/color.go
100.0%
| 1 | 0 | package coverage |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "math" |
| 6 | 0 | ) |
| 7 | 0 | |
| 8 | 0 | // Color returns the color for a given coverage percentage. |
| 9 | 5 | func Color(v float64) string { |
| 10 | 7 | if math.IsNaN(v) || math.IsInf(v, 0) { |
| 11 | 2 | v = 0 |
| 12 | 2 | } |
| 13 | 0 | |
| 14 | 5 | switch v = min(max(v, 0), 100); { |
| 15 | 1 | case v >= 80: |
| 16 | 1 | return bandColor(v, 80, 100, 118, 145, 54, 40) |
| 17 | 1 | case v >= 60: |
| 18 | 1 | return bandColor(v, 60, 80, 28, 42, 72, 46) |
| 19 | 3 | default: |
| 20 | 3 | return bandColor(v, 0, 60, 0, 8, 68, 46) |
| 21 | 0 | } |
| 22 | 0 | } |
| 23 | 0 | |
| 24 | 5 | func bandColor(v, start, end, startHue, endHue, saturation, lightness float64) string { |
| 25 | 5 | progress := 0.0 |
| 26 | 10 | if end > start { |
| 27 | 5 | progress = (v - start) / (end - start) |
| 28 | 5 | } |
| 29 | 0 | |
| 30 | 5 | hue := startHue + (endHue-startHue)*min(max(progress, 0), 1) |
| 31 | 5 | return hslHex(hue, saturation, lightness) |
| 32 | 0 | } |
| 33 | 0 | |
| 34 | 8 | func hslHex(hue, saturation, lightness float64) string { |
| 35 | 8 | h := math.Mod(hue, 360) / 360 |
| 36 | 8 | s := min(max(saturation/100, 0), 1) |
| 37 | 8 | l := min(max(lightness/100, 0), 1) |
| 38 | 8 | |
| 39 | 8 | r, g, b := l, l, l |
| 40 | 16 | if s != 0 { |
| 41 | 8 | q := l * (1 + s) |
| 42 | 11 | if l >= 0.5 { |
| 43 | 3 | q = l + s - l*s |
| 44 | 3 | } |
| 45 | 0 | |
| 46 | 8 | p := 2*l - q |
| 47 | 8 | r = hueToRGB(p, q, h+1.0/3.0) |
| 48 | 8 | g = hueToRGB(p, q, h) |
| 49 | 8 | b = hueToRGB(p, q, h-1.0/3.0) |
| 50 | 0 | } |
| 51 | 0 | |
| 52 | 8 | return fmt.Sprintf("#%02x%02x%02x", int(math.Round(r*255)), int(math.Round(g*255)), int(math.Round(b*255))) |
| 53 | 0 | } |
| 54 | 0 | |
| 55 | 27 | func hueToRGB(p, q, t float64) float64 { |
| 56 | 34 | if t < 0 { |
| 57 | 7 | t++ |
| 58 | 7 | } |
| 59 | 0 | |
| 60 | 28 | if t > 1 { |
| 61 | 1 | t-- |
| 62 | 1 | } |
| 63 | 0 | |
| 64 | 27 | switch { |
| 65 | 6 | case t < 1.0/6.0: |
| 66 | 6 | return p + (q-p)*6*t |
| 67 | 9 | case t < 1.0/2.0: |
| 68 | 9 | return q |
| 69 | 1 | case t < 2.0/3.0: |
| 70 | 1 | return p + (q-p)*(2.0/3.0-t)*6 |
| 71 | 11 | default: |
| 72 | 11 | return p |
| 73 | 0 | } |
| 74 | 0 | } |
render/coverage/percent.go
100.0%
render/coverage/percent.go
100.0%
| 1 | 0 | package coverage |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "math" |
| 6 | 0 | ) |
| 7 | 0 | |
| 8 | 0 | // Percent formats a float64 as a percentage string with one decimal place. |
| 9 | 4 | func Percent(v float64) string { |
| 10 | 6 | if math.IsNaN(v) || math.IsInf(v, 0) { |
| 11 | 2 | return "0.0%" |
| 12 | 2 | } |
| 13 | 0 | |
| 14 | 2 | return fmt.Sprintf("%.1f%%", v) |
| 15 | 0 | } |
| 16 | 0 | |
| 17 | 0 | // SharePercent returns the percentage of part over total as a string with one decimal place. |
| 18 | 3 | func SharePercent(part, total int) string { |
| 19 | 5 | if total <= 0 || part <= 0 { |
| 20 | 2 | return "0.0%" |
| 21 | 2 | } |
| 22 | 0 | |
| 23 | 1 | return fmt.Sprintf("%.1f%%", float64(part)*100/float64(total)) |
| 24 | 0 | } |
render/directory/directory.go
97.4%
render/directory/directory.go
97.4%
| 1 | 0 | package directory |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "math" |
| 6 | 0 | |
| 7 | 0 | "github.com/itsubaki/gocov/render/coverage" |
| 8 | 0 | "github.com/itsubaki/gocov/report" |
| 9 | 0 | ) |
| 10 | 0 | |
| 11 | 0 | type Directory struct { |
| 12 | 0 | Name string |
| 13 | 0 | Depth int |
| 14 | 0 | Statements int |
| 15 | 0 | Path string |
| 16 | 0 | Color string |
| 17 | 0 | Coverage string |
| 18 | 0 | Share string |
| 19 | 0 | } |
| 20 | 0 | |
| 21 | 1 | func New(rep *report.Report) []*Directory { |
| 22 | 1 | root := NewNode(rep.Directories) |
| 23 | 1 | total := root.TotalStatements() |
| 24 | 1 | if total <= 0 { |
| 25 | 0 | return nil |
| 26 | 0 | } |
| 27 | 0 | |
| 28 | 1 | return appendDir( |
| 29 | 1 | []*Directory{}, |
| 30 | 1 | root, |
| 31 | 1 | -90, |
| 32 | 1 | 270, |
| 33 | 1 | root.MaxDepth()+1, |
| 34 | 1 | total, |
| 35 | 1 | ) |
| 36 | 0 | } |
| 37 | 0 | |
| 38 | 4 | func appendDir(dirs []*Directory, node *Node, start, end float64, ringCount, total int) []*Directory { |
| 39 | 4 | // append current directory |
| 40 | 4 | weight := node.TotalStatements() |
| 41 | 4 | slices := append(dirs, &Directory{ |
| 42 | 4 | Name: node.displayPath, |
| 43 | 4 | Depth: node.depth, |
| 44 | 4 | Statements: weight, |
| 45 | 4 | Path: donutSegmentPath(start, end, node.depth, ringCount), |
| 46 | 4 | Color: coverage.Color(node.Stats.Percent), |
| 47 | 4 | Coverage: coverage.Percent(node.Stats.Percent), |
| 48 | 4 | Share: coverage.SharePercent(weight, total), |
| 49 | 4 | }) |
| 50 | 4 | |
| 51 | 6 | if len(node.children) == 0 || weight < 1 { |
| 52 | 2 | // no child or no coverable lines, skip children |
| 53 | 2 | return slices |
| 54 | 2 | } |
| 55 | 0 | |
| 56 | 0 | // append child directories |
| 57 | 2 | next, span := start, end-start |
| 58 | 5 | for _, v := range node.children { |
| 59 | 6 | if w := v.TotalStatements(); w > 0 { |
| 60 | 3 | start, end := next, next+span*float64(w)/float64(weight) |
| 61 | 3 | next = end |
| 62 | 3 | |
| 63 | 3 | // append child directory |
| 64 | 3 | slices = appendDir(slices, v, start, end, ringCount, total) |
| 65 | 3 | } |
| 66 | 0 | } |
| 67 | 0 | |
| 68 | 2 | return slices |
| 69 | 0 | } |
| 70 | 0 | |
| 71 | 0 | // donutSegmentPath returns the SVG path for a donut segment defined by start and end angles, depth, and total ring count. |
| 72 | 8 | func donutSegmentPath(start, end float64, depth, ringCount int) string { |
| 73 | 9 | if end <= start { |
| 74 | 1 | return "" |
| 75 | 1 | } |
| 76 | 0 | |
| 77 | 7 | inner, outer := ringRadii(depth, ringCount) |
| 78 | 9 | if end-start >= 359.999 { |
| 79 | 2 | return fmt.Sprintf("M 0 %.4f A %.4f %.4f 0 1 1 0 %.4f A %.4f %.4f 0 1 1 0 %.4f M 0 %.4f A %.4f %.4f 0 1 0 0 %.4f A %.4f %.4f 0 1 0 0 %.4f Z", |
| 80 | 2 | -outer, |
| 81 | 2 | outer, |
| 82 | 2 | outer, |
| 83 | 2 | outer, |
| 84 | 2 | outer, |
| 85 | 2 | outer, |
| 86 | 2 | -outer, |
| 87 | 2 | -inner, |
| 88 | 2 | inner, |
| 89 | 2 | inner, |
| 90 | 2 | inner, |
| 91 | 2 | inner, |
| 92 | 2 | inner, |
| 93 | 2 | -inner, |
| 94 | 2 | ) |
| 95 | 2 | } |
| 96 | 0 | |
| 97 | 25 | polarPoint := func(radius, degrees float64) (float64, float64) { |
| 98 | 20 | radians := degrees * math.Pi / 180 |
| 99 | 20 | return radius * math.Cos(radians), radius * math.Sin(radians) |
| 100 | 20 | } |
| 101 | 0 | |
| 102 | 5 | startOuterX, startOuterY := polarPoint(outer, start) |
| 103 | 5 | endOuterX, endOuterY := polarPoint(outer, end) |
| 104 | 5 | startInnerX, startInnerY := polarPoint(inner, start) |
| 105 | 5 | endInnerX, endInnerY := polarPoint(inner, end) |
| 106 | 5 | |
| 107 | 5 | var largeArc int |
| 108 | 7 | if end-start > 180 { |
| 109 | 2 | largeArc = 1 |
| 110 | 2 | } |
| 111 | 0 | |
| 112 | 5 | return fmt.Sprintf("M %.4f %.4f A %.4f %.4f 0 %d 1 %.4f %.4f L %.4f %.4f A %.4f %.4f 0 %d 0 %.4f %.4f Z", |
| 113 | 5 | startOuterX, |
| 114 | 5 | startOuterY, |
| 115 | 5 | outer, |
| 116 | 5 | outer, |
| 117 | 5 | largeArc, |
| 118 | 5 | endOuterX, |
| 119 | 5 | endOuterY, |
| 120 | 5 | endInnerX, |
| 121 | 5 | endInnerY, |
| 122 | 5 | inner, |
| 123 | 5 | inner, |
| 124 | 5 | largeArc, |
| 125 | 5 | startInnerX, |
| 126 | 5 | startInnerY, |
| 127 | 5 | ) |
| 128 | 0 | } |
| 129 | 0 | |
| 130 | 0 | // ringRadii returns the inner and outer radii for a given depth and total ring count. |
| 131 | 10 | func ringRadii(depth, count int) (float64, float64) { |
| 132 | 10 | const centerRadius = 0.34 |
| 133 | 11 | if count <= 0 { |
| 134 | 1 | return centerRadius, 1 |
| 135 | 1 | } |
| 136 | 0 | |
| 137 | 9 | width := (1 - centerRadius) / float64(count) |
| 138 | 9 | inner := centerRadius + float64(depth)*width |
| 139 | 9 | outer := centerRadius + float64(depth+1)*width |
| 140 | 9 | return inner, min(outer, 1) |
| 141 | 0 | } |
render/directory/node.go
95.7%
render/directory/node.go
95.7%
| 1 | 0 | package directory |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "sort" |
| 5 | 0 | "strings" |
| 6 | 0 | |
| 7 | 0 | "github.com/itsubaki/gocov/report" |
| 8 | 0 | ) |
| 9 | 0 | |
| 10 | 0 | type Node struct { |
| 11 | 0 | Stats report.Stats |
| 12 | 0 | SelfStats report.Stats |
| 13 | 0 | name string |
| 14 | 0 | displayPath string |
| 15 | 0 | depth int |
| 16 | 0 | children []*Node |
| 17 | 0 | childByName map[string]*Node |
| 18 | 0 | } |
| 19 | 0 | |
| 20 | 1 | func NewNode(dirs []*report.Directory) *Node { |
| 21 | 1 | root := &Node{ |
| 22 | 1 | name: "root", |
| 23 | 1 | displayPath: "root", |
| 24 | 1 | childByName: make(map[string]*Node), |
| 25 | 1 | } |
| 26 | 1 | |
| 27 | 5 | for _, dir := range dirs { |
| 28 | 4 | if dir.Stats.TotalStatements == 0 { |
| 29 | 0 | continue |
| 30 | 0 | } |
| 31 | 0 | |
| 32 | 5 | if dir.Name == "root" { |
| 33 | 1 | root.SelfStats = report.Merge(root.SelfStats, dir.Stats) |
| 34 | 1 | continue |
| 35 | 0 | } |
| 36 | 0 | |
| 37 | 3 | next, parts := root, pathParts(dir.Name) |
| 38 | 8 | for i, name := range parts { |
| 39 | 7 | if child := next.childByName[name]; child != nil { |
| 40 | 2 | next = child |
| 41 | 2 | continue |
| 42 | 0 | } |
| 43 | 3 | next = next.Add(&Node{ |
| 44 | 3 | name: name, |
| 45 | 3 | displayPath: strings.Join(parts[:i+1], "/"), |
| 46 | 3 | depth: next.depth + 1, |
| 47 | 3 | childByName: make(map[string]*Node), |
| 48 | 3 | }) |
| 49 | 0 | } |
| 50 | 3 | next.SelfStats = report.Merge(next.SelfStats, dir.Stats) |
| 51 | 0 | } |
| 52 | 0 | |
| 53 | 1 | accumulate(root) |
| 54 | 1 | nsort(root) |
| 55 | 1 | return root |
| 56 | 0 | } |
| 57 | 0 | |
| 58 | 0 | // TotalStatements returns the total number of coverable statements in the directory and its subdirectories. |
| 59 | 8 | func (n *Node) TotalStatements() int { |
| 60 | 8 | var sum int |
| 61 | 14 | for _, c := range n.children { |
| 62 | 6 | sum += c.Stats.TotalStatements |
| 63 | 6 | } |
| 64 | 0 | |
| 65 | 8 | return sum + n.SelfStats.TotalStatements |
| 66 | 0 | } |
| 67 | 0 | |
| 68 | 4 | func (n *Node) MaxDepth() int { |
| 69 | 4 | depth := n.depth |
| 70 | 6 | if len(n.children) > 0 && n.SelfStats.TotalStatements > 0 { |
| 71 | 2 | depth = max(depth, n.depth+1) |
| 72 | 2 | } |
| 73 | 0 | |
| 74 | 7 | for _, v := range n.children { |
| 75 | 3 | depth = max(depth, v.MaxDepth()) |
| 76 | 3 | } |
| 77 | 0 | |
| 78 | 4 | return depth |
| 79 | 0 | } |
| 80 | 0 | |
| 81 | 3 | func (n *Node) Add(child *Node) *Node { |
| 82 | 3 | n.childByName[child.name] = child |
| 83 | 3 | n.children = append(n.children, child) |
| 84 | 3 | return child |
| 85 | 3 | } |
| 86 | 0 | |
| 87 | 5 | func pathParts(path string) []string { |
| 88 | 5 | slash := strings.ReplaceAll(path, "\\", "/") |
| 89 | 5 | parts := strings.Split(slash, "/") |
| 90 | 5 | |
| 91 | 5 | out := make([]string, 0, len(parts)) |
| 92 | 14 | for _, part := range parts { |
| 93 | 9 | if part == "" || part == "." { |
| 94 | 0 | continue |
| 95 | 0 | } |
| 96 | 0 | |
| 97 | 9 | out = append(out, part) |
| 98 | 0 | } |
| 99 | 0 | |
| 100 | 5 | return out |
| 101 | 0 | } |
| 102 | 0 | |
| 103 | 4 | func accumulate(n *Node) report.Stats { |
| 104 | 4 | sum := n.SelfStats |
| 105 | 7 | for _, c := range n.children { |
| 106 | 3 | sum = report.Merge(sum, accumulate(c)) |
| 107 | 3 | } |
| 108 | 0 | |
| 109 | 4 | n.Stats = sum |
| 110 | 4 | return sum |
| 111 | 0 | } |
| 112 | 0 | |
| 113 | 4 | func nsort(n *Node) { |
| 114 | 5 | sort.Slice(n.children, func(i, j int) bool { |
| 115 | 1 | return n.children[i].displayPath < n.children[j].displayPath |
| 116 | 1 | }) |
| 117 | 0 | |
| 118 | 7 | for _, v := range n.children { |
| 119 | 3 | nsort(v) |
| 120 | 3 | } |
| 121 | 0 | } |
render/render.go
91.7%
render/render.go
91.7%
| 1 | 0 | package render |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "html/template" |
| 6 | 0 | "io" |
| 7 | 0 | "math" |
| 8 | 0 | "time" |
| 9 | 0 | |
| 10 | 0 | "github.com/itsubaki/gocov/render/coverage" |
| 11 | 0 | "github.com/itsubaki/gocov/render/directory" |
| 12 | 0 | "github.com/itsubaki/gocov/report" |
| 13 | 0 | ) |
| 14 | 0 | |
| 15 | 0 | // HTML renders the coverage report as HTML. |
| 16 | 1 | func HTML(w io.Writer, rep *report.Report) error { |
| 17 | 1 | tmpl, err := template.New("report").Funcs(template.FuncMap{ |
| 18 | 1 | "directories": directory.New, |
| 19 | 1 | "sharePct": coverage.SharePercent, |
| 20 | 1 | "pct": coverage.Percent, |
| 21 | 1 | "coverageColor": coverage.Color, |
| 22 | 1 | "generatedAt": generatedAt, |
| 23 | 1 | "stylePct": stylePct, |
| 24 | 1 | "lineClass": lineClass, |
| 25 | 1 | }).Parse(reportTemplate) |
| 26 | 1 | if err != nil { |
| 27 | 0 | return err |
| 28 | 0 | } |
| 29 | 0 | |
| 30 | 1 | return tmpl.Execute(w, rep) |
| 31 | 0 | } |
| 32 | 0 | |
| 33 | 3 | func generatedAt(t time.Time) string { |
| 34 | 4 | if t.IsZero() { |
| 35 | 1 | return "" |
| 36 | 1 | } |
| 37 | 0 | |
| 38 | 2 | return t.Format("2006-01-02 15:04:05 MST") |
| 39 | 0 | } |
| 40 | 0 | |
| 41 | 5 | func stylePct(v float64) string { |
| 42 | 7 | if math.IsNaN(v) || math.IsInf(v, 0) { |
| 43 | 2 | return "0%" |
| 44 | 2 | } |
| 45 | 0 | |
| 46 | 3 | return fmt.Sprintf("%.4f%%", min(max(v, 0), 100)) |
| 47 | 0 | } |
| 48 | 0 | |
| 49 | 9 | func covColor(v float64) template.CSS { |
| 50 | 9 | return template.CSS(coverage.Color(v)) |
| 51 | 9 | } |
| 52 | 0 | |
| 53 | 3 | func lineClass(state string) string { |
| 54 | 3 | return "line-" + state |
| 55 | 3 | } |
report/directory.go
100.0%
report/directory.go
100.0%
| 1 | 0 | package report |
| 2 | 0 | |
| 3 | 0 | import "sort" |
| 4 | 0 | |
| 5 | 0 | type Directory struct { |
| 6 | 0 | Name string |
| 7 | 0 | Stats Stats |
| 8 | 0 | } |
| 9 | 0 | |
| 10 | 2 | func NewDirectory(files []*File) []*Directory { |
| 11 | 2 | dirs := make(map[string]*Directory) |
| 12 | 7 | for _, f := range files { |
| 13 | 6 | if dir, ok := dirs[f.Directory]; ok { |
| 14 | 1 | dir.Stats = Merge(dir.Stats, f.Stats) |
| 15 | 1 | continue |
| 16 | 0 | } |
| 17 | 0 | |
| 18 | 4 | dirs[f.Directory] = &Directory{ |
| 19 | 4 | Name: f.Directory, |
| 20 | 4 | Stats: f.Stats, |
| 21 | 4 | } |
| 22 | 0 | } |
| 23 | 0 | |
| 24 | 2 | out := make([]*Directory, 0, len(dirs)) |
| 25 | 6 | for _, dir := range dirs { |
| 26 | 4 | out = append(out, dir) |
| 27 | 4 | } |
| 28 | 0 | |
| 29 | 4 | sort.Slice(out, func(i, j int) bool { |
| 30 | 2 | return out[i].Name < out[j].Name |
| 31 | 2 | }) |
| 32 | 0 | |
| 33 | 2 | return out |
| 34 | 0 | } |
report/file.go
50.9%
report/file.go
50.9%
| 1 | 0 | package report |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "os" |
| 6 | 0 | "path/filepath" |
| 7 | 0 | "strings" |
| 8 | 0 | |
| 9 | 0 | "github.com/itsubaki/gocov/profile" |
| 10 | 0 | ) |
| 11 | 0 | |
| 12 | 0 | type File struct { |
| 13 | 0 | ID string |
| 14 | 0 | ProfilePath string |
| 15 | 0 | SourcePath string |
| 16 | 0 | DisplayPath string |
| 17 | 0 | Directory string |
| 18 | 0 | Found bool |
| 19 | 0 | Blocks int |
| 20 | 0 | Stats Stats |
| 21 | 0 | Lines []Line |
| 22 | 0 | } |
| 23 | 0 | |
| 24 | 0 | func NewFile(rootPath, modulePath, profileFile string, blocks []*profile.Block) (*File, error) { |
| 25 | 0 | var lines []Line |
| 26 | 0 | srcPath, found := sourcePath(rootPath, modulePath, profileFile) |
| 27 | 0 | if found { |
| 28 | 0 | data, err := os.ReadFile(srcPath) |
| 29 | 0 | if err != nil { |
| 30 | 0 | return nil, fmt.Errorf("read source file %s: %w", srcPath, err) |
| 31 | 0 | } |
| 32 | 0 | |
| 33 | 0 | lines = NewLines(sourceLines(string(data)), blocks) |
| 34 | 0 | } |
| 35 | 0 | |
| 36 | 0 | dispPath := func() string { |
| 37 | 0 | if rel, ok := relativeSourcePath(rootPath, srcPath); ok { |
| 38 | 0 | return rel |
| 39 | 0 | } |
| 40 | 0 | |
| 41 | 0 | return displayPath(modulePath, profileFile) |
| 42 | 0 | }() |
| 43 | 0 | |
| 44 | 0 | dirName := func() string { |
| 45 | 0 | name := filepath.ToSlash(filepath.Dir(dispPath)) |
| 46 | 0 | if name == "." { |
| 47 | 0 | return "root" |
| 48 | 0 | } |
| 49 | 0 | |
| 50 | 0 | return name |
| 51 | 0 | }() |
| 52 | 0 | |
| 53 | 0 | return &File{ |
| 54 | 0 | ProfilePath: profileFile, |
| 55 | 0 | SourcePath: srcPath, |
| 56 | 0 | DisplayPath: dispPath, |
| 57 | 0 | Directory: dirName, |
| 58 | 0 | Found: found, |
| 59 | 0 | Blocks: len(blocks), |
| 60 | 0 | Lines: lines, |
| 61 | 0 | Stats: NewLineStats(lines, blocks), |
| 62 | 0 | }, nil |
| 63 | 0 | } |
| 64 | 0 | |
| 65 | 4 | func sourcePath(root, modulePath, profileFile string) (string, bool) { |
| 66 | 4 | var paths []string |
| 67 | 4 | if filepath.IsAbs(profileFile) { |
| 68 | 0 | paths = append(paths, profileFile) |
| 69 | 0 | } |
| 70 | 4 | slash := filepath.ToSlash(profileFile) |
| 71 | 4 | |
| 72 | 4 | paths = append(paths, filepath.Join(root, filepath.FromSlash(slash))) |
| 73 | 6 | if modulePath != "" { |
| 74 | 3 | if rel, ok := strings.CutPrefix(slash, modulePath+"/"); ok { |
| 75 | 1 | p := filepath.Join(root, filepath.FromSlash(rel)) |
| 76 | 1 | paths = append(paths, p) |
| 77 | 1 | } |
| 78 | 0 | |
| 79 | 2 | if idx := strings.Index(slash, modulePath+"/"); idx > 0 { |
| 80 | 0 | rel := strings.TrimPrefix(slash[idx:], modulePath+"/") |
| 81 | 0 | p := filepath.Join(root, filepath.FromSlash(rel)) |
| 82 | 0 | paths = append(paths, p) |
| 83 | 0 | } |
| 84 | 0 | } |
| 85 | 0 | |
| 86 | 8 | for _, v := range paths { |
| 87 | 4 | info, err := os.Stat(v) |
| 88 | 5 | if err != nil || info.IsDir() { |
| 89 | 1 | continue |
| 90 | 0 | } |
| 91 | 0 | |
| 92 | 3 | abs, err := filepath.Abs(v) |
| 93 | 3 | if err != nil { |
| 94 | 0 | return v, true |
| 95 | 0 | } |
| 96 | 0 | |
| 97 | 3 | return abs, true |
| 98 | 0 | } |
| 99 | 0 | |
| 100 | 1 | return "", false |
| 101 | 0 | } |
| 102 | 0 | |
| 103 | 0 | func relativeSourcePath(root, sourcePath string) (string, bool) { |
| 104 | 0 | if sourcePath == "" { |
| 105 | 0 | return "", false |
| 106 | 0 | } |
| 107 | 0 | |
| 108 | 0 | rel, err := filepath.Rel(root, sourcePath) |
| 109 | 0 | if err != nil || strings.HasPrefix(rel, "..") { |
| 110 | 0 | return "", false |
| 111 | 0 | } |
| 112 | 0 | |
| 113 | 0 | return filepath.ToSlash(rel), true |
| 114 | 0 | } |
| 115 | 0 | |
| 116 | 4 | func displayPath(modulePath, profileFile string) string { |
| 117 | 4 | slash := filepath.ToSlash(profileFile) |
| 118 | 5 | if modulePath == "" { |
| 119 | 1 | return slash |
| 120 | 1 | } |
| 121 | 0 | |
| 122 | 4 | if rel, ok := strings.CutPrefix(slash, modulePath+"/"); ok { |
| 123 | 1 | return rel |
| 124 | 1 | } |
| 125 | 0 | |
| 126 | 2 | return slash |
| 127 | 0 | } |
| 128 | 0 | |
| 129 | 1 | func sourceLines(data string) []string { |
| 130 | 1 | // normalize line endings to \n |
| 131 | 1 | text := strings.ReplaceAll(data, "\r\n", "\n") |
| 132 | 1 | text = strings.ReplaceAll(text, "\r", "\n") |
| 133 | 1 | lines := strings.Split(text, "\n") |
| 134 | 1 | |
| 135 | 1 | // remove trailing empty line |
| 136 | 2 | if len(lines) > 0 && lines[len(lines)-1] == "" { |
| 137 | 1 | lines = lines[:len(lines)-1] |
| 138 | 1 | } |
| 139 | 0 | |
| 140 | 1 | return lines |
| 141 | 0 | } |
report/line.go
94.1%
report/line.go
94.1%
| 1 | 0 | package report |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "fmt" |
| 5 | 0 | "strconv" |
| 6 | 0 | |
| 7 | 0 | "github.com/itsubaki/gocov/profile" |
| 8 | 0 | ) |
| 9 | 0 | |
| 10 | 0 | type Line struct { |
| 11 | 0 | Number int // line number, starting from 1 |
| 12 | 0 | Code string // source code of the line |
| 13 | 0 | Hits string // number of hits, formatted as string (e.g., "10", "1.5k", "2.3m") |
| 14 | 0 | State string // coverage state: "covered", "missed", "partial", or "neutral" |
| 15 | 0 | } |
| 16 | 0 | |
| 17 | 2 | func NewLines(source []string, blocks []*profile.Block) []Line { |
| 18 | 2 | type LineCoverage struct { |
| 19 | 2 | covered bool |
| 20 | 2 | missed bool |
| 21 | 2 | hits int64 |
| 22 | 2 | } |
| 23 | 2 | |
| 24 | 2 | // map line number to coverage state |
| 25 | 2 | cov := make([]LineCoverage, len(source)+1) |
| 26 | 6 | for _, v := range blocks { |
| 27 | 4 | start, end := max(1, v.StartLine), v.EndLine |
| 28 | 4 | if v.EndCol == 1 && end > start { |
| 29 | 0 | end-- |
| 30 | 0 | } |
| 31 | 0 | |
| 32 | 4 | end = min(end, len(source)) |
| 33 | 4 | if start > end { |
| 34 | 0 | continue |
| 35 | 0 | } |
| 36 | 0 | |
| 37 | 13 | for line := start; line <= end; line++ { |
| 38 | 14 | if v.Count > 0 { |
| 39 | 5 | cov[line].covered = true |
| 40 | 5 | cov[line].hits += v.Count |
| 41 | 5 | continue |
| 42 | 0 | } |
| 43 | 0 | |
| 44 | 4 | cov[line].missed = true |
| 45 | 0 | } |
| 46 | 0 | } |
| 47 | 0 | |
| 48 | 0 | // generate lines with coverage state |
| 49 | 2 | lines := make([]Line, 0, len(source)) |
| 50 | 12 | for i, code := range source { |
| 51 | 10 | lineNo, state, hits := i+1, "neutral", "0" |
| 52 | 10 | |
| 53 | 10 | switch v := cov[lineNo]; { |
| 54 | 2 | case v.covered && v.missed: |
| 55 | 2 | state = "partial" |
| 56 | 2 | hits = formatHits(v.hits) |
| 57 | 3 | case v.covered: |
| 58 | 3 | state = "covered" |
| 59 | 3 | hits = formatHits(v.hits) |
| 60 | 2 | case v.missed: |
| 61 | 2 | state = "missed" |
| 62 | 2 | hits = "0" |
| 63 | 0 | } |
| 64 | 0 | |
| 65 | 10 | lines = append(lines, Line{ |
| 66 | 10 | Number: lineNo, |
| 67 | 10 | Code: code, |
| 68 | 10 | Hits: hits, |
| 69 | 10 | State: state, |
| 70 | 10 | }) |
| 71 | 0 | } |
| 72 | 0 | |
| 73 | 2 | return lines |
| 74 | 0 | } |
| 75 | 0 | |
| 76 | 9 | func formatHits(hits int64) string { |
| 77 | 10 | if hits <= 0 { |
| 78 | 1 | return "" |
| 79 | 1 | } |
| 80 | 0 | |
| 81 | 14 | if hits < 1000 { |
| 82 | 6 | return strconv.FormatInt(hits, 10) |
| 83 | 6 | } |
| 84 | 0 | |
| 85 | 3 | if hits < 1000000 { |
| 86 | 1 | return fmt.Sprintf("%.1fk", float64(hits)/1000) |
| 87 | 1 | } |
| 88 | 0 | |
| 89 | 1 | return fmt.Sprintf("%.1fm", float64(hits)/1000000) |
| 90 | 0 | } |
report/report.go
17.6%
report/report.go
17.6%
| 1 | 0 | package report |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "bufio" |
| 5 | 0 | "fmt" |
| 6 | 0 | "io" |
| 7 | 0 | "os" |
| 8 | 0 | "path/filepath" |
| 9 | 0 | "sort" |
| 10 | 0 | "strings" |
| 11 | 0 | "time" |
| 12 | 0 | |
| 13 | 0 | "github.com/itsubaki/gocov/profile" |
| 14 | 0 | ) |
| 15 | 0 | |
| 16 | 0 | type Report struct { |
| 17 | 0 | GeneratedAt time.Time |
| 18 | 0 | RootPath string |
| 19 | 0 | ProfilePath string |
| 20 | 0 | OutputPath string |
| 21 | 0 | Mode string |
| 22 | 0 | ModulePath string |
| 23 | 0 | Stats Stats |
| 24 | 0 | Files []*File |
| 25 | 0 | Directories []*Directory |
| 26 | 0 | MissingFiles []string |
| 27 | 0 | } |
| 28 | 0 | |
| 29 | 0 | type Options struct { |
| 30 | 0 | RootPath string |
| 31 | 0 | ProfilePath string |
| 32 | 0 | OutputPath string |
| 33 | 0 | GeneratedAt time.Time |
| 34 | 0 | } |
| 35 | 0 | |
| 36 | 0 | func New(prof *profile.Profile, opts Options) (*Report, error) { |
| 37 | 0 | blocks := make(map[string][]*profile.Block) |
| 38 | 0 | for _, b := range prof.Blocks { |
| 39 | 0 | blocks[b.File] = append(blocks[b.File], b) |
| 40 | 0 | } |
| 41 | 0 | |
| 42 | 0 | blockFiles := make([]string, 0, len(blocks)) |
| 43 | 0 | for b := range blocks { |
| 44 | 0 | blockFiles = append(blockFiles, b) |
| 45 | 0 | } |
| 46 | 0 | sort.Strings(blockFiles) |
| 47 | 0 | |
| 48 | 0 | // module path |
| 49 | 0 | file, err := os.Open(filepath.Join(opts.RootPath, "go.mod")) |
| 50 | 0 | if err != nil { |
| 51 | 0 | return nil, fmt.Errorf("open go.mod: %w", err) |
| 52 | 0 | } |
| 53 | 0 | defer func() { |
| 54 | 0 | if err := file.Close(); err != nil { |
| 55 | 0 | fmt.Fprintf(os.Stderr, "gocov: %v\n", err) |
| 56 | 0 | } |
| 57 | 0 | }() |
| 58 | 0 | modulePath := modulePath(file) |
| 59 | 0 | |
| 60 | 0 | // files |
| 61 | 0 | var files []*File |
| 62 | 0 | var missing []string |
| 63 | 0 | for _, f := range blockFiles { |
| 64 | 0 | file, err := NewFile(opts.RootPath, modulePath, f, blocks[f]) |
| 65 | 0 | if err != nil { |
| 66 | 0 | return nil, fmt.Errorf("create file report for %s: %w", f, err) |
| 67 | 0 | } |
| 68 | 0 | |
| 69 | 0 | files = append(files, file) |
| 70 | 0 | if !file.Found { |
| 71 | 0 | missing = append(missing, f) |
| 72 | 0 | } |
| 73 | 0 | } |
| 74 | 0 | |
| 75 | 0 | // sort |
| 76 | 0 | sort.Slice(files, func(i, j int) bool { |
| 77 | 0 | return files[i].DisplayPath < files[j].DisplayPath |
| 78 | 0 | }) |
| 79 | 0 | |
| 80 | 0 | // update file IDs |
| 81 | 0 | for i := range files { |
| 82 | 0 | files[i].ID = fmt.Sprintf("file-%d", i+1) |
| 83 | 0 | } |
| 84 | 0 | |
| 85 | 0 | return &Report{ |
| 86 | 0 | GeneratedAt: opts.GeneratedAt, |
| 87 | 0 | RootPath: opts.RootPath, |
| 88 | 0 | ProfilePath: opts.ProfilePath, |
| 89 | 0 | OutputPath: opts.OutputPath, |
| 90 | 0 | Mode: prof.Mode, |
| 91 | 0 | ModulePath: modulePath, |
| 92 | 0 | Files: files, |
| 93 | 0 | Directories: NewDirectory(files), |
| 94 | 0 | Stats: NewFileStats(files), |
| 95 | 0 | MissingFiles: missing, |
| 96 | 0 | }, nil |
| 97 | 0 | } |
| 98 | 0 | |
| 99 | 4 | func modulePath(r io.Reader) string { |
| 100 | 4 | scanner := bufio.NewScanner(r) |
| 101 | 9 | for scanner.Scan() { |
| 102 | 5 | line := strings.TrimSpace(scanner.Text()) |
| 103 | 7 | if module, ok := strings.CutPrefix(line, "module "); ok { |
| 104 | 2 | return strings.TrimSpace(module) |
| 105 | 2 | } |
| 106 | 0 | } |
| 107 | 0 | |
| 108 | 2 | return "" |
| 109 | 0 | } |
report/stats.go
100.0%
report/stats.go
100.0%
| 1 | 0 | package report |
| 2 | 0 | |
| 3 | 0 | import ( |
| 4 | 0 | "github.com/itsubaki/gocov/profile" |
| 5 | 0 | ) |
| 6 | 0 | |
| 7 | 0 | type Stats struct { |
| 8 | 0 | TotalStatements int |
| 9 | 0 | CoveredStatements int |
| 10 | 0 | TotalLines int |
| 11 | 0 | CoveredLines int |
| 12 | 0 | PartialLines int |
| 13 | 0 | MissedLines int |
| 14 | 0 | TotalFiles int |
| 15 | 0 | Percent float64 |
| 16 | 0 | Status string |
| 17 | 0 | } |
| 18 | 0 | |
| 19 | 1 | func NewFileStats(files []*File) Stats { |
| 20 | 1 | var s Stats |
| 21 | 2 | for _, f := range files { |
| 22 | 1 | s = Merge(s, f.Stats) |
| 23 | 1 | } |
| 24 | 0 | |
| 25 | 1 | s.TotalFiles = len(files) |
| 26 | 1 | return s |
| 27 | 0 | } |
| 28 | 0 | |
| 29 | 2 | func NewLineStats(lines []Line, blocks []*profile.Block) Stats { |
| 30 | 2 | var s Stats |
| 31 | 5 | for _, v := range blocks { |
| 32 | 3 | s.TotalStatements += v.Statements |
| 33 | 5 | if v.Count > 0 { |
| 34 | 2 | s.CoveredStatements += v.Statements |
| 35 | 2 | } |
| 36 | 0 | } |
| 37 | 0 | |
| 38 | 7 | for _, line := range lines { |
| 39 | 5 | switch line.State { |
| 40 | 2 | case "covered": |
| 41 | 2 | s.CoveredLines++ |
| 42 | 2 | s.TotalLines++ |
| 43 | 1 | case "partial": |
| 44 | 1 | s.PartialLines++ |
| 45 | 1 | s.TotalLines++ |
| 46 | 2 | case "missed": |
| 47 | 2 | s.MissedLines++ |
| 48 | 2 | s.TotalLines++ |
| 49 | 0 | } |
| 50 | 0 | } |
| 51 | 0 | |
| 52 | 2 | s.update() |
| 53 | 2 | return s |
| 54 | 0 | } |
| 55 | 0 | |
| 56 | 3 | func Merge(a, b Stats) Stats { |
| 57 | 3 | a.TotalStatements += b.TotalStatements |
| 58 | 3 | a.CoveredStatements += b.CoveredStatements |
| 59 | 3 | a.TotalLines += b.TotalLines |
| 60 | 3 | a.CoveredLines += b.CoveredLines |
| 61 | 3 | a.PartialLines += b.PartialLines |
| 62 | 3 | a.MissedLines += b.MissedLines |
| 63 | 3 | a.TotalFiles += b.TotalFiles |
| 64 | 3 | |
| 65 | 3 | a.update() |
| 66 | 3 | return a |
| 67 | 3 | } |
| 68 | 0 | |
| 69 | 5 | func (s *Stats) update() { |
| 70 | 5 | s.Percent = 100 |
| 71 | 9 | if s.TotalStatements > 0 { |
| 72 | 4 | a := float64(s.CoveredStatements) |
| 73 | 4 | b := float64(s.TotalStatements) |
| 74 | 4 | s.Percent = a / b * 100 |
| 75 | 4 | } |
| 76 | 0 | |
| 77 | 5 | switch v := s.Percent; { |
| 78 | 2 | case v >= 80: |
| 79 | 2 | s.Status = "high" |
| 80 | 2 | case v >= 50: |
| 81 | 2 | s.Status = "medium" |
| 82 | 1 | default: |
| 83 | 1 | s.Status = "low" |
| 84 | 0 | } |
| 85 | 0 | } |