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 }