// 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 hosted by the rendered index HTML file.
//
// The header portion of the index HTML file will also render three buttons when served via HTTP and JavaScript is enabled:
//
// "expand" (or "collapse") - toggles opening (or closing) all subdirectories of the source tree
// "lines" - toggles showing or hiding a column of line numbers along the left side of the source code
// "funcs" - toggles showing or hiding the bodies of all top-level functions and methods in the source code
// "theme" - toggles between "light" and "dark" themes
package main
import (
"bufio"
"bytes"
"context"
"embed"
"errors"
"flag"
"fmt"
"go/ast"
"go/token"
"go/types"
"io"
"io/fs"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/jbunds/progress"
"golang.org/x/mod/modfile"
"golang.org/x/tools/cover"
"golang.org/x/tools/go/packages"
)
//go:embed html/* img/* css/* js/*
var embeddedFiles embed.FS
// coverage tracks per-file coverage.
type coverage struct {
covered uint64
total uint64
}
// coverageState accumulates coverage data.
type coverageState struct {
cov map[string]coverage // per-file coverage data
totalCovered atomic.Uint64 // module-wide total number of statements covered
totalStatements atomic.Uint64 // module-wide total number of statements
sort func() []string // returns file paths in the order they are printed to stdout
}
// reportGenerator orchestrates the generation of the HTML report.
type reportGenerator struct {
fsys writeFS // wrapper around the os functions used herein
embeddedFiles fs.FS // templates and static files referenced by the generated HTML
goModFiles []string // go.mod file paths; https://go.dev/ref/mod#glos-go-mod-file
modPaths []string // module paths; https://go.dev/ref/mod#glos-module-path
repoURLs []string // repo URLs (repoURLs[0] is only used in single-module mode to render index.html)
outRoot rootHandle // output root for the generated HTML files, per -outdir
profilePath string // value of the -coverprofile flag
profiles []*cover.Profile // coverage profiles generated by `go tool cover`
covState *coverageState // covState accumulates coverage data during report generation
pkgDirCache map[string]string // maps canonical Go packages to their respective paths on disk (keyed by package path)
globalTypes map[string]*types.Info // type info per source file (keyed by filename)
globalASTs map[string]*ast.File // AST per source file (keyed by filename)
globalFset *token.FileSet // single file set to consistently resolve source offsets across all packages
staticFiles []string // static CSS, HTML, and image files required by the generated HTML
iconFilename string // name of the icon file included by index.html and generated source HTML files
styleCSSFilename string // name of the CSS file included by index.html and generated source HTML files
syntaxCSSFilename string // name of the CSS file included by generated source HTML files
childJSFilename string // name of the JS file included by generated source HTML files (implements theme toggling, etc)
write bool // true when static and generated files should be written to disk
}
os.Exit(run())
}
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, // ctrl+c
syscall.SIGTERM, // standard kill signal
syscall.SIGHUP, // terminal closed, SSH disconnection, etc
)
defer stop()
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE) // prevent SIGPIPE from killing the process; writes return EPIPE (handled below)
fv, err := flags(flag.CommandLine, filterArgs(os.Args[1:]))
if err != nil { return fatal(1, "cannot parse flags: %v", err) }
rg, err := newReportGenerator(fv)
if err != nil { return fatal(2, "cannot instantiate report generator: %v", err) }
defer rg.outRoot.Close()
var progressWriter io.Writer = os.Stderr
if !isTerm(os.Stderr) { progressWriter = io.Discard }
fmt.Fprintf(os.Stderr, "processing %d source files...", len(rg.profiles))
if !isTerm(os.Stdin) { fmt.Fprintln(os.Stderr) }
if err := rg.registerModPaths(fv); err != nil { return fatal(3, "cannot determine module path: %v", err) }
if err := rg.resolveRepoURLs(&realRunner{}, fv); err != nil { return fatal(4, "cannot resolve repository URL: %v", err) }
if err := rg.primeCaches(ctx, packages.Load, progressWriter); err != nil { return fatal(5, "cannot index assets: %v", err) }
if err := rg.writeCovHTMLFiles(ctx, progressWriter); err != nil { return fatal(6, "cannot write HTML coverage files: %v", err) }
tb := &treeBuilder{
fsys: &localFS{},
modPaths: rg.modPaths,
outRoot: rg.outRoot,
covState: rg.covState,
}
if rg.write {
var treeHTML string
if treeHTML, err = tb.buildTree(ctx, progressWriter); err != nil { return fatal(7, "cannot build tree HTML: %v", err) }
if err := rg.writeIndexHTMLFile(treeHTML); err != nil { return fatal(8, "cannot write index.html: %v", err) }
if err := rg.writeStaticFiles(); err != nil { return fatal(9, "cannot write static files: %v", err) }
}
if err := rg.covState.printCoverage(os.Stdout); err != nil {
if !errors.Is(err, syscall.EPIPE) { // syscall.EPIPE indicates the downstream pipe closed; not an error
return fatal(10, "cannot print coverage: %v", err)
}
}
if err := rg.maybeOpenBrowser(&realRunner{}, fv); err != nil { return fatal(11, "cannot open browser: %v", err) }
return 0
}
// fatal logs a formatted message to stderr and returns the exit code.
fmt.Fprintf(os.Stderr, format + "\n", args...)
return code
}
profiles, err := cover.ParseProfiles(fv.coverProfileFile) // TODO(jbunds): (maybe?) add an adapter to handle both legacy textfmt and binary coverage profiles
if err != nil {
return nil, fmt.Errorf("cannot parse coverage profile file: %w", err)
}
rg := &reportGenerator{
fsys: &localFS{},
covState: &coverageState{},
outRoot: nullRoot{},
goModFiles: splitGoModFiles(fv.goModFiles),
profilePath: filepath.Clean(fv.coverProfileFile),
profiles: profiles,
pkgDirCache: make(map[string]string),
globalTypes: make(map[string]*types.Info),
globalASTs: make(map[string]*ast.File),
globalFset: token.NewFileSet(),
embeddedFiles: embeddedFiles,
iconFilename: "favicon.ico",
childJSFilename: "child.js",
styleCSSFilename: "style.css",
syntaxCSSFilename: "syntax.css",
staticFiles: []string{
"html/code.html",
"img/favicon.ico",
"img/go-blue-gradient.svg",
"css/style.css",
"css/syntax.css",
"css/modules.css",
"css/tree.css",
"js/events.js",
"js/child.js",
},
}
rg.covState.sort = fv.sortOrder.bind(rg.covState)
rg.write = shouldWrite(fv)
if rg.write {
info, err := rg.fsys.Stat(fv.outDir)
if err != nil || !info.IsDir() {
if err := rg.fsys.MkdirAll(fv.outDir, 0700); err != nil {
return nil, fmt.Errorf("cannot create directory %q: %w", fv.outDir, err)
}
}
root, err := os.OpenRoot(fv.outDir)
if err != nil {
return nil, fmt.Errorf("cannot open %q: %v", fv.outDir, err)
}
rg.outRoot = root
}
return rg, nil
}
// registerModPaths parses the go.work or go.mod files
// and appends their module paths to modPaths.
for _, file := range splitGoModFiles(fv.goModFiles) {
paths, err := rg.resolveGoModFiles(file)
if err != nil { return err }
for _, p := range paths {
if err := rg.appendModPath(p); err != nil {
return err
}
}
}
return nil
}
// resolveGoModFiles returns the go.mod paths for file: either the file
// itself if it's a go.mod file, or the modules listed in a go.work file.
if !strings.HasSuffix(file, ".work") {
return []string{file}, nil
}
data, err := rg.fsys.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("cannot read %q: %w", file, err)
}
workFile, err := modfile.ParseWork(file, data, nil)
if err != nil {
return nil, fmt.Errorf("cannot parse %q: %w", file, err)
}
paths := make([]string, len(workFile.Use))
for i, use := range workFile.Use {
paths[i] = filepath.Join(use.Path, "go.mod")
}
return paths, nil
}
// appendModPath parses a go.mod file and appends its module path to modPaths.
data, err := rg.fsys.ReadFile(goModFile)
if err != nil {
return fmt.Errorf("cannot read %q: %w", goModFile, err)
}
goMod, err := modfile.Parse(goModFile, data, nil)
if err != nil ||
goMod.Module == nil {
return fmt.Errorf("cannot parse %q: %w", goModFile, err)
}
rg.modPaths = append(rg.modPaths, goMod.Module.Mod.Path)
return nil
}
// resolveRepoURLs determines the repository URL for the Go module
// by reading the git remote origin and normalizing it to an HTTP(S) URL,
// falling back to the module path if no remote is configured.
// handles custom import paths (a.k.a. "vanity URLs") resolved via the `go-import`
// <meta> tag discovery mechanism described in https://go.dev/ref/mod#vcs-find:
//
// <meta name="go-import" content="import-prefix vcs repo-root subdir">
//
// see also:
//
// https://pkg.go.dev/cmd/go#hdr-Fully_qualified_import_paths
for i, goModFile := range splitGoModFiles(fv.goModFiles) {
cmd := exec.Command("git", "config", "--get", "remote.origin.url")
cmd.Dir = filepath.Dir(goModFile)
out, err := runner.Output(cmd)
if err != nil {
rg.repoURLs = append(rg.repoURLs, "https://" + rg.modPaths[i]) // fallback to module path
continue
}
originURL := strings.TrimSpace(string(out))
if idx := strings.Index(originURL, "@"); idx != -1 {
originURL = originURL[idx + 1:]
}
originURL = strings.TrimPrefix(originURL, "ssh://")
originURL = strings.TrimPrefix(originURL, "git://")
if !strings.HasPrefix(originURL, "http://" ) &&
!strings.HasPrefix(originURL, "https://") {
originURL = "https://" + strings.Replace(originURL, ":", "/", 1)
}
u, err := url.Parse(originURL)
if err != nil { return err }
repoURL := fmt.Sprintf("https://%s%s", u.Host, u.Path)
repoURL = strings.TrimSuffix(repoURL, ".git")
repoURL = strings.TrimSuffix(repoURL, "/")
rg.repoURLs = append(rg.repoURLs, repoURL)
}
return nil
}
// getAllPkgPaths extracts all unique package paths from the
// coverage profile file for subsequent use in primeCaches.
f, err := rg.fsys.Open(filepath.Clean(rg.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.Bytes()
if bytes.HasPrefix(line, []byte("mode:")) { continue }
idx := bytes.IndexByte(line, ':')
if idx < 1 { continue }
pkgSet[filepath.Dir(string(line[:idx]))] = struct{}{}
}
allPaths := make([]string, 0, len(pkgSet))
for p := range pkgSet { allPaths = append(allPaths, p) }
return allPaths, scanner.Err()
}
// loadResult holds the result of loading all packages comprising a module.
type loadResult struct {
pkgs []*packages.Package
err error
}
// loadParams holds the channel and progress-reporting configuration for loadPackages.
type loadParams struct {
loadCh <-chan loadResult
elapsed time.Time
tickInterval time.Duration
spinner []rune
prog *progress.Progress
}
// primeCaches populates package directory, AST, and type info maps for all source packages.
if err := ctx.Err(); err != nil { return err }
allPkgPaths, err := rg.getAllPkgPaths()
if err != nil { return err }
totalPkgs := len(allPkgPaths)
pkgsProg := progress.New(ctx, uint64(totalPkgs), progressWriter)
defer pkgsProg.Close()
pkgsProg.Report(0, "loading " + strconv.Itoa(totalPkgs) + " packages...")
var (
totalASTFiles int
allPkgs []*packages.Package
)
const tickInterval = 60 * time.Millisecond
elapsed := time.Now()
for _, goModFile := range rg.goModFiles {
cfg := &packages.Config{
Fset: rg.globalFset,
Dir: filepath.Dir(goModFile),
Mode: packages.NeedFiles |
packages.NeedModule |
packages.NeedName |
packages.NeedTypesInfo |
packages.NeedSyntax,
Tests: false,
}
loadCh := make(chan loadResult, 1)
go func() {
pkgs, err := pkgLoader(cfg, allPkgPaths...)
loadCh <- loadResult{pkgs, err}
}()
lp := loadParams{
loadCh: loadCh,
elapsed: elapsed,
tickInterval: tickInterval,
spinner: []rune("⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧"),
prog: pkgsProg,
}
if allPkgs, totalASTFiles, err = loadPackages(ctx, lp, totalPkgs); err != nil { return err }
}
astsProg := progress.New(ctx, uint64(totalASTFiles), progressWriter)
defer astsProg.Close()
astsProg.Report(0, "indexing " + strconv.Itoa(totalASTFiles) + " source files...")
for _, pkg := range allPkgs {
if len(pkg.GoFiles) > 0 {
rg.pkgDirCache[pkg.PkgPath] = filepath.Dir(pkg.GoFiles[0])
}
for _, syntaxFile := range pkg.Syntax {
f := pkg.Fset.File(syntaxFile.Pos()) // non-nil: coverage profile indicates compilation, which implies FileSet registration
rg.globalTypes[f.Name()] = pkg.TypesInfo
rg.globalASTs[ f.Name()] = syntaxFile
astsProg.Report(1, f.Name())
}
}
return nil
}
// loadPackages awaits the loading of all packages comprising a module, reporting progress on each tick.
if err = ctx.Err(); err != nil { return }
ticker := time.NewTicker(p.tickInterval)
defer ticker.Stop()
for {
select {
case res := <-p.loadCh:
if res.err != nil { err = res.err; return }
allPkgs = append(allPkgs, res.pkgs...)
p.prog.Report(float64(len(res.pkgs)), "loaded " + strconv.Itoa(len(allPkgs)) + " packages")
for _, pkg := range res.pkgs {
totalASTFiles += len(pkg.Syntax)
}
return
case <-ctx.Done():
err = ctx.Err()
return
case <-ticker.C:
n := int(time.Since(p.elapsed) / p.tickInterval) % len(p.spinner)
p.prog.Report(0, string(p.spinner[n]) + " type-checking " + strconv.Itoa(totalPkgs) + " package(s)")
}
}
}
// shouldWrite reports whether the user-specified
// outDir is the not OS-specific null device.
switch {
case runtime.GOOS == "darwin" && fv.outDir == "/dev/null":
return false
case runtime.GOOS == "linux" && fv.outDir == "/dev/null":
return false
case runtime.GOOS == "windows" && fv.outDir == "nul":
return false
}
return true
}
// splitGoModFiles splits the comma-delimited list of
// user-specified go.mod files into a string slice.
files := strings.Split(s, ",")
for i, f := range files {
files[i] = filepath.Clean(f)
}
return files
}