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
381 coverable statements across 6 directories
badge 100.0%
4 / 4 statements covered.
profile 80.0%
52 / 65 statements covered.
render 91.7%
11 / 12 statements covered.
render/coverage 100.0%
39 / 39 statements covered.
render/directory 96.5%
83 / 86 statements covered.
report 66.9%
117 / 175 statements covered.

Files

badge/badge.go

4 / 4 statements 3 blocks 0 missed lines
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

29 / 34 statements 22 blocks 10 missed lines
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

23 / 31 statements 21 blocks 15 missed lines
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

33 / 33 statements 23 blocks 0 missed lines
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

6 / 6 statements 6 blocks 0 missed lines
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

38 / 39 statements 21 blocks 2 missed lines
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

45 / 47 statements 32 blocks 2 missed lines
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

11 / 12 statements 11 blocks 2 missed lines
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

12 / 12 statements 9 blocks 0 missed lines
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

29 / 57 statements 40 blocks 47 missed lines
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

32 / 34 statements 23 blocks 3 missed lines
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

6 / 34 statements 23 blocks 52 missed lines
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

38 / 38 statements 19 blocks 0 missed lines
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 }