// Package main writes HTML files for each Go source file listed in the user-specified Go coverage profile file.
//
// This module also generates a directory tree HTML file rendered within an iframe of the index HTML file.
//
// The header portion of the index HTML file will also render two buttons if the browser's CORS policies allow it. These buttons are:
//
// "theme" - toggles between two hardcoded "light" and "dark" themes
// "expand" (or "collapse") - toggles the opening (or closing) of all subdirectories rendered within the tree HTML document
//
// Note that the "theme" and "expand" / "collapse" buttons will not be rendered when the index page is loaded via the file:// scheme.
//
// A simple workaround is to instantiate an HTTP server to serve the HTML files, e.g.:
//
// $ python3 -m http.server 8000
//
// and then load http://localhost:8000/ in a browser.
package main
import (
"bufio"
"bytes"
"cmp"
"context"
"embed"
"flag"
"fmt"
"io"
"io/fs"
"maps"
"math"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"text/template"
"github.com/graniticio/inifile"
"github.com/jbunds/coverage/progress"
"golang.org/x/mod/modfile"
"golang.org/x/sync/errgroup"
"golang.org/x/term"
"golang.org/x/tools/cover"
"golang.org/x/tools/go/packages"
)
//go:embed html/* css/* img/*
var embeddedFiles embed.FS
var (
isTerm = false
styleCSS = "css/style.css" // embedded template (.MaxWidth)
indexHTML = "html/index.html" // embedded template (.ModName)
treeHTML = "tree.html" // generated by tree.go
ancillaryFiles = []string{
"css/tree.css",
"img/favicon.ico",
"img/go-blue-gradient.svg",
}
)
func init() {
fd := os.Stderr.Fd()
if fd <= math.MaxInt && term.IsTerminal(int(fd)) {
isTerm = true
}
}
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
)
// stickyWriter is a wrapper interface used to enable sequential string writing with deferred error handling
type stickyWriter interface {
io.Writer
write(string)
err() error
}
// errorWriter tracks the first encountered I/O failure to allow multiple sequential writes without repetitive inline error checking
type errorWriter struct {
w io.Writer
e error
}
// write performs a sticky-error write
func (e *errorWriter) write(s string) {
if e.e != nil { return }
_, e.e = io.WriteString(e.w, s)
}
// write performs a sticky-error write that optionally wraps the provided string in ANSI color codes
func (e *errorWriter) writeColor(s, color string) {
if e.e != nil { return }
if isTerm { _, e.e = io.WriteString(e.w, color ) }
_, e.e = io.WriteString(e.w, s )
if isTerm { _, e.e = io.WriteString(e.w, colorReset) }
}
// err returns the first I/O failure encountered during multiple sequential write operations
func (e *errorWriter) err() error { return e.e }
// Write satisfies the io.Writer interface, but it unused
func (e *errorWriter) Write(p []byte) (int, error) {
if e.e != nil { return 0, e.e }
n := 0
n, e.e = e.w.Write(p)
return n, e.e
}
// pkgDirCache maps canonical Go packages to their respective paths on disk
type pkgDirCache struct {
cache map[string]string
mu sync.RWMutex
}
// coverage tracks per-file coverage
type coverage struct {
covered int64
total int64
}
// workUnit represents a unit of work to be performed: the generation of an HTML file for a Go source file's coverage profile
// the main program utilizes all available CPU cores to process all workUnits concurrently
type workUnit struct {
profile *cover.Profile
outPath string
}
// reportGenerator orchestrates the generation of the HTML report
// it stores state, simplifies method signatures, avoids copying values, and makes testing easier
type reportGenerator struct {
fsys writeFS
embeddedFiles fs.FS
modFile string
modName string
repoURL string
pkgDirCache *pkgDirCache
outRoot string
profilePath string
profiles []*cover.Profile
cov map[string]coverage
totalCovered atomic.Int64
totalStatements atomic.Int64
maxWidth int
ancillaryFiles []string
}
// wrappers to facilitate test injection
// writeFS defines an interface that extends fs.FS with writing capabilities.
// This abstraction is necessary to allow for mocking the file system within
// unit tests, as the standard "os" package cannot be directly mocked.
type writeFS interface {
fs.FS
Create (context.Context, string) (io.WriteCloser, error)
MkdirAll (context.Context, string, fs.FileMode) error
ReadFile (context.Context, string) ([]byte, error)
WriteFile (context.Context, string, []byte, fs.FileMode) error
OpenWithContext(context.Context, string) (fs.File, error)
}
// localFS provides a concrete implementation of the writeFS interface by
// wrapping the standard "os" package. This allows the program to perform
// actual system operations in production while remaining easily testable
// via alternative interface implementations.
type localFS struct{}
func (lfs *localFS) Open(name string) (fs.File, error) {
return os.Open(filepath.Clean(name))
}
func (lfs *localFS) Create(ctx context.Context, name string) (io.WriteCloser, error) {
if err := ctx.Err(); err != nil { return nil, err }
return os.Create(filepath.Clean(name))
}
func (lfs *localFS) MkdirAll(ctx context.Context, path string, perm fs.FileMode) error {
if err := ctx.Err(); err != nil { return err }
return os.MkdirAll(filepath.Clean(path), perm)
}
func (lfs *localFS) ReadFile(ctx context.Context, name string) ([]byte, error) {
if err := ctx.Err(); err != nil { return nil, err }
return fs.ReadFile(lfs, name)
}
func (lfs *localFS) WriteFile(ctx context.Context, name string, data []byte, perm fs.FileMode) error {
if err := ctx.Err(); err != nil { return err }
return os.WriteFile(filepath.Clean(name), data, perm)
}
func (lfs *localFS) OpenWithContext(ctx context.Context, name string) (fs.File, error) {
if err := ctx.Err(); err != nil { return nil, err }
return lfs.Open(filepath.Clean(name))
}
// wraps inifile.IniConfig.Value for test injection
type iniFileValueGetter interface { Value(string, string) (string, error) }
// wraps packages.Load for test injection
type pkgLoader func(cfg *packages.Config, patterns ...string) ([]*packages.Package, error)
func main() {
goModFile, profilePath, outRoot, err := flags(flag.CommandLine, filterArgs(os.Args[1:]), os.Stderr)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot parse flags: %v\n", err)
os.Exit(1)
}
profiles, err := cover.ParseProfiles(profilePath)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot parse coverage profile file: %v\n", err)
os.Exit(2)
}
fmt.Fprintf(os.Stderr, "processing %d source files...", len(profiles))
if !isTerm { fmt.Println() }
ctx := context.Background()
repGen := &reportGenerator{
fsys: &localFS{},
embeddedFiles: embeddedFiles,
modFile: goModFile,
outRoot: filepath.Clean(outRoot),
profilePath: filepath.Clean(profilePath),
profiles: profiles,
ancillaryFiles: ancillaryFiles,
}
if err := repGen.getModName(ctx, goModFile); err != nil { // sets repGen.modName
fmt.Fprintf(os.Stderr, "cannot determine module name: %v\n", err)
os.Exit(3)
}
var gitConfig *inifile.IniConfig
gitConfigPath := filepath.Join(filepath.Dir(goModFile), ".git", "config")
if gitConfig, err = inifile.NewIniConfigFromPath(gitConfigPath); err != nil {
fmt.Fprintf(os.Stderr, "cannot parse Git config file (%q): %v\n", gitConfigPath, err)
os.Exit(4)
}
if err := repGen.getRepoURL(ctx, gitConfig); err != nil { // sets repGen.repoURL
fmt.Fprintf(os.Stderr, "cannot determine repo URL: %v\n", err)
os.Exit(5)
}
if err := repGen.primePkgDirCache(ctx, packages.Load, profilePath); err != nil { // sets repGen.pkgDirCache
fmt.Fprintf(os.Stderr, "cannot prime package directory cache: %v\n", err)
os.Exit(6)
}
if err := repGen.writeCovHTMLFiles(ctx, os.Stderr); err != nil { // sets repGen.cov
fmt.Fprintf(os.Stderr, "cannot write HTML coverage files: %v\n", err)
os.Exit(7)
}
if err := repGen.writeIndexHTML(ctx, indexHTML); err != nil { // requires repGen.modName
fmt.Fprintf(os.Stderr, "cannot write %q: %v\n", indexHTML, err)
os.Exit(8)
}
tb := &treeBuilder{
fsys: &localFS{},
outRoot: filepath.Clean(outRoot),
cov: repGen.cov,
}
if repGen.maxWidth, err = tb.writeTreeHTML(ctx, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "cannot write %q: %v\n", treeHTML, err)
os.Exit(9)
}
if err := repGen.writeStyleCSS(ctx, styleCSS); err != nil { // requires repGen.maxWidth
fmt.Fprintf(os.Stderr, "cannot write %s: %v\n", styleCSS, err)
os.Exit(10)
}
if err := repGen.writeAncillaryFiles(ctx); err != nil {
fmt.Fprintf(os.Stderr, "cannot write ancillary files: %v\n", err)
os.Exit(11)
}
if err := repGen.printCoverage(ctx, os.Stdout); err != nil { // requires repGen.cov
fmt.Fprintf(os.Stderr, "cannot print per-file coverage figures: %v\n", err)
os.Exit(12)
}
}
// getModName reads the repo's root go.mod file to determine the name of the Go module
func (rg *reportGenerator) getModName(ctx context.Context, goModFile string) error {
if err := ctx.Err(); err != nil { return err }
goMod, err := rg.fsys.ReadFile(ctx, goModFile)
if err != nil { return fmt.Errorf("cannot read %q: %w", goModFile, err) }
modFile, err := modfile.Parse(goModFile, goMod, nil)
if err != nil || modFile.Module == nil { return fmt.Errorf("cannot parse %q: %w", goModFile, err) }
rg.modName = modFile.Module.Mod.Path
return nil
}
// getRepoURL converts a Git remote URL to an HTTP URL for subsequent use in writeIndexHTML
func (rg *reportGenerator) getRepoURL(ctx context.Context, gitConfig iniFileValueGetter) error {
if err := ctx.Err(); err != nil { return err }
gitRemoteURL, err := gitConfig.Value(`remote "origin"`, "url")
if err != nil { return err }
httpURL := gitRemoteURL
httpURL = strings.TrimPrefix(httpURL, "ssh://")
httpURL = strings.TrimPrefix(httpURL, "git://")
if idx := strings.Index (httpURL, "@"); idx != -1 { httpURL = httpURL[idx+1:] }
httpURL = strings.Replace (httpURL, ":", "/", 1)
httpURL = strings.TrimSuffix(httpURL, ".git")
httpURL = strings.TrimSuffix(httpURL, "/")
if !strings.HasPrefix(httpURL, "http") { httpURL = "https://" + httpURL }
rg.repoURL = httpURL
return nil
}
// getAllPkgPaths extracts all unique package paths from the coverage profile file for subsequent use in primePkgDirCache
func (rg *reportGenerator) getAllPkgPaths(ctx context.Context, profilePath string) ([]string, error) {
if err := ctx.Err(); err != nil { return nil, err }
f, err := rg.fsys.OpenWithContext(ctx, filepath.Clean(profilePath))
if err != nil { return nil, err }
defer func() {
closeErr := f.Close()
if err == nil { err = closeErr }
}()
pkgSet := make(map[string]struct{})
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "mode:") { continue }
parts := strings.Split(line, ":")
if len(parts) < 2 { continue }
pkgSet[filepath.Dir(parts[0])] = struct{}{}
}
allPaths := make([]string, 0, len(pkgSet))
for p := range pkgSet { allPaths = append(allPaths, p) }
return allPaths, scanner.Err()
}
// primePkgDirCache primes rg.pkgDirCache for subsequent use in buildCovHTML
func (rg *reportGenerator) primePkgDirCache(ctx context.Context, pkgLoader pkgLoader, profilePath string) error {
if err := ctx.Err(); err != nil { return err }
rg.pkgDirCache = &pkgDirCache{ cache: make(map[string]string) }
allPkgPaths, err := rg.getAllPkgPaths(ctx, profilePath)
if err != nil { return err }
cfg := &packages.Config{
Mode: packages.NeedFiles | packages.NeedModule | packages.NeedName,
Tests: false,
Dir: filepath.Dir(rg.modFile),
}
pkgs, err := pkgLoader(cfg, allPkgPaths...)
if err != nil { return err }
rg.pkgDirCache.mu.Lock()
defer rg.pkgDirCache.mu.Unlock()
for _, pkg := range pkgs {
if len(pkg.GoFiles) > 0 {
rg.pkgDirCache.cache[pkg.PkgPath] = filepath.Dir(pkg.GoFiles[0])
}
}
return nil
}
// writeCovHTMLFiles calculates per-file coverage percentages and writes a *.go.html file for each Go source file listed in the coverage profile file
func (rg *reportGenerator) writeCovHTMLFiles(ctx context.Context, progressOutput io.Writer) error {
if err := ctx.Err(); err != nil { return err }
rg.cov = make(map[string]coverage, len(rg.profiles))
units := make([]workUnit, 0, len(rg.profiles))
dirsToCreate := make(map[string]struct{}) // exclude duplicate directories and keep the relatively heavy mkdir syscalls outside of the concurrent loop
for _, profile := range rg.profiles {
outPath := filepath.Clean(filepath.Join(rg.outRoot, profile.FileName + ".html"))
units = append(units, workUnit{
profile: profile,
outPath: outPath,
})
dirsToCreate[filepath.Dir(outPath)] = struct{}{}
}
group, gCtx := errgroup.WithContext(ctx)
progDirs := progress.NewProgress(uint64(len(dirsToCreate)), progressOutput)
defer progDirs.Close()
for dir := range dirsToCreate {
group.Go(func() error {
if err := rg.fsys.MkdirAll(gCtx, dir, 0700); err != nil {
return fmt.Errorf("cannot create directory %q: %w", dir, err)
}
progDirs.Report(1, "created " + dir)
return nil
})
}
if err := group.Wait(); err != nil { return err }
progDirs.Close()
// The total number of statements (cover.ProfileBlock.NumStmt) is used as a normalizing
// proxy for work units to ensure the progress tracker accurately reflects the
// computational effort required for files of vastly different sizes, e.g.:
//
// k8s.io/kubernetes/pkg/kubelet/apis/config/register.go // contains 1 func def
// k8s.io/api/core/v1/generated.pb.go // contains 1692 func defs
//
// Since the global total of statements is not known a priori, dynamic discovery is
// implemented whereby each worker goroutine calls AddTotal() as it parses a source
// file's profiled blocks, allowing the progress tracker to refine its denominator
// in real-time without requiring a separate pre-processing pass.
perFileCov := make([]coverage, len(units))
progFiles := progress.NewProgress(0, progressOutput)
defer progFiles.Close()
group, gCtx = errgroup.WithContext(ctx)
group.SetLimit(runtime.NumCPU()) // full send
for i, unit := range units {
group.Go(func() error {
if err := gCtx.Err(); err != nil { return err }
var fileStatements, fileCovered int64
for _, block := range unit.profile.Blocks {
fileStatements += int64(block.NumStmt)
if block.Count > 0 {
fileCovered += int64(block.NumStmt)
}
}
progFiles.AddTotal(uint64(fileStatements))
var buf bytes.Buffer
ew := &errorWriter{w: &buf}
if err := rg.buildCovHTML(ctx, ew, unit.profile, unit.profile.FileName); err != nil {
return fmt.Errorf("cannot build HTML for %q: %w", unit.profile.FileName, err)
}
if err := rg.fsys.WriteFile(ctx, unit.outPath, buf.Bytes(), 0600); err != nil {
return fmt.Errorf("cannot write HTML file for %q: %w", unit.profile.FileName, err)
}
progFiles.Report(float64(fileStatements), unit.profile.FileName)
rg.totalCovered.Add(fileCovered)
rg.totalStatements.Add(fileStatements)
perFileCov[i] = coverage{
covered: fileCovered,
total: fileStatements,
}
return nil
})
}
err := group.Wait()
progFiles.Close()
for i, cov := range perFileCov {
rg.cov[units[i].profile.FileName] = cov
}
return err
}
// buildCovHTML builds the HTML content for a single *.go.html file, with green (covered) and red (uncovered) lines to indicate test coverage
func (rg *reportGenerator) buildCovHTML(ctx context.Context, ew stickyWriter, profile *cover.Profile, srcPath string) error {
if err := ctx.Err(); err != nil { return err }
pkgPath := filepath.Dir (profile.FileName)
fileName := filepath.Base(profile.FileName)
src, err := rg.fsys.ReadFile(ctx, filepath.Join(rg.pkgDirCache.cache[pkgPath], fileName))
if err != nil { return err }
cssPath := strings.Repeat("../", strings.Count(srcPath, "/")) + filepath.Base(styleCSS)
writePreamble(ew, cssPath, srcPath)
pos := 0
for _, b := range profile.Boundaries(src) {
chunk := src[pos:b.Offset]
if b.Start {
class := "miss"
if b.Count > 0 { class = "hit" }
if nl := bytes.LastIndexByte(chunk, '\n'); nl != -1 {
template.HTMLEscape(ew, chunk[:nl + 1])
ew.write(`<span class="`)
ew.write(class)
ew.write(`">`)
template.HTMLEscape(ew, chunk[nl + 1:])
} else {
template.HTMLEscape(ew, chunk)
ew.write(`<span class="`)
ew.write(class)
ew.write(`">`)
}
} else {
template.HTMLEscape(ew, chunk)
ew.write("</span>")
}
pos = b.Offset
}
template.HTMLEscape(ew, src[pos:])
writePostamble(ew)
return ew.err()
}
// writePreamble writes the preamble portion of the HTML content common to every Go source HTML file
func writePreamble(ew stickyWriter, cssRelPath, srcPath string) {
ew.write("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n")
ew.write("<link rel=\"stylesheet\" href=\"")
ew.write(cssRelPath)
ew.write("\" type=\"text/css\">\n<title>")
ew.write(srcPath)
ew.write("</title>\n</head>\n<body id=\"code\">\n<pre>\n")
}
// writePostamble writes the postamble portion of the HTML content common to every Go source HTML file
func writePostamble(ew stickyWriter) {
ew.write("</pre>\n<script>")
ew.write(`
try {
const parentTheme = window.parent.document.documentElement.getAttribute('theme');
if (parentTheme) document.documentElement.setAttribute('theme', parentTheme);
} catch (e) {
console.warn('direct parent access blocked by browser; waiting for postMessage');
}
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SET_THEME') document.documentElement.setAttribute('theme', event.data.theme);
});
</script>
</body>
</html>`)
}
// printCoverage prints per-file coverage percentages to the specified destination (typically stdout)
func (rg *reportGenerator) printCoverage(ctx context.Context, w io.Writer) error {
if err := ctx.Err(); err != nil { return err }
keys := slices.Collect(maps.Keys(rg.cov))
maxPathLen := len(slices.MaxFunc(keys, func(a, b string) int {
return cmp.Compare(len(a), len(b))
}))
maxPathLen = max(maxPathLen, 5) // 5 == len("Total")
// TODO(jeff): allow users to chose how the rows rendered in the tree should be sorted;
// default should probably path-depth, then alphanumerically, just like here
slices.SortFunc(keys, func(a, b string) int {
depthA, depthB := strings.Count(a, "/"), strings.Count(b, "/")
if depthA != depthB { return cmp.Compare(depthA, depthB) } // sort by path depth
return cmp.Compare(a, b) // sort alphanumerically
})
divider := strings.Repeat("—", maxPathLen + 9) + "\n" // 9 == 2 spaces + len("100.00%")
ew := &errorWriter{w: w}
ew.write("File")
ew.write(strings.Repeat(" ", maxPathLen - 4 + 1))
ew.write("Coverage\n")
ew.write(divider)
for _, path := range keys {
cov := rg.cov[path]
percent := 0.0
if cov.total > 0 {
percent = float64(cov.covered) / float64(cov.total) * 100
}
ew.write(path)
ew.write(strings.Repeat(" ", maxPathLen - len(path) + 2))
pct := strconv.FormatFloat(percent, 'f', 2, 64)
colorCode := colorGreen
if percent < 50 { colorCode = colorRed }
ew.write(strings.Repeat(" ", 6 - len(pct)))
ew.writeColor(pct + "%", colorCode)
ew.write("\n")
}
totalPercent := 0.0
totalCovered := rg.totalCovered.Load()
totalStatements := rg.totalStatements.Load()
if totalStatements > 0 {
totalPercent = float64(totalCovered) / float64(totalStatements) * 100
}
ew.write(divider)
ew.write("Total")
ew.write(strings.Repeat(" ", maxPathLen - 5 + 2))
totalPct := strconv.FormatFloat(totalPercent, 'f', 2, 64)
colorCode := colorGreen
if totalPercent < 50 { colorCode = colorRed }
ew.write(strings.Repeat(" ", 6 - len(totalPct)))
ew.writeColor(totalPct + "%", colorCode)
ew.write("\n")
return ew.err()
}
// writeIndexHTML writes the index HTML file, which contains two template parameters
// (ModName and ModURL), and hosts two iframes (directory tree & source code)
func (rg *reportGenerator) writeIndexHTML(ctx context.Context, indexHTML string) error {
if err := ctx.Err(); err != nil { return err }
data := struct{
ModName, ModURL string
}{
ModName: rg.modName,
ModURL: rg.repoURL,
}
return rg.writeTemplateFile(ctx, indexHTML, data)
}
// writeStyleCSS writes the style.css file, which contains a single "MaxWidth" template parameter
func (rg *reportGenerator) writeStyleCSS(ctx context.Context, styleCSS string) error {
if err := ctx.Err(); err != nil { return err }
data := struct{
MaxWidth int
}{
MaxWidth: rg.maxWidth,
}
return rg.writeTemplateFile(ctx, styleCSS, data)
}
// writeTemplateFile writes the specified template file
func (rg *reportGenerator) writeTemplateFile(ctx context.Context, file string, tmplVars any) error {
if err := ctx.Err(); err != nil { return err }
outFile := filepath.Clean(filepath.Join(rg.outRoot, filepath.Base(file)))
tmpl, err := template.ParseFS(rg.embeddedFiles, file)
if err != nil { return fmt.Errorf("cannot parse %q: %w", file, err) }
f, err := rg.fsys.Create(ctx, outFile)
if err != nil { return fmt.Errorf("cannot create %q: %w", outFile, err) }
if err := tmpl.Execute(f, tmplVars); err != nil { return fmt.Errorf("cannot render template: %w", err) }
return f.Close()
}
// writeAncillaryFiles writes the files required by the coverage report to the user-specified path
func (rg *reportGenerator) writeAncillaryFiles(ctx context.Context) error {
if err := ctx.Err(); err != nil { return err }
for _, file := range rg.ancillaryFiles {
outFile := filepath.Clean(filepath.Join(rg.outRoot, filepath.Base(file)))
f, err := rg.fsys.Create(ctx, outFile)
if err != nil { return fmt.Errorf("cannot create %q: %w", outFile, err) }
data, err := fs.ReadFile(rg.embeddedFiles, file)
if err != nil { return fmt.Errorf("cannot read %q: %w", file, err) }
if _, err := fmt.Fprint(f, string(data)); err != nil { return fmt.Errorf("cannot write file %q: %w", outFile, err) }
if err := f.Close(); err != nil { return fmt.Errorf("cannot close file %q: %w", outFile, err) }
}
return nil
}
// filterArgs discards any arguments up to and including "--" (potentially useful for testing)
func filterArgs(args []string) []string {
for i, arg := range args {
if arg == "--" {
args = args[i+1:]
break
}
}
return args
}