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 "html/template"
var parsedTreeTemplate = template.
Must(template.New("html").
Funcs(template.FuncMap{
"indent": func(i int) int { return i*30 + 8 },
}).
Parse(treeTemplate))
const treeTemplate = `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Coverage Report</title>
<style>
body {
margin: 0;
}
.main {
width: 100%;
display: flex;
}
.light {
background: #FFFFFF;
color: rgb(80, 80, 80);
}
.dark {
background: #000000;
color: rgb(160, 160, 160);
}
#tree {
width: 25%;
height: 100vh;
padding: 8px 0;
white-space: nowrap;
overflow: scroll;
position: sticky;
position: -webkit-sticky;
top: 0;
left: 0;
border-right: 1px solid rgb(160, 160, 160);
}
#tree div {
padding: 4px 0;
}
.clickable {
cursor: pointer;
color: #3EA6FF;
text-decoration: underline;
}
.current {
font-weight: bold;
}
.light .current {
background-color: #E6F0FF;
}
.dark .current {
background-color: #555555;
}
#coverage {
width: 70%;
margin-left: 16px;
margin-right: 32px;
}
.source {
white-space: nowrap;
}
pre {
counter-reset: line;
font-family: Menlo, monospace;
font-weight: bold;
}
ol {
list-style: none;
counter-reset: number;
margin: 0;
padding: 0;
}
li:before {
counter-increment: number;
content: counter(number);
margin-right: 24px;
display: inline-block;
width: 50px;
text-align: right;
}
.light li:before {
color: rgb(200, 200, 200);
}
.dark li:before {
color: rgb(80, 80, 80);
}
.cov0 {
color: rgb(192, 0, 0);
}
.cov1 {
color: rgb(44, 212, 149);
}
table, tr, td, th {
border-collapse: collapse;
border:1px solid #BBBBBB;
}
table {
margin: 16px 0 32px 74px;
}
table .total {
min-width: 300px;
text-align: left;
padding-left: 8px;
}
table .fnc {
min-width: 300px;
text-align: left;
padding: 0 20px 0 20px;
}
table .cov {
width: 70px;
text-align: right;
padding-right: 8px;
}
</style>
</head>
<body>
<div class="main {{.Theme}}">
<div id="tree">
{{range $i, $t := .Tree}}
{{if $t.IsFile}}
<div class="file clickable" style="padding-inline-start: {{indent $t.Indent}}px;" id="tree{{$t.ID}}" onclick="change({{$t.ID}}, {{$t.Indent}});">
{{$t.Name}} ({{$t.Coverage}}%)
</div>
{{else}}
<div style="padding-inline-start: {{indent $t.Indent}}px">{{$t.Name}}/ ({{$t.Coverage}}%)</div>
{{end}}
{{end}}
</div>
<div id="coverage">
{{range $i, $f := .Files}}
<div id="file{{$f.ID}}" style="display: none">
<table>
<tr><th colspan="2">Coverages</th></tr>
<tr><td class="total">Total</td><td class="cov">{{$f.Coverage}}%</td></tr>
{{range $j, $fn := .Functions}}
<tr>
<td class="fnc"><span class="clickable" onclick="scrollById('file{{$f.ID}}-{{$fn.Line}}');">{{$fn.Name}}</span></td>
<td class="cov">{{$fn.Coverage}}%</td>
</tr>
{{end}}
</table>
<div class="source">
<pre>{{$f.Body}}</pre>
</div>
</div>
{{end}}
</div>
</div>
<script>
// tree max width
const scrollWidth = document.getElementById('tree').scrollWidth;
let current;
let currentTree;
updateByQuery();
window.addEventListener('popstate', function(e) {
updateByQuery();
})
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);
}
}
function select(n) {
if (current) {
current.style.display = 'none';
}
current = document.getElementById('file' + n);
if (!current) {
return;
}
current.style.display = 'block';
scrollById('coverage');
}
function selectTree(n, indent) {
if (currentTree) {
currentTree.classList.remove('current');
}
currentTree = document.getElementById('tree' + n);
if (!current) {
return;
}
currentTree.classList.add('current');
currentTree.style.width = scrollWidth - (indent * 30 + 8) + 'px';
}
function scrollById(id) {
const elm = document.getElementById(id);
const rect = elm.getBoundingClientRect();
document.documentElement.scrollTop = rect.top + window.pageYOffset;
}
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);
}
}
}
</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
}