package cover
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"github.com/masakurapa/gover-html/internal/cover/filter"
"github.com/masakurapa/gover-html/internal/profile"
)
var reg = regexp.MustCompile(`^(.+):([0-9]+)\.([0-9]+),([0-9]+)\.([0-9]+) ([0-9]+) ([0-9]+)$`)
type importDir struct {
modulePath string
relative string
dir string
}
// ReadProfile is reads profiling data
func ReadProfile(r io.Reader, f filter.Filter) (profile.Profiles, error) {
files := make(map[string]*profile.Profile)
modeOk := false
id := 1
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
// first line must be "mode: xxx"
if !modeOk {
const p = "mode: "
if !strings.HasPrefix(line, p) || line == p {
return nil, fmt.Errorf("first line must be mode: %q", line)
}
modeOk = true
continue
}
matches := reg.FindStringSubmatch(line)
if matches == nil {
return nil, fmt.Errorf("%q does not match expected format: %v", line, reg)
}
fileName := matches[1]
p := files[fileName]
if p == nil {
p = &profile.Profile{ID: id, FileName: fileName}
files[fileName] = p
id++
}
p.Blocks = append(p.Blocks, profile.Block{
StartLine: toInt(matches[2]),
StartCol: toInt(matches[3]),
EndLine: toInt(matches[4]),
EndCol: toInt(matches[5]),
NumState: toInt(matches[6]),
Count: toInt(matches[7]),
})
}
return toProfiles(files, f)
}
func toInt(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return i
}
func toProfiles(files map[string]*profile.Profile, f filter.Filter) (profile.Profiles, error) {
dirs, err := makeImportDirMap(files)
if err != nil {
return nil, err
}
profiles := make(profile.Profiles, 0, len(files))
for _, p := range files {
p.Blocks = filterBlocks(p.Blocks)
sort.SliceStable(p.Blocks, func(i, j int) bool {
bi, bj := p.Blocks[i], p.Blocks[j]
return bi.StartLine < bj.StartLine || bi.StartLine == bj.StartLine && bi.StartCol < bj.StartCol
})
d := dirs[path.Dir(p.FileName)]
if !f.IsOutputTarget(d.relative, filepath.Base(p.FileName)) {
continue
}
p.Dir = d.dir
p.ModulePath = d.modulePath
pp, err := makeNewProfile(p, f)
if err != nil {
return nil, err
}
profiles = append(profiles, *pp)
}
sort.SliceStable(profiles, func(i, j int) bool {
return profiles[i].FileName < profiles[j].FileName
})
return profiles, nil
}
func filterBlocks(blocks []profile.Block) []profile.Block {
index := func(bs []profile.Block, b *profile.Block) int {
for i, bb := range bs {
if bb.StartLine == b.StartLine &&
bb.StartCol == b.StartCol &&
bb.EndLine == b.EndLine &&
bb.EndCol == b.EndCol {
return i
}
}
return -1
}
newBlocks := make([]profile.Block, 0, len(blocks))
for _, b := range blocks {
i := index(newBlocks, &b)
if i == -1 {
newBlocks = append(newBlocks, b)
continue
}
if b.Count > 0 {
newBlocks[i] = b
}
}
return newBlocks
}
func makeImportDirMap(files map[string]*profile.Profile) (map[string]importDir, error) {
stdout, err := execGoList(files)
if err != nil {
return nil, err
}
pkgs := make(map[string]importDir)
if len(stdout) == 0 {
return pkgs, nil
}
type pkg struct {
Dir string
Module *struct {
Path string
Dir string
}
ImportPath string
Error *struct {
Err string
}
}
dec := json.NewDecoder(bytes.NewReader(stdout))
for {
var p pkg
err := dec.Decode(&p)
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("decoding go list json: %v", err)
}
if p.Error != nil {
return nil, fmt.Errorf(p.Error.Err)
}
// should have the same result for "pkg.ImportPath" and "path.Path(Profile.FileName)"
pkgs[p.ImportPath] = importDir{
modulePath: p.Module.Path,
relative: strings.TrimPrefix(p.Dir, p.Module.Dir+"/"),
dir: p.Dir,
}
}
return pkgs, nil
}
// execute "go list" command
func execGoList(files map[string]*profile.Profile) ([]byte, error) {
dirs := make([]string, 0, len(files))
m := make(map[string]struct{})
for _, p := range files {
if p.IsRelativeOrAbsolute() {
continue
}
dir := path.Dir(p.FileName)
if _, ok := m[dir]; !ok {
m[dir] = struct{}{}
dirs = append(dirs, dir)
}
}
if len(dirs) == 0 {
return make([]byte, 0), nil
}
cmdName := filepath.Join(runtime.GOROOT(), "bin/go")
args := append([]string{"list", "-e", "-json"}, dirs...)
cmd := exec.Command(cmdName, args...)
return cmd.Output()
}
package filter
import (
"path/filepath"
"strings"
"github.com/masakurapa/gover-html/internal/option"
)
// Filter is filter the output directory
type Filter interface {
IsOutputTarget(string, string) bool
IsOutputTargetFunc(string, string, string) bool
}
type filter struct {
opt option.Option
}
// New is initialize the filter
func New(opt option.Option) Filter {
return &filter{opt: opt}
}
// IsOutputTarget returns true if output target
// The "relativePath" must be relative to the base path
func (f *filter) IsOutputTarget(relativePath, fileName string) bool {
// absolute path is always NG
if strings.HasPrefix(relativePath, "/") {
return false
}
path := f.convertPathForValidate(relativePath)
for _, s := range f.opt.Exclude {
if f.hasPrefix(path, fileName, s) {
return false
}
}
if len(f.opt.Include) == 0 {
return true
}
for _, s := range f.opt.Include {
if f.hasPrefix(path, fileName, s) {
return true
}
}
return false
}
func (f *filter) IsOutputTargetFunc(relativePath, structName, funcName string) bool {
path := f.convertPathForValidate(relativePath)
dir := filepath.Dir(path)
for _, opt := range f.opt.ExcludeFunc {
if opt.Path != "" && opt.Path != path && opt.Path != dir {
continue
}
if opt.Struct != "" && opt.Struct != structName {
continue
}
if opt.Func != funcName {
continue
}
return false
}
return true
}
func (f *filter) hasPrefix(path, fileName, prefix string) bool {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
return path+"/"+fileName == prefix
}
func (f *filter) convertPathForValidate(relativePath string) string {
path := strings.TrimPrefix(relativePath, "./")
return strings.TrimSuffix(path, "/")
}
package cover
import (
"bytes"
"go/ast"
"go/format"
"go/parser"
"go/token"
"github.com/masakurapa/gover-html/internal/cover/filter"
"github.com/masakurapa/gover-html/internal/profile"
)
type funcExtent struct {
structName string
funcName string
name string
startLine int
startCol int
endLine int
endCol int
}
type funcVisitor struct {
fset *token.FileSet
name string
astFile *ast.File
funcs []*funcExtent
}
func (v *funcVisitor) Visit(node ast.Node) ast.Visitor {
switch n := node.(type) {
case *ast.FuncDecl:
if n.Body == nil {
break
}
start := v.fset.Position(n.Pos())
end := v.fset.Position(n.End())
var buf bytes.Buffer
err := format.Node(&buf, token.NewFileSet(), &ast.FuncDecl{
Name: n.Name,
Recv: n.Recv,
Type: n.Type,
})
if err != nil {
panic(err)
}
src, err := format.Source(buf.Bytes())
if err != nil {
panic(err)
}
var structName string
if n.Recv != nil && len(n.Recv.List) > 0 {
if t, ok := n.Recv.List[0].Type.(*ast.Ident); ok {
structName = t.Name
}
if t, ok := n.Recv.List[0].Type.(*ast.StarExpr); ok {
structName = t.X.(*ast.Ident).Name
}
}
fe := &funcExtent{
structName: structName,
funcName: n.Name.Name,
name: string(src),
startLine: start.Line,
startCol: start.Column,
endLine: end.Line,
endCol: end.Column,
}
v.funcs = append(v.funcs, fe)
}
return v
}
func makeNewProfile(prof *profile.Profile, f filter.Filter) (*profile.Profile, error) {
exts, err := findFuncs(prof.FilePath())
if err != nil {
return nil, err
}
return newProfile(prof, exts, f), nil
}
func findFuncs(name string) ([]*funcExtent, error) {
fset := token.NewFileSet()
parsedFile, err := parser.ParseFile(fset, name, nil, 0)
if err != nil {
return nil, err
}
visitor := &funcVisitor{
fset: fset,
name: name,
astFile: parsedFile,
}
ast.Walk(visitor, visitor.astFile)
return visitor.funcs, nil
}
func newProfile(prof *profile.Profile, exts []*funcExtent, f filter.Filter) *profile.Profile {
fncs := make(profile.Functions, 0, len(exts))
blocks := make(profile.Blocks, 0, len(prof.Blocks))
bi := 0
for _, e := range exts {
isCoverageBlock := f.IsOutputTargetFunc(prof.RemoveModulePathFromFileName(), e.structName, e.funcName)
fnc := profile.Function{
Name: e.name,
StartLine: e.startLine,
StartCol: e.startCol,
}
for bi < len(prof.Blocks) {
b := prof.Blocks[bi]
if b.StartLine < e.startLine {
if isCoverageBlock {
blocks = append(blocks, b)
}
bi++
continue
}
if b.StartLine >= e.startLine &&
b.EndLine <= e.endLine {
fnc.Blocks = append(fnc.Blocks, b)
if isCoverageBlock {
blocks = append(blocks, b)
}
bi++
continue
}
break
}
if isCoverageBlock {
fncs = append(fncs, fnc)
}
}
prof.Blocks = blocks
prof.Functions = fncs
return prof
}
package html
import (
"bytes"
"fmt"
"html/template"
"io"
"os"
"path"
"github.com/masakurapa/gover-html/internal/html/tree"
"github.com/masakurapa/gover-html/internal/option"
"github.com/masakurapa/gover-html/internal/profile"
)
var (
escapeChar = map[byte]string{
'<': "<",
'>': ">",
'&': "&",
'\t': " ",
}
)
type templateData struct {
Theme string
Tree []templateTree
Files []templateFile
}
type templateTree struct {
ID int
Name string
Indent int
Coverage float64
IsFile bool
}
type templateFile struct {
ID int
Name string
Body template.HTML
Coverage float64
Functions []templateFunc
}
type templateFunc struct {
Name string
Line int
Coverage float64
}
// WriteTreeView outputs coverage as a tree view HTML file
func WriteTreeView(out io.Writer, profiles profile.Profiles, opt option.Option) error {
nodes := tree.Create(profiles)
tmpTree := make([]templateTree, 0)
makeTemplateTree(&tmpTree, nodes, 0)
data := templateData{
Theme: opt.Theme,
Tree: tmpTree,
Files: make([]templateFile, 0, len(profiles)),
}
var buf bytes.Buffer
for _, p := range profiles {
b, err := os.ReadFile(p.FilePath())
if err != nil {
return fmt.Errorf("can't read %q: %v", p.FileName, err)
}
writeSource(&buf, b, &p)
f := templateFile{
ID: p.ID,
Name: p.FileName,
Body: template.HTML(buf.String()),
Coverage: p.Blocks.Coverage(),
Functions: make([]templateFunc, 0, len(p.Functions)),
}
buf.Reset()
for _, fn := range p.Functions {
f.Functions = append(f.Functions, templateFunc{
Name: fn.Name,
Line: fn.StartLine,
Coverage: fn.Blocks.Coverage(),
})
}
data.Files = append(data.Files, f)
}
return parsedTreeTemplate.Execute(out, data)
}
func makeTemplateTree(tree *[]templateTree, nodes []tree.Node, indent int) {
for _, node := range nodes {
childBlocks := node.ChildBlocks()
*tree = append(*tree, templateTree{
Name: node.Name,
Indent: indent,
Coverage: childBlocks.Coverage(),
})
makeTemplateTree(tree, node.Dirs, indent+1)
for _, p := range node.Files {
*tree = append(*tree, templateTree{
ID: p.ID,
Name: path.Base(p.FileName),
Indent: indent + 1,
Coverage: p.Blocks.Coverage(),
IsFile: true,
})
}
}
}
func writeSource(buf *bytes.Buffer, src []byte, prof *profile.Profile) {
bi := 0
si := 0
line := 1
col := 1
cov0 := false
cov1 := false
buf.WriteString("<ol>")
for si < len(src) {
if col == 1 {
buf.WriteString(fmt.Sprintf(`<li id="file%d-%d">`, prof.ID, line))
if cov0 {
buf.WriteString(`<span class="cov0">`)
}
if cov1 {
buf.WriteString(`<span class="cov1">`)
}
}
if len(prof.Blocks) > bi {
block := prof.Blocks[bi]
if block.StartLine == line && block.StartCol == col {
if block.Count == 0 {
buf.WriteString(`<span class="cov0">`)
cov0 = true
} else {
buf.WriteString(`<span class="cov1">`)
cov1 = true
}
}
if block.EndLine == line && block.EndCol == col || line > block.EndLine {
buf.WriteString(`</span>`)
bi++
cov0 = false
cov1 = false
continue
}
}
b := src[si]
writeChar(buf, b)
if b == '\n' {
if cov0 || cov1 {
buf.WriteString("</span>")
}
buf.WriteString("</li>")
line++
col = 0
}
si++
col++
}
buf.WriteString("</ol>")
}
func writeChar(buf *bytes.Buffer, b byte) {
if s, ok := escapeChar[b]; ok {
buf.WriteString(s)
return
}
buf.WriteByte(b)
}
package html
import (
"fmt"
"html/template"
)
var parsedTreeTemplate = template.
Must(template.New("html").
Funcs(template.FuncMap{
"indent": func(i int) int { return i*24 + 12 },
"coverageColor": func(coverage float64) template.CSS {
// Professional color gradient with proper opacity
var h float64
if coverage < 50 {
// Red to orange gradient
h = coverage * 0.6 // 0-30 degrees (red to orange)
} else if coverage < 80 {
// Orange to yellow gradient
h = 30 + ((coverage - 50) / 30 * 30) // 30-60 degrees
} else {
// Yellow to green gradient
h = 60 + ((coverage - 80) / 20 * 60) // 60-120 degrees
}
return template.CSS(fmt.Sprintf("hsla(%.0f, 70%%, 50%%, 0.4)", h))
},
}).
Parse(treeTemplate))
const treeTemplate = `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coverage Report</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.5);
}
.dark ::-webkit-scrollbar-thumb {
background: rgba(71, 85, 105, 0.5);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(71, 85, 105, 0.7);
}
.main {
width: 100%;
min-height: 100vh;
display: flex;
}
.light {
background: #f8fafc;
color: #1e293b;
}
.dark {
background: #0f172a;
color: #e2e8f0;
}
#tree {
width: 280px;
min-width: 280px;
height: 100vh;
padding: 24px 16px;
white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
position: sticky;
top: 0;
left: 0;
background: #ffffff;
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.05);
}
.dark #tree {
background: #1e293b;
box-shadow: 1px 0 0 rgba(255, 255, 255, 0.05);
}
#tree > div {
padding: 0;
position: relative;
margin: 4px 0;
}
.tree-item {
position: relative;
padding: 8px 12px;
border-radius: 6px;
transition: all 0.2s ease;
background: transparent;
}
.tree-item:hover {
background-color: rgba(59, 130, 246, 0.05);
}
.dark .tree-item:hover {
background-color: rgba(96, 165, 250, 0.1);
}
.tree-item-bg {
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 0;
border-radius: 6px;
}
.tree-item-content {
position: relative;
z-index: 1;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
}
.tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-coverage {
margin-left: 12px;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.dark .tree-coverage {
color: #94a3b8;
}
.clickable {
cursor: pointer;
}
.clickable .tree-item-content {
color: inherit;
text-decoration: none;
}
.current .tree-item {
background-color: rgba(59, 130, 246, 0.1);
box-shadow: inset 3px 0 0 #3b82f6;
}
.dark .current .tree-item {
background-color: rgba(96, 165, 250, 0.15);
box-shadow: inset 3px 0 0 #60a5fa;
}
#coverage {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.source {
white-space: nowrap;
background: #ffffff;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow-x: auto;
margin-top: 24px;
}
.dark .source {
background: #1e293b;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
pre {
counter-reset: line;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
margin: 0;
}
ol {
list-style: none;
counter-reset: number;
margin: 0;
padding: 0;
}
li {
padding: 2px 0;
transition: background-color 0.2s ease;
}
li:hover {
background-color: rgba(59, 130, 246, 0.05);
}
.dark li:hover {
background-color: rgba(96, 165, 250, 0.05);
}
li:before {
counter-increment: number;
content: counter(number);
margin-right: 24px;
display: inline-block;
width: 50px;
text-align: right;
color: #94a3b8;
font-weight: normal;
cursor: pointer;
transition: color 0.2s ease;
}
li:hover:before {
color: #3b82f6;
}
.dark li:hover:before {
color: #60a5fa;
}
.dark li:before {
color: #64748b;
}
.range-highlight {
background-color: rgba(59, 130, 246, 0.15) !important;
}
.dark .range-highlight {
background-color: rgba(96, 165, 250, 0.15) !important;
}
.cov0 {
background-color: rgba(239, 68, 68, 0.1);
font-weight: 600;
}
.cov1 {
background-color: rgba(34, 197, 94, 0.1);
font-weight: normal;
}
table {
width: 100%;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dark table {
background: #1e293b;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
tr {
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.dark tr {
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
}
tr:last-child {
border-bottom: none;
}
th {
background: #f1f5f9;
font-weight: 600;
text-align: left;
padding: 10px 16px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.dark th {
background: #0f172a;
color: #94a3b8;
}
td {
padding: 8px 16px;
}
table .total {
font-weight: 600;
}
table .fnc {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 13px;
}
table .fnc .clickable {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s ease;
}
table .fnc .clickable:hover {
color: #2563eb;
text-decoration: underline;
}
.dark table .fnc .clickable {
color: #60a5fa;
}
.dark table .fnc .clickable:hover {
color: #93bbfc;
}
table .cov {
width: 120px;
text-align: right;
padding: 0;
}
.cov-cell {
position: relative;
padding: 8px 16px;
background-color: transparent;
}
.cov-text {
position: relative;
z-index: 1;
font-weight: 600;
font-size: 13px;
}
.cov-bg {
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 0;
}
</style>
</head>
<body>
<div class="main {{.Theme}}">
<div id="tree">
{{range $i, $t := .Tree}}
{{if $t.IsFile}}
<div class="file" id="tree{{$t.ID}}" onclick="change({{$t.ID}}, {{$t.Indent}});">
<div class="tree-item clickable" style="margin-inline-start: {{indent $t.Indent}}px;">
<div class="tree-item-bg" style="width: {{if eq $t.Coverage 0.0}}100{{else}}{{$t.Coverage}}{{end}}%; background-color: {{coverageColor $t.Coverage}}"></div>
<div class="tree-item-content">
<span class="tree-name">{{$t.Name}}</span>
<span class="tree-coverage">{{$t.Coverage}}%</span>
</div>
</div>
</div>
{{else}}
<div>
<div class="tree-item" style="margin-inline-start: {{indent $t.Indent}}px">
<div class="tree-item-bg" style="width: {{if eq $t.Coverage 0.0}}100{{else}}{{$t.Coverage}}{{end}}%; background-color: {{coverageColor $t.Coverage}}"></div>
<div class="tree-item-content">
<span class="tree-name">{{$t.Name}}/</span>
<span class="tree-coverage">{{$t.Coverage}}%</span>
</div>
</div>
</div>
{{end}}
{{end}}
</div>
<div id="coverage">
{{range $i, $f := .Files}}
<div id="file{{$f.ID}}" style="display: none">
<table>
<tr><th>Function</th><th style="text-align: right">Coverage</th></tr>
<tr>
<td class="total">Total Coverage</td>
<td class="cov">
<div class="cov-cell">
<div class="cov-bg" style="width: {{if eq $f.Coverage 0.0}}100{{else}}{{$f.Coverage}}{{end}}%; background-color: {{coverageColor $f.Coverage}}"></div>
<div class="cov-text">{{$f.Coverage}}%</div>
</div>
</td>
</tr>
{{range $j, $fn := .Functions}}
<tr>
<td class="fnc"><span class="clickable" onclick="scrollToLine({{$f.ID}}, {{$fn.Line}});">{{$fn.Name}}</span></td>
<td class="cov">
<div class="cov-cell">
<div class="cov-bg" style="width: {{if eq $fn.Coverage 0.0}}100{{else}}{{$fn.Coverage}}{{end}}%; background-color: {{coverageColor $fn.Coverage}}"></div>
<div class="cov-text">{{$fn.Coverage}}%</div>
</div>
</td>
</tr>
{{end}}
</table>
<div class="source">
<pre>{{$f.Body}}</pre>
</div>
</div>
{{end}}
</div>
</div>
<script>
let current;
let currentTree;
updateByQuery();
window.addEventListener('popstate', function(e) {
updateByQuery();
})
window.addEventListener('hashchange', function(e) {
handleHashChange();
})
window.addEventListener('load', function() {
handleHashChange();
setupLineNumberClicks();
})
function updateByQuery() {
const searchParams = new URLSearchParams(window.location.search);
const n = searchParams.get('n');
const i = searchParams.get('i');
if (n && i) {
change(n, i);
}
// Check for hash after loading the file
setTimeout(handleHashChange, 100);
}
function handleHashChange() {
const hash = window.location.hash;
if (hash) {
// Check if it's a range (e.g., #file1-L10-L20)
const rangeMatch = hash.match(/#file(\d+)-L(\d+)-L(\d+)/);
if (rangeMatch) {
const fileId = rangeMatch[1];
const startLine = parseInt(rangeMatch[2]);
const endLine = parseInt(rangeMatch[3]);
// Highlight the range
highlightLineRange(fileId, startLine, endLine);
// Scroll to start of range
const startElm = document.getElementById('file' + fileId + '-' + startLine);
if (startElm) {
startElm.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
// Single line
const elm = document.getElementById(hash.substring(1));
if (elm) {
elm.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Clear previous highlights
clearHighlights();
// Highlight the line briefly
elm.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
setTimeout(() => {
elm.style.backgroundColor = '';
}, 1500);
}
}
}
}
function select(n) {
if (current) {
current.style.display = 'none';
}
current = document.getElementById('file' + n);
if (!current) {
return;
}
current.style.display = 'block';
// Setup line number clicks for the newly displayed file
setTimeout(setupLineNumberClicks, 100);
}
function selectTree(n, indent) {
if (currentTree) {
currentTree.classList.remove('current');
}
currentTree = document.getElementById('tree' + n);
if (!currentTree) {
return;
}
currentTree.classList.add('current');
}
function scrollById(id) {
const elm = document.getElementById(id);
if (elm) {
elm.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function scrollToLine(fileId, lineNum) {
const lineId = 'file' + fileId + '-' + lineNum;
// Update URL with hash
const url = new URL(window.location.href);
url.hash = lineId;
history.pushState("", "", url);
// Scroll to the line
scrollById(lineId);
// Highlight the line
const elm = document.getElementById(lineId);
if (elm) {
elm.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
setTimeout(() => {
elm.style.backgroundColor = '';
}, 1500);
}
}
function change(n, i) {
select(n);
selectTree(n, i);
updateUrl(n, i)
}
function updateUrl(n, i) {
const url = new URL(window.location.href);
if( !url.searchParams.get('n') ) {
url.searchParams.append('n',n);
url.searchParams.append('i',i);
location.href = url;
} else {
if (url.searchParams.get('n') != n || url.searchParams.get('i') != i) {
url.searchParams.set('n',n);
url.searchParams.set('i',i);
history.pushState("", "", url);
}
}
}
let lastClickedLine = null;
function setupLineNumberClicks() {
// Wait for content to be loaded
setTimeout(() => {
document.querySelectorAll('li').forEach(li => {
const id = li.id;
if (id && id.includes('-')) {
// Create clickable line number
li.addEventListener('click', function(e) {
if (e.target === li || window.getComputedStyle(e.target, ':before').getPropertyValue('content')) {
const parts = id.split('-');
const fileId = parts[0].replace('file', '');
const lineNum = parseInt(parts[1]);
if (e.shiftKey && lastClickedLine && lastClickedLine.fileId === fileId) {
// Range selection
const startLine = Math.min(lastClickedLine.lineNum, lineNum);
const endLine = Math.max(lastClickedLine.lineNum, lineNum);
// Update URL with range
const url = new URL(window.location.href);
url.hash = 'file' + fileId + '-L' + startLine + '-L' + endLine;
history.pushState("", "", url);
// Highlight range
highlightLineRange(fileId, startLine, endLine);
} else {
// Single line selection
const url = new URL(window.location.href);
url.hash = id;
history.pushState("", "", url);
// Clear previous highlights
clearHighlights();
// Highlight the line
li.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
setTimeout(() => {
if (!li.classList.contains('range-highlight')) {
li.style.backgroundColor = '';
}
}, 1500);
lastClickedLine = { fileId, lineNum };
}
}
});
}
});
}, 100);
}
function highlightLineRange(fileId, startLine, endLine) {
clearHighlights();
for (let i = startLine; i <= endLine; i++) {
const li = document.getElementById('file' + fileId + '-' + i);
if (li) {
li.classList.add('range-highlight');
li.style.backgroundColor = 'rgba(59, 130, 246, 0.15)';
}
}
}
function clearHighlights() {
document.querySelectorAll('.range-highlight').forEach(li => {
li.classList.remove('range-highlight');
li.style.backgroundColor = '';
});
}
</script>
</body>
</html>
`
package tree
import (
"path/filepath"
"strings"
"github.com/masakurapa/gover-html/internal/profile"
)
// Node is single node of directory tree
type Node struct {
Name string
Files profile.Profiles
Dirs []Node
}
// ChildBlocks returns all child Blocks for Node
func (n *Node) ChildBlocks() profile.Blocks {
blocks := make(profile.Blocks, 0)
for _, f := range n.Files {
blocks = append(blocks, f.Blocks...)
}
for _, ch := range n.Dirs {
blocks = append(blocks, ch.ChildBlocks()...)
}
return blocks
}
// Create returns directory tree
func Create(profiles profile.Profiles) []Node {
nodes := make([]Node, 0)
for _, p := range profiles {
idx := index(nodes, p.ModulePath)
if idx == -1 {
nodes = append(nodes, Node{Name: p.ModulePath})
idx = len(nodes) - 1
}
addNode(&nodes[idx].Dirs, strings.Split(p.RemoveModulePathFromFileName(), "/"), &p)
}
for i, node := range nodes {
nodes[i].Dirs = mergeSingreDir(node.Dirs)
}
return nodes
}
func addNode(nodes *[]Node, paths []string, p *profile.Profile) {
name := paths[0]
nextPaths := paths[1:]
idx := index(*nodes, name)
if idx == -1 {
*nodes = append(*nodes, Node{Name: name})
idx = len(*nodes) - 1
}
n := *nodes
if len(nextPaths) == 1 {
n[idx].Files = append(n[idx].Files, *p)
return
}
addNode(&n[idx].Dirs, nextPaths, p)
}
func index(nodes []Node, name string) int {
for i, t := range nodes {
if t.Name == name {
return i
}
}
return -1
}
// merge directories with no files and only one child directory
//
// path/
// to/
// file.go
//
// to
//
// path/to/
// file.go
func mergeSingreDir(nodes []Node) []Node {
for i, n := range nodes {
if len(n.Dirs) == 0 {
continue
}
mergeSingreDir(n.Dirs)
if len(n.Files) > 0 || len(n.Dirs) != 1 {
continue
}
sub := n.Dirs[0]
nodes[i].Name = filepath.Join(n.Name, sub.Name)
nodes[i].Files = sub.Files
nodes[i].Dirs = sub.Dirs
}
return nodes
}
package option
import "strings"
type optionErrors []error
func (e *optionErrors) Error() string {
messages := make([]string, 0, len(*e))
for _, err := range *e {
messages = append(messages, err.Error())
}
return strings.Join(messages, "\n")
}
package option
import (
"fmt"
"io"
"regexp"
"strings"
"github.com/masakurapa/gover-html/internal/reader"
"gopkg.in/yaml.v2"
)
const (
fileName = ".gover.yml"
optionSeparator = ","
inputDefault = "coverage.out"
outputDefault = "coverage.html"
themeDark = "dark"
themeLight = "light"
themeDefault = themeDark
)
var (
// 関数除外設定の検証用の正規表現(ここでは緩い検証にする)
excludeFuncFormat = regexp.MustCompile(`^\((.+)\)\.([a-zA-Z].+)$`)
)
type optionConfig struct {
Input string
InputFiles []string `yaml:"input-files"`
Output string
Theme string
Include []string // include fire or directories
Exclude []string // exclude fire or directories
ExcludeFunc []string `yaml:"exclude-func"` // exclude functions
}
type Option struct {
Input string
InputFiles []string
Output string
Theme string
Include []string // include fire or directories
Exclude []string // exclude fire or directories
ExcludeFunc []ExcludeFuncOption // exclude functions
}
type ExcludeFuncOption struct {
Path string
Struct string
Func string
}
type Generator struct {
r reader.Reader
}
func New(r reader.Reader) *Generator {
return &Generator{r: r}
}
func (g *Generator) Generate(
input *string,
inputFiles *string,
output *string,
theme *string,
include *string,
exclude *string,
excludeFunc *string,
) (*Option, error) {
opt := &optionConfig{}
if g.r.Exists(fileName) {
fileOpt, err := g.readOptionFile()
if err != nil {
return nil, err
}
opt = fileOpt
}
opt.Input = g.stringValue(input, opt.Input)
opt.InputFiles = g.stringsValue(inputFiles, opt.InputFiles)
opt.Output = g.stringValue(output, opt.Output)
opt.Theme = g.stringValue(theme, opt.Theme)
opt.Include = g.stringsValue(include, opt.Include)
opt.Exclude = g.stringsValue(exclude, opt.Exclude)
opt.ExcludeFunc = g.stringsValue(excludeFunc, opt.ExcludeFunc)
return g.getValidatedOption(opt)
}
func (g *Generator) readOptionFile() (*optionConfig, error) {
r, err := g.r.Read(fileName)
if err != nil {
return nil, err
}
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}
opt := optionConfig{}
if err := yaml.Unmarshal(b, &opt); err != nil {
return nil, err
}
return &opt, nil
}
func (g *Generator) stringValue(arg *string, opt string) string {
if arg == nil {
return opt
}
return *arg
}
func (g *Generator) stringsValue(arg *string, opt []string) []string {
if arg == nil {
return opt
}
return strings.Split(*arg, optionSeparator)
}
func (g *Generator) getValidatedOption(opt *optionConfig) (*Option, error) {
if err := g.validate(opt); err != nil {
return nil, err
}
return g.getOptionWithDefaultValue(opt), nil
}
func (g *Generator) validate(opt *optionConfig) error {
errs := make(optionErrors, 0)
if !g.isEmpty(opt.Theme) && opt.Theme != themeDark && opt.Theme != themeLight {
errs = append(errs, fmt.Errorf("theme must be %q or %q", themeDark, themeLight))
}
if es := g.validateFilter("include", opt.Include); len(es) > 0 {
errs = append(errs, es...)
}
if es := g.validateFilter("exclude", opt.Exclude); len(es) > 0 {
errs = append(errs, es...)
}
if es := g.validateExcludeFunc(opt.ExcludeFunc); len(es) > 0 {
errs = append(errs, es...)
}
if len(errs) > 0 {
return &errs
}
return nil
}
func (g *Generator) validateFilter(f string, values []string) optionErrors {
errs := make(optionErrors, 0)
for _, v := range values {
if g.isEmpty(v) {
continue
}
if strings.HasPrefix(v, "/") {
errs = append(errs, fmt.Errorf("%s value %q must not be an absolute path", f, v))
}
}
return errs
}
func (g *Generator) validateExcludeFunc(values []string) optionErrors {
errs := make(optionErrors, 0)
for _, v := range values {
if g.isEmpty(v) {
continue
}
if strings.HasPrefix(v, "(/") {
errs = append(errs, fmt.Errorf("exclude-func value %q must not be an absolute path", v))
continue
}
// ()が含まれない場合は関数名のみとみなしてOK
if !strings.Contains(v, "(") && !strings.Contains(v, ")") {
continue
}
if !excludeFuncFormat.MatchString(v) {
errs = append(errs, fmt.Errorf("exclude-func value %q format is invalid", v))
}
}
return errs
}
func (g *Generator) getOptionWithDefaultValue(opt *optionConfig) *Option {
newOpt := &Option{
Input: opt.Input,
Output: opt.Output,
Theme: opt.Theme,
Include: opt.Include,
Exclude: opt.Exclude,
}
if g.isEmpty(newOpt.Input) {
newOpt.Input = inputDefault
}
if g.isEmpty(newOpt.Output) {
newOpt.Output = outputDefault
}
if g.isEmpty(newOpt.Theme) {
newOpt.Theme = themeDefault
}
newOpt.InputFiles = g.convertInputFilesOption(opt.InputFiles)
newOpt.Include = g.convertFilterValue(newOpt.Include)
newOpt.Exclude = g.convertFilterValue(newOpt.Exclude)
newOpt.ExcludeFunc = g.convertExcludeFuncOption(opt.ExcludeFunc)
return newOpt
}
func (g *Generator) isEmpty(s string) bool {
return s == ""
}
func (g *Generator) convertInputFilesOption(values []string) []string {
ret := make([]string, 0, len(values))
for _, v := range values {
s := strings.TrimSpace(v)
if g.isEmpty(s) {
continue
}
ret = append(ret, s)
}
return ret
}
func (g *Generator) convertFilterValue(values []string) []string {
ret := make([]string, 0, len(values))
for _, v := range values {
s := strings.TrimSpace(v)
if g.isEmpty(s) {
continue
}
s = strings.TrimPrefix(s, "./")
s = strings.TrimSuffix(s, "/")
ret = append(ret, s)
}
return ret
}
func (g *Generator) convertExcludeFuncOption(values []string) []ExcludeFuncOption {
ret := make([]ExcludeFuncOption, 0, len(values))
for _, v := range values {
s := strings.TrimSpace(v)
if g.isEmpty(s) {
continue
}
// ()が含まれない場合は関数名のみとみなして終了
if !strings.Contains(s, "(") && !strings.Contains(s, ")") {
ret = append(ret, ExcludeFuncOption{Func: s})
continue
}
// excludeFuncFormat = regexp.MustCompile(`^\(.+\)\.([a-zA-Z].+)$`)
matches := excludeFuncFormat.FindStringSubmatch(s)
optPath := strings.TrimSuffix(strings.TrimPrefix(matches[1], "./"), "/")
var path, structName string
idx := strings.LastIndex(optPath, ".")
if idx == -1 {
// ファイル名で指定していない or 構造体名未指定
path = optPath
} else {
s := optPath[idx+1:]
if s == "go" {
// ファイル名のみ指定している
path = optPath
} else {
path = optPath[:idx]
structName = s
}
}
if path == "*" {
path = ""
}
ret = append(ret, ExcludeFuncOption{
Path: strings.TrimSuffix(strings.TrimPrefix(path, "./"), "/"),
Struct: structName,
Func: matches[2],
})
}
return ret
}
package profile
import (
"math"
"path"
"path/filepath"
"strings"
)
// Profiles is a type that represents a slice of Profile
type Profiles []Profile
// Profile is profiling data for each file
type Profile struct {
ID int
ModulePath string
Dir string
FileName string
Blocks Blocks
Functions Functions
}
// Functions is a type that represents a slice of Function
type Functions []Function
// Function is single func of profiling data
type Function struct {
Name string
StartLine int
StartCol int
Blocks Blocks
}
// Blocks is a type that represents a slice of Block
type Blocks []Block
// Block is single block of profiling data
type Block struct {
StartLine int
StartCol int
EndLine int
EndCol int
NumState int
Count int
}
// IsRelativeOrAbsolute returns true if FileName is relative path or absolute path
func (prof *Profile) IsRelativeOrAbsolute() bool {
return strings.HasPrefix(prof.FileName, ".") || filepath.IsAbs(prof.FileName)
}
// RemoveModulePathFromFileName returns FileName with ModulePath removed
func (prof *Profile) RemoveModulePathFromFileName() string {
return strings.TrimPrefix(strings.TrimPrefix(prof.FileName, prof.ModulePath), "/")
}
// FilePath returns readable file path
func (prof *Profile) FilePath() string {
if prof.IsRelativeOrAbsolute() {
return prof.FileName
}
return filepath.Join(prof.Dir, path.Base(prof.FileName))
}
// Coverage returns covered ratio for file
func (blocks *Blocks) Coverage() float64 {
var total, covered int64
for _, b := range *blocks {
total += int64(b.NumState)
if b.Count > 0 {
covered += int64(b.NumState)
}
}
if total == 0 {
return 0
}
return math.Round((float64(covered)/float64(total)*100)*10) / 10
}
package profile
import (
"bufio"
"bytes"
"io"
"os"
"github.com/masakurapa/gover-html/internal/option"
)
var (
newline = []byte("\n")
)
func Read(opt option.Option) (io.Reader, error) {
// InputFilesが指定されていない場合だけInputを使う
if len(opt.InputFiles) == 0 {
b, err := os.ReadFile(opt.Input)
if err != nil {
return nil, err
}
return bytes.NewReader(b), nil
}
// 1行目は "mode: set" を固定にする
buf := bytes.NewBuffer([]byte("mode: set"))
buf.Write(newline)
for _, in := range opt.InputFiles {
if err := read(buf, in); err != nil {
return nil, err
}
}
return buf, nil
}
func read(buf *bytes.Buffer, in string) error {
f, err := os.Open(in)
if err != nil {
return err
}
defer f.Close()
// 1行目には "mode: xxx" が入っているはずなので無視して2行目から読み込む
skip := true
r := bufio.NewScanner(f)
for r.Scan() {
if skip {
skip = false
continue
}
buf.Write(r.Bytes())
buf.Write(newline)
}
return nil
}
package reader
import (
"io"
"os"
)
// Reader is file reader
type Reader interface {
Read(string) (io.Reader, error)
Exists(string) bool
}
type fileReader struct{}
// New is initialization the file reader
func New() Reader {
return &fileReader{}
}
func (r *fileReader) Read(file string) (io.Reader, error) {
return os.Open(file)
}
func (r *fileReader) Exists(file string) bool {
_, err := os.Stat(file)
return err == nil
}