Created
April 17, 2026 23:00
-
-
Save zachmu/23975276bd89a1cfd338e2c81c05e2cd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // generate.go transforms a Go coverage HTML report (produced by `go tool cover -html`) | |
| // into a directory of per-file HTML pages with a shared stylesheet and an index page | |
| // showing a table of all files with their coverage percentages. | |
| // | |
| // Usage: go run generate.go <coverage.html> | |
| // | |
| // Output is written to the current directory: | |
| // index.html — table of all source files with coverage stats | |
| // style.css — shared stylesheet | |
| // files/<path>.html — per-file coverage pages mirroring source paths | |
| package main | |
| import ( | |
| "bufio" | |
| "fmt" | |
| "html" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "strconv" | |
| "strings" | |
| ) | |
| type fileEntry struct { | |
| id string // "file0", "file1", ... | |
| path string // "github.com/dolthub/dolt/go/cmd/..." | |
| coverage float64 // 0.0 to 100.0 | |
| content string // raw HTML content between <pre> tags | |
| } | |
| // htmlPath returns the output path for a file's coverage page. | |
| // e.g. "github.com/dolthub/dolt/go/cmd/dolt/cli/help.go" -> "files/github.com/dolthub/dolt/go/cmd/dolt/cli/help.html" | |
| func (e fileEntry) htmlPath() string { | |
| p := e.path | |
| if strings.HasSuffix(p, ".go") { | |
| p = p[:len(p)-3] + ".html" | |
| } else { | |
| p = p + ".html" | |
| } | |
| return filepath.Join("files", p) | |
| } | |
| // cssRelPath returns the relative path from a file's coverage page back to style.css at the root. | |
| func (e fileEntry) cssRelPath() string { | |
| depth := strings.Count(e.htmlPath(), string(os.PathSeparator)) | |
| return strings.Repeat("../", depth) + "style.css" | |
| } | |
| // indexRelPath returns the relative path from a file's coverage page back to index.html at the root. | |
| func (e fileEntry) indexRelPath() string { | |
| depth := strings.Count(e.htmlPath(), string(os.PathSeparator)) | |
| return strings.Repeat("../", depth) + "index.html" | |
| } | |
| var optionRe = regexp.MustCompile(`<option value="(file\d+)">(.+?) \(([0-9.]+)%\)</option>`) | |
| var preOpenRe = regexp.MustCompile(`<pre class="file" id="(file\d+)"[^>]*>`) | |
| func main() { | |
| if len(os.Args) != 2 { | |
| fmt.Fprintf(os.Stderr, "usage: %s <coverage.html>\n", os.Args[0]) | |
| os.Exit(1) | |
| } | |
| inputPath := os.Args[1] | |
| entries, err := parse(inputPath) | |
| if err != nil { | |
| fmt.Fprintf(os.Stderr, "error: %v\n", err) | |
| os.Exit(1) | |
| } | |
| writeCSS("style.css") | |
| writeIndex("index.html", entries) | |
| for _, e := range entries { | |
| outPath := e.htmlPath() | |
| if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { | |
| fmt.Fprintf(os.Stderr, "error creating dir for %s: %v\n", outPath, err) | |
| os.Exit(1) | |
| } | |
| writeFilePage(outPath, e) | |
| } | |
| fmt.Printf("Generated index.html + %d file pages\n", len(entries)) | |
| } | |
| func parse(path string) ([]fileEntry, error) { | |
| f, err := os.Open(path) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer f.Close() | |
| entryMap := make(map[string]*fileEntry) | |
| var entryOrder []string | |
| scanner := bufio.NewScanner(f) | |
| scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) | |
| type state int | |
| const ( | |
| stateOptions state = iota | |
| stateContent | |
| ) | |
| currentState := stateOptions | |
| var currentID string | |
| var contentBuf strings.Builder | |
| for scanner.Scan() { | |
| line := scanner.Text() | |
| switch currentState { | |
| case stateOptions: | |
| if m := optionRe.FindStringSubmatch(line); m != nil { | |
| id := m[1] | |
| filePath := m[2] | |
| cov, _ := strconv.ParseFloat(m[3], 64) | |
| entryMap[id] = &fileEntry{ | |
| id: id, | |
| path: filePath, | |
| coverage: cov, | |
| } | |
| entryOrder = append(entryOrder, id) | |
| } | |
| if m := preOpenRe.FindStringSubmatch(line); m != nil { | |
| currentState = stateContent | |
| currentID = m[1] | |
| idx := preOpenRe.FindStringIndex(line) | |
| contentBuf.Reset() | |
| contentBuf.WriteString(line[idx[1]:]) | |
| } | |
| case stateContent: | |
| if strings.Contains(line, "</pre>") { | |
| idx := strings.Index(line, "</pre>") | |
| contentBuf.WriteString("\n") | |
| contentBuf.WriteString(line[:idx]) | |
| if e, ok := entryMap[currentID]; ok { | |
| e.content = contentBuf.String() | |
| } | |
| contentBuf.Reset() | |
| currentID = "" | |
| if m := preOpenRe.FindStringSubmatch(line[idx:]); m != nil { | |
| currentID = m[1] | |
| pidx := preOpenRe.FindStringIndex(line[idx:]) | |
| contentBuf.WriteString(line[idx+pidx[1]:]) | |
| } else { | |
| currentState = stateOptions | |
| } | |
| } else { | |
| contentBuf.WriteString("\n") | |
| contentBuf.WriteString(line) | |
| } | |
| } | |
| } | |
| if err := scanner.Err(); err != nil { | |
| return nil, err | |
| } | |
| entries := make([]fileEntry, 0, len(entryOrder)) | |
| for _, id := range entryOrder { | |
| if e, ok := entryMap[id]; ok { | |
| entries = append(entries, *e) | |
| } | |
| } | |
| return entries, nil | |
| } | |
| func writeCSS(path string) { | |
| css := `/* Shared coverage stylesheet */ | |
| * { box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| margin: 0; | |
| padding: 0; | |
| background: #1a1a2e; | |
| color: #e0e0e0; | |
| } | |
| /* ---- Index page ---- */ | |
| .index-header { | |
| padding: 20px 30px; | |
| border-bottom: 1px solid #333; | |
| background: #16213e; | |
| } | |
| .index-header h1 { | |
| margin: 0 0 4px 0; | |
| font-size: 22px; | |
| color: #eee; | |
| } | |
| .index-header .summary { | |
| font-size: 14px; | |
| color: #999; | |
| } | |
| .search-box { | |
| padding: 10px 30px; | |
| background: #16213e; | |
| border-bottom: 1px solid #333; | |
| position: sticky; | |
| top: 0; | |
| z-index: 10; | |
| } | |
| .search-box input { | |
| width: 100%; | |
| max-width: 600px; | |
| padding: 8px 12px; | |
| border-radius: 4px; | |
| border: 1px solid #444; | |
| background: #1a1a2e; | |
| color: #e0e0e0; | |
| font-size: 14px; | |
| font-family: Menlo, monospace; | |
| } | |
| .search-box input::placeholder { color: #666; } | |
| table.coverage-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 13px; | |
| } | |
| table.coverage-table th { | |
| text-align: left; | |
| padding: 8px 12px; | |
| border-bottom: 2px solid #444; | |
| background: #16213e; | |
| position: sticky; | |
| top: 52px; | |
| z-index: 5; | |
| cursor: pointer; | |
| user-select: none; | |
| color: #aaa; | |
| font-size: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| white-space: nowrap; | |
| } | |
| table.coverage-table th:hover { color: #fff; } | |
| table.coverage-table td { | |
| padding: 6px 12px; | |
| border-bottom: 1px solid #2a2a3e; | |
| } | |
| table.coverage-table tr:hover { background: #222244; } | |
| table.coverage-table a { | |
| color: #7eb8da; | |
| text-decoration: none; | |
| font-family: Menlo, monospace; | |
| font-size: 12px; | |
| } | |
| table.coverage-table a:hover { text-decoration: underline; } | |
| .file-cell { | |
| max-width: 0; | |
| width: 100%; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .file-cell a { | |
| display: block; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .cov-pct { | |
| font-family: Menlo, monospace; | |
| font-size: 12px; | |
| text-align: right; | |
| white-space: nowrap; | |
| } | |
| .cov-high { color: #20ec9b; } | |
| .cov-med { color: #e8c54a; } | |
| .cov-low { color: #e05555; } | |
| .cov-zero { color: #777; } | |
| .bar-cell { | |
| width: 180px; | |
| min-width: 180px; | |
| } | |
| .bar-bg { | |
| background: #2a2a2a; | |
| border-radius: 3px; | |
| height: 14px; | |
| overflow: hidden; | |
| } | |
| .bar-fill { | |
| height: 100%; | |
| border-radius: 3px; | |
| min-width: 1px; | |
| } | |
| /* ---- File page ---- */ | |
| .file-header { | |
| padding: 16px 24px; | |
| border-bottom: 1px solid #333; | |
| background: #16213e; | |
| } | |
| .file-header h1 { | |
| margin: 0; | |
| font-size: 16px; | |
| font-family: Menlo, monospace; | |
| color: #eee; | |
| word-break: break-all; | |
| } | |
| .file-header .back { | |
| display: inline-block; | |
| margin-bottom: 8px; | |
| color: #7eb8da; | |
| text-decoration: none; | |
| font-size: 13px; | |
| } | |
| .file-header .back:hover { text-decoration: underline; } | |
| .file-header .file-cov { | |
| margin-top: 6px; | |
| font-size: 13px; | |
| } | |
| .source-container { | |
| padding: 0; | |
| overflow-x: auto; | |
| } | |
| table.source-code { | |
| border-collapse: collapse; | |
| font-family: Menlo, monospace; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| width: 100%; | |
| } | |
| table.source-code td { | |
| padding: 0 12px; | |
| white-space: pre; | |
| vertical-align: top; | |
| } | |
| table.source-code .line-no { | |
| text-align: right; | |
| color: #555; | |
| user-select: none; | |
| width: 1%; | |
| padding-right: 16px; | |
| border-right: 1px solid #333; | |
| } | |
| table.source-code .line-content { | |
| padding-left: 16px; | |
| } | |
| /* Coverage coloring — matches Go's coverage tool classes */ | |
| .cov0 { color: rgb(192, 0, 0); } | |
| .cov1 { color: rgb(128, 128, 128); } | |
| .cov2 { color: rgb(116, 140, 131); } | |
| .cov3 { color: rgb(104, 152, 134); } | |
| .cov4 { color: rgb(92, 164, 137); } | |
| .cov5 { color: rgb(80, 176, 140); } | |
| .cov6 { color: rgb(68, 188, 143); } | |
| .cov7 { color: rgb(56, 200, 146); } | |
| .cov8 { color: rgb(44, 212, 149); } | |
| .cov9 { color: rgb(32, 224, 152); } | |
| .cov10 { color: rgb(20, 236, 155); } | |
| /* Not-tracked text (default color) */ | |
| .source-code .line-content { color: rgb(80, 80, 80); } | |
| ` | |
| os.WriteFile(path, []byte(css), 0644) | |
| } | |
| func writeIndex(path string, entries []fileEntry) { | |
| f, _ := os.Create(path) | |
| defer f.Close() | |
| w := bufio.NewWriter(f) | |
| defer w.Flush() | |
| var totalCov float64 | |
| var nonZero int | |
| for _, e := range entries { | |
| totalCov += e.coverage | |
| if e.coverage > 0 { | |
| nonZero++ | |
| } | |
| } | |
| avgCov := 0.0 | |
| if len(entries) > 0 { | |
| avgCov = totalCov / float64(len(entries)) | |
| } | |
| fmt.Fprintf(w, `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Go Coverage Report</title> | |
| <link rel="stylesheet" href="style.css"> | |
| </head> | |
| <body> | |
| <div class="index-header"> | |
| <h1>Go Coverage Report</h1> | |
| <div class="summary">%d files · %d with coverage · %.1f%% average</div> | |
| </div> | |
| <div class="search-box"> | |
| <input type="text" id="filter" placeholder="Filter files..." autofocus> | |
| </div> | |
| <table class="coverage-table"> | |
| <thead> | |
| <tr> | |
| <th onclick="sortTable(0)">File</th> | |
| <th onclick="sortTable(1)" style="text-align:right">Coverage</th> | |
| <th>Bar</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tbody"> | |
| `, len(entries), nonZero, avgCov) | |
| for _, e := range entries { | |
| covClass := coverageClass(e.coverage) | |
| barColor := barColorForCoverage(e.coverage) | |
| fmt.Fprintf(w, `<tr data-path="%s"> | |
| <td class="file-cell"><a href="%s">%s</a></td> | |
| <td class="cov-pct %s">%.1f%%</td> | |
| <td class="bar-cell"><div class="bar-bg"><div class="bar-fill" style="width:%.1f%%;background:%s"></div></div></td> | |
| </tr> | |
| `, html.EscapeString(e.path), html.EscapeString(e.htmlPath()), html.EscapeString(e.path), covClass, e.coverage, e.coverage, barColor) | |
| } | |
| fmt.Fprintf(w, `</tbody> | |
| </table> | |
| <script> | |
| document.getElementById('filter').addEventListener('input', function() { | |
| var val = this.value.toLowerCase(); | |
| var rows = document.querySelectorAll('#tbody tr'); | |
| for (var i = 0; i < rows.length; i++) { | |
| var path = rows[i].getAttribute('data-path').toLowerCase(); | |
| rows[i].style.display = path.indexOf(val) >= 0 ? '' : 'none'; | |
| } | |
| }); | |
| var sortDir = [1, 1, 1]; | |
| function sortTable(col) { | |
| var tbody = document.getElementById('tbody'); | |
| var rows = Array.from(tbody.querySelectorAll('tr')); | |
| sortDir[col] *= -1; | |
| rows.sort(function(a, b) { | |
| var av, bv; | |
| if (col === 1) { | |
| av = parseFloat(a.children[col].textContent); | |
| bv = parseFloat(b.children[col].textContent); | |
| } else { | |
| av = a.children[col].textContent.toLowerCase(); | |
| bv = b.children[col].textContent.toLowerCase(); | |
| } | |
| if (av < bv) return -sortDir[col]; | |
| if (av > bv) return sortDir[col]; | |
| return 0; | |
| }); | |
| for (var i = 0; i < rows.length; i++) tbody.appendChild(rows[i]); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| `) | |
| } | |
| func writeFilePage(path string, e fileEntry) { | |
| f, _ := os.Create(path) | |
| defer f.Close() | |
| w := bufio.NewWriter(f) | |
| defer w.Flush() | |
| _, shortName := splitPkgFile(e.path) | |
| covClass := coverageClass(e.coverage) | |
| fmt.Fprintf(w, `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>%s — Coverage</title> | |
| <link rel="stylesheet" href="%s"> | |
| </head> | |
| <body> | |
| <div class="file-header"> | |
| <a class="back" href="%s">← Back to index</a> | |
| <h1>%s</h1> | |
| <div class="file-cov">Coverage: <span class="%s">%.1f%%</span></div> | |
| </div> | |
| <div class="source-container"> | |
| <table class="source-code"> | |
| `, html.EscapeString(shortName), e.cssRelPath(), e.indexRelPath(), html.EscapeString(e.path), covClass, e.coverage) | |
| lines := strings.Split(e.content, "\n") | |
| lineNum := 1 | |
| // Track the currently open <span> tag so we can re-open it on continuation lines. | |
| // Coverage spans in the original HTML can cross line boundaries. | |
| var openSpan string // e.g. `<span class="cov0" title="0">` or "" | |
| for i, line := range lines { | |
| if i == 0 && strings.TrimSpace(line) == "" { | |
| continue | |
| } | |
| // Build the line content with proper span wrapping | |
| var rendered string | |
| if openSpan != "" { | |
| rendered = openSpan + line | |
| } else { | |
| rendered = line | |
| } | |
| // Update openSpan state by scanning for span open/close tags in this line. | |
| // After processing, openSpan reflects whatever span is still open at end of line. | |
| openSpan = trackOpenSpan(openSpan, line) | |
| // If a span is still open at end of line, close it so the <td> is well-formed. | |
| if openSpan != "" { | |
| rendered += "</span>" | |
| } | |
| fmt.Fprintf(w, "<tr><td class=\"line-no\">%d</td><td class=\"line-content\">%s</td></tr>\n", lineNum, rendered) | |
| lineNum++ | |
| } | |
| fmt.Fprintf(w, `</table> | |
| </div> | |
| </body> | |
| </html> | |
| `) | |
| } | |
| var spanOpenRe = regexp.MustCompile(`<span class="cov\d+" title="\d+">`) | |
| // trackOpenSpan processes a line of HTML and returns the span tag that is still open | |
| // at the end of the line (if any). It scans left-to-right for <span ...> and </span> tags. | |
| func trackOpenSpan(currentOpen string, line string) string { | |
| open := currentOpen | |
| s := line | |
| for len(s) > 0 { | |
| openIdx := strings.Index(s, "<span ") | |
| closeIdx := strings.Index(s, "</span>") | |
| // Neither found — done | |
| if openIdx < 0 && closeIdx < 0 { | |
| break | |
| } | |
| // Only close found, or close comes first | |
| if openIdx < 0 || (closeIdx >= 0 && closeIdx < openIdx) { | |
| open = "" | |
| s = s[closeIdx+len("</span>"):] | |
| continue | |
| } | |
| // Open found (and comes before any close) | |
| m := spanOpenRe.FindString(s[openIdx:]) | |
| if m == "" { | |
| break | |
| } | |
| open = m | |
| s = s[openIdx+len(m):] | |
| } | |
| return open | |
| } | |
| func splitPkgFile(path string) (pkg, file string) { | |
| idx := strings.LastIndex(path, "/") | |
| if idx < 0 { | |
| return "", path | |
| } | |
| return path[:idx], path[idx+1:] | |
| } | |
| func coverageClass(pct float64) string { | |
| if pct == 0 { | |
| return "cov-zero" | |
| } | |
| if pct < 30 { | |
| return "cov-low" | |
| } | |
| if pct < 70 { | |
| return "cov-med" | |
| } | |
| return "cov-high" | |
| } | |
| func barColorForCoverage(pct float64) string { | |
| if pct == 0 { | |
| return "#444" | |
| } | |
| if pct < 30 { | |
| return "#e05555" | |
| } | |
| if pct < 70 { | |
| return "#e8c54a" | |
| } | |
| return "#20ec9b" | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment