//go:build !coverage
// +build !coverage
// Generate an HTML visualization of a Go coverage profile.
// Serves a similar purpose to "go tool cover -html", but has a different
// visual style.
package main
import (
"flag"
"fmt"
"html/template"
"math"
"os"
"strings"
"golang.org/x/tools/cover"
)
var (
input = flag.String("input", "", "input file")
output = flag.String("output", "", "output file")
strip = flag.Int("strip", 0, "how many path entries to strip")
title = flag.String("title", "Coverage report", "page title")
notes = flag.String("notes", "", "notes to add at the beginning (HTML)")
)
func errorf(f string, a ...interface{}) {
fmt.Printf(f, a...)
os.Exit(1)
}
func main() {
flag.Parse()
profiles, err := cover.ParseProfiles(*input)
if err != nil {
errorf("Error parsing input %q: %v\n", *input, err)
}
totals := &Totals{
totalF: map[string]int{},
coveredF: map[string]int{},
}
files := []string{}
code := map[string]template.HTML{}
for _, p := range profiles {
files = append(files, p.FileName)
totals.Add(p)
fname := strings.Join(strings.Split(p.FileName, "/")[*strip:], "/")
src, err := os.ReadFile(fname)
if err != nil {
errorf("Failed to read %q: %v", fname, err)
}
code[p.FileName] = genHTML(src, p.Boundaries(src))
}
out, err := os.Create(*output)
if err != nil {
errorf("Failed to open output file %q: %v", *output, err)
}
data := struct {
Title string
Notes template.HTML
Files []string
Code map[string]template.HTML
Totals *Totals
}{
Title: *title,
Notes: template.HTML(*notes),
Files: files,
Code: code,
Totals: totals,
}
tmpl := template.Must(template.New("html").Parse(htmlTmpl))
err = tmpl.Execute(out, data)
if err != nil {
errorf("Failed to execute template: %v", err)
}
for _, f := range files {
fmt.Printf("%5.1f%% %v\n", totals.Percent(f), f)
}
fmt.Printf("\n")
fmt.Printf("Total: %.1f\n", totals.TotalPercent())
}
// Totals is used to keep track of total counters.
type Totals struct {
// Total statements.
total int
// Covered statements.
covered int
// Total statements per file.
totalF map[string]int
// Covered statements per file.
coveredF map[string]int
}
// Add the given profile to the total counters.
func (t *Totals) Add(p *cover.Profile) {
for _, b := range p.Blocks {
t.total += b.NumStmt
t.totalF[p.FileName] += b.NumStmt
if b.Count > 0 {
t.covered += b.NumStmt
t.coveredF[p.FileName] += b.NumStmt
}
}
}
// Percent covered for the given file.
func (t *Totals) Percent(f string) float32 {
return float32(t.coveredF[f]) / float32(t.totalF[f]) * 100
}
// TotalPercent covered, across all files.
func (t *Totals) TotalPercent() float32 {
return float32(t.covered) / float32(t.total) * 100
}
func genHTML(src []byte, boundaries []cover.Boundary) template.HTML {
// Position -> []Boundary
// The order matters, we expect to receive start-end pairs in order, so
// they are properly added.
bs := map[int][]cover.Boundary{}
for _, b := range boundaries {
bs[b.Offset] = append(bs[b.Offset], b)
}
w := &strings.Builder{}
for i := range src {
// Emit boundary markers.
for _, b := range bs[i] {
if b.Start {
n := 0
if b.Count > 0 {
n = int(math.Floor(b.Norm*4)) + 1
}
fmt.Fprintf(w, `<span class="cov%v" title="%v">`, n, b.Count)
} else {
w.WriteString("</span>")
}
}
switch b := src[i]; b {
case '>':
w.WriteString(">")
case '<':
w.WriteString("<")
case '&':
w.WriteString("&")
case '\t':
w.WriteString(" ")
default:
w.WriteByte(b)
}
}
return template.HTML(w.String())
}
const htmlTmpl = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
:root {
--txt: #333;
--bg: white;
--a: #1c3986;
--a-hover: #069;
--odd-bg: #f5f5f7;
--pre-bg: #f8f8f8;
--pre-border: #e5e5e5;
// color palette from graphiq.
--cov0: red;
--cov1: #0b7bab;
--cov2: #09639b;
--cov3: #034a8b;
--cov4: #00337c;
--cov5: #032663;
}
@media (prefers-color-scheme: dark) {
:root {
--txt: rgba(255, 255, 255, 0.90);
--bg: #121212;
--a: #44b4ec;
--a-hover: #7fc9ee;
--odd-bg: #222222;
--pre-bg: #111;
--pre-border: #333;
--cov0: #f2a359;
--cov1: #2b84b1;
--cov2: #3393c4;
--cov3: #3ca3d7;
--cov4: #44b3eb;
--cov5: #4dc3ff;
}
}
body {
font: 100%/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans",
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
color: var(--txt);
background: var(--bg);
}
h1 {
margin: 0 0 0.5em;
}
a {
color: var(--a);
text-decoration: none;
cursor: pointer;
}
a:hover {
color: var(--a-hover);
}
table {
border-collapse: collapse;
}
tr:nth-child(odd) {
background: var(--odd-bg);
}
tr.total {
border-top: 1px solid;
font-weight: bold;
}
td {
padding: 0.2em 1em;
}
td.pcnt {
text-align: right;
}
code, pre, tt {
font-family: Monaco, Bitstream Vera Sans Mono, Lucida Console,
Terminal, Consolas, Liberation Mono, DejaVu Sans Mono,
Courier New, monospace;
color: var(--txt);
}
pre {
padding: 0.5em 0.8em;
background: var(--pre-bg);
border-radius: 1em;
border:1px solid var(--pre-border);
overflow-x: auto;
}
.cov0 { color: var(--cov0); }
.cov1 { color: var(--cov1); }
.cov2 { color: var(--cov2); }
.cov3 { color: var(--cov3); }
.cov4 { color: var(--cov4); }
.cov5 { color: var(--cov5); }
</style>
<script>
function visible(id) {
history.replaceState(undefined, undefined, "#" + id);
var all = document.getElementsByClassName("file");
for (var i = 0; i < all.length; i++) {
var elem = all.item(i);
elem.style.display = "none";
}
var chosen = document.getElementById(id);
chosen.style.display = "block";
}
window.onload = function() {
var id = window.location.hash.replace("#", "");
if (id != "") {
visible(id);
}
};
</script>
</head>
<body>
<h1>{{.Title}}</h1>
{{.Notes}}<p>
<table>
{{range .Files}}
<tr>
<td><a onclick="visible('f::{{.}}')" tabindex="0"> {{.}} </a></td>
<td class="pcnt">{{$.Totals.Percent . | printf "%.1f%%"}}</td>
</tr>
{{- end}}
<tr class="total">
<td>Total</td>
<td class="pcnt">{{.Totals.TotalPercent | printf "%.1f"}}%</td>
</tr>
</table>
<div id="source">
{{range .Files}}
<pre class="file" id="f::{{.}}" style="display: none">{{index $.Code .}}</pre>
{{end}}
</div>
</body>
</html>
`