From ccf344c60268b2ffee902e5738552ff837b36022 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 23 Jul 2024 17:10:51 +0900 Subject: [PATCH 01/37] basic statement coverage --- .../cmd/gno/coverage/analysis/cfg/builder.go | 334 ++++++++++++++++++ gnovm/cmd/gno/coverage/checker/statement.go | 147 ++++++++ .../gno/coverage/checker/statement_test.go | 94 +++++ gnovm/cmd/gno/test.go | 10 + 4 files changed, 585 insertions(+) create mode 100644 gnovm/cmd/gno/coverage/analysis/cfg/builder.go create mode 100644 gnovm/cmd/gno/coverage/checker/statement.go create mode 100644 gnovm/cmd/gno/coverage/checker/statement_test.go diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/builder.go b/gnovm/cmd/gno/coverage/analysis/cfg/builder.go new file mode 100644 index 00000000000..2752f998c4a --- /dev/null +++ b/gnovm/cmd/gno/coverage/analysis/cfg/builder.go @@ -0,0 +1,334 @@ +package cfg + +import ( + "go/ast" + "go/token" +) + +// ref: https://github.com/godoctor/godoctor/blob/master/analysis/cfg/cfg.go + +type builder struct { + blocks map[ast.Stmt]*block + prev []ast.Stmt // blocks to hook up to current block + branches []*ast.BranchStmt // accumulated branches from current inner blocks + entry, exit *ast.BadStmt // single-entry, single-exit nodes + defers []*ast.DeferStmt // all defers encountered +} + +// NewBuilder constructs a CFG from the given slice of statements. +func NewBuilder() *builder { + // The ENTRY and EXIT nodes are given positions -2 and -1 so cfg.Sort + // will work correct: ENTRY will always be first, followed by EXIT, + // followed by the other CFG nodes. + return &builder{ + blocks: map[ast.Stmt]*block{}, + entry: &ast.BadStmt{From: -2, To: -2}, + exit: &ast.BadStmt{From: -1, To: -1}, + } +} + +// Build runs buildBlock on the given block (traversing nested statements), and +// adds entry and exit nodes. +func (b *builder) Build(s []ast.Stmt) *CFG { + b.prev = []ast.Stmt{b.entry} + b.buildBlock(s) + b.addSucc(b.exit) + + return &CFG{ + blocks: b.blocks, + Entry: b.entry, + Exit: b.exit, + Defers: b.defers, + } +} + +// addSucc adds a control flow edge from all previous blocks to the block for the given statement. +// It updates both the successors of the previous blocks and the predecessors of the current block. +func (b *builder) addSucc(current ast.Stmt) { + cur := b.block(current) + + for _, p := range b.prev { + p := b.block(p) + p.succs = appendNoDuplicates(p.succs, cur.stmt) + cur.preds = appendNoDuplicates(cur.preds, p.stmt) + } +} + +func appendNoDuplicates(list []ast.Stmt, stmt ast.Stmt) []ast.Stmt { + for _, s := range list { + if s == stmt { + return list + } + } + return append(list, stmt) +} + +// block returns a block for the given statement, creating one and inserting it +// into the CFG if it doesn't already exist. +func (b *builder) block(s ast.Stmt) *block { + bl, ok := b.blocks[s] + if !ok { + bl = &block{stmt: s} + b.blocks[s] = bl + } + return bl +} + +// buildStmt adds the given statement and all nested statements to the control +// flow graph under construction. Upon completion, b.prev is set to all +// control flow exits generated from traversing cur. +func (b *builder) buildStmt(cur ast.Stmt) { + if dfr, ok := cur.(*ast.DeferStmt); ok { + b.defers = append(b.defers, dfr) + return // never flow to or from defer + } + + // Each buildXxx method will flow the previous blocks to itself appropriately and also + // set the appropriate blocks to flow from at the end of the method. + switch cur := cur.(type) { + case *ast.BlockStmt: + b.buildBlock(cur.List) + case *ast.IfStmt: + b.buildIf(cur) + case *ast.ForStmt, *ast.RangeStmt: + b.buildLoop(cur) + case *ast.SwitchStmt, *ast.SelectStmt, *ast.TypeSwitchStmt: + b.buildSwitch(cur) + case *ast.BranchStmt: + b.buildBranch(cur) + case *ast.LabeledStmt: + b.addSucc(cur) + b.prev = []ast.Stmt{cur} + b.buildStmt(cur.Stmt) + case *ast.ReturnStmt: + b.addSucc(cur) + b.prev = []ast.Stmt{cur} + b.addSucc(b.exit) + b.prev = nil + default: // most statements have straight-line control flow + b.addSucc(cur) + b.prev = []ast.Stmt{cur} + } +} + +// buildBranch handles the creation of CFG nodes for branch statements (break, continue, goto, fallthrough). +// It updates the CFG based on the type of branch statement. +func (b *builder) buildBranch(br *ast.BranchStmt) { + b.addSucc(br) + b.prev = []ast.Stmt{br} + + switch br.Tok { + case token.FALLTHROUGH: + // successors handled in buildSwitch, so skip this here + case token.GOTO: + b.addSucc(br.Label.Obj.Decl.(ast.Stmt)) // flow to label + case token.BREAK, token.CONTINUE: + b.branches = append(b.branches, br) // to handle at switch/for/etc level + } + b.prev = nil // successors handled elsewhere +} + +// buildBlock iterates over a slice of statements, typically from an ast.BlockStmt, +// adding them successively to the CFG. +func (b *builder) buildBlock(block []ast.Stmt) { + for _, stmt := range block { + b.buildStmt(stmt) + } +} + +// buildIf constructs the CFG for an if statement, including its condition, body, and else clause (if present). +func (b *builder) buildIf(f *ast.IfStmt) { + if f.Init != nil { + b.addSucc(f.Init) + b.prev = []ast.Stmt{f.Init} + } + b.addSucc(f) + + b.prev = []ast.Stmt{f} + b.buildBlock(f.Body.List) // build then + + ctrlExits := b.prev // aggregate of b.prev from each condition + + switch s := f.Else.(type) { + case *ast.BlockStmt: // build else + b.prev = []ast.Stmt{f} + b.buildBlock(s.List) + ctrlExits = append(ctrlExits, b.prev...) + case *ast.IfStmt: // build else if + b.prev = []ast.Stmt{f} + b.addSucc(s) + b.buildIf(s) + ctrlExits = append(ctrlExits, b.prev...) + case nil: // no else + ctrlExits = append(ctrlExits, f) + } + + b.prev = ctrlExits +} + +// buildLoop constructs the CFG for loop statements (for and range). +// It handles the initialization, condition, post-statement (for for loops), and body of the loop. +func (b *builder) buildLoop(stmt ast.Stmt) { + // flows as such (range same w/o init & post): + // previous -> [ init -> ] for -> body -> [ post -> ] for -> next + + var post ast.Stmt = stmt // post in for loop, or for stmt itself; body flows to this + + switch stmt := stmt.(type) { + case *ast.ForStmt: + if stmt.Init != nil { + b.addSucc(stmt.Init) + b.prev = []ast.Stmt{stmt.Init} + } + b.addSucc(stmt) + + if stmt.Post != nil { + post = stmt.Post + b.prev = []ast.Stmt{post} + b.addSucc(stmt) + } + + b.prev = []ast.Stmt{stmt} + b.buildBlock(stmt.Body.List) + case *ast.RangeStmt: + b.addSucc(stmt) + b.prev = []ast.Stmt{stmt} + b.buildBlock(stmt.Body.List) + } + + b.addSucc(post) + + ctrlExits := []ast.Stmt{stmt} + + // handle any branches; if no label or for me: handle and remove from branches. + for i := 0; i < len(b.branches); i++ { + br := b.branches[i] + if br.Label == nil || br.Label.Obj.Decl.(*ast.LabeledStmt).Stmt == stmt { + switch br.Tok { // can only be one of these two cases + case token.CONTINUE: + b.prev = []ast.Stmt{br} + b.addSucc(post) // connect to .Post statement if present, for stmt otherwise + case token.BREAK: + ctrlExits = append(ctrlExits, br) + } + b.branches = append(b.branches[:i], b.branches[i+1:]...) + i-- // removed in place, so go back to this i + } + } + + b.prev = ctrlExits // for stmt and any appropriate break statements +} + +// buildSwitch constructs the CFG for switch, type switch, and select statements. +// It handles the initialization (if present), switch expression, and all case clauses. +func (b *builder) buildSwitch(sw ast.Stmt) { + var cases []ast.Stmt // case 1:, case 2:, ... + + switch sw := sw.(type) { + case *ast.SwitchStmt: // i.e. switch [ x := 0; ] [ x ] { } + if sw.Init != nil { + b.addSucc(sw.Init) + b.prev = []ast.Stmt{sw.Init} + } + b.addSucc(sw) + b.prev = []ast.Stmt{sw} + + cases = sw.Body.List + case *ast.TypeSwitchStmt: // i.e. switch [ x := 0; ] t := x.(type) { } + if sw.Init != nil { + b.addSucc(sw.Init) + b.prev = []ast.Stmt{sw.Init} + } + b.addSucc(sw) + b.prev = []ast.Stmt{sw} + b.addSucc(sw.Assign) + b.prev = []ast.Stmt{sw.Assign} + + cases = sw.Body.List + case *ast.SelectStmt: // i.e. select { } + b.addSucc(sw) + b.prev = []ast.Stmt{sw} + + cases = sw.Body.List + } + + var caseExits []ast.Stmt // aggregate of b.prev's resulting from each case + swPrev := b.prev // save for each case's previous; Switch or Assign + var ft *ast.BranchStmt // fallthrough to handle from previous case, if any + defaultCase := false + + for _, clause := range cases { + b.prev = swPrev + b.addSucc(clause) + b.prev = []ast.Stmt{clause} + if ft != nil { + b.prev = append(b.prev, ft) + } + + var caseBody []ast.Stmt + + // both of the following cases are guaranteed in spec + switch clause := clause.(type) { + case *ast.CaseClause: // i.e. case: [expr,expr,...]: + if clause.List == nil { + defaultCase = true + } + caseBody = clause.Body + case *ast.CommClause: // i.e. case c <- chan: + if clause.Comm == nil { + defaultCase = true + } else { + b.addSucc(clause.Comm) + b.prev = []ast.Stmt{clause.Comm} + } + caseBody = clause.Body + } + + b.buildBlock(caseBody) + + if ft = fallThrough(caseBody); ft == nil { + caseExits = append(caseExits, b.prev...) + } + } + + if !defaultCase { + caseExits = append(caseExits, swPrev...) + } + + // handle any breaks that are unlabeled or for me + for i := 0; i < len(b.branches); i++ { + br := b.branches[i] + if br.Tok == token.BREAK && (br.Label == nil || br.Label.Obj.Decl.(*ast.LabeledStmt).Stmt == sw) { + caseExits = append(caseExits, br) + b.branches = append(b.branches[:i], b.branches[i+1:]...) + i-- // we removed in place, so go back to this index + } + } + + b.prev = caseExits // control exits of each case and breaks +} + +// fallThrough returns the fallthrough statement at the end of the given slice of statements, if one exists. +// It returns nil if no fallthrough statement is found. +func fallThrough(stmts []ast.Stmt) *ast.BranchStmt { + if len(stmts) < 1 { + return nil + } + + // fallthrough can only be last statement in clause (possibly labeled) + ft := stmts[len(stmts)-1] + + for { // recursively descend LabeledStmts. + switch s := ft.(type) { + case *ast.BranchStmt: + if s.Tok == token.FALLTHROUGH { + return s + } + case *ast.LabeledStmt: + ft = s.Stmt + continue + } + break + } + return nil +} diff --git a/gnovm/cmd/gno/coverage/checker/statement.go b/gnovm/cmd/gno/coverage/checker/statement.go new file mode 100644 index 00000000000..77b62bf2dcc --- /dev/null +++ b/gnovm/cmd/gno/coverage/checker/statement.go @@ -0,0 +1,147 @@ +package checker + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + + "github.com/gnolang/gno/tm2/pkg/std" + "golang.org/x/tools/go/ast/astutil" +) + +type StatementCoverage struct { + covered map[token.Pos]bool + files map[string]*ast.File + fset *token.FileSet + debug bool +} + +func NewStatementCoverage(files []*std.MemFile) *StatementCoverage { + sc := &StatementCoverage{ + covered: make(map[token.Pos]bool), + files: make(map[string]*ast.File), + fset: token.NewFileSet(), + debug: true, + } + + for _, file := range files { + sc.log("parsing file: %s", file.Name) + if astFile, err := parser.ParseFile(sc.fset, file.Name, file.Body, parser.AllErrors); err == nil { + sc.files[file.Name] = astFile + ast.Inspect(astFile, func(n ast.Node) bool { + if stmt, ok := n.(ast.Stmt); ok { + switch stmt.(type) { + case *ast.BlockStmt: + // Skip block statements + default: + sc.covered[stmt.Pos()] = false + } + } + return true + }) + } else { + sc.log("error parsing file %s: %v", file.Name, err) + } + } + + sc.log("total statements found: %d", len(sc.covered)) + return sc +} + +func (sc *StatementCoverage) Instrument(file *std.MemFile) *std.MemFile { + sc.log("instrumenting file: %s", file.Name) + astFile, ok := sc.files[file.Name] + if !ok { + return file + } + + instrumentedFile := astutil.Apply(astFile, nil, func(c *astutil.Cursor) bool { + node := c.Node() + if stmt, ok := node.(ast.Stmt); ok { + if _, exists := sc.covered[stmt.Pos()]; exists { + pos := sc.fset.Position(stmt.Pos()) + sc.log("instrumenting statement at %s:%d%d", pos.Filename, pos.Line, pos.Column) + markStmt := &ast.ExprStmt{ + X: &ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: ast.NewIdent("sc"), + Sel: ast.NewIdent("MarkCovered"), + }, + Args: []ast.Expr{ + &ast.BasicLit{ + Kind: token.INT, + Value: fmt.Sprintf("%d", sc.fset.Position(stmt.Pos()).Offset), + }, + }, + }, + } + + switch s := stmt.(type) { + case *ast.BlockStmt: + s.List = append([]ast.Stmt{markStmt}, s.List...) + case *ast.ForStmt: + if s.Body != nil { + s.Body.List = append([]ast.Stmt{markStmt}, s.Body.List...) + } + default: + c.Replace(&ast.BlockStmt{ + List: []ast.Stmt{markStmt, stmt}, + }) + } + } + } + return true + }) + + var buf bytes.Buffer + if err := printer.Fprint(&buf, sc.fset, instrumentedFile); err != nil { + return file + } + + return &std.MemFile{ + Name: file.Name, + Body: buf.String(), + } +} + +func (sc *StatementCoverage) MarkCovered(pos token.Pos) { + for stmt := range sc.covered { + if stmt == pos { + filePos := sc.fset.Position(pos) + sc.log("marking covered: %s:%d:%d", filePos.Filename, filePos.Line, filePos.Column) + sc.covered[stmt] = true + return + } + } +} + +func (sc *StatementCoverage) CalculateCoverage() float64 { + total := len(sc.covered) + if total == 0 { + return 0 + } + + covered := 0 + for stmt, isCovered := range sc.covered { + pos := sc.fset.Position(stmt) + if isCovered { + sc.log("covered: %s:%d:%d", pos.Filename, pos.Line, pos.Column) + covered++ + } else { + sc.log("not covered: %s:%d:%d", pos.Filename, pos.Line, pos.Column) + } + } + + coverage := float64(covered) / float64(total) + sc.log("total statement: %d, covered: %d, coverage: %.2f", total, covered, coverage) + return coverage +} + +func (sc *StatementCoverage) log(format string, args ...interface{}) { + if sc.debug { + fmt.Printf(format+"\n", args...) + } +} diff --git a/gnovm/cmd/gno/coverage/checker/statement_test.go b/gnovm/cmd/gno/coverage/checker/statement_test.go new file mode 100644 index 00000000000..2c30751d713 --- /dev/null +++ b/gnovm/cmd/gno/coverage/checker/statement_test.go @@ -0,0 +1,94 @@ +package checker + +import ( + "fmt" + "go/ast" + "math" + "testing" + + "github.com/gnolang/gno/tm2/pkg/std" +) + +func TestStatementCoverage(t *testing.T) { + tests := []struct { + name string + files []*std.MemFile + executeLines []int + expectedCoverage float64 + }{ + { + name: "Simple function", + files: []*std.MemFile{ + { + Name: "test.go", + Body: `package main // 1 + // 2 +func test() { // 3 + a := 1 // 4 + b := 2 // 5 + c := a + b // 6 +} // 7 +`, + }, + }, + executeLines: []int{4, 5, 6}, + expectedCoverage: 1.0, + }, + { + name: "Function with if statement", + files: []*std.MemFile{ + { + Name: "test.go", + Body: `package main // 1 + // 2 +func test(x int) int { // 3 + if x > 0 { // 4 + return x // 5 + } // 6 + return -x // 7 +} // 8 +`, + }, + }, + executeLines: []int{4, 5}, + expectedCoverage: 0.67, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := NewStatementCoverage(tt.files) + + // Instrument files + for i, file := range tt.files { + tt.files[i] = sc.Instrument(file) + } + + // Simulate execution by marking covered lines + for _, line := range tt.executeLines { + for _, file := range sc.files { + ast.Inspect(file, func(n ast.Node) bool { + if stmt, ok := n.(ast.Stmt); ok { + pos := sc.fset.Position(stmt.Pos()) + if pos.Line == line { + sc.MarkCovered(stmt.Pos()) + fmt.Printf("Marked line %d in file %s\n", line, file.Name) + return false + } + } + return true + }) + } + } + + coverage := sc.CalculateCoverage() + if !almostEqual(coverage, tt.expectedCoverage, 0.01) { + t.Errorf("Expected coverage %f, but got %f", tt.expectedCoverage, coverage) + } + }) + } +} + +func almostEqual(a, b, tolerance float64) bool { + return math.Abs(a-b) <= tolerance +} diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 5884463a552..9aebe9d1ec6 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -26,6 +26,8 @@ import ( "github.com/gnolang/gno/tm2/pkg/random" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/testutils" + + _ "github.com/gnolang/gno/gnovm/cmd/gno/coverage/checker" ) type testCfg struct { @@ -36,6 +38,7 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool withNativeFallback bool + withCoverage bool } func newTestCmd(io commands.IO) *commands.Command { @@ -149,6 +152,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { false, "print runtime metrics (gas, memory, cpu cycles)", ) + + fs.BoolVar( + &c.withCoverage, + "coverage", + false, + "print test coverage metrics", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { From 78226e2fb0d3f33d02e2232da537dd33a5b18a9a Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 23 Jul 2024 17:11:22 +0900 Subject: [PATCH 02/37] add control flow graph --- gnovm/cmd/gno/coverage/analysis/cfg/cfg.go | 123 +++ .../cmd/gno/coverage/analysis/cfg/cfg_test.go | 891 ++++++++++++++++++ 2 files changed, 1014 insertions(+) create mode 100644 gnovm/cmd/gno/coverage/analysis/cfg/cfg.go create mode 100644 gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/cfg.go b/gnovm/cmd/gno/coverage/analysis/cfg/cfg.go new file mode 100644 index 00000000000..36b4b6ec887 --- /dev/null +++ b/gnovm/cmd/gno/coverage/analysis/cfg/cfg.go @@ -0,0 +1,123 @@ +package cfg + +import ( + "fmt" + "go/ast" + "go/token" + "io" + "sort" + "strings" + + "golang.org/x/tools/go/ast/astutil" +) + +// CFGBuilder defines the interface for building a control flow graph (CFG). +type CFGBuilder interface { + Build(stmts []ast.Stmt) *CFG + Sort(stmts []ast.Stmt) + PrintDot(f io.Writer, fset *token.FileSet, addl func(n ast.Stmt) string) +} + +// CFG defines a control flow graph with statement-level granularity, in which +// there is a 1-1 correspondence between a block in the CFG and an ast.Stmt. +type CFG struct { + // Sentinel nodes for single-entry CFG. Not in original AST. + Entry *ast.BadStmt + + // Sentinel nodes for single-exit CFG. Not in original AST. + Exit *ast.BadStmt + + // All defers found in CFG, disjoint from blocks. May be flowed to after Exit. + Defers []*ast.DeferStmt + blocks map[ast.Stmt]*block +} + +type block struct { + stmt ast.Stmt + preds []ast.Stmt + succs []ast.Stmt +} + +// FromStmts returns the control-flow graph for the given sequence of statements. +func FromStmts(s []ast.Stmt) *CFG { + return NewBuilder().Build(s) +} + +// FromFunc is a convenience function for creating a CFG from a given function declaration. +func FromFunc(f *ast.FuncDecl) *CFG { + return FromStmts(f.Body.List) +} + +// Preds returns a slice of all immediate predecessors for the given statement. +// May include Entry node. +func (c *CFG) Preds(s ast.Stmt) []ast.Stmt { + return c.blocks[s].preds +} + +// Succs returns a slice of all immediate successors to the given statement. +// May include Exit node. +func (c *CFG) Succs(s ast.Stmt) []ast.Stmt { + return c.blocks[s].succs +} + +// Blocks returns a slice of all blocks in a CFG, including the Entry and Exit nodes. +// The blocks are roughly in the order they appear in the source code. +func (c *CFG) Blocks() []ast.Stmt { + blocks := make([]ast.Stmt, 0, len(c.blocks)) + for s := range c.blocks { + blocks = append(blocks, s) + } + return blocks +} + +// type for sorting statements by their starting positions in the source code +type stmtSlice []ast.Stmt + +func (n stmtSlice) Len() int { return len(n) } +func (n stmtSlice) Swap(i, j int) { n[i], n[j] = n[j], n[i] } +func (n stmtSlice) Less(i, j int) bool { + return n[i].Pos() < n[j].Pos() +} + +func (c *CFG) Sort(stmts []ast.Stmt) { + sort.Sort(stmtSlice(stmts)) +} + +func (c *CFG) PrintDot(f io.Writer, fset *token.FileSet, addl func(n ast.Stmt) string) { + fmt.Fprintf(f, `digraph mgraph { +mode="heir"; +splines="ortho"; + +`) + blocks := c.Blocks() + c.Sort(blocks) + for _, from := range blocks { + succs := c.Succs(from) + c.Sort(succs) + for _, to := range succs { + fmt.Fprintf(f, "\t\"%s\" -> \"%s\"\n", + c.printVertex(from, fset, addl(from)), + c.printVertex(to, fset, addl(to))) + } + } + fmt.Fprintf(f, "}\n") +} + +func (c *CFG) printVertex(stmt ast.Stmt, fset *token.FileSet, addl string) string { + switch stmt { + case c.Entry: + return "ENTRY" + case c.Exit: + return "EXIT" + case nil: + return "" + } + addl = strings.Replace(addl, "\n", "\\n", -1) + if addl != "" { + addl = "\\n" + addl + } + return fmt.Sprintf("%s - line %d%s", + astutil.NodeDescription(stmt), + fset.Position(stmt.Pos()).Line, + addl) +} diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go b/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go new file mode 100644 index 00000000000..ff183c5f98c --- /dev/null +++ b/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go @@ -0,0 +1,891 @@ +package cfg + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "go/types" + "regexp" + "strings" + "testing" +) + +func TestFromStmts(t *testing.T) { + src := ` + package main + func main() { + x := 1 + if x > 0 { + x = 2 + } else { + x = 3 + } + for i := 0; i < 10; i++ { + x += i + } + } + ` + + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "src.go", src, 0) + if err != nil { + t.Fatal(err) + } + + var funcDecl *ast.FuncDecl + for _, decl := range node.Decls { + if fn, isFn := decl.(*ast.FuncDecl); isFn { + funcDecl = fn + break + } + } + + if funcDecl == nil { + t.Fatal("No function declaration found") + } + + cfgGraph := FromFunc(funcDecl) + + if cfgGraph.Entry == nil { + t.Errorf("Expected Entry node, got nil") + } + if cfgGraph.Exit == nil { + t.Errorf("Expected Exit node, got nil") + } + + blocks := cfgGraph.Blocks() + if len(blocks) == 0 { + t.Errorf("Expected some blocks, got none") + } + + for _, block := range blocks { + preds := cfgGraph.Preds(block) + succs := cfgGraph.Succs(block) + t.Logf("Block: %v, Preds: %v, Succs: %v", block, preds, succs) + } +} + +func TestCFG(t *testing.T) { + tests := []struct { + name string + src string + expectedBlocks int + }{ + { + name: "MultiStatementFunction", + src: ` + package main + func main() { + x := 1 + if x > 0 { + x = 2 + } else { + x = 3 + } + for i := 0; i < 10; i++ { + x += i + } + }`, + expectedBlocks: 10, + }, + { + name: "Switch", + src: ` + package main + func withSwitch(day string) int { + switch day { + case "Monday": + return 1 + case "Tuesday": + return 2 + case "Wednesday": + fallthrough + case "Thursday": + return 3 + case "Friday": + break + default: + return 0 + } + }`, + expectedBlocks: 15, + }, + { + name: "TypeSwitch", + src: ` + package main + type MyType int + func withTypeSwitch(i interface{}) int { + switch i.(type) { + case int: + return 1 + case MyType: + return 2 + default: + return 0 + } + return 0 + }`, + expectedBlocks: 11, + }, + { + name: "EmptyFunc", + src: ` + package main + func empty() {}`, + expectedBlocks: 2, + }, + { + name: "SingleStatementFunc", + src: ` + package main + func single() { + x := 1 + }`, + expectedBlocks: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "src.go", tt.src, 0) + if err != nil { + t.Fatalf("failed to parse source: %v", err) + } + + var funcDecl *ast.FuncDecl + for _, decl := range node.Decls { + if fn, isFn := decl.(*ast.FuncDecl); isFn { + funcDecl = fn + break + } + } + + if funcDecl == nil { + t.Fatal("No function declaration found") + } + + cfgGraph := FromFunc(funcDecl) + + if cfgGraph.Entry == nil { + t.Error("Expected Entry node, got nil") + } + if cfgGraph.Exit == nil { + t.Error("Expected Exit node, got nil") + } + + blocks := cfgGraph.Blocks() + if len(blocks) != tt.expectedBlocks { + t.Errorf("Expected %d blocks, got %d", tt.expectedBlocks, len(blocks)) + } + + // Additional checks can be added here if needed + }) + } +} + +func TestPrintDot2(t *testing.T) { + src := ` +package main +func main() { + x := 1 + if x > 0 { + x = 2 + } else { + x = 3 + } + for i := 0; i < 10; i++ { + x += i + } +}` + + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "src.go", src, 0) + if err != nil { + t.Fatal(err) + } + + var funcDecl *ast.FuncDecl + for _, decl := range node.Decls { + if fn, isFn := decl.(*ast.FuncDecl); isFn { + funcDecl = fn + break + } + } + + if funcDecl == nil { + t.Fatal("No function declaration found") + } + + cfgGraph := FromFunc(funcDecl) + + var buf bytes.Buffer + cfgGraph.PrintDot(&buf, fset, func(n ast.Stmt) string { return "" }) + + output := buf.String() + expected := ` +digraph mgraph { + mode="heir"; + splines="ortho"; + + "ENTRY" -> "assignment - line 4" + "assignment - line 4" -> "if statement - line 5" + "if statement - line 5" -> "assignment - line 6" + "if statement - line 5" -> "assignment - line 8" + "assignment - line 6" -> "assignment - line 10" + "assignment - line 8" -> "assignment - line 10" + "for loop - line 10" -> "EXIT" + "for loop - line 10" -> "assignment - line 11" + "assignment - line 10" -> "for loop - line 10" + "increment statement - line 10" -> "for loop - line 10" + "assignment - line 11" -> "increment statement - line 10" +} +` + + if normalizeDotOutput(output) != normalizeDotOutput(expected) { + t.Errorf("Expected DOT output:\n%s\nGot:\n%s", expected, output) + } +} + +func normalizeDotOutput(dot string) string { + lines := strings.Split(dot, "\n") + var normalized []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + normalized = append(normalized, trimmed) + } + } + return strings.Join(normalized, "\n") +} + +// ref: https://github.com/godoctor/godoctor/blob/master/analysis/cfg/cfg_test.go#L500 + +const ( + START = 0 + END = 100000000 // if there's this many statements, may god have mercy on your soul +) + +func TestBlockStmt(t *testing.T) { + c := getWrapper(t, ` +package main + +func foo(i int) { + { + { + bar(i) //1 + } + } +} +func bar(i int) {}`) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, END) +} + +func TestIfElseIfGoto(t *testing.T) { + c := getWrapper(t, ` + package main + + func main() { + i := 5 //1 + i++ //2 + if i == 6 { //3 + goto ABC //4 + } else if i == 8 { //5 + goto DEF //6 + } + ABC: fmt.Println("6") //7, 8 + DEF: fmt.Println("8") //9, 10 + }`) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2) + c.expectSuccs(t, 2, 3) + c.expectSuccs(t, 3, 4, 5) + c.expectSuccs(t, 4, 7) + c.expectSuccs(t, 5, 6, 7) + c.expectSuccs(t, 6, 9) + c.expectSuccs(t, 7, 8) + c.expectSuccs(t, 8, 9) + c.expectSuccs(t, 9, 10) +} + +func TestDoubleForBreak(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + for { //1 + for { //2 + break //3 + } + } + print("this") //4 + //END + }`) + + // t, stmt, ...successors + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2, 4) + c.expectSuccs(t, 2, 3, 1) + c.expectSuccs(t, 3, 1) + + c.expectPreds(t, 3, 2) + c.expectPreds(t, 4, 1) + c.expectPreds(t, END, 4) +} + +func TestFor(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + for i := 0; i < c; i++ { // 2, 1, 3 + println(i) //4 + } + println(c) //5 + //END + }`) + + c.expectSuccs(t, START, 2) + c.expectSuccs(t, 2, 1) + c.expectSuccs(t, 1, 4, 5) + c.expectSuccs(t, 4, 3) + c.expectSuccs(t, 3, 1) + + c.expectPreds(t, 5, 1) + c.expectPreds(t, END, 5) +} + +func TestForContinue(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + for i := 0; i < c; i++ { // 2, 1, 3 + println(i) // 4 + if i > 1 { // 5 + continue // 6 + } else { + break // 7 + } + } + println(c) // 8 + //END + }`) + + c.expectSuccs(t, START, 2) + c.expectSuccs(t, 2, 1) + c.expectSuccs(t, 1, 4, 8) + c.expectSuccs(t, 6, 3) + c.expectSuccs(t, 3, 1) + c.expectSuccs(t, 7, 8) + + c.expectPreds(t, END, 8) +} + +func TestIfElse(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + if c := 1; c > 0 { // 2, 1 + print("there") // 3 + } else { + print("nowhere") // 4 + } + //END + }`) + + c.expectSuccs(t, START, 2) + c.expectSuccs(t, 2, 1) + c.expectSuccs(t, 1, 3, 4) + + c.expectPreds(t, 4, 1) + c.expectPreds(t, END, 4, 3) +} + +func TestIfNoElse(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + if c > 0 && true { // 1 + println("here") // 2 + } + print("there") // 3 + //END + } + `) + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2, 3) + + c.expectPreds(t, 3, 1, 2) + c.expectPreds(t, END, 3) +} + +func TestIfElseIf(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + if c > 0 { //1 + println("here") //2 + } else if c == 0 { //3 + println("there") //4 + } else { + println("everywhere") //5 + } + print("almost end") //6 + //END + }`) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2, 3) + c.expectSuccs(t, 2, 6) + c.expectSuccs(t, 3, 4, 5) + c.expectSuccs(t, 4, 6) + c.expectSuccs(t, 5, 6) + + c.expectPreds(t, 6, 5, 4, 2) +} + +func TestDefer(t *testing.T) { + c := getWrapper(t, ` +package main + +func foo() { + //START + print("this") //1 + defer print("one") //2 + if 1 != 0 { //3 + defer print("two") //4 + return //5 + } + print("that") //6 + defer print("three") //7 + return //8 + //END +} +`) + c.expectSuccs(t, 3, 5, 6) + c.expectSuccs(t, 5, END) + + c.expectPreds(t, 8, 6) + c.expectDefers(t, 2, 4, 7) +} + +func TestRange(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo() { + //START + c := []int{1, 2, 3} //1 + lbl: //2 + for i, v := range c { //3 + for j, k := range c { //4 + if i == j { //5 + break //6 + } + print(i*i) //7 + break lbl //8 + } + } + //END + } + `) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2) + c.expectSuccs(t, 2, 3) + c.expectSuccs(t, 3, 4, END) + c.expectSuccs(t, 4, 5, 3) + c.expectSuccs(t, 6, 3) + c.expectSuccs(t, 8, END) + + c.expectPreds(t, END, 8, 3) +} + +func TestTypeSwitchDefault(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(s ast.Stmt) { + //START + switch s.(type) { // 1, 2 + case *ast.AssignStmt: //3 + print("assign") //4 + case *ast.ForStmt: //5 + print("for") //6 + default: //7 + print("default") //8 + } + //END + } + `) + + c.expectSuccs(t, 2, 3, 5, 7) + + c.expectPreds(t, END, 8, 6, 4) +} + +func TestTypeSwitchNoDefault(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(s ast.Stmt) { + //START + switch x := 1; s := s.(type) { // 2, 1, 3 + case *ast.AssignStmt: // 4 + print("assign") // 5 + case *ast.ForStmt: // 6 + print("for") // 7 + } + //END + } +`) + + c.expectSuccs(t, START, 2) + c.expectSuccs(t, 2, 1) + c.expectSuccs(t, 1, 3) + c.expectSuccs(t, 3, 4, 6, END) +} + +func TestSwitch(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + print("hi") //1 + switch c+=1; c { //2, 3 + case 1: //4 + print("one") //5 + fallthrough //6 + case 2: //7 + break //8 + print("two") //9 + case 3: //10 + case 4: //11 + if i > 3 { //12 + print("> 3") //13 + } else { + print("< 3") //14 + } + default: //15 + print("done") //16 + } + //END + } + `) + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 3) + c.expectSuccs(t, 3, 2) + c.expectSuccs(t, 2, 4, 7, 10, 11, 15) + + c.expectPreds(t, END, 16, 14, 13, 10, 9, 8) +} + +func TestLabeledFallthrough(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + switch c { //1 + case 1: //2 + print("one") //3 + goto lbl //4 + case 2: //5 + print("two") //6 + lbl: //7 + mlbl: //8 + fallthrough //9 + default: //10 + print("number") //11 + } + //END + }`) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2, 5, 10) + c.expectSuccs(t, 4, 7) + c.expectSuccs(t, 7, 8) + c.expectSuccs(t, 8, 9) + c.expectSuccs(t, 9, 11) + c.expectSuccs(t, 10, 11) + + c.expectPreds(t, END, 11) +} + +func TestSelectDefault(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + //START + ch := make(chan int) // 1 + + // go func() { // 2 + // for i := 0; i < c; i++ { // 4, 3, 5 + // ch <- c // 6 + // } + // }() + + select { // 2 + case got := <- ch: // 3, 4 + print(got) // 5 + default: // 6 + print("done") // 7 + } + //END + }`) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2) + c.expectSuccs(t, 2, 3, 6) + c.expectSuccs(t, 3, 4) + c.expectSuccs(t, 4, 5) + + c.expectPreds(t, END, 5, 7) +} + +func TestDietyExistence(t *testing.T) { + c := getWrapper(t, ` + package main + + func foo(c int) { + b := 7 // 1 + hello: // 2 + for c < b { // 3 + for { // 4 + if c&2 == 2 { // 5 + continue hello // 6 + println("even") // 7 + } else if c&1 == 1 { // 8 + defer println("sup") // 9 + println("odd") // 10 + break // 11 + } else { + println("something wrong") // 12 + goto ending // 13 + } + println("something") // 14 + } + println("poo") // 15 + } + println("hello") // 16 + ending: // 17 + } + `) + + c.expectSuccs(t, START, 1) + c.expectSuccs(t, 1, 2) + c.expectSuccs(t, 2, 3) + c.expectSuccs(t, 3, 4, 16) + c.expectSuccs(t, 4, 5, 15) + c.expectSuccs(t, 5, 6, 8) + c.expectSuccs(t, 6, 3) + c.expectSuccs(t, 7, 14) + c.expectSuccs(t, 8, 10, 12) + + c.expectDefers(t, 9) + + c.expectSuccs(t, 10, 11) + c.expectSuccs(t, 11, 15) + c.expectSuccs(t, 12, 13) + c.expectSuccs(t, 13, 17) + c.expectSuccs(t, 14, 4) + c.expectSuccs(t, 15, 3) + c.expectSuccs(t, 16, 17) +} + +// lo and behold how it's done -- caution: disgust may ensue +type CFGWrapper struct { + cfg *CFG + exp map[int]ast.Stmt + stmts map[ast.Stmt]int + info *types.Info + fset *token.FileSet + f *ast.File +} + +// uses first function in given string to produce CFG +// w/ some other convenient fields for printing in test +// cases when need be... +func getWrapper(t *testing.T, str string) *CFGWrapper { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", str, 0) + if err != nil { + t.Error(err.Error()) + t.FailNow() + return nil + } + + conf := types.Config{Importer: nil} + info := &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + } + conf.Check("test", fset, []*ast.File{f}, info) + + cfg := FromFunc(f.Decls[0].(*ast.FuncDecl)) + v := make(map[int]ast.Stmt) + stmts := make(map[ast.Stmt]int) + i := 1 + + ast.Inspect(f.Decls[0].(*ast.FuncDecl), func(n ast.Node) bool { + switch x := n.(type) { + case ast.Stmt: + switch x.(type) { + case *ast.BlockStmt: + return true + } + v[i] = x + stmts[x] = i + i++ + } + return true + }) + v[END] = cfg.Exit + v[START] = cfg.Entry + if len(v) != len(cfg.blocks)+len(cfg.Defers) { + t.Logf("expected %d vertices, got %d --construction error", len(v), len(cfg.blocks)) + } + return &CFGWrapper{cfg, v, stmts, info, fset, f} +} + +func (c *CFGWrapper) expIntsToStmts(args []int) map[ast.Stmt]struct{} { + stmts := make(map[ast.Stmt]struct{}) + for _, a := range args { + stmts[c.exp[a]] = struct{}{} + } + return stmts +} + +// give generics +func expectFromMaps(actual, exp map[ast.Stmt]struct{}) (dnf, found map[ast.Stmt]struct{}) { + for stmt := range exp { + if _, ok := actual[stmt]; ok { + delete(exp, stmt) + delete(actual, stmt) + } + } + + return exp, actual +} + +func (c *CFGWrapper) expectDefers(t *testing.T, exp ...int) { + actualDefers := make(map[ast.Stmt]struct{}) + for _, d := range c.cfg.Defers { + actualDefers[d] = struct{}{} + } + + expDefers := c.expIntsToStmts(exp) + dnf, found := expectFromMaps(actualDefers, expDefers) + + for stmt := range dnf { + t.Error("did not find", c.stmts[stmt], "in defers") + } + + for stmt := range found { + t.Error("found", c.stmts[stmt], "as a defer") + } +} + +func (c *CFGWrapper) expectSuccs(t *testing.T, s int, exp ...int) { + if _, ok := c.cfg.blocks[c.exp[s]]; !ok { + t.Error("did not find parent", s) + return + } + + // get successors for stmt s as slice, put in map + actualSuccs := make(map[ast.Stmt]struct{}) + for _, v := range c.cfg.Succs(c.exp[s]) { + actualSuccs[v] = struct{}{} + } + + expSuccs := c.expIntsToStmts(exp) + dnf, found := expectFromMaps(actualSuccs, expSuccs) + + for stmt := range dnf { + t.Error("did not find", c.stmts[stmt], "in successors for", s) + } + + for stmt := range found { + t.Error("found", c.stmts[stmt], "as a successor for", s) + } +} + +func (c *CFGWrapper) expectPreds(t *testing.T, s int, exp ...int) { + if _, ok := c.cfg.blocks[c.exp[s]]; !ok { + t.Error("did not find parent", s) + } + + // get predecessors for stmt s as slice, put in map + actualPreds := make(map[ast.Stmt]struct{}) + for _, v := range c.cfg.Preds(c.exp[s]) { + actualPreds[v] = struct{}{} + } + + expPreds := c.expIntsToStmts(exp) + dnf, found := expectFromMaps(actualPreds, expPreds) + + for stmt := range dnf { + t.Error("did not find", c.stmts[stmt], "in predecessors for", s) + } + + for stmt := range found { + t.Error("found", c.stmts[stmt], "as a predecessor for", s) + } +} + +func TestPrintDot(t *testing.T) { + c := getWrapper(t, ` + package main + + func main() { + i := 5 //1 + i++ //2 + }`) + + var buf bytes.Buffer + c.cfg.PrintDot(&buf, c.fset, func(s ast.Stmt) string { + if _, ok := s.(*ast.AssignStmt); ok { + return "!" + } else { + return "" + } + }) + dot := buf.String() + + expected := []string{ + `^digraph mgraph { +mode="heir"; +splines="ortho"; + +`, + "\"assignment - line 5\\\\n!\" -> \"increment statement - line 6\"\n", + "\"ENTRY\" -> \"assignment - line 5\\\\n!\"\n", + "\"increment statement - line 6\" -> \"EXIT\"\n", + } + // The order of the three lines may vary (they're from a map), so + // just make sure all three lines appear somewhere + for _, re := range expected { + ok, _ := regexp.MatchString(re, dot) + if !ok { + t.Fatalf("[%s]", dot) + } + } +} From 509d36b2056288f0e77a9abac7af3720f4d5c413 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 23 Jul 2024 19:00:23 +0900 Subject: [PATCH 03/37] wip: branch --- .../cmd/gno/coverage/analysis/cfg/cfg_test.go | 2 +- gnovm/cmd/gno/coverage/checker/branch.go | 168 ++++++++++++++++++ gnovm/cmd/gno/coverage/checker/branch_test.go | 60 +++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 gnovm/cmd/gno/coverage/checker/branch.go create mode 100644 gnovm/cmd/gno/coverage/checker/branch_test.go diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go b/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go index ff183c5f98c..fe68398530a 100644 --- a/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go +++ b/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go @@ -715,7 +715,7 @@ type CFGWrapper struct { cfg *CFG exp map[int]ast.Stmt stmts map[ast.Stmt]int - info *types.Info + info *types.Info fset *token.FileSet f *ast.File } diff --git a/gnovm/cmd/gno/coverage/checker/branch.go b/gnovm/cmd/gno/coverage/checker/branch.go new file mode 100644 index 00000000000..fe7158db39d --- /dev/null +++ b/gnovm/cmd/gno/coverage/checker/branch.go @@ -0,0 +1,168 @@ +package checker + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + + "github.com/gnolang/gno/tm2/pkg/std" + "golang.org/x/tools/go/ast/astutil" +) + +type Branch struct { + Pos token.Pos + Taken bool + Filename string + Line int +} + +type BranchCoverage struct { + branches map[token.Pos]*Branch + fset *token.FileSet + debug bool +} + +func NewBranchCoverage(files []*std.MemFile) *BranchCoverage { + bc := &BranchCoverage{ + branches: make(map[token.Pos]*Branch), + fset: token.NewFileSet(), + debug: true, + } + + for _, file := range files { + if astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors); err == nil { + ast.Inspect(astFile, func(n ast.Node) bool { + switch stmt := n.(type) { + case *ast.IfStmt: + pos := stmt.If + bc.branches[pos] = &Branch{Pos: pos, Taken: false} + if stmt.Else != nil { + pos = stmt.Else.Pos() + bc.branches[pos] = &Branch{Pos: pos, Taken: false} + } + // TODO: add more cases + } + return true + }) + } + } + + return bc +} + +func (bc *BranchCoverage) Instrument(file *std.MemFile) *std.MemFile { + bc.log("instrumenting file: %s", file.Name) + astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors) + if err != nil { + bc.log("error parsing file %s: %v", file.Name, err) + return file + } + + instrumentedFile := astutil.Apply(astFile, nil, func(c *astutil.Cursor) bool { + node := c.Node() + switch n := node.(type) { + case *ast.IfStmt: + bc.instrumentIfStmt(n) + case *ast.SwitchStmt: + bc.instrumentSwitchStmt(n) + case *ast.ForStmt: + bc.instrumentForStmt(n) + } + return true + }) + + var buf bytes.Buffer + if err := printer.Fprint(&buf, bc.fset, instrumentedFile); err != nil { + bc.log("error printing instrumented file: %v", err) + return file + } + + return &std.MemFile{ + Name: file.Name, + Body: buf.String(), + } +} + +func (bc *BranchCoverage) instrumentIfStmt(ifStmt *ast.IfStmt) { + // instrument if statement + ifPos := bc.fset.Position(ifStmt.If) + bc.branches[ifStmt.If] = &Branch{Pos: ifStmt.If, Filename: ifPos.Filename, Line: ifPos.Line, Taken: false} + ifStmt.Body.List = append([]ast.Stmt{bc.createMarkBranchStmt(ifStmt.If)}, ifStmt.Body.List...) + + // instrument else statement + if ifStmt.Else != nil { + elsePos := bc.fset.Position(ifStmt.Else.Pos()) + bc.branches[ifStmt.Else.Pos()] = &Branch{Pos: ifStmt.Else.Pos(), Taken: false, Filename: elsePos.Filename, Line: elsePos.Line} + switch elseBody := ifStmt.Else.(type) { + case *ast.BlockStmt: + elseBody.List = append([]ast.Stmt{bc.createMarkBranchStmt(ifStmt.Else.Pos())}, elseBody.List...) + case *ast.IfStmt: + // For 'else if', recursively instrument + bc.instrumentIfStmt(elseBody) + } + } +} + +func (bc *BranchCoverage) instrumentSwitchStmt(switchStmt *ast.SwitchStmt) { + for _, stmt := range switchStmt.Body.List { + if caseClause, ok := stmt.(*ast.CaseClause); ok { + casePos := bc.fset.Position(caseClause.Pos()) + bc.branches[caseClause.Pos()] = &Branch{Pos: caseClause.Pos(), Taken: false, Filename: casePos.Filename, Line: casePos.Line} + caseClause.Body = append([]ast.Stmt{bc.createMarkBranchStmt(caseClause.Pos())}, caseClause.Body...) + } + } +} + +func (bc *BranchCoverage) instrumentForStmt(forStmt *ast.ForStmt) { + forPos := bc.fset.Position(forStmt.For) + bc.branches[forStmt.For] = &Branch{Pos: forStmt.For, Taken: false, Filename: forPos.Filename, Line: forPos.Line} + forStmt.Body.List = append([]ast.Stmt{bc.createMarkBranchStmt(forStmt.For)}, forStmt.Body.List...) +} + +func (bc *BranchCoverage) createMarkBranchStmt(pos token.Pos) ast.Stmt { + return &ast.ExprStmt{ + X: &ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: ast.NewIdent("bc"), + Sel: ast.NewIdent("MarkBranchTaken"), + }, + Args: []ast.Expr{ + &ast.BasicLit{ + Kind: token.INT, + Value: fmt.Sprintf("%d", bc.fset.Position(pos).Offset), + }, + }, + }, + } +} + +func (bc *BranchCoverage) MarkBranchTaken(pos token.Pos) { + if branch, exists := bc.branches[pos]; exists { + branch.Taken = true + } +} + +func (bc *BranchCoverage) CalculateCoverage() float64 { + total := len(bc.branches) + if total == 0 { + return 0 + } + + taken := 0 + for _, branch := range bc.branches { + if branch.Taken { + taken++ + } + } + + return float64(taken) / float64(total) +} + +func (bc *BranchCoverage) log(format string, args ...interface{}) { + if bc.debug { + fmt.Printf(format+"\n", args...) + } +} diff --git a/gnovm/cmd/gno/coverage/checker/branch_test.go b/gnovm/cmd/gno/coverage/checker/branch_test.go new file mode 100644 index 00000000000..a95fbc42a54 --- /dev/null +++ b/gnovm/cmd/gno/coverage/checker/branch_test.go @@ -0,0 +1,60 @@ +package checker + +import ( + "strconv" + "testing" + + "github.com/gnolang/gno/tm2/pkg/std" +) + +func TestBranchCoverage(t *testing.T) { + t.Skip("TODO") + tests := []struct { + name string + files []*std.MemFile + executeBranches []int + expectedCoverage float64 + }{ + { + name: "Simple if statement", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test(x int) int { + if x > 0 { + return x + } + return -x +} +`, + }, + }, + executeBranches: []int{5}, + expectedCoverage: 0.5, // 1 out of 2 branches covered + }, + // TODO: add more tests + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bc := NewBranchCoverage(tt.files) + + for i, file := range tt.files { + tt.files[i] = bc.Instrument(file) + } + + coverage := bc.CalculateCoverage() + if !almostEqual(coverage, tt.expectedCoverage, 0.01) { + t.Errorf("Expected coverage %f, but got %f", tt.expectedCoverage, coverage) + } + }) + } +} + +func stringToInt(s string) int { + i, _ := strconv.Atoi(s) + return i +} From 9f5e5510cb7db5c072c94fba54a7bc93ac11052a Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 23 Jul 2024 22:52:28 +0900 Subject: [PATCH 04/37] fix branch test --- gnovm/cmd/gno/coverage/checker/branch.go | 130 ++++++++++-------- gnovm/cmd/gno/coverage/checker/branch_test.go | 107 ++++++++++++-- 2 files changed, 166 insertions(+), 71 deletions(-) diff --git a/gnovm/cmd/gno/coverage/checker/branch.go b/gnovm/cmd/gno/coverage/checker/branch.go index fe7158db39d..88d533c3f83 100644 --- a/gnovm/cmd/gno/coverage/checker/branch.go +++ b/gnovm/cmd/gno/coverage/checker/branch.go @@ -13,21 +13,20 @@ import ( ) type Branch struct { - Pos token.Pos - Taken bool - Filename string - Line int + Pos token.Pos + Taken bool + Offset int } type BranchCoverage struct { - branches map[token.Pos]*Branch + branches map[int]*Branch fset *token.FileSet debug bool } func NewBranchCoverage(files []*std.MemFile) *BranchCoverage { bc := &BranchCoverage{ - branches: make(map[token.Pos]*Branch), + branches: make(map[int]*Branch), fset: token.NewFileSet(), debug: true, } @@ -35,48 +34,69 @@ func NewBranchCoverage(files []*std.MemFile) *BranchCoverage { for _, file := range files { if astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors); err == nil { ast.Inspect(astFile, func(n ast.Node) bool { - switch stmt := n.(type) { - case *ast.IfStmt: - pos := stmt.If - bc.branches[pos] = &Branch{Pos: pos, Taken: false} - if stmt.Else != nil { - pos = stmt.Else.Pos() - bc.branches[pos] = &Branch{Pos: pos, Taken: false} - } - // TODO: add more cases - } + bc.identifyBranch(n) return true }) } } + bc.log("Total branches identified: %d", len(bc.branches)) return bc } +func (bc *BranchCoverage) identifyBranch(n ast.Node) { + switch stmt := n.(type) { + case *ast.IfStmt: + bc.addBranch(stmt.If) + if stmt.Else != nil { + switch elseStmt := stmt.Else.(type) { + case *ast.BlockStmt: + bc.addBranch(elseStmt.Pos()) + case *ast.IfStmt: + bc.addBranch(elseStmt.If) + } + } else { + // implicit else branch + bc.addBranch(stmt.Body.End()) + } + case *ast.CaseClause: + bc.addBranch(stmt.Pos()) + case *ast.SwitchStmt: + for _, s := range stmt.Body.List { + if cc, ok := s.(*ast.CaseClause); ok { + bc.addBranch(cc.Pos()) + } + } + } +} + +func (bc *BranchCoverage) addBranch(pos token.Pos) { + offset := bc.fset.Position(pos).Offset + bc.branches[offset] = &Branch{Pos: pos, Taken: false, Offset: offset} + bc.log("Branch added at offset %d", offset) +} + func (bc *BranchCoverage) Instrument(file *std.MemFile) *std.MemFile { - bc.log("instrumenting file: %s", file.Name) astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors) if err != nil { - bc.log("error parsing file %s: %v", file.Name, err) + bc.log("Error parsing file %s: %v", file.Name, err) return file } - instrumentedFile := astutil.Apply(astFile, nil, func(c *astutil.Cursor) bool { - node := c.Node() - switch n := node.(type) { + astutil.Apply(astFile, func(c *astutil.Cursor) bool { + n := c.Node() + switch stmt := n.(type) { case *ast.IfStmt: - bc.instrumentIfStmt(n) - case *ast.SwitchStmt: - bc.instrumentSwitchStmt(n) - case *ast.ForStmt: - bc.instrumentForStmt(n) + bc.instrumentIfStmt(stmt) + case *ast.CaseClause: + bc.instrumentCaseClause(stmt) } return true - }) + }, nil) var buf bytes.Buffer - if err := printer.Fprint(&buf, bc.fset, instrumentedFile); err != nil { - bc.log("error printing instrumented file: %v", err) + if err := printer.Fprint(&buf, bc.fset, astFile); err != nil { + bc.log("Error printing instrumented file: %v", err) return file } @@ -87,43 +107,25 @@ func (bc *BranchCoverage) Instrument(file *std.MemFile) *std.MemFile { } func (bc *BranchCoverage) instrumentIfStmt(ifStmt *ast.IfStmt) { - // instrument if statement - ifPos := bc.fset.Position(ifStmt.If) - bc.branches[ifStmt.If] = &Branch{Pos: ifStmt.If, Filename: ifPos.Filename, Line: ifPos.Line, Taken: false} - ifStmt.Body.List = append([]ast.Stmt{bc.createMarkBranchStmt(ifStmt.If)}, ifStmt.Body.List...) - - // instrument else statement + bc.insertMarkBranchStmt(ifStmt.Body, ifStmt.If) if ifStmt.Else != nil { - elsePos := bc.fset.Position(ifStmt.Else.Pos()) - bc.branches[ifStmt.Else.Pos()] = &Branch{Pos: ifStmt.Else.Pos(), Taken: false, Filename: elsePos.Filename, Line: elsePos.Line} - switch elseBody := ifStmt.Else.(type) { + switch elseStmt := ifStmt.Else.(type) { case *ast.BlockStmt: - elseBody.List = append([]ast.Stmt{bc.createMarkBranchStmt(ifStmt.Else.Pos())}, elseBody.List...) + bc.insertMarkBranchStmt(elseStmt, ifStmt.Else.Pos()) case *ast.IfStmt: - // For 'else if', recursively instrument - bc.instrumentIfStmt(elseBody) - } - } -} - -func (bc *BranchCoverage) instrumentSwitchStmt(switchStmt *ast.SwitchStmt) { - for _, stmt := range switchStmt.Body.List { - if caseClause, ok := stmt.(*ast.CaseClause); ok { - casePos := bc.fset.Position(caseClause.Pos()) - bc.branches[caseClause.Pos()] = &Branch{Pos: caseClause.Pos(), Taken: false, Filename: casePos.Filename, Line: casePos.Line} - caseClause.Body = append([]ast.Stmt{bc.createMarkBranchStmt(caseClause.Pos())}, caseClause.Body...) + bc.instrumentIfStmt(elseStmt) } } } -func (bc *BranchCoverage) instrumentForStmt(forStmt *ast.ForStmt) { - forPos := bc.fset.Position(forStmt.For) - bc.branches[forStmt.For] = &Branch{Pos: forStmt.For, Taken: false, Filename: forPos.Filename, Line: forPos.Line} - forStmt.Body.List = append([]ast.Stmt{bc.createMarkBranchStmt(forStmt.For)}, forStmt.Body.List...) +func (bc *BranchCoverage) instrumentCaseClause(caseClause *ast.CaseClause) { + bc.insertMarkBranchStmt(&ast.BlockStmt{List: caseClause.Body}, caseClause.Pos()) } -func (bc *BranchCoverage) createMarkBranchStmt(pos token.Pos) ast.Stmt { - return &ast.ExprStmt{ +func (bc *BranchCoverage) insertMarkBranchStmt(block *ast.BlockStmt, pos token.Pos) { + offset := bc.fset.Position(pos).Offset + bc.log("Inserting branch mark at offset %d", offset) + markStmt := &ast.ExprStmt{ X: &ast.CallExpr{ Fun: &ast.SelectorExpr{ X: ast.NewIdent("bc"), @@ -132,16 +134,20 @@ func (bc *BranchCoverage) createMarkBranchStmt(pos token.Pos) ast.Stmt { Args: []ast.Expr{ &ast.BasicLit{ Kind: token.INT, - Value: fmt.Sprintf("%d", bc.fset.Position(pos).Offset), + Value: fmt.Sprintf("%d", offset), }, }, }, } + block.List = append([]ast.Stmt{markStmt}, block.List...) } -func (bc *BranchCoverage) MarkBranchTaken(pos token.Pos) { - if branch, exists := bc.branches[pos]; exists { +func (bc *BranchCoverage) MarkBranchTaken(offset int) { + if branch, exists := bc.branches[offset]; exists { branch.Taken = true + bc.log("Branch taken at offset %d", offset) + } else { + bc.log("No branch found at offset %d", offset) } } @@ -158,7 +164,9 @@ func (bc *BranchCoverage) CalculateCoverage() float64 { } } - return float64(taken) / float64(total) + coverage := float64(taken) / float64(total) + bc.log("Total branches: %d, Taken: %d, Coverage: %.2f", total, taken, coverage) + return coverage } func (bc *BranchCoverage) log(format string, args ...interface{}) { diff --git a/gnovm/cmd/gno/coverage/checker/branch_test.go b/gnovm/cmd/gno/coverage/checker/branch_test.go index a95fbc42a54..a0e9f149cf6 100644 --- a/gnovm/cmd/gno/coverage/checker/branch_test.go +++ b/gnovm/cmd/gno/coverage/checker/branch_test.go @@ -1,14 +1,12 @@ package checker import ( - "strconv" "testing" "github.com/gnolang/gno/tm2/pkg/std" ) func TestBranchCoverage(t *testing.T) { - t.Skip("TODO") tests := []struct { name string files []*std.MemFile @@ -32,10 +30,100 @@ func test(x int) int { `, }, }, - executeBranches: []int{5}, - expectedCoverage: 0.5, // 1 out of 2 branches covered + // total branch added offset: 39, 63 + executeBranches: []int{39}, + expectedCoverage: 0.5, // 1/2 + }, + { + name: "If statement with else", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test(x int) int { + if x > 0 { + return x + } else { + return -x + } +}`, + }, + }, + executeBranches: []int{39, 69}, + expectedCoverage: 1.0, + }, + { + name: "Nested if statement", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test(x int) int { + if x > 0 { + if x < 10 { + return x + } else { + return 10 + } + } + return -x +} +`, + }, + }, + // total branch added offset: 39, 106, 52, 85 + executeBranches: []int{39, 52, 85}, + expectedCoverage: 0.75, // 3/4 + }, + { + name: "Multiple conditions", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test(x int, y int) int { + if x > 0 && y > 0 { + return x + y + } + return -x +} +`, + }, + }, + // total branch added offset: 46, 83 + executeBranches: []int{46}, + expectedCoverage: 0.5, // 1/2 + }, + { + name: "Switch statement", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test(x int) int { + switch x { + case 1: + return 1 + case 2: + return 2 + default: + return 0 + } +} +`, + }, + }, + executeBranches: []int{51, 71, 91}, + expectedCoverage: 1, }, - // TODO: add more tests } for _, tt := range tests { @@ -46,6 +134,10 @@ func test(x int) int { tt.files[i] = bc.Instrument(file) } + for _, offset := range tt.executeBranches { + bc.MarkBranchTaken(offset) + } + coverage := bc.CalculateCoverage() if !almostEqual(coverage, tt.expectedCoverage, 0.01) { t.Errorf("Expected coverage %f, but got %f", tt.expectedCoverage, coverage) @@ -53,8 +145,3 @@ func test(x int) int { }) } } - -func stringToInt(s string) int { - i, _ := strconv.Atoi(s) - return i -} From 9a06318cd062e9715f7527070ec341d56c7c7c79 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 23 Jul 2024 23:20:21 +0900 Subject: [PATCH 05/37] use cfg later. check more conditions --- .../cmd/gno/coverage/analysis/cfg/builder.go | 334 ------- gnovm/cmd/gno/coverage/analysis/cfg/cfg.go | 123 --- .../cmd/gno/coverage/analysis/cfg/cfg_test.go | 891 ------------------ gnovm/cmd/gno/coverage/checker/branch.go | 30 + gnovm/cmd/gno/coverage/checker/branch_test.go | 105 ++- gnovm/cmd/gno/coverage/checker/statement.go | 2 +- gnovm/cmd/gno/test.go | 2 +- 7 files changed, 130 insertions(+), 1357 deletions(-) delete mode 100644 gnovm/cmd/gno/coverage/analysis/cfg/builder.go delete mode 100644 gnovm/cmd/gno/coverage/analysis/cfg/cfg.go delete mode 100644 gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/builder.go b/gnovm/cmd/gno/coverage/analysis/cfg/builder.go deleted file mode 100644 index 2752f998c4a..00000000000 --- a/gnovm/cmd/gno/coverage/analysis/cfg/builder.go +++ /dev/null @@ -1,334 +0,0 @@ -package cfg - -import ( - "go/ast" - "go/token" -) - -// ref: https://github.com/godoctor/godoctor/blob/master/analysis/cfg/cfg.go - -type builder struct { - blocks map[ast.Stmt]*block - prev []ast.Stmt // blocks to hook up to current block - branches []*ast.BranchStmt // accumulated branches from current inner blocks - entry, exit *ast.BadStmt // single-entry, single-exit nodes - defers []*ast.DeferStmt // all defers encountered -} - -// NewBuilder constructs a CFG from the given slice of statements. -func NewBuilder() *builder { - // The ENTRY and EXIT nodes are given positions -2 and -1 so cfg.Sort - // will work correct: ENTRY will always be first, followed by EXIT, - // followed by the other CFG nodes. - return &builder{ - blocks: map[ast.Stmt]*block{}, - entry: &ast.BadStmt{From: -2, To: -2}, - exit: &ast.BadStmt{From: -1, To: -1}, - } -} - -// Build runs buildBlock on the given block (traversing nested statements), and -// adds entry and exit nodes. -func (b *builder) Build(s []ast.Stmt) *CFG { - b.prev = []ast.Stmt{b.entry} - b.buildBlock(s) - b.addSucc(b.exit) - - return &CFG{ - blocks: b.blocks, - Entry: b.entry, - Exit: b.exit, - Defers: b.defers, - } -} - -// addSucc adds a control flow edge from all previous blocks to the block for the given statement. -// It updates both the successors of the previous blocks and the predecessors of the current block. -func (b *builder) addSucc(current ast.Stmt) { - cur := b.block(current) - - for _, p := range b.prev { - p := b.block(p) - p.succs = appendNoDuplicates(p.succs, cur.stmt) - cur.preds = appendNoDuplicates(cur.preds, p.stmt) - } -} - -func appendNoDuplicates(list []ast.Stmt, stmt ast.Stmt) []ast.Stmt { - for _, s := range list { - if s == stmt { - return list - } - } - return append(list, stmt) -} - -// block returns a block for the given statement, creating one and inserting it -// into the CFG if it doesn't already exist. -func (b *builder) block(s ast.Stmt) *block { - bl, ok := b.blocks[s] - if !ok { - bl = &block{stmt: s} - b.blocks[s] = bl - } - return bl -} - -// buildStmt adds the given statement and all nested statements to the control -// flow graph under construction. Upon completion, b.prev is set to all -// control flow exits generated from traversing cur. -func (b *builder) buildStmt(cur ast.Stmt) { - if dfr, ok := cur.(*ast.DeferStmt); ok { - b.defers = append(b.defers, dfr) - return // never flow to or from defer - } - - // Each buildXxx method will flow the previous blocks to itself appropriately and also - // set the appropriate blocks to flow from at the end of the method. - switch cur := cur.(type) { - case *ast.BlockStmt: - b.buildBlock(cur.List) - case *ast.IfStmt: - b.buildIf(cur) - case *ast.ForStmt, *ast.RangeStmt: - b.buildLoop(cur) - case *ast.SwitchStmt, *ast.SelectStmt, *ast.TypeSwitchStmt: - b.buildSwitch(cur) - case *ast.BranchStmt: - b.buildBranch(cur) - case *ast.LabeledStmt: - b.addSucc(cur) - b.prev = []ast.Stmt{cur} - b.buildStmt(cur.Stmt) - case *ast.ReturnStmt: - b.addSucc(cur) - b.prev = []ast.Stmt{cur} - b.addSucc(b.exit) - b.prev = nil - default: // most statements have straight-line control flow - b.addSucc(cur) - b.prev = []ast.Stmt{cur} - } -} - -// buildBranch handles the creation of CFG nodes for branch statements (break, continue, goto, fallthrough). -// It updates the CFG based on the type of branch statement. -func (b *builder) buildBranch(br *ast.BranchStmt) { - b.addSucc(br) - b.prev = []ast.Stmt{br} - - switch br.Tok { - case token.FALLTHROUGH: - // successors handled in buildSwitch, so skip this here - case token.GOTO: - b.addSucc(br.Label.Obj.Decl.(ast.Stmt)) // flow to label - case token.BREAK, token.CONTINUE: - b.branches = append(b.branches, br) // to handle at switch/for/etc level - } - b.prev = nil // successors handled elsewhere -} - -// buildBlock iterates over a slice of statements, typically from an ast.BlockStmt, -// adding them successively to the CFG. -func (b *builder) buildBlock(block []ast.Stmt) { - for _, stmt := range block { - b.buildStmt(stmt) - } -} - -// buildIf constructs the CFG for an if statement, including its condition, body, and else clause (if present). -func (b *builder) buildIf(f *ast.IfStmt) { - if f.Init != nil { - b.addSucc(f.Init) - b.prev = []ast.Stmt{f.Init} - } - b.addSucc(f) - - b.prev = []ast.Stmt{f} - b.buildBlock(f.Body.List) // build then - - ctrlExits := b.prev // aggregate of b.prev from each condition - - switch s := f.Else.(type) { - case *ast.BlockStmt: // build else - b.prev = []ast.Stmt{f} - b.buildBlock(s.List) - ctrlExits = append(ctrlExits, b.prev...) - case *ast.IfStmt: // build else if - b.prev = []ast.Stmt{f} - b.addSucc(s) - b.buildIf(s) - ctrlExits = append(ctrlExits, b.prev...) - case nil: // no else - ctrlExits = append(ctrlExits, f) - } - - b.prev = ctrlExits -} - -// buildLoop constructs the CFG for loop statements (for and range). -// It handles the initialization, condition, post-statement (for for loops), and body of the loop. -func (b *builder) buildLoop(stmt ast.Stmt) { - // flows as such (range same w/o init & post): - // previous -> [ init -> ] for -> body -> [ post -> ] for -> next - - var post ast.Stmt = stmt // post in for loop, or for stmt itself; body flows to this - - switch stmt := stmt.(type) { - case *ast.ForStmt: - if stmt.Init != nil { - b.addSucc(stmt.Init) - b.prev = []ast.Stmt{stmt.Init} - } - b.addSucc(stmt) - - if stmt.Post != nil { - post = stmt.Post - b.prev = []ast.Stmt{post} - b.addSucc(stmt) - } - - b.prev = []ast.Stmt{stmt} - b.buildBlock(stmt.Body.List) - case *ast.RangeStmt: - b.addSucc(stmt) - b.prev = []ast.Stmt{stmt} - b.buildBlock(stmt.Body.List) - } - - b.addSucc(post) - - ctrlExits := []ast.Stmt{stmt} - - // handle any branches; if no label or for me: handle and remove from branches. - for i := 0; i < len(b.branches); i++ { - br := b.branches[i] - if br.Label == nil || br.Label.Obj.Decl.(*ast.LabeledStmt).Stmt == stmt { - switch br.Tok { // can only be one of these two cases - case token.CONTINUE: - b.prev = []ast.Stmt{br} - b.addSucc(post) // connect to .Post statement if present, for stmt otherwise - case token.BREAK: - ctrlExits = append(ctrlExits, br) - } - b.branches = append(b.branches[:i], b.branches[i+1:]...) - i-- // removed in place, so go back to this i - } - } - - b.prev = ctrlExits // for stmt and any appropriate break statements -} - -// buildSwitch constructs the CFG for switch, type switch, and select statements. -// It handles the initialization (if present), switch expression, and all case clauses. -func (b *builder) buildSwitch(sw ast.Stmt) { - var cases []ast.Stmt // case 1:, case 2:, ... - - switch sw := sw.(type) { - case *ast.SwitchStmt: // i.e. switch [ x := 0; ] [ x ] { } - if sw.Init != nil { - b.addSucc(sw.Init) - b.prev = []ast.Stmt{sw.Init} - } - b.addSucc(sw) - b.prev = []ast.Stmt{sw} - - cases = sw.Body.List - case *ast.TypeSwitchStmt: // i.e. switch [ x := 0; ] t := x.(type) { } - if sw.Init != nil { - b.addSucc(sw.Init) - b.prev = []ast.Stmt{sw.Init} - } - b.addSucc(sw) - b.prev = []ast.Stmt{sw} - b.addSucc(sw.Assign) - b.prev = []ast.Stmt{sw.Assign} - - cases = sw.Body.List - case *ast.SelectStmt: // i.e. select { } - b.addSucc(sw) - b.prev = []ast.Stmt{sw} - - cases = sw.Body.List - } - - var caseExits []ast.Stmt // aggregate of b.prev's resulting from each case - swPrev := b.prev // save for each case's previous; Switch or Assign - var ft *ast.BranchStmt // fallthrough to handle from previous case, if any - defaultCase := false - - for _, clause := range cases { - b.prev = swPrev - b.addSucc(clause) - b.prev = []ast.Stmt{clause} - if ft != nil { - b.prev = append(b.prev, ft) - } - - var caseBody []ast.Stmt - - // both of the following cases are guaranteed in spec - switch clause := clause.(type) { - case *ast.CaseClause: // i.e. case: [expr,expr,...]: - if clause.List == nil { - defaultCase = true - } - caseBody = clause.Body - case *ast.CommClause: // i.e. case c <- chan: - if clause.Comm == nil { - defaultCase = true - } else { - b.addSucc(clause.Comm) - b.prev = []ast.Stmt{clause.Comm} - } - caseBody = clause.Body - } - - b.buildBlock(caseBody) - - if ft = fallThrough(caseBody); ft == nil { - caseExits = append(caseExits, b.prev...) - } - } - - if !defaultCase { - caseExits = append(caseExits, swPrev...) - } - - // handle any breaks that are unlabeled or for me - for i := 0; i < len(b.branches); i++ { - br := b.branches[i] - if br.Tok == token.BREAK && (br.Label == nil || br.Label.Obj.Decl.(*ast.LabeledStmt).Stmt == sw) { - caseExits = append(caseExits, br) - b.branches = append(b.branches[:i], b.branches[i+1:]...) - i-- // we removed in place, so go back to this index - } - } - - b.prev = caseExits // control exits of each case and breaks -} - -// fallThrough returns the fallthrough statement at the end of the given slice of statements, if one exists. -// It returns nil if no fallthrough statement is found. -func fallThrough(stmts []ast.Stmt) *ast.BranchStmt { - if len(stmts) < 1 { - return nil - } - - // fallthrough can only be last statement in clause (possibly labeled) - ft := stmts[len(stmts)-1] - - for { // recursively descend LabeledStmts. - switch s := ft.(type) { - case *ast.BranchStmt: - if s.Tok == token.FALLTHROUGH { - return s - } - case *ast.LabeledStmt: - ft = s.Stmt - continue - } - break - } - return nil -} diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/cfg.go b/gnovm/cmd/gno/coverage/analysis/cfg/cfg.go deleted file mode 100644 index 36b4b6ec887..00000000000 --- a/gnovm/cmd/gno/coverage/analysis/cfg/cfg.go +++ /dev/null @@ -1,123 +0,0 @@ -package cfg - -import ( - "fmt" - "go/ast" - "go/token" - "io" - "sort" - "strings" - - "golang.org/x/tools/go/ast/astutil" -) - -// CFGBuilder defines the interface for building a control flow graph (CFG). -type CFGBuilder interface { - Build(stmts []ast.Stmt) *CFG - Sort(stmts []ast.Stmt) - PrintDot(f io.Writer, fset *token.FileSet, addl func(n ast.Stmt) string) -} - -// CFG defines a control flow graph with statement-level granularity, in which -// there is a 1-1 correspondence between a block in the CFG and an ast.Stmt. -type CFG struct { - // Sentinel nodes for single-entry CFG. Not in original AST. - Entry *ast.BadStmt - - // Sentinel nodes for single-exit CFG. Not in original AST. - Exit *ast.BadStmt - - // All defers found in CFG, disjoint from blocks. May be flowed to after Exit. - Defers []*ast.DeferStmt - blocks map[ast.Stmt]*block -} - -type block struct { - stmt ast.Stmt - preds []ast.Stmt - succs []ast.Stmt -} - -// FromStmts returns the control-flow graph for the given sequence of statements. -func FromStmts(s []ast.Stmt) *CFG { - return NewBuilder().Build(s) -} - -// FromFunc is a convenience function for creating a CFG from a given function declaration. -func FromFunc(f *ast.FuncDecl) *CFG { - return FromStmts(f.Body.List) -} - -// Preds returns a slice of all immediate predecessors for the given statement. -// May include Entry node. -func (c *CFG) Preds(s ast.Stmt) []ast.Stmt { - return c.blocks[s].preds -} - -// Succs returns a slice of all immediate successors to the given statement. -// May include Exit node. -func (c *CFG) Succs(s ast.Stmt) []ast.Stmt { - return c.blocks[s].succs -} - -// Blocks returns a slice of all blocks in a CFG, including the Entry and Exit nodes. -// The blocks are roughly in the order they appear in the source code. -func (c *CFG) Blocks() []ast.Stmt { - blocks := make([]ast.Stmt, 0, len(c.blocks)) - for s := range c.blocks { - blocks = append(blocks, s) - } - return blocks -} - -// type for sorting statements by their starting positions in the source code -type stmtSlice []ast.Stmt - -func (n stmtSlice) Len() int { return len(n) } -func (n stmtSlice) Swap(i, j int) { n[i], n[j] = n[j], n[i] } -func (n stmtSlice) Less(i, j int) bool { - return n[i].Pos() < n[j].Pos() -} - -func (c *CFG) Sort(stmts []ast.Stmt) { - sort.Sort(stmtSlice(stmts)) -} - -func (c *CFG) PrintDot(f io.Writer, fset *token.FileSet, addl func(n ast.Stmt) string) { - fmt.Fprintf(f, `digraph mgraph { -mode="heir"; -splines="ortho"; - -`) - blocks := c.Blocks() - c.Sort(blocks) - for _, from := range blocks { - succs := c.Succs(from) - c.Sort(succs) - for _, to := range succs { - fmt.Fprintf(f, "\t\"%s\" -> \"%s\"\n", - c.printVertex(from, fset, addl(from)), - c.printVertex(to, fset, addl(to))) - } - } - fmt.Fprintf(f, "}\n") -} - -func (c *CFG) printVertex(stmt ast.Stmt, fset *token.FileSet, addl string) string { - switch stmt { - case c.Entry: - return "ENTRY" - case c.Exit: - return "EXIT" - case nil: - return "" - } - addl = strings.Replace(addl, "\n", "\\n", -1) - if addl != "" { - addl = "\\n" + addl - } - return fmt.Sprintf("%s - line %d%s", - astutil.NodeDescription(stmt), - fset.Position(stmt.Pos()).Line, - addl) -} diff --git a/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go b/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go deleted file mode 100644 index fe68398530a..00000000000 --- a/gnovm/cmd/gno/coverage/analysis/cfg/cfg_test.go +++ /dev/null @@ -1,891 +0,0 @@ -package cfg - -import ( - "bytes" - "go/ast" - "go/parser" - "go/token" - "go/types" - "regexp" - "strings" - "testing" -) - -func TestFromStmts(t *testing.T) { - src := ` - package main - func main() { - x := 1 - if x > 0 { - x = 2 - } else { - x = 3 - } - for i := 0; i < 10; i++ { - x += i - } - } - ` - - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "src.go", src, 0) - if err != nil { - t.Fatal(err) - } - - var funcDecl *ast.FuncDecl - for _, decl := range node.Decls { - if fn, isFn := decl.(*ast.FuncDecl); isFn { - funcDecl = fn - break - } - } - - if funcDecl == nil { - t.Fatal("No function declaration found") - } - - cfgGraph := FromFunc(funcDecl) - - if cfgGraph.Entry == nil { - t.Errorf("Expected Entry node, got nil") - } - if cfgGraph.Exit == nil { - t.Errorf("Expected Exit node, got nil") - } - - blocks := cfgGraph.Blocks() - if len(blocks) == 0 { - t.Errorf("Expected some blocks, got none") - } - - for _, block := range blocks { - preds := cfgGraph.Preds(block) - succs := cfgGraph.Succs(block) - t.Logf("Block: %v, Preds: %v, Succs: %v", block, preds, succs) - } -} - -func TestCFG(t *testing.T) { - tests := []struct { - name string - src string - expectedBlocks int - }{ - { - name: "MultiStatementFunction", - src: ` - package main - func main() { - x := 1 - if x > 0 { - x = 2 - } else { - x = 3 - } - for i := 0; i < 10; i++ { - x += i - } - }`, - expectedBlocks: 10, - }, - { - name: "Switch", - src: ` - package main - func withSwitch(day string) int { - switch day { - case "Monday": - return 1 - case "Tuesday": - return 2 - case "Wednesday": - fallthrough - case "Thursday": - return 3 - case "Friday": - break - default: - return 0 - } - }`, - expectedBlocks: 15, - }, - { - name: "TypeSwitch", - src: ` - package main - type MyType int - func withTypeSwitch(i interface{}) int { - switch i.(type) { - case int: - return 1 - case MyType: - return 2 - default: - return 0 - } - return 0 - }`, - expectedBlocks: 11, - }, - { - name: "EmptyFunc", - src: ` - package main - func empty() {}`, - expectedBlocks: 2, - }, - { - name: "SingleStatementFunc", - src: ` - package main - func single() { - x := 1 - }`, - expectedBlocks: 3, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "src.go", tt.src, 0) - if err != nil { - t.Fatalf("failed to parse source: %v", err) - } - - var funcDecl *ast.FuncDecl - for _, decl := range node.Decls { - if fn, isFn := decl.(*ast.FuncDecl); isFn { - funcDecl = fn - break - } - } - - if funcDecl == nil { - t.Fatal("No function declaration found") - } - - cfgGraph := FromFunc(funcDecl) - - if cfgGraph.Entry == nil { - t.Error("Expected Entry node, got nil") - } - if cfgGraph.Exit == nil { - t.Error("Expected Exit node, got nil") - } - - blocks := cfgGraph.Blocks() - if len(blocks) != tt.expectedBlocks { - t.Errorf("Expected %d blocks, got %d", tt.expectedBlocks, len(blocks)) - } - - // Additional checks can be added here if needed - }) - } -} - -func TestPrintDot2(t *testing.T) { - src := ` -package main -func main() { - x := 1 - if x > 0 { - x = 2 - } else { - x = 3 - } - for i := 0; i < 10; i++ { - x += i - } -}` - - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "src.go", src, 0) - if err != nil { - t.Fatal(err) - } - - var funcDecl *ast.FuncDecl - for _, decl := range node.Decls { - if fn, isFn := decl.(*ast.FuncDecl); isFn { - funcDecl = fn - break - } - } - - if funcDecl == nil { - t.Fatal("No function declaration found") - } - - cfgGraph := FromFunc(funcDecl) - - var buf bytes.Buffer - cfgGraph.PrintDot(&buf, fset, func(n ast.Stmt) string { return "" }) - - output := buf.String() - expected := ` -digraph mgraph { - mode="heir"; - splines="ortho"; - - "ENTRY" -> "assignment - line 4" - "assignment - line 4" -> "if statement - line 5" - "if statement - line 5" -> "assignment - line 6" - "if statement - line 5" -> "assignment - line 8" - "assignment - line 6" -> "assignment - line 10" - "assignment - line 8" -> "assignment - line 10" - "for loop - line 10" -> "EXIT" - "for loop - line 10" -> "assignment - line 11" - "assignment - line 10" -> "for loop - line 10" - "increment statement - line 10" -> "for loop - line 10" - "assignment - line 11" -> "increment statement - line 10" -} -` - - if normalizeDotOutput(output) != normalizeDotOutput(expected) { - t.Errorf("Expected DOT output:\n%s\nGot:\n%s", expected, output) - } -} - -func normalizeDotOutput(dot string) string { - lines := strings.Split(dot, "\n") - var normalized []string - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - normalized = append(normalized, trimmed) - } - } - return strings.Join(normalized, "\n") -} - -// ref: https://github.com/godoctor/godoctor/blob/master/analysis/cfg/cfg_test.go#L500 - -const ( - START = 0 - END = 100000000 // if there's this many statements, may god have mercy on your soul -) - -func TestBlockStmt(t *testing.T) { - c := getWrapper(t, ` -package main - -func foo(i int) { - { - { - bar(i) //1 - } - } -} -func bar(i int) {}`) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, END) -} - -func TestIfElseIfGoto(t *testing.T) { - c := getWrapper(t, ` - package main - - func main() { - i := 5 //1 - i++ //2 - if i == 6 { //3 - goto ABC //4 - } else if i == 8 { //5 - goto DEF //6 - } - ABC: fmt.Println("6") //7, 8 - DEF: fmt.Println("8") //9, 10 - }`) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2) - c.expectSuccs(t, 2, 3) - c.expectSuccs(t, 3, 4, 5) - c.expectSuccs(t, 4, 7) - c.expectSuccs(t, 5, 6, 7) - c.expectSuccs(t, 6, 9) - c.expectSuccs(t, 7, 8) - c.expectSuccs(t, 8, 9) - c.expectSuccs(t, 9, 10) -} - -func TestDoubleForBreak(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - for { //1 - for { //2 - break //3 - } - } - print("this") //4 - //END - }`) - - // t, stmt, ...successors - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2, 4) - c.expectSuccs(t, 2, 3, 1) - c.expectSuccs(t, 3, 1) - - c.expectPreds(t, 3, 2) - c.expectPreds(t, 4, 1) - c.expectPreds(t, END, 4) -} - -func TestFor(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - for i := 0; i < c; i++ { // 2, 1, 3 - println(i) //4 - } - println(c) //5 - //END - }`) - - c.expectSuccs(t, START, 2) - c.expectSuccs(t, 2, 1) - c.expectSuccs(t, 1, 4, 5) - c.expectSuccs(t, 4, 3) - c.expectSuccs(t, 3, 1) - - c.expectPreds(t, 5, 1) - c.expectPreds(t, END, 5) -} - -func TestForContinue(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - for i := 0; i < c; i++ { // 2, 1, 3 - println(i) // 4 - if i > 1 { // 5 - continue // 6 - } else { - break // 7 - } - } - println(c) // 8 - //END - }`) - - c.expectSuccs(t, START, 2) - c.expectSuccs(t, 2, 1) - c.expectSuccs(t, 1, 4, 8) - c.expectSuccs(t, 6, 3) - c.expectSuccs(t, 3, 1) - c.expectSuccs(t, 7, 8) - - c.expectPreds(t, END, 8) -} - -func TestIfElse(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - if c := 1; c > 0 { // 2, 1 - print("there") // 3 - } else { - print("nowhere") // 4 - } - //END - }`) - - c.expectSuccs(t, START, 2) - c.expectSuccs(t, 2, 1) - c.expectSuccs(t, 1, 3, 4) - - c.expectPreds(t, 4, 1) - c.expectPreds(t, END, 4, 3) -} - -func TestIfNoElse(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - if c > 0 && true { // 1 - println("here") // 2 - } - print("there") // 3 - //END - } - `) - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2, 3) - - c.expectPreds(t, 3, 1, 2) - c.expectPreds(t, END, 3) -} - -func TestIfElseIf(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - if c > 0 { //1 - println("here") //2 - } else if c == 0 { //3 - println("there") //4 - } else { - println("everywhere") //5 - } - print("almost end") //6 - //END - }`) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2, 3) - c.expectSuccs(t, 2, 6) - c.expectSuccs(t, 3, 4, 5) - c.expectSuccs(t, 4, 6) - c.expectSuccs(t, 5, 6) - - c.expectPreds(t, 6, 5, 4, 2) -} - -func TestDefer(t *testing.T) { - c := getWrapper(t, ` -package main - -func foo() { - //START - print("this") //1 - defer print("one") //2 - if 1 != 0 { //3 - defer print("two") //4 - return //5 - } - print("that") //6 - defer print("three") //7 - return //8 - //END -} -`) - c.expectSuccs(t, 3, 5, 6) - c.expectSuccs(t, 5, END) - - c.expectPreds(t, 8, 6) - c.expectDefers(t, 2, 4, 7) -} - -func TestRange(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo() { - //START - c := []int{1, 2, 3} //1 - lbl: //2 - for i, v := range c { //3 - for j, k := range c { //4 - if i == j { //5 - break //6 - } - print(i*i) //7 - break lbl //8 - } - } - //END - } - `) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2) - c.expectSuccs(t, 2, 3) - c.expectSuccs(t, 3, 4, END) - c.expectSuccs(t, 4, 5, 3) - c.expectSuccs(t, 6, 3) - c.expectSuccs(t, 8, END) - - c.expectPreds(t, END, 8, 3) -} - -func TestTypeSwitchDefault(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(s ast.Stmt) { - //START - switch s.(type) { // 1, 2 - case *ast.AssignStmt: //3 - print("assign") //4 - case *ast.ForStmt: //5 - print("for") //6 - default: //7 - print("default") //8 - } - //END - } - `) - - c.expectSuccs(t, 2, 3, 5, 7) - - c.expectPreds(t, END, 8, 6, 4) -} - -func TestTypeSwitchNoDefault(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(s ast.Stmt) { - //START - switch x := 1; s := s.(type) { // 2, 1, 3 - case *ast.AssignStmt: // 4 - print("assign") // 5 - case *ast.ForStmt: // 6 - print("for") // 7 - } - //END - } -`) - - c.expectSuccs(t, START, 2) - c.expectSuccs(t, 2, 1) - c.expectSuccs(t, 1, 3) - c.expectSuccs(t, 3, 4, 6, END) -} - -func TestSwitch(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - print("hi") //1 - switch c+=1; c { //2, 3 - case 1: //4 - print("one") //5 - fallthrough //6 - case 2: //7 - break //8 - print("two") //9 - case 3: //10 - case 4: //11 - if i > 3 { //12 - print("> 3") //13 - } else { - print("< 3") //14 - } - default: //15 - print("done") //16 - } - //END - } - `) - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 3) - c.expectSuccs(t, 3, 2) - c.expectSuccs(t, 2, 4, 7, 10, 11, 15) - - c.expectPreds(t, END, 16, 14, 13, 10, 9, 8) -} - -func TestLabeledFallthrough(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - switch c { //1 - case 1: //2 - print("one") //3 - goto lbl //4 - case 2: //5 - print("two") //6 - lbl: //7 - mlbl: //8 - fallthrough //9 - default: //10 - print("number") //11 - } - //END - }`) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2, 5, 10) - c.expectSuccs(t, 4, 7) - c.expectSuccs(t, 7, 8) - c.expectSuccs(t, 8, 9) - c.expectSuccs(t, 9, 11) - c.expectSuccs(t, 10, 11) - - c.expectPreds(t, END, 11) -} - -func TestSelectDefault(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - //START - ch := make(chan int) // 1 - - // go func() { // 2 - // for i := 0; i < c; i++ { // 4, 3, 5 - // ch <- c // 6 - // } - // }() - - select { // 2 - case got := <- ch: // 3, 4 - print(got) // 5 - default: // 6 - print("done") // 7 - } - //END - }`) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2) - c.expectSuccs(t, 2, 3, 6) - c.expectSuccs(t, 3, 4) - c.expectSuccs(t, 4, 5) - - c.expectPreds(t, END, 5, 7) -} - -func TestDietyExistence(t *testing.T) { - c := getWrapper(t, ` - package main - - func foo(c int) { - b := 7 // 1 - hello: // 2 - for c < b { // 3 - for { // 4 - if c&2 == 2 { // 5 - continue hello // 6 - println("even") // 7 - } else if c&1 == 1 { // 8 - defer println("sup") // 9 - println("odd") // 10 - break // 11 - } else { - println("something wrong") // 12 - goto ending // 13 - } - println("something") // 14 - } - println("poo") // 15 - } - println("hello") // 16 - ending: // 17 - } - `) - - c.expectSuccs(t, START, 1) - c.expectSuccs(t, 1, 2) - c.expectSuccs(t, 2, 3) - c.expectSuccs(t, 3, 4, 16) - c.expectSuccs(t, 4, 5, 15) - c.expectSuccs(t, 5, 6, 8) - c.expectSuccs(t, 6, 3) - c.expectSuccs(t, 7, 14) - c.expectSuccs(t, 8, 10, 12) - - c.expectDefers(t, 9) - - c.expectSuccs(t, 10, 11) - c.expectSuccs(t, 11, 15) - c.expectSuccs(t, 12, 13) - c.expectSuccs(t, 13, 17) - c.expectSuccs(t, 14, 4) - c.expectSuccs(t, 15, 3) - c.expectSuccs(t, 16, 17) -} - -// lo and behold how it's done -- caution: disgust may ensue -type CFGWrapper struct { - cfg *CFG - exp map[int]ast.Stmt - stmts map[ast.Stmt]int - info *types.Info - fset *token.FileSet - f *ast.File -} - -// uses first function in given string to produce CFG -// w/ some other convenient fields for printing in test -// cases when need be... -func getWrapper(t *testing.T, str string) *CFGWrapper { - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, "", str, 0) - if err != nil { - t.Error(err.Error()) - t.FailNow() - return nil - } - - conf := types.Config{Importer: nil} - info := &types.Info{ - Types: make(map[ast.Expr]types.TypeAndValue), - Defs: make(map[*ast.Ident]types.Object), - Uses: make(map[*ast.Ident]types.Object), - } - conf.Check("test", fset, []*ast.File{f}, info) - - cfg := FromFunc(f.Decls[0].(*ast.FuncDecl)) - v := make(map[int]ast.Stmt) - stmts := make(map[ast.Stmt]int) - i := 1 - - ast.Inspect(f.Decls[0].(*ast.FuncDecl), func(n ast.Node) bool { - switch x := n.(type) { - case ast.Stmt: - switch x.(type) { - case *ast.BlockStmt: - return true - } - v[i] = x - stmts[x] = i - i++ - } - return true - }) - v[END] = cfg.Exit - v[START] = cfg.Entry - if len(v) != len(cfg.blocks)+len(cfg.Defers) { - t.Logf("expected %d vertices, got %d --construction error", len(v), len(cfg.blocks)) - } - return &CFGWrapper{cfg, v, stmts, info, fset, f} -} - -func (c *CFGWrapper) expIntsToStmts(args []int) map[ast.Stmt]struct{} { - stmts := make(map[ast.Stmt]struct{}) - for _, a := range args { - stmts[c.exp[a]] = struct{}{} - } - return stmts -} - -// give generics -func expectFromMaps(actual, exp map[ast.Stmt]struct{}) (dnf, found map[ast.Stmt]struct{}) { - for stmt := range exp { - if _, ok := actual[stmt]; ok { - delete(exp, stmt) - delete(actual, stmt) - } - } - - return exp, actual -} - -func (c *CFGWrapper) expectDefers(t *testing.T, exp ...int) { - actualDefers := make(map[ast.Stmt]struct{}) - for _, d := range c.cfg.Defers { - actualDefers[d] = struct{}{} - } - - expDefers := c.expIntsToStmts(exp) - dnf, found := expectFromMaps(actualDefers, expDefers) - - for stmt := range dnf { - t.Error("did not find", c.stmts[stmt], "in defers") - } - - for stmt := range found { - t.Error("found", c.stmts[stmt], "as a defer") - } -} - -func (c *CFGWrapper) expectSuccs(t *testing.T, s int, exp ...int) { - if _, ok := c.cfg.blocks[c.exp[s]]; !ok { - t.Error("did not find parent", s) - return - } - - // get successors for stmt s as slice, put in map - actualSuccs := make(map[ast.Stmt]struct{}) - for _, v := range c.cfg.Succs(c.exp[s]) { - actualSuccs[v] = struct{}{} - } - - expSuccs := c.expIntsToStmts(exp) - dnf, found := expectFromMaps(actualSuccs, expSuccs) - - for stmt := range dnf { - t.Error("did not find", c.stmts[stmt], "in successors for", s) - } - - for stmt := range found { - t.Error("found", c.stmts[stmt], "as a successor for", s) - } -} - -func (c *CFGWrapper) expectPreds(t *testing.T, s int, exp ...int) { - if _, ok := c.cfg.blocks[c.exp[s]]; !ok { - t.Error("did not find parent", s) - } - - // get predecessors for stmt s as slice, put in map - actualPreds := make(map[ast.Stmt]struct{}) - for _, v := range c.cfg.Preds(c.exp[s]) { - actualPreds[v] = struct{}{} - } - - expPreds := c.expIntsToStmts(exp) - dnf, found := expectFromMaps(actualPreds, expPreds) - - for stmt := range dnf { - t.Error("did not find", c.stmts[stmt], "in predecessors for", s) - } - - for stmt := range found { - t.Error("found", c.stmts[stmt], "as a predecessor for", s) - } -} - -func TestPrintDot(t *testing.T) { - c := getWrapper(t, ` - package main - - func main() { - i := 5 //1 - i++ //2 - }`) - - var buf bytes.Buffer - c.cfg.PrintDot(&buf, c.fset, func(s ast.Stmt) string { - if _, ok := s.(*ast.AssignStmt); ok { - return "!" - } else { - return "" - } - }) - dot := buf.String() - - expected := []string{ - `^digraph mgraph { -mode="heir"; -splines="ortho"; - -`, - "\"assignment - line 5\\\\n!\" -> \"increment statement - line 6\"\n", - "\"ENTRY\" -> \"assignment - line 5\\\\n!\"\n", - "\"increment statement - line 6\" -> \"EXIT\"\n", - } - // The order of the three lines may vary (they're from a map), so - // just make sure all three lines appear somewhere - for _, re := range expected { - ok, _ := regexp.MatchString(re, dot) - if !ok { - t.Fatalf("[%s]", dot) - } - } -} diff --git a/gnovm/cmd/gno/coverage/checker/branch.go b/gnovm/cmd/gno/coverage/checker/branch.go index 88d533c3f83..0ca25f5593b 100644 --- a/gnovm/cmd/gno/coverage/checker/branch.go +++ b/gnovm/cmd/gno/coverage/checker/branch.go @@ -48,6 +48,7 @@ func (bc *BranchCoverage) identifyBranch(n ast.Node) { switch stmt := n.(type) { case *ast.IfStmt: bc.addBranch(stmt.If) + bc.handleComplexCondition(stmt.Cond) if stmt.Else != nil { switch elseStmt := stmt.Else.(type) { case *ast.BlockStmt: @@ -67,6 +68,16 @@ func (bc *BranchCoverage) identifyBranch(n ast.Node) { bc.addBranch(cc.Pos()) } } + case *ast.FuncDecl: + bc.addBranch(stmt.Body.Lbrace) + case *ast.ForStmt: + if stmt.Cond != nil { + bc.addBranch(stmt.Cond.Pos()) + } + case *ast.RangeStmt: + bc.addBranch(stmt.For) + case *ast.DeferStmt: + bc.addBranch(stmt.Defer) } } @@ -76,6 +87,25 @@ func (bc *BranchCoverage) addBranch(pos token.Pos) { bc.log("Branch added at offset %d", offset) } +func (bc *BranchCoverage) handleComplexCondition(expr ast.Expr) { + switch e := expr.(type) { + case *ast.BinaryExpr: + if e.Op == token.LAND || e.Op == token.LOR { + bc.addBranch(e.X.Pos()) + bc.addBranch(e.Y.Pos()) + bc.handleComplexCondition(e.X) + bc.handleComplexCondition(e.Y) + } + case *ast.ParenExpr: + bc.handleComplexCondition(e.X) + case *ast.UnaryExpr: + if e.Op == token.NOT { + bc.addBranch(e.X.Pos()) + bc.handleComplexCondition(e.X) + } + } +} + func (bc *BranchCoverage) Instrument(file *std.MemFile) *std.MemFile { astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors) if err != nil { diff --git a/gnovm/cmd/gno/coverage/checker/branch_test.go b/gnovm/cmd/gno/coverage/checker/branch_test.go index a0e9f149cf6..277938b6689 100644 --- a/gnovm/cmd/gno/coverage/checker/branch_test.go +++ b/gnovm/cmd/gno/coverage/checker/branch_test.go @@ -30,9 +30,9 @@ func test(x int) int { `, }, }, - // total branch added offset: 39, 63 + // total branch added offset: 36, 39, 63 executeBranches: []int{39}, - expectedCoverage: 0.5, // 1/2 + expectedCoverage: 0.33, }, { name: "If statement with else", @@ -51,8 +51,9 @@ func test(x int) int { }`, }, }, + // total branch added offset: 36, 39, 69 executeBranches: []int{39, 69}, - expectedCoverage: 1.0, + expectedCoverage: 0.67, // 2/3 }, { name: "Nested if statement", @@ -75,9 +76,9 @@ func test(x int) int { `, }, }, - // total branch added offset: 39, 106, 52, 85 + // total branch added offset: 36, 39, 106, 52, 85 executeBranches: []int{39, 52, 85}, - expectedCoverage: 0.75, // 3/4 + expectedCoverage: 0.60, // 3/5 }, { name: "Multiple conditions", @@ -96,9 +97,9 @@ func test(x int, y int) int { `, }, }, - // total branch added offset: 46, 83 + // total branch added offset: 43, 46, 49, 58 83 executeBranches: []int{46}, - expectedCoverage: 0.5, // 1/2 + expectedCoverage: 0.20, // 1/5 }, { name: "Switch statement", @@ -121,9 +122,99 @@ func test(x int) int { `, }, }, + // total branch added offset: 36, 51, 71, 91 executeBranches: []int{51, 71, 91}, + expectedCoverage: 0.75, // 3/4 + }, + { + name: "Function coverage", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func foo() int { + return 1 +} + +func bar() int { + return 2 +} + +func main() { + foo() +} +`, + }, + }, + // total branch added offset: 30, 63, 93 + executeBranches: []int{30, 63}, + expectedCoverage: 0.67, + }, + { + name: "For loop", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test() int { + sum := 0 + for i := 0; i < 5; i++ { + sum += i + } + return sum +} +`, + }, + }, + executeBranches: []int{31, 62}, // 함수 시작과 for 루프 조건 expectedCoverage: 1, }, + { + name: "Range loop", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test() int { + numbers := []int{1, 2, 3, 4, 5} + sum := 0 + for _, num := range numbers { + sum += num + } + return sum +} +`, + }, + }, + executeBranches: []int{31, 86}, + expectedCoverage: 1.0, + }, + { + name: "Defer statement", + files: []*std.MemFile{ + { + Name: "test.go", + Body: ` +package main + +func test() { + defer func() { + recover() + }() + panic("test panic") +} +`, + }, + }, + executeBranches: []int{27, 33}, + expectedCoverage: 1.0, + }, } for _, tt := range tests { diff --git a/gnovm/cmd/gno/coverage/checker/statement.go b/gnovm/cmd/gno/coverage/checker/statement.go index 77b62bf2dcc..c4b8efe0ae8 100644 --- a/gnovm/cmd/gno/coverage/checker/statement.go +++ b/gnovm/cmd/gno/coverage/checker/statement.go @@ -24,7 +24,7 @@ func NewStatementCoverage(files []*std.MemFile) *StatementCoverage { covered: make(map[token.Pos]bool), files: make(map[string]*ast.File), fset: token.NewFileSet(), - debug: true, + // debug: true, } for _, file := range files { diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 9aebe9d1ec6..a8423eab335 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -38,7 +38,7 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool withNativeFallback bool - withCoverage bool + withCoverage bool } func newTestCmd(io commands.IO) *commands.Command { From f9e44cea6d218ae94c2ae9c7186a1637481e0e11 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 29 Aug 2024 13:08:43 +0900 Subject: [PATCH 06/37] revert --- gnovm/cmd/gno/coverage/checker/branch.go | 206 --------------- gnovm/cmd/gno/coverage/checker/branch_test.go | 238 ------------------ gnovm/cmd/gno/coverage/checker/statement.go | 147 ----------- .../gno/coverage/checker/statement_test.go | 94 ------- gnovm/cmd/gno/test.go | 10 - 5 files changed, 695 deletions(-) delete mode 100644 gnovm/cmd/gno/coverage/checker/branch.go delete mode 100644 gnovm/cmd/gno/coverage/checker/branch_test.go delete mode 100644 gnovm/cmd/gno/coverage/checker/statement.go delete mode 100644 gnovm/cmd/gno/coverage/checker/statement_test.go diff --git a/gnovm/cmd/gno/coverage/checker/branch.go b/gnovm/cmd/gno/coverage/checker/branch.go deleted file mode 100644 index 0ca25f5593b..00000000000 --- a/gnovm/cmd/gno/coverage/checker/branch.go +++ /dev/null @@ -1,206 +0,0 @@ -package checker - -import ( - "bytes" - "fmt" - "go/ast" - "go/parser" - "go/printer" - "go/token" - - "github.com/gnolang/gno/tm2/pkg/std" - "golang.org/x/tools/go/ast/astutil" -) - -type Branch struct { - Pos token.Pos - Taken bool - Offset int -} - -type BranchCoverage struct { - branches map[int]*Branch - fset *token.FileSet - debug bool -} - -func NewBranchCoverage(files []*std.MemFile) *BranchCoverage { - bc := &BranchCoverage{ - branches: make(map[int]*Branch), - fset: token.NewFileSet(), - debug: true, - } - - for _, file := range files { - if astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors); err == nil { - ast.Inspect(astFile, func(n ast.Node) bool { - bc.identifyBranch(n) - return true - }) - } - } - - bc.log("Total branches identified: %d", len(bc.branches)) - return bc -} - -func (bc *BranchCoverage) identifyBranch(n ast.Node) { - switch stmt := n.(type) { - case *ast.IfStmt: - bc.addBranch(stmt.If) - bc.handleComplexCondition(stmt.Cond) - if stmt.Else != nil { - switch elseStmt := stmt.Else.(type) { - case *ast.BlockStmt: - bc.addBranch(elseStmt.Pos()) - case *ast.IfStmt: - bc.addBranch(elseStmt.If) - } - } else { - // implicit else branch - bc.addBranch(stmt.Body.End()) - } - case *ast.CaseClause: - bc.addBranch(stmt.Pos()) - case *ast.SwitchStmt: - for _, s := range stmt.Body.List { - if cc, ok := s.(*ast.CaseClause); ok { - bc.addBranch(cc.Pos()) - } - } - case *ast.FuncDecl: - bc.addBranch(stmt.Body.Lbrace) - case *ast.ForStmt: - if stmt.Cond != nil { - bc.addBranch(stmt.Cond.Pos()) - } - case *ast.RangeStmt: - bc.addBranch(stmt.For) - case *ast.DeferStmt: - bc.addBranch(stmt.Defer) - } -} - -func (bc *BranchCoverage) addBranch(pos token.Pos) { - offset := bc.fset.Position(pos).Offset - bc.branches[offset] = &Branch{Pos: pos, Taken: false, Offset: offset} - bc.log("Branch added at offset %d", offset) -} - -func (bc *BranchCoverage) handleComplexCondition(expr ast.Expr) { - switch e := expr.(type) { - case *ast.BinaryExpr: - if e.Op == token.LAND || e.Op == token.LOR { - bc.addBranch(e.X.Pos()) - bc.addBranch(e.Y.Pos()) - bc.handleComplexCondition(e.X) - bc.handleComplexCondition(e.Y) - } - case *ast.ParenExpr: - bc.handleComplexCondition(e.X) - case *ast.UnaryExpr: - if e.Op == token.NOT { - bc.addBranch(e.X.Pos()) - bc.handleComplexCondition(e.X) - } - } -} - -func (bc *BranchCoverage) Instrument(file *std.MemFile) *std.MemFile { - astFile, err := parser.ParseFile(bc.fset, file.Name, file.Body, parser.AllErrors) - if err != nil { - bc.log("Error parsing file %s: %v", file.Name, err) - return file - } - - astutil.Apply(astFile, func(c *astutil.Cursor) bool { - n := c.Node() - switch stmt := n.(type) { - case *ast.IfStmt: - bc.instrumentIfStmt(stmt) - case *ast.CaseClause: - bc.instrumentCaseClause(stmt) - } - return true - }, nil) - - var buf bytes.Buffer - if err := printer.Fprint(&buf, bc.fset, astFile); err != nil { - bc.log("Error printing instrumented file: %v", err) - return file - } - - return &std.MemFile{ - Name: file.Name, - Body: buf.String(), - } -} - -func (bc *BranchCoverage) instrumentIfStmt(ifStmt *ast.IfStmt) { - bc.insertMarkBranchStmt(ifStmt.Body, ifStmt.If) - if ifStmt.Else != nil { - switch elseStmt := ifStmt.Else.(type) { - case *ast.BlockStmt: - bc.insertMarkBranchStmt(elseStmt, ifStmt.Else.Pos()) - case *ast.IfStmt: - bc.instrumentIfStmt(elseStmt) - } - } -} - -func (bc *BranchCoverage) instrumentCaseClause(caseClause *ast.CaseClause) { - bc.insertMarkBranchStmt(&ast.BlockStmt{List: caseClause.Body}, caseClause.Pos()) -} - -func (bc *BranchCoverage) insertMarkBranchStmt(block *ast.BlockStmt, pos token.Pos) { - offset := bc.fset.Position(pos).Offset - bc.log("Inserting branch mark at offset %d", offset) - markStmt := &ast.ExprStmt{ - X: &ast.CallExpr{ - Fun: &ast.SelectorExpr{ - X: ast.NewIdent("bc"), - Sel: ast.NewIdent("MarkBranchTaken"), - }, - Args: []ast.Expr{ - &ast.BasicLit{ - Kind: token.INT, - Value: fmt.Sprintf("%d", offset), - }, - }, - }, - } - block.List = append([]ast.Stmt{markStmt}, block.List...) -} - -func (bc *BranchCoverage) MarkBranchTaken(offset int) { - if branch, exists := bc.branches[offset]; exists { - branch.Taken = true - bc.log("Branch taken at offset %d", offset) - } else { - bc.log("No branch found at offset %d", offset) - } -} - -func (bc *BranchCoverage) CalculateCoverage() float64 { - total := len(bc.branches) - if total == 0 { - return 0 - } - - taken := 0 - for _, branch := range bc.branches { - if branch.Taken { - taken++ - } - } - - coverage := float64(taken) / float64(total) - bc.log("Total branches: %d, Taken: %d, Coverage: %.2f", total, taken, coverage) - return coverage -} - -func (bc *BranchCoverage) log(format string, args ...interface{}) { - if bc.debug { - fmt.Printf(format+"\n", args...) - } -} diff --git a/gnovm/cmd/gno/coverage/checker/branch_test.go b/gnovm/cmd/gno/coverage/checker/branch_test.go deleted file mode 100644 index 277938b6689..00000000000 --- a/gnovm/cmd/gno/coverage/checker/branch_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package checker - -import ( - "testing" - - "github.com/gnolang/gno/tm2/pkg/std" -) - -func TestBranchCoverage(t *testing.T) { - tests := []struct { - name string - files []*std.MemFile - executeBranches []int - expectedCoverage float64 - }{ - { - name: "Simple if statement", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test(x int) int { - if x > 0 { - return x - } - return -x -} -`, - }, - }, - // total branch added offset: 36, 39, 63 - executeBranches: []int{39}, - expectedCoverage: 0.33, - }, - { - name: "If statement with else", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test(x int) int { - if x > 0 { - return x - } else { - return -x - } -}`, - }, - }, - // total branch added offset: 36, 39, 69 - executeBranches: []int{39, 69}, - expectedCoverage: 0.67, // 2/3 - }, - { - name: "Nested if statement", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test(x int) int { - if x > 0 { - if x < 10 { - return x - } else { - return 10 - } - } - return -x -} -`, - }, - }, - // total branch added offset: 36, 39, 106, 52, 85 - executeBranches: []int{39, 52, 85}, - expectedCoverage: 0.60, // 3/5 - }, - { - name: "Multiple conditions", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test(x int, y int) int { - if x > 0 && y > 0 { - return x + y - } - return -x -} -`, - }, - }, - // total branch added offset: 43, 46, 49, 58 83 - executeBranches: []int{46}, - expectedCoverage: 0.20, // 1/5 - }, - { - name: "Switch statement", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test(x int) int { - switch x { - case 1: - return 1 - case 2: - return 2 - default: - return 0 - } -} -`, - }, - }, - // total branch added offset: 36, 51, 71, 91 - executeBranches: []int{51, 71, 91}, - expectedCoverage: 0.75, // 3/4 - }, - { - name: "Function coverage", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func foo() int { - return 1 -} - -func bar() int { - return 2 -} - -func main() { - foo() -} -`, - }, - }, - // total branch added offset: 30, 63, 93 - executeBranches: []int{30, 63}, - expectedCoverage: 0.67, - }, - { - name: "For loop", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test() int { - sum := 0 - for i := 0; i < 5; i++ { - sum += i - } - return sum -} -`, - }, - }, - executeBranches: []int{31, 62}, // 함수 시작과 for 루프 조건 - expectedCoverage: 1, - }, - { - name: "Range loop", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test() int { - numbers := []int{1, 2, 3, 4, 5} - sum := 0 - for _, num := range numbers { - sum += num - } - return sum -} -`, - }, - }, - executeBranches: []int{31, 86}, - expectedCoverage: 1.0, - }, - { - name: "Defer statement", - files: []*std.MemFile{ - { - Name: "test.go", - Body: ` -package main - -func test() { - defer func() { - recover() - }() - panic("test panic") -} -`, - }, - }, - executeBranches: []int{27, 33}, - expectedCoverage: 1.0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bc := NewBranchCoverage(tt.files) - - for i, file := range tt.files { - tt.files[i] = bc.Instrument(file) - } - - for _, offset := range tt.executeBranches { - bc.MarkBranchTaken(offset) - } - - coverage := bc.CalculateCoverage() - if !almostEqual(coverage, tt.expectedCoverage, 0.01) { - t.Errorf("Expected coverage %f, but got %f", tt.expectedCoverage, coverage) - } - }) - } -} diff --git a/gnovm/cmd/gno/coverage/checker/statement.go b/gnovm/cmd/gno/coverage/checker/statement.go deleted file mode 100644 index c4b8efe0ae8..00000000000 --- a/gnovm/cmd/gno/coverage/checker/statement.go +++ /dev/null @@ -1,147 +0,0 @@ -package checker - -import ( - "bytes" - "fmt" - "go/ast" - "go/parser" - "go/printer" - "go/token" - - "github.com/gnolang/gno/tm2/pkg/std" - "golang.org/x/tools/go/ast/astutil" -) - -type StatementCoverage struct { - covered map[token.Pos]bool - files map[string]*ast.File - fset *token.FileSet - debug bool -} - -func NewStatementCoverage(files []*std.MemFile) *StatementCoverage { - sc := &StatementCoverage{ - covered: make(map[token.Pos]bool), - files: make(map[string]*ast.File), - fset: token.NewFileSet(), - // debug: true, - } - - for _, file := range files { - sc.log("parsing file: %s", file.Name) - if astFile, err := parser.ParseFile(sc.fset, file.Name, file.Body, parser.AllErrors); err == nil { - sc.files[file.Name] = astFile - ast.Inspect(astFile, func(n ast.Node) bool { - if stmt, ok := n.(ast.Stmt); ok { - switch stmt.(type) { - case *ast.BlockStmt: - // Skip block statements - default: - sc.covered[stmt.Pos()] = false - } - } - return true - }) - } else { - sc.log("error parsing file %s: %v", file.Name, err) - } - } - - sc.log("total statements found: %d", len(sc.covered)) - return sc -} - -func (sc *StatementCoverage) Instrument(file *std.MemFile) *std.MemFile { - sc.log("instrumenting file: %s", file.Name) - astFile, ok := sc.files[file.Name] - if !ok { - return file - } - - instrumentedFile := astutil.Apply(astFile, nil, func(c *astutil.Cursor) bool { - node := c.Node() - if stmt, ok := node.(ast.Stmt); ok { - if _, exists := sc.covered[stmt.Pos()]; exists { - pos := sc.fset.Position(stmt.Pos()) - sc.log("instrumenting statement at %s:%d%d", pos.Filename, pos.Line, pos.Column) - markStmt := &ast.ExprStmt{ - X: &ast.CallExpr{ - Fun: &ast.SelectorExpr{ - X: ast.NewIdent("sc"), - Sel: ast.NewIdent("MarkCovered"), - }, - Args: []ast.Expr{ - &ast.BasicLit{ - Kind: token.INT, - Value: fmt.Sprintf("%d", sc.fset.Position(stmt.Pos()).Offset), - }, - }, - }, - } - - switch s := stmt.(type) { - case *ast.BlockStmt: - s.List = append([]ast.Stmt{markStmt}, s.List...) - case *ast.ForStmt: - if s.Body != nil { - s.Body.List = append([]ast.Stmt{markStmt}, s.Body.List...) - } - default: - c.Replace(&ast.BlockStmt{ - List: []ast.Stmt{markStmt, stmt}, - }) - } - } - } - return true - }) - - var buf bytes.Buffer - if err := printer.Fprint(&buf, sc.fset, instrumentedFile); err != nil { - return file - } - - return &std.MemFile{ - Name: file.Name, - Body: buf.String(), - } -} - -func (sc *StatementCoverage) MarkCovered(pos token.Pos) { - for stmt := range sc.covered { - if stmt == pos { - filePos := sc.fset.Position(pos) - sc.log("marking covered: %s:%d:%d", filePos.Filename, filePos.Line, filePos.Column) - sc.covered[stmt] = true - return - } - } -} - -func (sc *StatementCoverage) CalculateCoverage() float64 { - total := len(sc.covered) - if total == 0 { - return 0 - } - - covered := 0 - for stmt, isCovered := range sc.covered { - pos := sc.fset.Position(stmt) - if isCovered { - sc.log("covered: %s:%d:%d", pos.Filename, pos.Line, pos.Column) - covered++ - } else { - sc.log("not covered: %s:%d:%d", pos.Filename, pos.Line, pos.Column) - } - } - - coverage := float64(covered) / float64(total) - sc.log("total statement: %d, covered: %d, coverage: %.2f", total, covered, coverage) - return coverage -} - -func (sc *StatementCoverage) log(format string, args ...interface{}) { - if sc.debug { - fmt.Printf(format+"\n", args...) - } -} diff --git a/gnovm/cmd/gno/coverage/checker/statement_test.go b/gnovm/cmd/gno/coverage/checker/statement_test.go deleted file mode 100644 index 2c30751d713..00000000000 --- a/gnovm/cmd/gno/coverage/checker/statement_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package checker - -import ( - "fmt" - "go/ast" - "math" - "testing" - - "github.com/gnolang/gno/tm2/pkg/std" -) - -func TestStatementCoverage(t *testing.T) { - tests := []struct { - name string - files []*std.MemFile - executeLines []int - expectedCoverage float64 - }{ - { - name: "Simple function", - files: []*std.MemFile{ - { - Name: "test.go", - Body: `package main // 1 - // 2 -func test() { // 3 - a := 1 // 4 - b := 2 // 5 - c := a + b // 6 -} // 7 -`, - }, - }, - executeLines: []int{4, 5, 6}, - expectedCoverage: 1.0, - }, - { - name: "Function with if statement", - files: []*std.MemFile{ - { - Name: "test.go", - Body: `package main // 1 - // 2 -func test(x int) int { // 3 - if x > 0 { // 4 - return x // 5 - } // 6 - return -x // 7 -} // 8 -`, - }, - }, - executeLines: []int{4, 5}, - expectedCoverage: 0.67, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sc := NewStatementCoverage(tt.files) - - // Instrument files - for i, file := range tt.files { - tt.files[i] = sc.Instrument(file) - } - - // Simulate execution by marking covered lines - for _, line := range tt.executeLines { - for _, file := range sc.files { - ast.Inspect(file, func(n ast.Node) bool { - if stmt, ok := n.(ast.Stmt); ok { - pos := sc.fset.Position(stmt.Pos()) - if pos.Line == line { - sc.MarkCovered(stmt.Pos()) - fmt.Printf("Marked line %d in file %s\n", line, file.Name) - return false - } - } - return true - }) - } - } - - coverage := sc.CalculateCoverage() - if !almostEqual(coverage, tt.expectedCoverage, 0.01) { - t.Errorf("Expected coverage %f, but got %f", tt.expectedCoverage, coverage) - } - }) - } -} - -func almostEqual(a, b, tolerance float64) bool { - return math.Abs(a-b) <= tolerance -} diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index a8423eab335..5884463a552 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -26,8 +26,6 @@ import ( "github.com/gnolang/gno/tm2/pkg/random" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/testutils" - - _ "github.com/gnolang/gno/gnovm/cmd/gno/coverage/checker" ) type testCfg struct { @@ -38,7 +36,6 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool withNativeFallback bool - withCoverage bool } func newTestCmd(io commands.IO) *commands.Command { @@ -152,13 +149,6 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { false, "print runtime metrics (gas, memory, cpu cycles)", ) - - fs.BoolVar( - &c.withCoverage, - "coverage", - false, - "print test coverage metrics", - ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { From fea30449bbb4a07c8482d0a8d37fc227b3e4dc40 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 29 Aug 2024 16:31:30 +0900 Subject: [PATCH 07/37] coverage type --- gnovm/pkg/gnolang/coverage.go | 81 +++++++++++++ gnovm/pkg/gnolang/coverage_test.go | 188 +++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 gnovm/pkg/gnolang/coverage.go create mode 100644 gnovm/pkg/gnolang/coverage_test.go diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go new file mode 100644 index 00000000000..38166fe881b --- /dev/null +++ b/gnovm/pkg/gnolang/coverage.go @@ -0,0 +1,81 @@ +package gnolang + +import ( + "fmt" + "sort" + "strings" +) + +type CoverageData struct { + Files map[string]*FileCoverage +} + +type FileCoverage struct { + Statements map[int]int // line number -> count +} + +func NewCoverageData() *CoverageData { + return &CoverageData{ + Files: make(map[string]*FileCoverage), + } +} + +func (c *CoverageData) AddHit(file string, line int) { + if c.Files[file] == nil { + c.Files[file] = &FileCoverage{ + Statements: make(map[int]int), + } + } + + c.Files[file].Statements[line]++ +} + +func (c *CoverageData) Report() string { + var report strings.Builder + report.WriteString("Coverage Report:\n") + report.WriteString("=================\n\n") + + var fileNames []string + for fileName := range c.Files { + fileNames = append(fileNames, fileName) + } + sort.Strings(fileNames) + + totalStatements := 0 + totalCovered := 0 + + for _, fileName := range fileNames { + fileCoverage := c.Files[fileName] + statements := len(fileCoverage.Statements) + covered := 0 + for _, count := range fileCoverage.Statements { + if count > 0 { + covered++ + } + } + + totalStatements += statements + totalCovered += covered + + percentage := calculateCoverage(covered, statements) + report.WriteString(fmt.Sprintf("%s:\n", fileName)) + report.WriteString(fmt.Sprintf(" Statements: %d\n", statements)) + report.WriteString(fmt.Sprintf(" Covered: %d\n", covered)) + report.WriteString(fmt.Sprintf(" Coverage: %.2f%%\n\n", percentage)) + } + + totalPercentage := calculateCoverage(totalCovered, totalStatements) + report.WriteString("Total Coverage:\n") + report.WriteString(fmt.Sprintf(" Statements: %d\n", totalStatements)) + report.WriteString(fmt.Sprintf(" Covered: %d\n", totalCovered)) + report.WriteString(fmt.Sprintf(" Coverage: %.2f%%\n", totalPercentage)) + + return report.String() +} + +func calculateCoverage(a, b int) float64 { + if a == 0 || b == 0 { + return 0.0 + } + return float64(a) / float64(b) * 100 +} diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go new file mode 100644 index 00000000000..6712f816f5e --- /dev/null +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -0,0 +1,188 @@ +package gnolang + +import ( + "reflect" + "testing" +) + +func TestNewCoverageData(t *testing.T) { + cd := NewCoverageData() + if cd == nil { + t.Error("NewCoverageData() returned nil") + } + if cd == nil || cd.Files == nil { + t.Error("NewCoverageData() did not initialize Files map") + } +} + +func TestCoverageDataAddHit(t *testing.T) { + tests := []struct { + name string + file string + line int + expected map[string]*FileCoverage + }{ + { + name: "Add hit to new file", + file: "test.go", + line: 10, + expected: map[string]*FileCoverage{ + "test.go": { + Statements: map[int]int{10: 1}, + }, + }, + }, + { + name: "Add hit to existing file", + file: "test.go", + line: 10, + expected: map[string]*FileCoverage{ + "test.go": { + Statements: map[int]int{10: 2}, + }, + }, + }, + { + name: "Add hit to new line in existing file", + file: "test.go", + line: 20, + expected: map[string]*FileCoverage{ + "test.go": { + Statements: map[int]int{10: 2, 20: 1}, + }, + }, + }, + } + + cd := NewCoverageData() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cd.AddHit(tt.file, tt.line) + if !reflect.DeepEqual(cd.Files, tt.expected) { + t.Errorf("AddHit() = %v, want %v", cd.Files, tt.expected) + } + }) + } +} + +func TestCoverageData(t *testing.T) { + tests := []struct { + name string + hits []struct { + file string + line int + } + wantReport string + }{ + { + name: "Empty coverage", + hits: []struct { + file string + line int + }{}, + wantReport: `Coverage Report: +================= + +Total Coverage: + Statements: 0 + Covered: 0 + Coverage: 0.00% +`, + }, + { + name: "Single file, single line", + hits: []struct { + file string + line int + }{ + {"file1.go", 10}, + }, + wantReport: `Coverage Report: +================= + +file1.go: + Statements: 1 + Covered: 1 + Coverage: 100.00% + +Total Coverage: + Statements: 1 + Covered: 1 + Coverage: 100.00% +`, + }, + { + name: "Multiple files, multiple lines", + hits: []struct { + file string + line int + }{ + {"file1.go", 10}, + {"file1.go", 20}, + {"file1.go", 10}, + {"file2.go", 5}, + {"file2.go", 15}, + }, + wantReport: `Coverage Report: +================= + +file1.go: + Statements: 2 + Covered: 2 + Coverage: 100.00% + +file2.go: + Statements: 2 + Covered: 2 + Coverage: 100.00% + +Total Coverage: + Statements: 4 + Covered: 4 + Coverage: 100.00% +`, + }, + { + name: "Partial coverage", + hits: []struct { + file string + line int + }{ + {"file1.go", 10}, + {"file1.go", 20}, + {"file2.go", 5}, + }, + wantReport: `Coverage Report: +================= + +file1.go: + Statements: 2 + Covered: 2 + Coverage: 100.00% + +file2.go: + Statements: 1 + Covered: 1 + Coverage: 100.00% + +Total Coverage: + Statements: 3 + Covered: 3 + Coverage: 100.00% +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cd := NewCoverageData() + for _, hit := range tt.hits { + cd.AddHit(hit.file, hit.line) + } + got := cd.Report() + if got != tt.wantReport { + t.Errorf("CoverageData.Report() =\n%v\nwant:\n%v", got, tt.wantReport) + } + }) + } +} From b43bd022f7400d2ea8463f1ef1c045824d7d4246 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 29 Aug 2024 17:03:29 +0900 Subject: [PATCH 08/37] something works --- gnovm/cmd/gno/test.go | 23 +++++++++++++++++++++-- gnovm/pkg/gnolang/machine.go | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 5884463a552..efaaefebaf3 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -36,6 +36,7 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool withNativeFallback bool + coverage bool } func newTestCmd(io commands.IO) *commands.Command { @@ -149,6 +150,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { false, "print runtime metrics (gas, memory, cpu cycles)", ) + + fs.BoolVar( + &c.coverage, + "cover", + false, + "enable coverage analysis", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -228,6 +236,7 @@ func gnoTestPkg( rootDir = cfg.rootDir runFlag = cfg.run printRuntimeMetrics = cfg.printRuntimeMetrics + coverage = cfg.coverage stdin = io.In() stdout = io.Out() @@ -295,7 +304,7 @@ func gnoTestPkg( m.Alloc = gno.NewAllocator(maxAllocTx) } m.RunMemPackage(memPkg, true) - err := runTestFiles(m, tfiles, memPkg.Name, verbose, printRuntimeMetrics, runFlag, io) + err := runTestFiles(m, tfiles, memPkg.Name, verbose, printRuntimeMetrics, coverage, runFlag, io) if err != nil { errs = multierr.Append(errs, err) } @@ -329,7 +338,7 @@ func gnoTestPkg( memPkg.Path = memPkg.Path + "_test" m.RunMemPackage(memPkg, true) - err := runTestFiles(m, ifiles, testPkgName, verbose, printRuntimeMetrics, runFlag, io) + err := runTestFiles(m, ifiles, testPkgName, verbose, printRuntimeMetrics, coverage, runFlag, io) if err != nil { errs = multierr.Append(errs, err) } @@ -419,6 +428,7 @@ func runTestFiles( pkgName string, verbose bool, printRuntimeMetrics bool, + coverage bool, runFlag string, io commands.IO, ) (errs error) { @@ -443,10 +453,14 @@ func runTestFiles( log.Fatal(err) } + coverageData := gno.NewCoverageData() + m.RunFiles(files.Files...) n := gno.MustParseFile("main_test.gno", testmain) m.RunFiles(n) + m.Coverage = coverageData + for _, test := range testFuncs.Tests { testFuncStr := fmt.Sprintf("%q", test.Name) @@ -492,6 +506,11 @@ func runTestFiles( allocsVal, ) } + + if coverage { + report := coverageData.Report() + io.Out().Write([]byte(report)) + } } return errs diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 24f94abc10b..d428264afd2 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -79,6 +79,9 @@ type Machine struct { // it is executed. It is reset to zero after the defer functions in the current // scope have finished executing. DeferPanicScope uint + + // Test Coverage + Coverage *CoverageData } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -177,6 +180,7 @@ func NewMachineWithOptions(opts MachineOptions) *Machine { mm.Debugger.enabled = opts.Debug mm.Debugger.in = opts.Input mm.Debugger.out = output + mm.Coverage = NewCoverageData() if pv != nil { mm.SetActivePackage(pv) @@ -1256,6 +1260,10 @@ func (m *Machine) Run() { m.Debug() } op := m.PopOp() + + loc := m.getCurrentLocation() + m.Coverage.AddHit(loc.PkgPath, loc.Line) + // TODO: this can be optimized manually, even into tiers. switch op { /* Control operators */ @@ -1582,6 +1590,23 @@ func (m *Machine) Run() { } } +func (m *Machine) getCurrentLocation() Location { + if len(m.Frames) == 0 { + return Location{} + } + + lastFrame := m.Frames[len(m.Frames)-1] + if lastFrame.Source == nil { + return Location{} + } + + return Location{ + PkgPath: m.Package.PkgPath, + Line: lastFrame.Source.GetLine(), + Column: lastFrame.Source.GetColumn(), + } +} + //---------------------------------------- // push pop methods. From 6cbd004d84dc84c6470f58bf564cb38413e2e0d3 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 29 Aug 2024 18:13:18 +0900 Subject: [PATCH 09/37] save --- gnovm/pkg/gnolang/coverage.go | 70 +++++++++++++++++++++++++++++------ gnovm/pkg/gnolang/machine.go | 14 +++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 38166fe881b..3843566bc58 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -8,28 +8,55 @@ import ( type CoverageData struct { Files map[string]*FileCoverage + Debug bool } type FileCoverage struct { - Statements map[int]int // line number -> count + Statements map[int]int // line number -> execution count + TotalLines int // total number of lines in the file + Content []string // each line's content } func NewCoverageData() *CoverageData { return &CoverageData{ Files: make(map[string]*FileCoverage), + Debug: true, } } -func (c *CoverageData) AddHit(file string, line int) { - if c.Files[file] == nil { +func (c *CoverageData) AddFile(file string, totalLines int) { + if _, exists := c.Files[file]; !exists { + c.Files[file] = &FileCoverage{ + TotalLines: totalLines, + Statements: make(map[int]int), + } + } +} + +func (c *CoverageData) AddFileContent(file string, content string) { + if fc, exists := c.Files[file]; exists { + fc.Content = strings.Split(content, "\n") + } else { c.Files[file] = &FileCoverage{ + TotalLines: strings.Count(content, "\n") + 1, Statements: make(map[int]int), + Content: strings.Split(content, "\n"), } } +} + +func (c *CoverageData) AddHit(file string, line int) { + if c.Files[file] == nil { + c.AddFile(file, line) + } c.Files[file].Statements[line]++ } +func (c *CoverageData) SetDebug(debug bool) { + c.Debug = debug +} + func (c *CoverageData) Report() string { var report strings.Builder report.WriteString("Coverage Report:\n") @@ -46,22 +73,41 @@ func (c *CoverageData) Report() string { for _, fileName := range fileNames { fileCoverage := c.Files[fileName] - statements := len(fileCoverage.Statements) + totalLines := fileCoverage.TotalLines + stmts := len(fileCoverage.Statements) covered := 0 - for _, count := range fileCoverage.Statements { + + var coveredLines []int + for line, count := range fileCoverage.Statements { if count > 0 { covered++ + coveredLines = append(coveredLines, line) } } - totalStatements += statements - totalCovered += covered + sort.Ints(coveredLines) + + percentage := float64(stmts) / float64(totalLines) * 100 + report.WriteString(fmt.Sprintf("%s:\n", fileName)) + report.WriteString(fmt.Sprintf(" Total Lines: %d\n", totalLines)) + report.WriteString(fmt.Sprintf(" Covered: %d\n", stmts)) + report.WriteString(fmt.Sprintf(" Coverage: %.2f%%\n\n", percentage)) + + totalStatements += totalLines + totalCovered += stmts + + if c.Debug { + report.WriteString(" Covered lines: ") + for i, line := range coveredLines { + if i > 0 { + report.WriteString(", ") + } + report.WriteString(fmt.Sprintf("%d", line)) + } + report.WriteString("\n") + } - percentage := calculateCoverage(covered, statements) - report.WriteString(fmt.Sprintf("%s:\n", fileName)) - report.WriteString(fmt.Sprintf(" Statements: %d\n", statements)) - report.WriteString(fmt.Sprintf(" Covered: %d\n", covered)) - report.WriteString(fmt.Sprintf(" Coverage: %.2f%%\n\n", percentage)) + report.WriteString("\n") } totalPercentage := calculateCoverage(totalCovered, totalStatements) diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index d428264afd2..e6269db1b8a 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + // "path/filepath" "reflect" "strings" "sync" @@ -267,6 +268,11 @@ func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { // and corresponding package node, package value, and types to store. Save // is set to false for tests where package values may be native. func (m *Machine) RunMemPackage(memPkg *std.MemPackage, save bool) (*PackageNode, *PackageValue) { + // for _, file := range memPkg.Files { + // if strings.HasSuffix(file.Name, ".gno") { + // m.AddFileContentToCodeCoverage(filepath.Join(memPkg.Path, file.Name), file.Body) + // } + // } return m.runMemPackage(memPkg, save, false) } @@ -1607,6 +1613,14 @@ func (m *Machine) getCurrentLocation() Location { } } +func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { + m.Coverage.AddFile(file, totalLines) +} + +func (m *Machine) AddFileContentToCodeCoverage(file string, content string) { + m.Coverage.AddFileContent(file, content) +} + //---------------------------------------- // push pop methods. From 7ec4d19e45447e2d970e836eeaf0ae54470f8dba Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 29 Aug 2024 18:41:54 +0900 Subject: [PATCH 10/37] save --- gnovm/pkg/gnolang/coverage.go | 36 ++++++++++++++++++++++++++--------- gnovm/pkg/gnolang/machine.go | 4 ---- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 3843566bc58..7bc743570a5 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -1,25 +1,29 @@ package gnolang import ( + "bufio" "fmt" + "os" + "path/filepath" "sort" "strings" ) type CoverageData struct { Files map[string]*FileCoverage + SourceCode map[string][]string Debug bool } type FileCoverage struct { Statements map[int]int // line number -> execution count TotalLines int // total number of lines in the file - Content []string // each line's content } func NewCoverageData() *CoverageData { return &CoverageData{ Files: make(map[string]*FileCoverage), + SourceCode: make(map[string][]string), Debug: true, } } @@ -33,16 +37,30 @@ func (c *CoverageData) AddFile(file string, totalLines int) { } } -func (c *CoverageData) AddFileContent(file string, content string) { - if fc, exists := c.Files[file]; exists { - fc.Content = strings.Split(content, "\n") - } else { - c.Files[file] = &FileCoverage{ - TotalLines: strings.Count(content, "\n") + 1, - Statements: make(map[int]int), - Content: strings.Split(content, "\n"), +func (c *CoverageData) LoadSourceCode(rootDir string) error { + for file := range c.Files { + fullPath := filepath.Join(rootDir, file) + f, err := os.Open(fullPath) + if err != nil { + return err + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) } + + if err := scanner.Err(); err != nil { + return err + } + + c.SourceCode[file] = lines + c.Files[file].TotalLines = len(lines) } + + return nil } func (c *CoverageData) AddHit(file string, line int) { diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index e6269db1b8a..18aae806996 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1617,10 +1617,6 @@ func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { m.Coverage.AddFile(file, totalLines) } -func (m *Machine) AddFileContentToCodeCoverage(file string, content string) { - m.Coverage.AddFileContent(file, content) -} - //---------------------------------------- // push pop methods. From 4295d5f36b5bfdbfe44e3c921801cabfcad981a6 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 5 Sep 2024 22:29:18 +0900 Subject: [PATCH 11/37] update logic --- gnovm/cmd/gno/test.go | 75 +++++-- gnovm/pkg/gnolang/coverage.go | 146 +++--------- gnovm/pkg/gnolang/coverage_test.go | 344 ++++++++++++++--------------- gnovm/pkg/gnolang/go2gno_test.go | 1 - gnovm/pkg/gnolang/machine.go | 38 +++- 5 files changed, 291 insertions(+), 313 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index efaaefebaf3..fe11a93273c 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -236,7 +236,6 @@ func gnoTestPkg( rootDir = cfg.rootDir runFlag = cfg.run printRuntimeMetrics = cfg.printRuntimeMetrics - coverage = cfg.coverage stdin = io.In() stdout = io.Out() @@ -255,6 +254,8 @@ func gnoTestPkg( stdout = commands.WriteNopCloser(mockOut) } + coverageData := gno.NewCoverageData() + // testing with *_test.gno if len(unittestFiles) > 0 { // Determine gnoPkgPath by reading gno.mod @@ -296,6 +297,14 @@ func gnoTestPkg( } m := tests.TestMachine(testStore, stdout, gnoPkgPath) + m.Coverage = coverageData + m.CurrentPackage = memPkg.Path + for _, file := range memPkg.Files { + if strings.HasSuffix(file.Name, ".gno") && !(strings.HasSuffix(file.Name, "_test.gno") || strings.HasSuffix(file.Name, "_testing.gno")) { + totalLines := countCodeLines(file.Body) + m.Coverage.AddFile(m.CurrentPackage+"/"+m.CurrentFile, totalLines) + } + } if printRuntimeMetrics { // from tm2/pkg/sdk/vm/keeper.go // XXX: make maxAllocTx configurable. @@ -304,7 +313,7 @@ func gnoTestPkg( m.Alloc = gno.NewAllocator(maxAllocTx) } m.RunMemPackage(memPkg, true) - err := runTestFiles(m, tfiles, memPkg.Name, verbose, printRuntimeMetrics, coverage, runFlag, io) + err := runTestFiles(m, tfiles, memPkg.Name, verbose, printRuntimeMetrics, runFlag, io, coverageData) if err != nil { errs = multierr.Append(errs, err) } @@ -338,7 +347,7 @@ func gnoTestPkg( memPkg.Path = memPkg.Path + "_test" m.RunMemPackage(memPkg, true) - err := runTestFiles(m, ifiles, testPkgName, verbose, printRuntimeMetrics, coverage, runFlag, io) + err := runTestFiles(m, ifiles, testPkgName, verbose, printRuntimeMetrics, runFlag, io, coverageData) if err != nil { errs = multierr.Append(errs, err) } @@ -390,6 +399,10 @@ func gnoTestPkg( } } + if cfg.coverage { + coverageData.PrintResults() + } + return errs } @@ -428,9 +441,9 @@ func runTestFiles( pkgName string, verbose bool, printRuntimeMetrics bool, - coverage bool, runFlag string, io commands.IO, + coverageData *gno.CoverageData, ) (errs error) { defer func() { if r := recover(); r != nil { @@ -453,14 +466,10 @@ func runTestFiles( log.Fatal(err) } - coverageData := gno.NewCoverageData() - m.RunFiles(files.Files...) n := gno.MustParseFile("main_test.gno", testmain) m.RunFiles(n) - m.Coverage = coverageData - for _, test := range testFuncs.Tests { testFuncStr := fmt.Sprintf("%q", test.Name) @@ -488,6 +497,18 @@ func runTestFiles( errs = multierr.Append(errs, err) } + for file, fileCoverage := range m.Coverage.Files { + existingCoverage, exists := coverageData.Files[file] + if !exists { + coverageData.Files[file] = fileCoverage + } else { + for line, count := range fileCoverage.HitLines { + existingCoverage.HitLines[line] += count + } + coverageData.Files[file] = existingCoverage + } + } + if printRuntimeMetrics { imports := m.Store.NumMemPackages() - numPackagesBefore - 1 // XXX: store changes @@ -506,11 +527,6 @@ func runTestFiles( allocsVal, ) } - - if coverage { - report := coverageData.Report() - io.Out().Write([]byte(report)) - } } return errs @@ -636,3 +652,36 @@ func shouldRun(filter filterMatch, path string) bool { ok, _ := filter.matches(elem, matchString) return ok } + +func countCodeLines(content string) int { + lines := strings.Split(content, "\n") + codeLines := 0 + inBlockComment := false + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + if inBlockComment { + if strings.Contains(trimmedLine, "*/") { + inBlockComment = false + } + continue + } + + if trimmedLine == "" || strings.HasPrefix(trimmedLine, "//") { + continue + } + + if strings.HasPrefix(trimmedLine, "/*") { + inBlockComment = true + if strings.Contains(trimmedLine, "*/") { + inBlockComment = false + } + continue + } + + codeLines++ + } + + return codeLines +} diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 7bc743570a5..22e0281d465 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -1,145 +1,57 @@ package gnolang import ( - "bufio" "fmt" - "os" - "path/filepath" - "sort" - "strings" ) +// CoverageData stores code coverage information type CoverageData struct { - Files map[string]*FileCoverage - SourceCode map[string][]string - Debug bool + Files map[string]FileCoverage } +// FileCoverage stores coverage information for a single file type FileCoverage struct { - Statements map[int]int // line number -> execution count - TotalLines int // total number of lines in the file + TotalLines int + HitLines map[int]int } func NewCoverageData() *CoverageData { return &CoverageData{ - Files: make(map[string]*FileCoverage), - SourceCode: make(map[string][]string), - Debug: true, + Files: make(map[string]FileCoverage), } } -func (c *CoverageData) AddFile(file string, totalLines int) { - if _, exists := c.Files[file]; !exists { - c.Files[file] = &FileCoverage{ - TotalLines: totalLines, - Statements: make(map[int]int), +func (c *CoverageData) AddHit(pkgPath string, line int) { + fileCoverage, exists := c.Files[pkgPath] + if !exists { + fileCoverage = FileCoverage{ + TotalLines: 0, + HitLines: make(map[int]int), } } -} - -func (c *CoverageData) LoadSourceCode(rootDir string) error { - for file := range c.Files { - fullPath := filepath.Join(rootDir, file) - f, err := os.Open(fullPath) - if err != nil { - return err - } - defer f.Close() - - var lines []string - scanner := bufio.NewScanner(f) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - if err := scanner.Err(); err != nil { - return err - } - - c.SourceCode[file] = lines - c.Files[file].TotalLines = len(lines) - } - - return nil -} - -func (c *CoverageData) AddHit(file string, line int) { - if c.Files[file] == nil { - c.AddFile(file, line) - } - - c.Files[file].Statements[line]++ -} -func (c *CoverageData) SetDebug(debug bool) { - c.Debug = debug + fileCoverage.TotalLines++ + fileCoverage.HitLines[line]++ + c.Files[pkgPath] = fileCoverage } -func (c *CoverageData) Report() string { - var report strings.Builder - report.WriteString("Coverage Report:\n") - report.WriteString("=================\n\n") - - var fileNames []string - for fileName := range c.Files { - fileNames = append(fileNames, fileName) - } - sort.Strings(fileNames) - - totalStatements := 0 - totalCovered := 0 - - for _, fileName := range fileNames { - fileCoverage := c.Files[fileName] - totalLines := fileCoverage.TotalLines - stmts := len(fileCoverage.Statements) - covered := 0 - - var coveredLines []int - for line, count := range fileCoverage.Statements { - if count > 0 { - covered++ - coveredLines = append(coveredLines, line) - } - } - - sort.Ints(coveredLines) - - percentage := float64(stmts) / float64(totalLines) * 100 - report.WriteString(fmt.Sprintf("%s:\n", fileName)) - report.WriteString(fmt.Sprintf(" Total Lines: %d\n", totalLines)) - report.WriteString(fmt.Sprintf(" Covered: %d\n", stmts)) - report.WriteString(fmt.Sprintf(" Coverage: %.2f%%\n\n", percentage)) - - totalStatements += totalLines - totalCovered += stmts - - if c.Debug { - report.WriteString(" Covered lines: ") - for i, line := range coveredLines { - if i > 0 { - report.WriteString(", ") - } - report.WriteString(fmt.Sprintf("%d", line)) - } - report.WriteString("\n") +func (c *CoverageData) AddFile(pkgPath string, totalLines int) { + fileCoverage, exists := c.Files[pkgPath] + if !exists { + fileCoverage = FileCoverage{ + HitLines: make(map[int]int), } - - report.WriteString("\n") } - totalPercentage := calculateCoverage(totalCovered, totalStatements) - report.WriteString("Total Coverage:\n") - report.WriteString(fmt.Sprintf(" Statements: %d\n", totalStatements)) - report.WriteString(fmt.Sprintf(" Covered: %d\n", totalCovered)) - report.WriteString(fmt.Sprintf(" Coverage: %.2f%%\n", totalPercentage)) - - return report.String() + fileCoverage.TotalLines = totalLines + c.Files[pkgPath] = fileCoverage } -func calculateCoverage(a, b int) float64 { - if a == 0 || b == 0 { - return 0.0 - } - return float64(a) / float64(b) * 100 +func (c *CoverageData) PrintResults() { + fmt.Println("Coverage Results:") + for file, coverage := range c.Files { + hitLines := len(coverage.HitLines) + percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 + fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) + } } diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 6712f816f5e..4970e92476e 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -1,188 +1,188 @@ package gnolang -import ( - "reflect" - "testing" -) +// import ( +// "reflect" +// "testing" +// ) -func TestNewCoverageData(t *testing.T) { - cd := NewCoverageData() - if cd == nil { - t.Error("NewCoverageData() returned nil") - } - if cd == nil || cd.Files == nil { - t.Error("NewCoverageData() did not initialize Files map") - } -} +// func TestNewCoverageData(t *testing.T) { +// cd := NewCoverageData() +// if cd == nil { +// t.Error("NewCoverageData() returned nil") +// } +// if cd == nil || cd.Files == nil { +// t.Error("NewCoverageData() did not initialize Files map") +// } +// } -func TestCoverageDataAddHit(t *testing.T) { - tests := []struct { - name string - file string - line int - expected map[string]*FileCoverage - }{ - { - name: "Add hit to new file", - file: "test.go", - line: 10, - expected: map[string]*FileCoverage{ - "test.go": { - Statements: map[int]int{10: 1}, - }, - }, - }, - { - name: "Add hit to existing file", - file: "test.go", - line: 10, - expected: map[string]*FileCoverage{ - "test.go": { - Statements: map[int]int{10: 2}, - }, - }, - }, - { - name: "Add hit to new line in existing file", - file: "test.go", - line: 20, - expected: map[string]*FileCoverage{ - "test.go": { - Statements: map[int]int{10: 2, 20: 1}, - }, - }, - }, - } +// func TestCoverageDataAddHit(t *testing.T) { +// tests := []struct { +// name string +// file string +// line int +// expected map[string]*FileCoverage +// }{ +// { +// name: "Add hit to new file", +// file: "test.go", +// line: 10, +// expected: map[string]*FileCoverage{ +// "test.go": { +// Statements: map[int]int{10: 1}, +// }, +// }, +// }, +// { +// name: "Add hit to existing file", +// file: "test.go", +// line: 10, +// expected: map[string]*FileCoverage{ +// "test.go": { +// Statements: map[int]int{10: 2}, +// }, +// }, +// }, +// { +// name: "Add hit to new line in existing file", +// file: "test.go", +// line: 20, +// expected: map[string]*FileCoverage{ +// "test.go": { +// Statements: map[int]int{10: 2, 20: 1}, +// }, +// }, +// }, +// } - cd := NewCoverageData() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cd.AddHit(tt.file, tt.line) - if !reflect.DeepEqual(cd.Files, tt.expected) { - t.Errorf("AddHit() = %v, want %v", cd.Files, tt.expected) - } - }) - } -} +// cd := NewCoverageData() +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// cd.AddHit(tt.file, tt.line) +// if !reflect.DeepEqual(cd.Files, tt.expected) { +// t.Errorf("AddHit() = %v, want %v", cd.Files, tt.expected) +// } +// }) +// } +// } -func TestCoverageData(t *testing.T) { - tests := []struct { - name string - hits []struct { - file string - line int - } - wantReport string - }{ - { - name: "Empty coverage", - hits: []struct { - file string - line int - }{}, - wantReport: `Coverage Report: -================= +// func TestCoverageData(t *testing.T) { +// tests := []struct { +// name string +// hits []struct { +// file string +// line int +// } +// wantReport string +// }{ +// { +// name: "Empty coverage", +// hits: []struct { +// file string +// line int +// }{}, +// wantReport: `Coverage Report: +// ================= -Total Coverage: - Statements: 0 - Covered: 0 - Coverage: 0.00% -`, - }, - { - name: "Single file, single line", - hits: []struct { - file string - line int - }{ - {"file1.go", 10}, - }, - wantReport: `Coverage Report: -================= +// Total Coverage: +// Statements: 0 +// Covered: 0 +// Coverage: 0.00% +// `, +// }, +// { +// name: "Single file, single line", +// hits: []struct { +// file string +// line int +// }{ +// {"file1.go", 10}, +// }, +// wantReport: `Coverage Report: +// ================= -file1.go: - Statements: 1 - Covered: 1 - Coverage: 100.00% +// file1.go: +// Statements: 1 +// Covered: 1 +// Coverage: 100.00% -Total Coverage: - Statements: 1 - Covered: 1 - Coverage: 100.00% -`, - }, - { - name: "Multiple files, multiple lines", - hits: []struct { - file string - line int - }{ - {"file1.go", 10}, - {"file1.go", 20}, - {"file1.go", 10}, - {"file2.go", 5}, - {"file2.go", 15}, - }, - wantReport: `Coverage Report: -================= +// Total Coverage: +// Statements: 1 +// Covered: 1 +// Coverage: 100.00% +// `, +// }, +// { +// name: "Multiple files, multiple lines", +// hits: []struct { +// file string +// line int +// }{ +// {"file1.go", 10}, +// {"file1.go", 20}, +// {"file1.go", 10}, +// {"file2.go", 5}, +// {"file2.go", 15}, +// }, +// wantReport: `Coverage Report: +// ================= -file1.go: - Statements: 2 - Covered: 2 - Coverage: 100.00% +// file1.go: +// Statements: 2 +// Covered: 2 +// Coverage: 100.00% -file2.go: - Statements: 2 - Covered: 2 - Coverage: 100.00% +// file2.go: +// Statements: 2 +// Covered: 2 +// Coverage: 100.00% -Total Coverage: - Statements: 4 - Covered: 4 - Coverage: 100.00% -`, - }, - { - name: "Partial coverage", - hits: []struct { - file string - line int - }{ - {"file1.go", 10}, - {"file1.go", 20}, - {"file2.go", 5}, - }, - wantReport: `Coverage Report: -================= +// Total Coverage: +// Statements: 4 +// Covered: 4 +// Coverage: 100.00% +// `, +// }, +// { +// name: "Partial coverage", +// hits: []struct { +// file string +// line int +// }{ +// {"file1.go", 10}, +// {"file1.go", 20}, +// {"file2.go", 5}, +// }, +// wantReport: `Coverage Report: +// ================= -file1.go: - Statements: 2 - Covered: 2 - Coverage: 100.00% +// file1.go: +// Statements: 2 +// Covered: 2 +// Coverage: 100.00% -file2.go: - Statements: 1 - Covered: 1 - Coverage: 100.00% +// file2.go: +// Statements: 1 +// Covered: 1 +// Coverage: 100.00% -Total Coverage: - Statements: 3 - Covered: 3 - Coverage: 100.00% -`, - }, - } +// Total Coverage: +// Statements: 3 +// Covered: 3 +// Coverage: 100.00% +// `, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cd := NewCoverageData() - for _, hit := range tt.hits { - cd.AddHit(hit.file, hit.line) - } - got := cd.Report() - if got != tt.wantReport { - t.Errorf("CoverageData.Report() =\n%v\nwant:\n%v", got, tt.wantReport) - } - }) - } -} +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// cd := NewCoverageData() +// for _, hit := range tt.hits { +// cd.AddHit(hit.file, hit.line) +// } +// got := cd.Report() +// if got != tt.wantReport { +// t.Errorf("CoverageData.Report() =\n%v\nwant:\n%v", got, tt.wantReport) +// } +// }) +// } +// } diff --git a/gnovm/pkg/gnolang/go2gno_test.go b/gnovm/pkg/gnolang/go2gno_test.go index d85c142ca52..95584f044e1 100644 --- a/gnovm/pkg/gnolang/go2gno_test.go +++ b/gnovm/pkg/gnolang/go2gno_test.go @@ -26,7 +26,6 @@ func main(){ assert.NotNil(t, n, "ParseFile error") fmt.Printf("CODE:\n%s\n\n", gocode) fmt.Printf("AST:\n%#v\n\n", n) - fmt.Printf("AST.String():\n%s\n", n.String()) } type mockPackageGetter []*std.MemPackage diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 18aae806996..93887662094 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "os" - // "path/filepath" "reflect" "strings" "sync" @@ -83,6 +82,8 @@ type Machine struct { // Test Coverage Coverage *CoverageData + CurrentPackage string + CurrentFile string } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -268,9 +269,13 @@ func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { // and corresponding package node, package value, and types to store. Save // is set to false for tests where package values may be native. func (m *Machine) RunMemPackage(memPkg *std.MemPackage, save bool) (*PackageNode, *PackageValue) { + m.CurrentPackage = memPkg.Path + // for _, file := range memPkg.Files { - // if strings.HasSuffix(file.Name, ".gno") { - // m.AddFileContentToCodeCoverage(filepath.Join(memPkg.Path, file.Name), file.Body) + // if strings.HasSuffix(file.Name, ".gno") && !(strings.HasSuffix(file.Name, "_test.gno") && strings.HasSuffix(file.Name, "_testing.gno")) { + // m.CurrentFile = file.Name + // totalLines := countCodeLines(file.Body) + // m.AddFileToCodeCoverage(m.CurrentPackage+"/"+m.CurrentFile, totalLines) // } // } return m.runMemPackage(memPkg, save, false) @@ -986,6 +991,10 @@ func (m *Machine) runDeclaration(d Decl) { } } +func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { + m.Coverage.AddFile(file, totalLines) +} + //---------------------------------------- // Op @@ -1268,7 +1277,16 @@ func (m *Machine) Run() { op := m.PopOp() loc := m.getCurrentLocation() - m.Coverage.AddHit(loc.PkgPath, loc.Line) + var printedMessages = make(map[string]bool) + + if loc.PkgPath != "" && loc.File != "" { + message := fmt.Sprintf("%s/%s:%d", loc.PkgPath, loc.File, loc.Line) + if !printedMessages[message] { + fmt.Printf("Executing: %s\n", message) + m.Coverage.AddHit(loc.PkgPath+"/"+loc.File, loc.Line) + printedMessages[message] = true + } + } // TODO: this can be optimized manually, even into tiers. switch op { @@ -1607,16 +1625,13 @@ func (m *Machine) getCurrentLocation() Location { } return Location{ - PkgPath: m.Package.PkgPath, - Line: lastFrame.Source.GetLine(), + PkgPath: m.CurrentPackage, + File: m.CurrentFile, + Line: lastFrame.Source.GetLine(), Column: lastFrame.Source.GetColumn(), } } -func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { - m.Coverage.AddFile(file, totalLines) -} - //---------------------------------------- // push pop methods. @@ -1916,6 +1931,9 @@ func (m *Machine) PushFrameCall(cx *CallExpr, fv *FuncValue, recv TypedValue) { if rlm != nil && m.Realm != rlm { m.Realm = rlm // enter new realm } + + m.CurrentPackage = fv.PkgPath + m.CurrentFile = string(fv.FileName) } func (m *Machine) PushFrameGoNative(cx *CallExpr, fv *NativeValue) { From 6aa2cfdce44334b0e193141d432e1f51852a197d Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 6 Sep 2024 12:08:04 +0900 Subject: [PATCH 12/37] fix: file line handler --- gnovm/cmd/gno/test.go | 40 +-- gnovm/pkg/gnolang/coverage.go | 56 ++++- gnovm/pkg/gnolang/coverage_test.go | 375 +++++++++++++++-------------- gnovm/pkg/gnolang/machine.go | 17 +- 4 files changed, 251 insertions(+), 237 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index fe11a93273c..a549a626cf7 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -299,12 +299,7 @@ func gnoTestPkg( m := tests.TestMachine(testStore, stdout, gnoPkgPath) m.Coverage = coverageData m.CurrentPackage = memPkg.Path - for _, file := range memPkg.Files { - if strings.HasSuffix(file.Name, ".gno") && !(strings.HasSuffix(file.Name, "_test.gno") || strings.HasSuffix(file.Name, "_testing.gno")) { - totalLines := countCodeLines(file.Body) - m.Coverage.AddFile(m.CurrentPackage+"/"+m.CurrentFile, totalLines) - } - } + if printRuntimeMetrics { // from tm2/pkg/sdk/vm/keeper.go // XXX: make maxAllocTx configurable. @@ -652,36 +647,3 @@ func shouldRun(filter filterMatch, path string) bool { ok, _ := filter.matches(elem, matchString) return ok } - -func countCodeLines(content string) int { - lines := strings.Split(content, "\n") - codeLines := 0 - inBlockComment := false - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if inBlockComment { - if strings.Contains(trimmedLine, "*/") { - inBlockComment = false - } - continue - } - - if trimmedLine == "" || strings.HasPrefix(trimmedLine, "//") { - continue - } - - if strings.HasPrefix(trimmedLine, "/*") { - inBlockComment = true - if strings.Contains(trimmedLine, "*/") { - inBlockComment = false - } - continue - } - - codeLines++ - } - - return codeLines -} diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 22e0281d465..12fec4f7f8f 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -2,6 +2,7 @@ package gnolang import ( "fmt" + "strings" ) // CoverageData stores code coverage information @@ -26,16 +27,26 @@ func (c *CoverageData) AddHit(pkgPath string, line int) { if !exists { fileCoverage = FileCoverage{ TotalLines: 0, - HitLines: make(map[int]int), + HitLines: make(map[int]int), } + c.Files[pkgPath] = fileCoverage } - fileCoverage.TotalLines++ fileCoverage.HitLines[line]++ + + // Only update the file coverage, without incrementing TotalLines c.Files[pkgPath] = fileCoverage } +func isTestFile(pkgPath string) bool { + return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") +} + func (c *CoverageData) AddFile(pkgPath string, totalLines int) { + if isTestFile(pkgPath) { + return + } + fileCoverage, exists := c.Files[pkgPath] if !exists { fileCoverage = FileCoverage{ @@ -50,8 +61,43 @@ func (c *CoverageData) AddFile(pkgPath string, totalLines int) { func (c *CoverageData) PrintResults() { fmt.Println("Coverage Results:") for file, coverage := range c.Files { - hitLines := len(coverage.HitLines) - percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 - fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) + if !isTestFile(file) { + hitLines := len(coverage.HitLines) + percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 + fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) + } } } + +func countCodeLines(content string) int { + lines := strings.Split(content, "\n") + codeLines := 0 + inBlockComment := false + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + if inBlockComment { + if strings.Contains(trimmedLine, "*/") { + inBlockComment = false + } + continue + } + + if trimmedLine == "" || strings.HasPrefix(trimmedLine, "//") { + continue + } + + if strings.HasPrefix(trimmedLine, "/*") { + inBlockComment = true + if strings.Contains(trimmedLine, "*/") { + inBlockComment = false + } + continue + } + + codeLines++ + } + + return codeLines +} diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 4970e92476e..edbfd93093c 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -1,188 +1,191 @@ package gnolang -// import ( -// "reflect" -// "testing" -// ) - -// func TestNewCoverageData(t *testing.T) { -// cd := NewCoverageData() -// if cd == nil { -// t.Error("NewCoverageData() returned nil") -// } -// if cd == nil || cd.Files == nil { -// t.Error("NewCoverageData() did not initialize Files map") -// } -// } - -// func TestCoverageDataAddHit(t *testing.T) { -// tests := []struct { -// name string -// file string -// line int -// expected map[string]*FileCoverage -// }{ -// { -// name: "Add hit to new file", -// file: "test.go", -// line: 10, -// expected: map[string]*FileCoverage{ -// "test.go": { -// Statements: map[int]int{10: 1}, -// }, -// }, -// }, -// { -// name: "Add hit to existing file", -// file: "test.go", -// line: 10, -// expected: map[string]*FileCoverage{ -// "test.go": { -// Statements: map[int]int{10: 2}, -// }, -// }, -// }, -// { -// name: "Add hit to new line in existing file", -// file: "test.go", -// line: 20, -// expected: map[string]*FileCoverage{ -// "test.go": { -// Statements: map[int]int{10: 2, 20: 1}, -// }, -// }, -// }, -// } - -// cd := NewCoverageData() -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// cd.AddHit(tt.file, tt.line) -// if !reflect.DeepEqual(cd.Files, tt.expected) { -// t.Errorf("AddHit() = %v, want %v", cd.Files, tt.expected) -// } -// }) -// } -// } - -// func TestCoverageData(t *testing.T) { -// tests := []struct { -// name string -// hits []struct { -// file string -// line int -// } -// wantReport string -// }{ -// { -// name: "Empty coverage", -// hits: []struct { -// file string -// line int -// }{}, -// wantReport: `Coverage Report: -// ================= - -// Total Coverage: -// Statements: 0 -// Covered: 0 -// Coverage: 0.00% -// `, -// }, -// { -// name: "Single file, single line", -// hits: []struct { -// file string -// line int -// }{ -// {"file1.go", 10}, -// }, -// wantReport: `Coverage Report: -// ================= - -// file1.go: -// Statements: 1 -// Covered: 1 -// Coverage: 100.00% - -// Total Coverage: -// Statements: 1 -// Covered: 1 -// Coverage: 100.00% -// `, -// }, -// { -// name: "Multiple files, multiple lines", -// hits: []struct { -// file string -// line int -// }{ -// {"file1.go", 10}, -// {"file1.go", 20}, -// {"file1.go", 10}, -// {"file2.go", 5}, -// {"file2.go", 15}, -// }, -// wantReport: `Coverage Report: -// ================= - -// file1.go: -// Statements: 2 -// Covered: 2 -// Coverage: 100.00% - -// file2.go: -// Statements: 2 -// Covered: 2 -// Coverage: 100.00% - -// Total Coverage: -// Statements: 4 -// Covered: 4 -// Coverage: 100.00% -// `, -// }, -// { -// name: "Partial coverage", -// hits: []struct { -// file string -// line int -// }{ -// {"file1.go", 10}, -// {"file1.go", 20}, -// {"file2.go", 5}, -// }, -// wantReport: `Coverage Report: -// ================= - -// file1.go: -// Statements: 2 -// Covered: 2 -// Coverage: 100.00% - -// file2.go: -// Statements: 1 -// Covered: 1 -// Coverage: 100.00% - -// Total Coverage: -// Statements: 3 -// Covered: 3 -// Coverage: 100.00% -// `, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// cd := NewCoverageData() -// for _, hit := range tt.hits { -// cd.AddHit(hit.file, hit.line) -// } -// got := cd.Report() -// if got != tt.wantReport { -// t.Errorf("CoverageData.Report() =\n%v\nwant:\n%v", got, tt.wantReport) -// } -// }) -// } -// } +import ( + "bytes" + "os" + "testing" +) + +func TestAddHit(t *testing.T) { + tests := []struct { + name string + initialData *CoverageData + pkgPath string + line int + expectedHits int + }{ + { + name: "Add hit to new file", + initialData: NewCoverageData(), + pkgPath: "file1.gno", + line: 10, + expectedHits: 1, + }, + { + name: "Add hit to existing file and line", + initialData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": {HitLines: map[int]int{10: 1}}, + }, + }, + pkgPath: "file1.gno", + line: 10, + expectedHits: 2, + }, + { + name: "Add hit to new line in existing file", + initialData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": {HitLines: map[int]int{10: 1}}, + }, + }, + pkgPath: "file1.gno", + line: 20, + expectedHits: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initialData.AddHit(tt.pkgPath, tt.line) + fileCoverage := tt.initialData.Files[tt.pkgPath] + + // Validate the hit count for the specific line + if fileCoverage.HitLines[tt.line] != tt.expectedHits { + t.Errorf("got %d hits for line %d, want %d", fileCoverage.HitLines[tt.line], tt.line, tt.expectedHits) + } + }) + } +} + + +func TestAddFile(t *testing.T) { + tests := []struct { + name string + pkgPath string + totalLines int + initialData *CoverageData + expectedTotal int + }{ + { + name: "Add new file", + pkgPath: "file1.gno", + totalLines: 100, + initialData: NewCoverageData(), + expectedTotal: 100, + }, + { + name: "Do not add test file *_test.gno", + pkgPath: "file1_test.gno", + totalLines: 100, + initialData: NewCoverageData(), + expectedTotal: 0, + }, + { + name: "Do not add test file *_testing.gno", + pkgPath: "file1_testing.gno", + totalLines: 100, + initialData: NewCoverageData(), + expectedTotal: 0, + }, + { + name: "Update existing file's total lines", + pkgPath: "file1.gno", + totalLines: 200, + initialData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": {TotalLines: 100, HitLines: map[int]int{10: 1}}, + }, + }, + expectedTotal: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initialData.AddFile(tt.pkgPath, tt.totalLines) + if tt.pkgPath == "file1_test.gno" && len(tt.initialData.Files) != 0 { + t.Errorf("expected no files to be added for test files") + } else { + if fileCoverage, ok := tt.initialData.Files[tt.pkgPath]; ok { + if fileCoverage.TotalLines != tt.expectedTotal { + t.Errorf("got %d total lines, want %d", fileCoverage.TotalLines, tt.expectedTotal) + } + } else if len(tt.initialData.Files) > 0 { + t.Errorf("expected file not added") + } + } + }) + } +} + +func TestIsTestFile(t *testing.T) { + tests := []struct { + pkgPath string + want bool + }{ + {"file1_test.gno", true}, + {"file1_testing.gno", true}, + {"file1.gno", false}, + {"random_test.go", false}, + } + + for _, tt := range tests { + t.Run(tt.pkgPath, func(t *testing.T) { + got := isTestFile(tt.pkgPath) + if got != tt.want { + t.Errorf("isTestFile(%s) = %v, want %v", tt.pkgPath, got, tt.want) + } + }) + } +} + +func TestPrintResults(t *testing.T) { + tests := []struct { + name string + initialData *CoverageData + expectedOutput string + }{ + { + name: "Print results with one file", + initialData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": {TotalLines: 100, HitLines: map[int]int{10: 1, 20: 1}}, + }, + }, + expectedOutput: "Coverage Results:\nfile1.gno: 2.00% (2/100 lines)\n", + }, + { + name: "Print results with multiple files", + initialData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": {TotalLines: 100, HitLines: map[int]int{10: 1, 20: 1}}, + "file2.gno": {TotalLines: 200, HitLines: map[int]int{30: 1}}, + }, + }, + expectedOutput: "Coverage Results:\nfile1.gno: 2.00% (2/100 lines)\nfile2.gno: 0.50% (1/200 lines)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origStdout := os.Stdout + + r, w, _ := os.Pipe() + os.Stdout = w + + tt.initialData.PrintResults() + + w.Close() + os.Stdout = origStdout + + var buf bytes.Buffer + buf.ReadFrom(r) + + got := buf.String() + if got != tt.expectedOutput { + t.Errorf("got %q, want %q", got, tt.expectedOutput) + } + }) + } +} diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 93887662094..2a332bca12b 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -271,13 +271,13 @@ func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { func (m *Machine) RunMemPackage(memPkg *std.MemPackage, save bool) (*PackageNode, *PackageValue) { m.CurrentPackage = memPkg.Path - // for _, file := range memPkg.Files { - // if strings.HasSuffix(file.Name, ".gno") && !(strings.HasSuffix(file.Name, "_test.gno") && strings.HasSuffix(file.Name, "_testing.gno")) { - // m.CurrentFile = file.Name - // totalLines := countCodeLines(file.Body) - // m.AddFileToCodeCoverage(m.CurrentPackage+"/"+m.CurrentFile, totalLines) - // } - // } + for _, file := range memPkg.Files { + if strings.HasSuffix(file.Name, ".gno") && !(strings.HasSuffix(file.Name, "_test.gno") && strings.HasSuffix(file.Name, "_testing.gno")) { + m.CurrentFile = file.Name + totalLines := countCodeLines(file.Body) + m.AddFileToCodeCoverage(m.CurrentPackage+"/"+m.CurrentFile, totalLines) + } + } return m.runMemPackage(memPkg, save, false) } @@ -992,6 +992,9 @@ func (m *Machine) runDeclaration(d Decl) { } func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { + if isTestFile(file) { + return + } m.Coverage.AddFile(file, totalLines) } From c5854d7850c305979afa33e2772f18aeb963f023 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 6 Sep 2024 20:55:14 +0900 Subject: [PATCH 13/37] inspect only the package that contains the executed test file --- gnovm/cmd/gno/test.go | 3 +- gnovm/pkg/gnolang/coverage.go | 50 ++++++++++++++++-------------- gnovm/pkg/gnolang/coverage_test.go | 45 +++++++++++++-------------- gnovm/pkg/gnolang/machine.go | 25 +++++---------- 4 files changed, 58 insertions(+), 65 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index a549a626cf7..581441ce4ed 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -263,6 +263,7 @@ func gnoTestPkg( modfile, err := gnomod.ParseAt(pkgPath) if err == nil { gnoPkgPath = modfile.Module.Mod.Path + coverageData.PkgPath = gnoPkgPath } else { gnoPkgPath = pkgPathFromRootDir(pkgPath, rootDir) if gnoPkgPath == "" { @@ -270,10 +271,10 @@ func gnoTestPkg( io.ErrPrintfln("--- WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") gnoPkgPath = gno.RealmPathPrefix + random.RandStr(8) } + coverageData.PkgPath = gnoPkgPath } memPkg := gno.ReadMemPackage(pkgPath, gnoPkgPath) - // tfiles, ifiles := gno.ParseMemPackageTests(memPkg) var tfiles, ifiles *gno.FileSet hasError := catchRuntimeError(gnoPkgPath, stderr, func() { diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 12fec4f7f8f..27525fdeb74 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -7,7 +7,8 @@ import ( // CoverageData stores code coverage information type CoverageData struct { - Files map[string]FileCoverage + Files map[string]FileCoverage + PkgPath string } // FileCoverage stores coverage information for a single file @@ -18,7 +19,8 @@ type FileCoverage struct { func NewCoverageData() *CoverageData { return &CoverageData{ - Files: make(map[string]FileCoverage), + Files: make(map[string]FileCoverage), + PkgPath: "", } } @@ -38,16 +40,12 @@ func (c *CoverageData) AddHit(pkgPath string, line int) { c.Files[pkgPath] = fileCoverage } -func isTestFile(pkgPath string) bool { - return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") -} - -func (c *CoverageData) AddFile(pkgPath string, totalLines int) { - if isTestFile(pkgPath) { - return - } +func (c *CoverageData) AddFile(filePath string, totalLines int) { + if isTestFile(filePath) { + return + } - fileCoverage, exists := c.Files[pkgPath] + fileCoverage, exists := c.Files[filePath] if !exists { fileCoverage = FileCoverage{ HitLines: make(map[int]int), @@ -55,18 +53,18 @@ func (c *CoverageData) AddFile(pkgPath string, totalLines int) { } fileCoverage.TotalLines = totalLines - c.Files[pkgPath] = fileCoverage + c.Files[filePath] = fileCoverage } func (c *CoverageData) PrintResults() { - fmt.Println("Coverage Results:") - for file, coverage := range c.Files { - if !isTestFile(file) { + fmt.Println("Coverage Results:") + for file, coverage := range c.Files { + if !isTestFile(file) && strings.Contains(file, c.PkgPath) { hitLines := len(coverage.HitLines) percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) } - } + } } func countCodeLines(content string) int { @@ -89,15 +87,19 @@ func countCodeLines(content string) int { } if strings.HasPrefix(trimmedLine, "/*") { - inBlockComment = true - if strings.Contains(trimmedLine, "*/") { - inBlockComment = false - } - continue - } - - codeLines++ + inBlockComment = true + if strings.Contains(trimmedLine, "*/") { + inBlockComment = false + } + continue + } + + codeLines++ } return codeLines } + +func isTestFile(pkgPath string) bool { + return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") +} diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index edbfd93093c..1f6b18b0493 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -15,10 +15,10 @@ func TestAddHit(t *testing.T) { expectedHits int }{ { - name: "Add hit to new file", - initialData: NewCoverageData(), - pkgPath: "file1.gno", - line: 10, + name: "Add hit to new file", + initialData: NewCoverageData(), + pkgPath: "file1.gno", + line: 10, expectedHits: 1, }, { @@ -28,8 +28,8 @@ func TestAddHit(t *testing.T) { "file1.gno": {HitLines: map[int]int{10: 1}}, }, }, - pkgPath: "file1.gno", - line: 10, + pkgPath: "file1.gno", + line: 10, expectedHits: 2, }, { @@ -39,8 +39,8 @@ func TestAddHit(t *testing.T) { "file1.gno": {HitLines: map[int]int{10: 1}}, }, }, - pkgPath: "file1.gno", - line: 20, + pkgPath: "file1.gno", + line: 20, expectedHits: 1, }, } @@ -58,7 +58,6 @@ func TestAddHit(t *testing.T) { } } - func TestAddFile(t *testing.T) { tests := []struct { name string @@ -68,29 +67,29 @@ func TestAddFile(t *testing.T) { expectedTotal int }{ { - name: "Add new file", - pkgPath: "file1.gno", - totalLines: 100, - initialData: NewCoverageData(), + name: "Add new file", + pkgPath: "file1.gno", + totalLines: 100, + initialData: NewCoverageData(), expectedTotal: 100, }, { - name: "Do not add test file *_test.gno", - pkgPath: "file1_test.gno", - totalLines: 100, - initialData: NewCoverageData(), + name: "Do not add test file *_test.gno", + pkgPath: "file1_test.gno", + totalLines: 100, + initialData: NewCoverageData(), expectedTotal: 0, }, { - name: "Do not add test file *_testing.gno", - pkgPath: "file1_testing.gno", - totalLines: 100, - initialData: NewCoverageData(), + name: "Do not add test file *_testing.gno", + pkgPath: "file1_testing.gno", + totalLines: 100, + initialData: NewCoverageData(), expectedTotal: 0, }, { - name: "Update existing file's total lines", - pkgPath: "file1.gno", + name: "Update existing file's total lines", + pkgPath: "file1.gno", totalLines: 200, initialData: &CoverageData{ Files: map[string]FileCoverage{ diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 2a332bca12b..5ada9e3b86f 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -81,9 +81,9 @@ type Machine struct { DeferPanicScope uint // Test Coverage - Coverage *CoverageData + Coverage *CoverageData CurrentPackage string - CurrentFile string + CurrentFile string } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -993,8 +993,8 @@ func (m *Machine) runDeclaration(d Decl) { func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { if isTestFile(file) { - return - } + return + } m.Coverage.AddFile(file, totalLines) } @@ -1280,16 +1280,7 @@ func (m *Machine) Run() { op := m.PopOp() loc := m.getCurrentLocation() - var printedMessages = make(map[string]bool) - - if loc.PkgPath != "" && loc.File != "" { - message := fmt.Sprintf("%s/%s:%d", loc.PkgPath, loc.File, loc.Line) - if !printedMessages[message] { - fmt.Printf("Executing: %s\n", message) - m.Coverage.AddHit(loc.PkgPath+"/"+loc.File, loc.Line) - printedMessages[message] = true - } - } + m.Coverage.AddHit(loc.PkgPath+"/"+loc.File, loc.Line) // TODO: this can be optimized manually, even into tiers. switch op { @@ -1629,9 +1620,9 @@ func (m *Machine) getCurrentLocation() Location { return Location{ PkgPath: m.CurrentPackage, - File: m.CurrentFile, - Line: lastFrame.Source.GetLine(), - Column: lastFrame.Source.GetColumn(), + File: m.CurrentFile, + Line: lastFrame.Source.GetLine(), + Column: lastFrame.Source.GetColumn(), } } From b8f9e0c3a19824257c6a20a4958d9e56fdcd877e Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 9 Sep 2024 17:26:37 +0900 Subject: [PATCH 14/37] feat: more precise coverage and add colored output command --- gnovm/cmd/gno/test.go | 24 +++++++++++-- gnovm/pkg/gnolang/coverage.go | 54 ++++++++++++++++++++++++++++-- gnovm/pkg/gnolang/coverage_test.go | 12 +++---- gnovm/pkg/gnolang/machine.go | 24 ++++++++++++- gnovm/pkg/gnolang/op_exec.go | 13 +++++++ 5 files changed, 115 insertions(+), 12 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 581441ce4ed..6fe9f34d418 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -37,6 +37,7 @@ type testCfg struct { printRuntimeMetrics bool withNativeFallback bool coverage bool + showColoredCoverage bool } func newTestCmd(io commands.IO) *commands.Command { @@ -157,6 +158,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { false, "enable coverage analysis", ) + + fs.BoolVar( + &c.showColoredCoverage, + "show-colored-coverage", + false, + "show colored coverage in terminal", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -254,7 +262,7 @@ func gnoTestPkg( stdout = commands.WriteNopCloser(mockOut) } - coverageData := gno.NewCoverageData() + coverageData := gno.NewCoverageData(cfg.rootDir) // testing with *_test.gno if len(unittestFiles) > 0 { @@ -396,7 +404,19 @@ func gnoTestPkg( } if cfg.coverage { - coverageData.PrintResults() + if cfg.coverage { + coverageData.Report() + + if cfg.showColoredCoverage { + for filePath := range coverageData.Files { + if err := coverageData.ColoredCoverage(filePath); err != nil { + io.ErrPrintfln("Error printing colored coverage for %s: %v", filePath, err) + } + } + } + } else { + coverageData.Report() + } } return errs diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 27525fdeb74..86b948f60c2 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -1,14 +1,24 @@ package gnolang import ( + "bufio" "fmt" + "os" + "path/filepath" "strings" ) +const ( + colorReset = "\033[0m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" +) + // CoverageData stores code coverage information type CoverageData struct { Files map[string]FileCoverage PkgPath string + RootDir string } // FileCoverage stores coverage information for a single file @@ -17,10 +27,11 @@ type FileCoverage struct { HitLines map[int]int } -func NewCoverageData() *CoverageData { +func NewCoverageData(rootDir string) *CoverageData { return &CoverageData{ Files: make(map[string]FileCoverage), PkgPath: "", + RootDir: rootDir, } } @@ -56,7 +67,7 @@ func (c *CoverageData) AddFile(filePath string, totalLines int) { c.Files[filePath] = fileCoverage } -func (c *CoverageData) PrintResults() { +func (c *CoverageData) Report() { fmt.Println("Coverage Results:") for file, coverage := range c.Files { if !isTestFile(file) && strings.Contains(file, c.PkgPath) { @@ -67,6 +78,43 @@ func (c *CoverageData) PrintResults() { } } +func (c *CoverageData) ColoredCoverage(filePath string) error { + realPath := filepath.Join(c.RootDir, "examples", filePath) + if isTestFile(filePath) || !strings.Contains(realPath, c.PkgPath) || !strings.HasSuffix(realPath, ".gno") { + return nil + } + file, err := os.Open(realPath) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNumber := 1 + + fileCoverage, exists := c.Files[filePath] + if !exists { + return fmt.Errorf("no coverage data for file %s", filePath) + } + + fmt.Printf("Coverage Results for %s:\n", filePath) + for scanner.Scan() { + line := scanner.Text() + if _, covered := fileCoverage.HitLines[lineNumber]; covered { + fmt.Printf("%s%4d: %s%s\n", colorGreen, lineNumber, line, colorReset) + } else { + fmt.Printf("%s%4d: %s%s\n", colorYellow, lineNumber, line, colorReset) + } + lineNumber++ + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} + func countCodeLines(content string) int { lines := strings.Split(content, "\n") codeLines := 0 @@ -101,5 +149,5 @@ func countCodeLines(content string) int { } func isTestFile(pkgPath string) bool { - return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") + return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") || strings.HasSuffix(pkgPath, "_filetest.gno") } diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 1f6b18b0493..3a88f342848 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -16,7 +16,7 @@ func TestAddHit(t *testing.T) { }{ { name: "Add hit to new file", - initialData: NewCoverageData(), + initialData: NewCoverageData(""), pkgPath: "file1.gno", line: 10, expectedHits: 1, @@ -70,21 +70,21 @@ func TestAddFile(t *testing.T) { name: "Add new file", pkgPath: "file1.gno", totalLines: 100, - initialData: NewCoverageData(), + initialData: NewCoverageData(""), expectedTotal: 100, }, { name: "Do not add test file *_test.gno", pkgPath: "file1_test.gno", totalLines: 100, - initialData: NewCoverageData(), + initialData: NewCoverageData(""), expectedTotal: 0, }, { name: "Do not add test file *_testing.gno", pkgPath: "file1_testing.gno", totalLines: 100, - initialData: NewCoverageData(), + initialData: NewCoverageData(""), expectedTotal: 0, }, { @@ -139,7 +139,7 @@ func TestIsTestFile(t *testing.T) { } } -func TestPrintResults(t *testing.T) { +func TestReport(t *testing.T) { tests := []struct { name string initialData *CoverageData @@ -173,7 +173,7 @@ func TestPrintResults(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - tt.initialData.PrintResults() + tt.initialData.Report() w.Close() os.Stdout = origStdout diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 5ada9e3b86f..9d2a532a0a3 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "reflect" "strings" "sync" @@ -84,6 +85,7 @@ type Machine struct { Coverage *CoverageData CurrentPackage string CurrentFile string + currentNode Node } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -182,7 +184,7 @@ func NewMachineWithOptions(opts MachineOptions) *Machine { mm.Debugger.enabled = opts.Debug mm.Debugger.in = opts.Input mm.Debugger.out = output - mm.Coverage = NewCoverageData() + mm.Coverage = NewCoverageData("") if pv != nil { mm.SetActivePackage(pv) @@ -998,6 +1000,26 @@ func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { m.Coverage.AddFile(file, totalLines) } +func (m *Machine) recordCoverage(node Node) Location { + if node == nil { + return Location{} + } + + pkgPath := m.CurrentPackage + file := m.CurrentFile + line := node.GetLine() + + path := filepath.Join(pkgPath, file) + m.Coverage.AddHit(path, line) + + return Location{ + PkgPath: pkgPath, + File: file, + Line: line, + Column: node.GetColumn(), + } +} + //---------------------------------------- // Op diff --git a/gnovm/pkg/gnolang/op_exec.go b/gnovm/pkg/gnolang/op_exec.go index c7e8ffd600c..7721178ca5a 100644 --- a/gnovm/pkg/gnolang/op_exec.go +++ b/gnovm/pkg/gnolang/op_exec.go @@ -433,6 +433,7 @@ EXEC_SWITCH: } switch cs := s.(type) { case *AssignStmt: + m.recordCoverage(cs) switch cs.Op { case ASSIGN: m.PushOp(OpAssign) @@ -540,6 +541,7 @@ EXEC_SWITCH: // Push eval operations if needed. m.PushForPointer(cs.X) case *ReturnStmt: + m.recordCoverage(cs) m.PopStmt() fr := m.MustLastCallFrame(1) ft := fr.Func.GetType(m.Store) @@ -728,6 +730,7 @@ EXEC_SWITCH: m.PushOp(OpEval) } case *TypeDecl: // SimpleDeclStmt + m.recordCoverage(cs) m.PushOp(OpTypeDecl) m.PushExpr(cs.Type) m.PushOp(OpEval) @@ -786,10 +789,15 @@ EXEC_SWITCH: func (m *Machine) doOpIfCond() { is := m.PopStmt().(*IfStmt) + m.recordCoverage(is) // start record coverage when IfStmt is popped + b := m.LastBlock() + + m.recordCoverage(is) // record Condition Evaluation Coverage // Test cond and run Body or Else. cond := m.PopValue() if cond.GetBool() { + m.recordCoverage(&is.Then) if len(is.Then.Body) != 0 { // expand block size if nn := is.Then.GetNumNames(); int(nn) > len(b.Values) { @@ -804,7 +812,9 @@ func (m *Machine) doOpIfCond() { m.PushOp(OpBody) m.PushStmt(b.GetBodyStmt()) } + m.recordCoverage(&is.Then) } else { + m.recordCoverage(&is.Else) if len(is.Else.Body) != 0 { // expand block size if nn := is.Else.GetNumNames(); int(nn) > len(b.Values) { @@ -819,7 +829,10 @@ func (m *Machine) doOpIfCond() { m.PushOp(OpBody) m.PushStmt(b.GetBodyStmt()) } + m.recordCoverage(&is.Else) } + + m.recordCoverage(is) } func (m *Machine) doOpTypeSwitch() { From 6a458baea1a5bf767b7a698017ff6fe6bdc6b9b0 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 9 Sep 2024 18:08:44 +0900 Subject: [PATCH 15/37] json output --- gnovm/cmd/gno/test.go | 18 ++++++++++++++ gnovm/pkg/gnolang/coverage.go | 40 ++++++++++++++++++++++++++++++++ gnovm/pkg/gnolang/go2gno_test.go | 1 + gnovm/pkg/gnolang/op_exec.go | 5 ++++ 4 files changed, 64 insertions(+) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 6fe9f34d418..daa9392b541 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -36,8 +36,11 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool withNativeFallback bool + + // coverage flags coverage bool showColoredCoverage bool + output string } func newTestCmd(io commands.IO) *commands.Command { @@ -165,6 +168,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { false, "show colored coverage in terminal", ) + + fs.StringVar( + &c.output, + "out", + "", + "save coverage data as JSON to specified file", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -417,6 +427,14 @@ func gnoTestPkg( } else { coverageData.Report() } + + if cfg.output != "" { + err := coverageData.SaveJSON(cfg.output) + if err != nil { + return fmt.Errorf("failed to save coverage data: %w", err) + } + io.Println("coverage data saved to", cfg.output) + } } return errs diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 86b948f60c2..2c5e992f63f 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -2,9 +2,11 @@ package gnolang import ( "bufio" + "encoding/json" "fmt" "os" "path/filepath" + "strconv" "strings" ) @@ -151,3 +153,41 @@ func countCodeLines(content string) int { func isTestFile(pkgPath string) bool { return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") || strings.HasSuffix(pkgPath, "_filetest.gno") } + +type JSONCoverage struct { + Files map[string]JSONFileCoverage `json:"files"` +} + +type JSONFileCoverage struct { + TotalLines int `json:"total_lines"` + HitLines map[string]int `json:"hit_lines"` +} + +func (c *CoverageData) ToJSON() ([]byte, error) { + jsonCov := JSONCoverage{ + Files: make(map[string]JSONFileCoverage), + } + + for file, coverage := range c.Files { + hitLines := make(map[string]int) + for line, count := range coverage.HitLines { + hitLines[strconv.Itoa(line)] = count + } + + jsonCov.Files[file] = JSONFileCoverage{ + TotalLines: coverage.TotalLines, + HitLines: hitLines, + } + } + + return json.MarshalIndent(jsonCov, "", " ") +} + +func (c *CoverageData) SaveJSON(fileName string) error { + data, err := c.ToJSON() + if err != nil { + return err + } + + return os.WriteFile(fileName, data, 0o644) +} diff --git a/gnovm/pkg/gnolang/go2gno_test.go b/gnovm/pkg/gnolang/go2gno_test.go index 95584f044e1..d85c142ca52 100644 --- a/gnovm/pkg/gnolang/go2gno_test.go +++ b/gnovm/pkg/gnolang/go2gno_test.go @@ -26,6 +26,7 @@ func main(){ assert.NotNil(t, n, "ParseFile error") fmt.Printf("CODE:\n%s\n\n", gocode) fmt.Printf("AST:\n%#v\n\n", n) + fmt.Printf("AST.String():\n%s\n", n.String()) } type mockPackageGetter []*std.MemPackage diff --git a/gnovm/pkg/gnolang/op_exec.go b/gnovm/pkg/gnolang/op_exec.go index 7721178ca5a..c333a8d1c57 100644 --- a/gnovm/pkg/gnolang/op_exec.go +++ b/gnovm/pkg/gnolang/op_exec.go @@ -920,6 +920,7 @@ func (m *Machine) doOpTypeSwitch() { func (m *Machine) doOpSwitchClause() { ss := m.PeekStmt1().(*SwitchStmt) + m.recordCoverage(ss) // tv := m.PeekValue(1) // switch tag value // caiv := m.PeekValue(2) // switch clause case index (reuse) cliv := m.PeekValue(3) // switch clause index (reuse) @@ -933,6 +934,7 @@ func (m *Machine) doOpSwitchClause() { // done! } else { cl := ss.Clauses[idx] + m.recordCoverage(&cl) if len(cl.Cases) == 0 { // default clause m.PopStmt() // pop switch stmt @@ -959,7 +961,10 @@ func (m *Machine) doOpSwitchClause() { m.PushOp(OpEval) m.PushExpr(cl.Cases[0]) } + m.recordCoverage(&cl) } + + m.recordCoverage(ss) } func (m *Machine) doOpSwitchClauseCase() { From 38e7be4f9fac445ba579615586893a3a85941acb Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 10 Sep 2024 00:41:16 +0900 Subject: [PATCH 16/37] Record coverage for variable assignments and expressions in op_assign.go and op_eval.go --- gnovm/pkg/gnolang/machine.go | 1 - gnovm/pkg/gnolang/op_assign.go | 31 +++++++++++++++++++++++++++++++ gnovm/pkg/gnolang/op_eval.go | 29 ++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 83a97808c0c..5166f237dfa 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -85,7 +85,6 @@ type Machine struct { Coverage *CoverageData CurrentPackage string CurrentFile string - currentNode Node } // NewMachine initializes a new gno virtual machine, acting as a shorthand diff --git a/gnovm/pkg/gnolang/op_assign.go b/gnovm/pkg/gnolang/op_assign.go index eb67ffcc351..09a0215e76d 100644 --- a/gnovm/pkg/gnolang/op_assign.go +++ b/gnovm/pkg/gnolang/op_assign.go @@ -2,12 +2,16 @@ package gnolang func (m *Machine) doOpDefine() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + // Define each value evaluated for Lhs. // NOTE: PopValues() returns a slice in // forward order, not the usual reverse. rvs := m.PopValues(len(s.Lhs)) lb := m.LastBlock() for i := 0; i < len(s.Lhs); i++ { + // Record coverage for each variable being defined + m.recordCoverage(s.Lhs[i]) // Get name and value of i'th term. nx := s.Lhs[i].(*NameExpr) // Finally, define (or assign if loop block). @@ -16,33 +20,60 @@ func (m *Machine) doOpDefine() { if m.ReadOnly { if oo, ok := ptr.Base.(Object); ok { if oo.GetIsReal() { + m.recordCoverage(s) panic("readonly violation") } } } + + // Record coverage for each right-hand side expression + if i < len(s.Rhs) { + m.recordCoverage(s.Rhs[i]) // Record coverage for the expression being assigned + } + ptr.Assign2(m.Alloc, m.Store, m.Realm, rvs[i], true) } + + // record entire AssignStmt again to mark its completion + m.recordCoverage(s) } func (m *Machine) doOpAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + // Assign each value evaluated for Lhs. // NOTE: PopValues() returns a slice in // forward order, not the usual reverse. rvs := m.PopValues(len(s.Lhs)) for i := len(s.Lhs) - 1; 0 <= i; i-- { + // Track which variable is assigned + // in a compound assignment statement + m.recordCoverage(s.Lhs[i]) + // Pop lhs value and desired type. lv := m.PopAsPointer(s.Lhs[i]) // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { if oo.GetIsReal() { + m.recordCoverage(s) panic("readonly violation") } } } + + // Used to track the source of the assigned value. + // However, since the number of expressions on the right-hand side + // may be fewer than on the left (e.g., in multiple assignments), add an index check. + if i < len(s.Rhs) { + m.recordCoverage(s.Rhs[i]) + } lv.Assign2(m.Alloc, m.Store, m.Realm, rvs[i], true) } + + // coverage record for end of assignment. + m.recordCoverage(s) } func (m *Machine) doOpAddAssign() { diff --git a/gnovm/pkg/gnolang/op_eval.go b/gnovm/pkg/gnolang/op_eval.go index 701615fff13..42d993e42df 100644 --- a/gnovm/pkg/gnolang/op_eval.go +++ b/gnovm/pkg/gnolang/op_eval.go @@ -19,10 +19,12 @@ var ( func (m *Machine) doOpEval() { x := m.PeekExpr(1) + m.recordCoverage(x) + if debug { debug.Printf("EVAL: (%T) %v\n", x, x) - // fmt.Println(m.String()) } + // This case moved out of switch for performance. // TODO: understand this better. if nx, ok := x.(*NameExpr); ok { @@ -45,6 +47,7 @@ func (m *Machine) doOpEval() { // case NameExpr: handled above case *BasicLitExpr: m.PopExpr() + m.recordCoverage(x) switch x.Kind { case INT: x.Value = strings.ReplaceAll(x.Value, blankIdentifier, "") @@ -224,6 +227,7 @@ func (m *Machine) doOpEval() { panic(fmt.Sprintf("unexpected lit kind %v", x.Kind)) } case *BinaryExpr: + m.recordCoverage(x) switch x.Op { case LAND, LOR: m.PushOp(OpBinary1) @@ -242,6 +246,7 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *CallExpr: + m.recordCoverage(x) m.PushOp(OpPrecall) // Eval args. args := x.Args @@ -253,6 +258,7 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Func) m.PushOp(OpEval) case *IndexExpr: + m.recordCoverage(x) if x.HasOK { m.PushOp(OpIndex2) } else { @@ -265,11 +271,13 @@ func (m *Machine) doOpEval() { m.PushExpr(x.X) m.PushOp(OpEval) case *SelectorExpr: + m.recordCoverage(x) m.PushOp(OpSelector) // evaluate x m.PushExpr(x.X) m.PushOp(OpEval) case *SliceExpr: + m.recordCoverage(x) m.PushOp(OpSlice) // evaluate max if x.Max != nil { @@ -290,40 +298,48 @@ func (m *Machine) doOpEval() { m.PushExpr(x.X) m.PushOp(OpEval) case *StarExpr: + m.recordCoverage(x) m.PopExpr() m.PushOp(OpStar) // evaluate x. m.PushExpr(x.X) m.PushOp(OpEval) case *RefExpr: + m.recordCoverage(x) m.PushOp(OpRef) // evaluate x m.PushForPointer(x.X) case *UnaryExpr: + m.recordCoverage(x) op := word2UnaryOp(x.Op) m.PushOp(op) // evaluate x m.PushExpr(x.X) m.PushOp(OpEval) case *CompositeLitExpr: + m.recordCoverage(x) m.PushOp(OpCompositeLit) // evaluate type m.PushExpr(x.Type) m.PushOp(OpEval) case *FuncLitExpr: + m.recordCoverage(x) m.PushOp(OpFuncLit) // evaluate func type m.PushExpr(&x.Type) m.PushOp(OpEval) case *ConstExpr: + m.recordCoverage(x) m.PopExpr() // push preprocessed value m.PushValue(x.TypedValue) case *constTypeExpr: + m.recordCoverage(x) m.PopExpr() // push preprocessed type as value m.PushValue(asValue(x.Type)) case *FieldTypeExpr: + m.recordCoverage(x) m.PushOp(OpFieldType) // evaluate field type m.PushExpr(x.Type) @@ -334,6 +350,7 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *ArrayTypeExpr: + m.recordCoverage(x) m.PushOp(OpArrayType) // evaluate length if set if x.Len != nil { @@ -344,11 +361,13 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Elt) m.PushOp(OpEval) // OpEvalType? case *SliceTypeExpr: + m.recordCoverage(x) m.PushOp(OpSliceType) // evaluate elem type m.PushExpr(x.Elt) m.PushOp(OpEval) // OpEvalType? case *InterfaceTypeExpr: + m.recordCoverage(x) m.PushOp(OpInterfaceType) // evaluate methods for i := len(x.Methods) - 1; 0 <= i; i-- { @@ -356,6 +375,7 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *FuncTypeExpr: + m.recordCoverage(x) // NOTE params and results are evaluated in // the parent scope. m.PushOp(OpFuncType) @@ -370,6 +390,7 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *MapTypeExpr: + m.recordCoverage(x) m.PopExpr() m.PushOp(OpMapType) // evaluate value type @@ -379,6 +400,7 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Key) m.PushOp(OpEval) // OpEvalType? case *StructTypeExpr: + m.recordCoverage(x) m.PushOp(OpStructType) // evaluate fields for i := len(x.Fields) - 1; 0 <= i; i-- { @@ -386,6 +408,7 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *TypeAssertExpr: + m.recordCoverage(x) if x.HasOK { m.PushOp(OpTypeAssert2) } else { @@ -407,6 +430,10 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Type) m.PushOp(OpEval) default: + m.recordCoverage(x) // record coverage for unknown type panic(fmt.Sprintf("unexpected expression %#v", x)) } + + // Record coverage after evaluating the expression + m.recordCoverage(x) } From 75431a017e020d14790724e4457c844581c4625d Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 10 Sep 2024 15:15:28 +0900 Subject: [PATCH 17/37] calculate precise executable lines --- gnovm/cmd/gno/test.go | 17 +- gnovm/pkg/gnolang/coverage.go | 165 ++++++++++++--- gnovm/pkg/gnolang/coverage_test.go | 329 +++++++++++++++++++++++++++++ gnovm/pkg/gnolang/machine.go | 37 +--- gnovm/pkg/gnolang/op_assign.go | 6 +- 5 files changed, 481 insertions(+), 73 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index daa9392b541..c1f318051e4 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -289,7 +289,7 @@ func gnoTestPkg( io.ErrPrintfln("--- WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") gnoPkgPath = gno.RealmPathPrefix + random.RandStr(8) } - coverageData.PkgPath = gnoPkgPath + coverageData.PkgPath = pkgPath } memPkg := gno.ReadMemPackage(pkgPath, gnoPkgPath) @@ -414,18 +414,13 @@ func gnoTestPkg( } if cfg.coverage { - if cfg.coverage { - coverageData.Report() - - if cfg.showColoredCoverage { - for filePath := range coverageData.Files { - if err := coverageData.ColoredCoverage(filePath); err != nil { - io.ErrPrintfln("Error printing colored coverage for %s: %v", filePath, err) - } + coverageData.Report() + if cfg.showColoredCoverage { + for filePath := range coverageData.Files { + if err := coverageData.ColoredCoverage(filePath); err != nil { + io.ErrPrintfln("Error printing colored coverage for %s: %v", filePath, err) } } - } else { - coverageData.Report() } if cfg.output != "" { diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 2c5e992f63f..2c3dc5e708d 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -4,8 +4,12 @@ import ( "bufio" "encoding/json" "fmt" + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" + "sort" "strconv" "strings" ) @@ -71,7 +75,16 @@ func (c *CoverageData) AddFile(filePath string, totalLines int) { func (c *CoverageData) Report() { fmt.Println("Coverage Results:") - for file, coverage := range c.Files { + + // Sort files by name for consistent output + var files []string + for file := range c.Files { + files = append(files, file) + } + sort.Strings(files) + + for _, file := range files { + coverage := c.Files[file] if !isTestFile(file) && strings.Contains(file, c.PkgPath) { hitLines := len(coverage.HitLines) percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 @@ -81,7 +94,12 @@ func (c *CoverageData) Report() { } func (c *CoverageData) ColoredCoverage(filePath string) error { - realPath := filepath.Join(c.RootDir, "examples", filePath) + realPath, err := c.determineRealPath(filePath) + if err != nil { + // skipping invalid file paths + return nil + } + if isTestFile(filePath) || !strings.Contains(realPath, c.PkgPath) || !strings.HasSuffix(realPath, ".gno") { return nil } @@ -117,41 +135,39 @@ func (c *CoverageData) ColoredCoverage(filePath string) error { return nil } -func countCodeLines(content string) int { - lines := strings.Split(content, "\n") - codeLines := 0 - inBlockComment := false - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if inBlockComment { - if strings.Contains(trimmedLine, "*/") { - inBlockComment = false - } - continue - } +// Attempts to determine the full real path based on the filePath alone. +// It dynamically checks if the file exists in either examples or gnovm/stdlibs directories. +func (c *CoverageData) determineRealPath(filePath string) (string, error) { + if !strings.HasSuffix(filePath, ".gno") { + return "", fmt.Errorf("invalid file type: %s (not a .gno file)", filePath) + } + if isTestFile(filePath) { + return "", fmt.Errorf("cannot determine real path for test file: %s", filePath) + } - if trimmedLine == "" || strings.HasPrefix(trimmedLine, "//") { - continue - } + // Define possible base directories + baseDirs := []string{ + filepath.Join(c.RootDir, "examples"), // p, r packages + filepath.Join(c.RootDir, "gnovm", "stdlibs"), + } - if strings.HasPrefix(trimmedLine, "/*") { - inBlockComment = true - if strings.Contains(trimmedLine, "*/") { - inBlockComment = false - } - continue - } + // Try finding the file in each base directory + for _, baseDir := range baseDirs { + realPath := filepath.Join(baseDir, filePath) - codeLines++ + // Check if the file exists + if _, err := os.Stat(realPath); err == nil { + return realPath, nil + } } - return codeLines + return "", fmt.Errorf("file %s not found in known paths", filePath) } func isTestFile(pkgPath string) bool { - return strings.HasSuffix(pkgPath, "_test.gno") || strings.HasSuffix(pkgPath, "_testing.gno") || strings.HasSuffix(pkgPath, "_filetest.gno") + return strings.HasSuffix(pkgPath, "_test.gno") || + strings.HasSuffix(pkgPath, "_testing.gno") || + strings.HasSuffix(pkgPath, "_filetest.gno") } type JSONCoverage struct { @@ -191,3 +207,94 @@ func (c *CoverageData) SaveJSON(fileName string) error { return os.WriteFile(fileName, data, 0o644) } + +func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { + if isTestFile(file) { + return + } + m.Coverage.AddFile(file, totalLines) +} + +// recordCoverage records the execution of a specific node in the AST. +// This function tracking which parts of the code have been executed during the runtime. +// +// Note: This function assumes that CurrentPackage and CurrentFile are correctly set in the Machine +// before it's called. These fields provide the context necessary to accurately record the coverage information. +func (m *Machine) recordCoverage(node Node) Location { + if node == nil { + return Location{} + } + + pkgPath := m.CurrentPackage + file := m.CurrentFile + line := node.GetLine() + + path := filepath.Join(pkgPath, file) + m.Coverage.AddHit(path, line) + + return Location{ + PkgPath: pkgPath, + File: file, + Line: line, + Column: node.GetColumn(), + } +} + +// region Executable Lines Detection + +func countCodeLines(content string) int { + lines, err := detectExecutableLines(content) + if err != nil { + return 0 + } + + return len(lines) +} + +// TODO: use gno Node type +func isExecutableLine(node ast.Node) bool { + switch node.(type) { + case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, + *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: + return true + case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.SelectStmt: + return true + case *ast.FuncDecl: + return false + case *ast.BlockStmt: + return false + case *ast.DeclStmt: + return false + case *ast.ImportSpec, *ast.TypeSpec, *ast.ValueSpec: + return false + case *ast.GenDecl: + return false + default: + return false + } +} + +func detectExecutableLines(content string) (map[int]bool, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "", content, parser.ParseComments) + if err != nil { + return nil, err + } + + executableLines := make(map[int]bool) + + ast.Inspect(node, func(n ast.Node) bool { + if n == nil { + return true + } + + if isExecutableLine(n) { + line := fset.Position(n.Pos()).Line + executableLines[line] = true + } + + return true + }) + + return executableLines, nil +} diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 3a88f342848..f691d6e7b9d 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -2,8 +2,12 @@ package gnolang import ( "bytes" + "encoding/json" "os" + "path/filepath" "testing" + + "github.com/stretchr/testify/assert" ) func TestAddHit(t *testing.T) { @@ -188,3 +192,328 @@ func TestReport(t *testing.T) { }) } } + +type mockNode struct { + line int + column int +} + +func (m *mockNode) assertNode() {} +func (m *mockNode) String() string { return "" } +func (m *mockNode) Copy() Node { return &mockNode{} } +func (m *mockNode) GetLabel() Name { return "mockNode" } +func (m *mockNode) SetLabel(n Name) {} +func (m *mockNode) HasAttribute(n interface{}) bool { return false } +func (m *mockNode) GetAttribute(n interface{}) interface{} { return nil } +func (m *mockNode) SetAttribute(n interface{}, v interface{}) {} +func (m *mockNode) GetLine() int { return m.line } +func (m *mockNode) SetLine(l int) {} +func (m *mockNode) GetColumn() int { return m.column } +func (m *mockNode) SetColumn(c int) {} + +var _ Node = &mockNode{} + +func TestRecordCoverage(t *testing.T) { + tests := []struct { + name string + node Node + currentPackage string + currentFile string + expectedLoc Location + expectedHits map[string]map[int]int + }{ + { + name: "Basic node coverage", + node: &mockNode{line: 10, column: 5}, + currentPackage: "testpkg", + currentFile: "testfile.gno", + expectedLoc: Location{PkgPath: "testpkg", File: "testfile.gno", Line: 10, Column: 5}, + expectedHits: map[string]map[int]int{"testpkg/testfile.gno": {10: 1}}, + }, + { + name: "Nil node", + node: nil, + currentPackage: "testpkg", + currentFile: "testfile.gno", + expectedLoc: Location{}, + expectedHits: map[string]map[int]int{}, + }, + { + name: "Multiple hits on same line", + node: &mockNode{line: 15, column: 3}, + currentPackage: "testpkg", + currentFile: "testfile.gno", + expectedLoc: Location{PkgPath: "testpkg", File: "testfile.gno", Line: 15, Column: 3}, + expectedHits: map[string]map[int]int{"testpkg/testfile.gno": {15: 2}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Machine{ + CurrentPackage: tt.currentPackage, + CurrentFile: tt.currentFile, + Coverage: NewCoverageData(""), + } + + // First call to set up initial state for "Multiple hits on same line" test + if tt.name == "Multiple hits on same line" { + m.recordCoverage(tt.node) + } + + loc := m.recordCoverage(tt.node) + + assert.Equal(t, tt.expectedLoc, loc, "Location should match") + + for file, lines := range tt.expectedHits { + for line, hits := range lines { + actualHits, exists := m.Coverage.Files[file].HitLines[line] + assert.True(t, exists, "Line should be recorded in coverage data") + assert.Equal(t, hits, actualHits, "Number of hits should match") + } + } + }) + } +} + +func TestToJSON(t *testing.T) { + tests := []struct { + name string + coverageData *CoverageData + expectedJSON string + }{ + { + name: "Single file with hits", + coverageData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": { + TotalLines: 100, + HitLines: map[int]int{10: 1, 20: 2}, + }, + }, + }, + expectedJSON: `{ + "files": { + "file1.gno": { + "total_lines": 100, + "hit_lines": { + "10": 1, + "20": 2 + } + } + } +}`, + }, + { + name: "Multiple files with hits", + coverageData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": { + TotalLines: 100, + HitLines: map[int]int{10: 1, 20: 2}, + }, + "file2.gno": { + TotalLines: 200, + HitLines: map[int]int{30: 3}, + }, + }, + }, + expectedJSON: `{ + "files": { + "file1.gno": { + "total_lines": 100, + "hit_lines": { + "10": 1, + "20": 2 + } + }, + "file2.gno": { + "total_lines": 200, + "hit_lines": { + "30": 3 + } + } + } +}`, + }, + { + name: "No files", + coverageData: &CoverageData{ + Files: map[string]FileCoverage{}, + }, + expectedJSON: `{ + "files": {} +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jsonData, err := tt.coverageData.ToJSON() + assert.NoError(t, err) + + var got map[string]interface{} + var expected map[string]interface{} + + err = json.Unmarshal(jsonData, &got) + assert.NoError(t, err) + + err = json.Unmarshal([]byte(tt.expectedJSON), &expected) + assert.NoError(t, err) + + assert.Equal(t, expected, got) + }) + } +} + +func TestDetermineRealPath(t *testing.T) { + rootDir := t.TempDir() + + examplesDir := filepath.Join(rootDir, "examples") + stdlibsDir := filepath.Join(rootDir, "gnovm", "stdlibs") + + if err := os.MkdirAll(examplesDir, 0o755); err != nil { + t.Fatalf("failed to create examples directory: %v", err) + } + if err := os.MkdirAll(stdlibsDir, 0o755); err != nil { + t.Fatalf("failed to create stdlibs directory: %v", err) + } + + exampleFile := filepath.Join(examplesDir, "example.gno") + stdlibFile := filepath.Join(stdlibsDir, "stdlib.gno") + if _, err := os.Create(exampleFile); err != nil { + t.Fatalf("failed to create example file: %v", err) + } + if _, err := os.Create(stdlibFile); err != nil { + t.Fatalf("failed to create stdlib file: %v", err) + } + + c := &CoverageData{ + RootDir: rootDir, + } + + tests := []struct { + name string + filePath string + expectedPath string + expectError bool + }{ + { + name: "File in examples directory", + filePath: "example.gno", + expectedPath: exampleFile, + expectError: false, + }, + { + name: "File in stdlibs directory", + filePath: "stdlib.gno", + expectedPath: stdlibFile, + expectError: false, + }, + { + name: "Non-existent file", + filePath: "nonexistent.gno", + expectedPath: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualPath, err := c.determineRealPath(tt.filePath) + + if tt.expectError { + if err == nil { + t.Errorf("expected an error but got none") + } + } else { + if err != nil { + t.Errorf("did not expect an error but got: %v", err) + } + if actualPath != tt.expectedPath { + t.Errorf("expected path %s, but got %s", tt.expectedPath, actualPath) + } + } + }) + } +} + +func TestDetectExecutableLines(t *testing.T) { + tests := []struct { + name string + content string + want map[int]bool + wantErr bool + }{ + { + name: "Simple function", + content: ` +package main + +func main() { + x := 5 + if x > 3 { + println("Greater") + } +}`, + want: map[int]bool{ + 5: true, // x := 5 + 6: true, // if x > 3 + 7: true, // println("Greater") + }, + wantErr: false, + }, + { + name: "Function with loop", + content: ` +package main + +func loopFunction() { + for i := 0; i < 5; i++ { + if i%2 == 0 { + continue + } + println(i) + } +}`, + want: map[int]bool{ + 5: true, // for i := 0; i < 5; i++ + 6: true, // if i%2 == 0 + 7: true, // continue + 9: true, // println(i) + }, + wantErr: false, + }, + { + name: "Only declarations", + content: ` +package main + +import "fmt" + +var x int + +type MyStruct struct { + field int +}`, + want: map[int]bool{}, + wantErr: false, + }, + { + name: "Invalid Go code", + content: ` +This is not valid Go code +It should result in an error`, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := detectExecutableLines(tt.content) + assert.Equal(t, tt.wantErr, err != nil) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 5166f237dfa..c00ba1fe0d3 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -273,13 +273,17 @@ func (m *Machine) RunMemPackage(memPkg *std.MemPackage, save bool) (*PackageNode m.CurrentPackage = memPkg.Path for _, file := range memPkg.Files { - if strings.HasSuffix(file.Name, ".gno") && !(strings.HasSuffix(file.Name, "_test.gno") && strings.HasSuffix(file.Name, "_testing.gno")) { + if strings.HasSuffix(file.Name, ".gno") && !isTestFile(file.Name) { m.CurrentFile = file.Name + totalLines := countCodeLines(file.Body) - m.AddFileToCodeCoverage(m.CurrentPackage+"/"+m.CurrentFile, totalLines) + path := filepath.Join(m.CurrentPackage, m.CurrentFile) + + m.AddFileToCodeCoverage(path, totalLines) } } - return m.runMemPackage(memPkg, save, false) + node, val := m.runMemPackage(memPkg, save, false) + return node, val } // RunMemPackageWithOverrides works as [RunMemPackage], however after parsing, @@ -992,33 +996,6 @@ func (m *Machine) runDeclaration(d Decl) { } } -func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { - if isTestFile(file) { - return - } - m.Coverage.AddFile(file, totalLines) -} - -func (m *Machine) recordCoverage(node Node) Location { - if node == nil { - return Location{} - } - - pkgPath := m.CurrentPackage - file := m.CurrentFile - line := node.GetLine() - - path := filepath.Join(pkgPath, file) - m.Coverage.AddHit(path, line) - - return Location{ - PkgPath: pkgPath, - File: file, - Line: line, - Column: node.GetColumn(), - } -} - //---------------------------------------- // Op diff --git a/gnovm/pkg/gnolang/op_assign.go b/gnovm/pkg/gnolang/op_assign.go index 09a0215e76d..0957c6c8bae 100644 --- a/gnovm/pkg/gnolang/op_assign.go +++ b/gnovm/pkg/gnolang/op_assign.go @@ -27,9 +27,9 @@ func (m *Machine) doOpDefine() { } // Record coverage for each right-hand side expression - if i < len(s.Rhs) { - m.recordCoverage(s.Rhs[i]) // Record coverage for the expression being assigned - } + if i < len(s.Rhs) { + m.recordCoverage(s.Rhs[i]) // Record coverage for the expression being assigned + } ptr.Assign2(m.Alloc, m.Store, m.Realm, rvs[i], true) } From 12fbad61691cdd6999abafb7906ce64305618a80 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 10 Sep 2024 18:25:42 +0900 Subject: [PATCH 18/37] Refactor coverage data handling in Machine --- gnovm/cmd/gno/test.go | 2 +- gnovm/pkg/gnolang/coverage.go | 59 ++++++++++++++------- gnovm/pkg/gnolang/coverage_test.go | 82 ++++++++++++++++++++---------- gnovm/pkg/gnolang/machine.go | 24 +++++---- gnovm/pkg/gnolang/op_assign.go | 78 ++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 56 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index c1f318051e4..b65ae775edc 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -317,7 +317,7 @@ func gnoTestPkg( m := tests.TestMachine(testStore, stdout, gnoPkgPath) m.Coverage = coverageData - m.CurrentPackage = memPkg.Path + m.Coverage.CurrentPackage = memPkg.Path if printRuntimeMetrics { // from tm2/pkg/sdk/vm/keeper.go diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 2c3dc5e708d..51e1cb3eec2 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -22,26 +22,52 @@ const ( // CoverageData stores code coverage information type CoverageData struct { - Files map[string]FileCoverage - PkgPath string - RootDir string + Files map[string]FileCoverage + PkgPath string + RootDir string + CurrentPackage string + CurrentFile string } // FileCoverage stores coverage information for a single file type FileCoverage struct { - TotalLines int - HitLines map[int]int + TotalLines int + HitLines map[int]int + ExecutableLines map[int]bool } func NewCoverageData(rootDir string) *CoverageData { return &CoverageData{ - Files: make(map[string]FileCoverage), - PkgPath: "", - RootDir: rootDir, + Files: make(map[string]FileCoverage), + PkgPath: "", + RootDir: rootDir, + CurrentPackage: "", + CurrentFile: "", } } +func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[int]bool) { + cov, exists := c.Files[filePath] + if !exists { + cov = FileCoverage{ + TotalLines: 0, + HitLines: make(map[int]int), + ExecutableLines: make(map[int]bool), + } + } + + cov.ExecutableLines = executableLines + c.Files[filePath] = cov +} + func (c *CoverageData) AddHit(pkgPath string, line int) { + if !strings.HasSuffix(pkgPath, ".gno") { + return + } + if isTestFile(pkgPath) { + return + } + fileCoverage, exists := c.Files[pkgPath] if !exists { fileCoverage = FileCoverage{ @@ -51,7 +77,9 @@ func (c *CoverageData) AddHit(pkgPath string, line int) { c.Files[pkgPath] = fileCoverage } - fileCoverage.HitLines[line]++ + if fileCoverage.ExecutableLines[line] { + fileCoverage.HitLines[line]++ + } // Only update the file coverage, without incrementing TotalLines c.Files[pkgPath] = fileCoverage @@ -138,13 +166,6 @@ func (c *CoverageData) ColoredCoverage(filePath string) error { // Attempts to determine the full real path based on the filePath alone. // It dynamically checks if the file exists in either examples or gnovm/stdlibs directories. func (c *CoverageData) determineRealPath(filePath string) (string, error) { - if !strings.HasSuffix(filePath, ".gno") { - return "", fmt.Errorf("invalid file type: %s (not a .gno file)", filePath) - } - if isTestFile(filePath) { - return "", fmt.Errorf("cannot determine real path for test file: %s", filePath) - } - // Define possible base directories baseDirs := []string{ filepath.Join(c.RootDir, "examples"), // p, r packages @@ -225,8 +246,8 @@ func (m *Machine) recordCoverage(node Node) Location { return Location{} } - pkgPath := m.CurrentPackage - file := m.CurrentFile + pkgPath := m.Coverage.CurrentPackage + file := m.Coverage.CurrentFile line := node.GetLine() path := filepath.Join(pkgPath, file) @@ -257,7 +278,7 @@ func isExecutableLine(node ast.Node) bool { case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: return true - case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.SelectStmt: + case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.SelectStmt, *ast.CaseClause: return true case *ast.FuncDecl: return false diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index f691d6e7b9d..280af3b3a00 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -12,51 +12,79 @@ import ( func TestAddHit(t *testing.T) { tests := []struct { - name string - initialData *CoverageData - pkgPath string - line int - expectedHits int + name string + initialData *CoverageData + pkgPath string + line int + expectedHits int + executableLines map[int]bool }{ { - name: "Add hit to new file", - initialData: NewCoverageData(""), - pkgPath: "file1.gno", - line: 10, - expectedHits: 1, + name: "Add hit to existing file and executable line", + initialData: &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": { + HitLines: map[int]int{10: 1}, + ExecutableLines: map[int]bool{10: true, 20: true}, + }, + }, + }, + pkgPath: "file1.gno", + line: 10, + expectedHits: 2, + executableLines: map[int]bool{10: true, 20: true}, }, { - name: "Add hit to existing file and line", + name: "Add hit to new executable line in existing file", initialData: &CoverageData{ Files: map[string]FileCoverage{ - "file1.gno": {HitLines: map[int]int{10: 1}}, + "file1.gno": { + HitLines: map[int]int{10: 1}, + ExecutableLines: map[int]bool{10: true, 20: true}, + }, }, }, - pkgPath: "file1.gno", - line: 10, - expectedHits: 2, + pkgPath: "file1.gno", + line: 20, + expectedHits: 1, + executableLines: map[int]bool{10: true, 20: true}, }, { - name: "Add hit to new line in existing file", + name: "Add hit to non-executable line", initialData: &CoverageData{ Files: map[string]FileCoverage{ - "file1.gno": {HitLines: map[int]int{10: 1}}, + "file1.gno": { + HitLines: map[int]int{10: 1}, + ExecutableLines: map[int]bool{10: true}, + }, }, }, - pkgPath: "file1.gno", - line: 20, - expectedHits: 1, + pkgPath: "file1.gno", + line: 20, + expectedHits: 0, + executableLines: map[int]bool{10: true}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.initialData.AddHit(tt.pkgPath, tt.line) + // Set executable lines fileCoverage := tt.initialData.Files[tt.pkgPath] + fileCoverage.ExecutableLines = tt.executableLines + tt.initialData.Files[tt.pkgPath] = fileCoverage + + tt.initialData.AddHit(tt.pkgPath, tt.line) + updatedFileCoverage := tt.initialData.Files[tt.pkgPath] // Validate the hit count for the specific line - if fileCoverage.HitLines[tt.line] != tt.expectedHits { - t.Errorf("got %d hits for line %d, want %d", fileCoverage.HitLines[tt.line], tt.line, tt.expectedHits) + actualHits := updatedFileCoverage.HitLines[tt.line] + if actualHits != tt.expectedHits { + t.Errorf("got %d hits for line %d, want %d", actualHits, tt.line, tt.expectedHits) + } + + // Check if non-executable lines are not added to HitLines + if !tt.executableLines[tt.line] && actualHits > 0 { + t.Errorf("non-executable line %d was added to HitLines", tt.line) } }) } @@ -250,10 +278,12 @@ func TestRecordCoverage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + coverage := NewCoverageData("") + coverage.CurrentPackage = tt.currentPackage + coverage.CurrentFile = tt.currentFile + m := &Machine{ - CurrentPackage: tt.currentPackage, - CurrentFile: tt.currentFile, - Coverage: NewCoverageData(""), + Coverage: coverage, } // First call to set up initial state for "Multiple hits on same line" test diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index c00ba1fe0d3..989ddebb66f 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -82,9 +82,7 @@ type Machine struct { DeferPanicScope uint // Test Coverage - Coverage *CoverageData - CurrentPackage string - CurrentFile string + Coverage *CoverageData } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -270,15 +268,21 @@ func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { // and corresponding package node, package value, and types to store. Save // is set to false for tests where package values may be native. func (m *Machine) RunMemPackage(memPkg *std.MemPackage, save bool) (*PackageNode, *PackageValue) { - m.CurrentPackage = memPkg.Path + m.Coverage.CurrentPackage = memPkg.Path for _, file := range memPkg.Files { if strings.HasSuffix(file.Name, ".gno") && !isTestFile(file.Name) { - m.CurrentFile = file.Name + m.Coverage.CurrentFile = file.Name totalLines := countCodeLines(file.Body) - path := filepath.Join(m.CurrentPackage, m.CurrentFile) + path := filepath.Join(m.Coverage.CurrentPackage, m.Coverage.CurrentFile) + executableLines, err := detectExecutableLines(file.Body) + if err != nil { + continue + } + + m.Coverage.SetExecutableLines(path, executableLines) m.AddFileToCodeCoverage(path, totalLines) } } @@ -1617,8 +1621,8 @@ func (m *Machine) getCurrentLocation() Location { } return Location{ - PkgPath: m.CurrentPackage, - File: m.CurrentFile, + PkgPath: m.Coverage.CurrentPackage, + File: m.Coverage.CurrentFile, Line: lastFrame.Source.GetLine(), Column: lastFrame.Source.GetColumn(), } @@ -1924,8 +1928,8 @@ func (m *Machine) PushFrameCall(cx *CallExpr, fv *FuncValue, recv TypedValue) { m.Realm = rlm // enter new realm } - m.CurrentPackage = fv.PkgPath - m.CurrentFile = string(fv.FileName) + m.Coverage.CurrentPackage = fv.PkgPath + m.Coverage.CurrentFile = string(fv.FileName) } func (m *Machine) PushFrameGoNative(cx *CallExpr, fv *NativeValue) { diff --git a/gnovm/pkg/gnolang/op_assign.go b/gnovm/pkg/gnolang/op_assign.go index 0957c6c8bae..b9571ff90ca 100644 --- a/gnovm/pkg/gnolang/op_assign.go +++ b/gnovm/pkg/gnolang/op_assign.go @@ -78,12 +78,17 @@ func (m *Machine) doOpAssign() { func (m *Machine) doOpAddAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -97,16 +102,23 @@ func (m *Machine) doOpAddAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpSubAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -120,16 +132,23 @@ func (m *Machine) doOpSubAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpMulAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -143,16 +162,23 @@ func (m *Machine) doOpMulAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpQuoAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -166,16 +192,23 @@ func (m *Machine) doOpQuoAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpRemAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -189,16 +222,23 @@ func (m *Machine) doOpRemAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpBandAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -212,16 +252,23 @@ func (m *Machine) doOpBandAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpBandnAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -230,21 +277,29 @@ func (m *Machine) doOpBandnAssign() { } } } + // lv &^= rv bandnAssign(lv.TV, rv) if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpBorAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -258,16 +313,23 @@ func (m *Machine) doOpBorAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpXorAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } + m.recordCoverage(s) + m.recordCoverage(s) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -281,13 +343,20 @@ func (m *Machine) doOpXorAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpShlAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -301,13 +370,20 @@ func (m *Machine) doOpShlAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } func (m *Machine) doOpShrAssign() { s := m.PopStmt().(*AssignStmt) + m.recordCoverage(s) + rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) + m.recordCoverage(s.Lhs[0]) + m.recordCoverage(s.Rhs[0]) + // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -321,4 +397,6 @@ func (m *Machine) doOpShrAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } + + m.recordCoverage(s) } From 0870a7bded38621636073fd5947d31ad72cd8d93 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 11 Sep 2024 12:10:17 +0900 Subject: [PATCH 19/37] remove unnecessat recodings --- gnovm/pkg/gnolang/machine.go | 1 + gnovm/pkg/gnolang/op_assign.go | 109 --------------------------------- gnovm/pkg/gnolang/op_eval.go | 29 +-------- gnovm/pkg/gnolang/op_exec.go | 10 --- 4 files changed, 2 insertions(+), 147 deletions(-) diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 989ddebb66f..a4bf08e02de 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1707,6 +1707,7 @@ func (m *Machine) PushStmts(ss ...Stmt) { func (m *Machine) PopStmt() Stmt { numStmts := len(m.Stmts) s := m.Stmts[numStmts-1] + m.recordCoverage(s) if debug { m.Printf("-s %v\n", s) } diff --git a/gnovm/pkg/gnolang/op_assign.go b/gnovm/pkg/gnolang/op_assign.go index b9571ff90ca..eb67ffcc351 100644 --- a/gnovm/pkg/gnolang/op_assign.go +++ b/gnovm/pkg/gnolang/op_assign.go @@ -2,16 +2,12 @@ package gnolang func (m *Machine) doOpDefine() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - // Define each value evaluated for Lhs. // NOTE: PopValues() returns a slice in // forward order, not the usual reverse. rvs := m.PopValues(len(s.Lhs)) lb := m.LastBlock() for i := 0; i < len(s.Lhs); i++ { - // Record coverage for each variable being defined - m.recordCoverage(s.Lhs[i]) // Get name and value of i'th term. nx := s.Lhs[i].(*NameExpr) // Finally, define (or assign if loop block). @@ -20,75 +16,43 @@ func (m *Machine) doOpDefine() { if m.ReadOnly { if oo, ok := ptr.Base.(Object); ok { if oo.GetIsReal() { - m.recordCoverage(s) panic("readonly violation") } } } - - // Record coverage for each right-hand side expression - if i < len(s.Rhs) { - m.recordCoverage(s.Rhs[i]) // Record coverage for the expression being assigned - } - ptr.Assign2(m.Alloc, m.Store, m.Realm, rvs[i], true) } - - // record entire AssignStmt again to mark its completion - m.recordCoverage(s) } func (m *Machine) doOpAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - // Assign each value evaluated for Lhs. // NOTE: PopValues() returns a slice in // forward order, not the usual reverse. rvs := m.PopValues(len(s.Lhs)) for i := len(s.Lhs) - 1; 0 <= i; i-- { - // Track which variable is assigned - // in a compound assignment statement - m.recordCoverage(s.Lhs[i]) - // Pop lhs value and desired type. lv := m.PopAsPointer(s.Lhs[i]) // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { if oo.GetIsReal() { - m.recordCoverage(s) panic("readonly violation") } } } - - // Used to track the source of the assigned value. - // However, since the number of expressions on the right-hand side - // may be fewer than on the left (e.g., in multiple assignments), add an index check. - if i < len(s.Rhs) { - m.recordCoverage(s.Rhs[i]) - } lv.Assign2(m.Alloc, m.Store, m.Realm, rvs[i], true) } - - // coverage record for end of assignment. - m.recordCoverage(s) } func (m *Machine) doOpAddAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -102,23 +66,16 @@ func (m *Machine) doOpAddAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpSubAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -132,23 +89,16 @@ func (m *Machine) doOpSubAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpMulAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -162,23 +112,16 @@ func (m *Machine) doOpMulAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpQuoAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -192,23 +135,16 @@ func (m *Machine) doOpQuoAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpRemAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -222,23 +158,16 @@ func (m *Machine) doOpRemAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpBandAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -252,23 +181,16 @@ func (m *Machine) doOpBandAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpBandnAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -277,29 +199,21 @@ func (m *Machine) doOpBandnAssign() { } } } - // lv &^= rv bandnAssign(lv.TV, rv) if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpBorAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -313,23 +227,16 @@ func (m *Machine) doOpBorAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpXorAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) if debug { debugAssertSameTypes(lv.TV.T, rv.T) } - m.recordCoverage(s) - m.recordCoverage(s) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -343,20 +250,13 @@ func (m *Machine) doOpXorAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpShlAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -370,20 +270,13 @@ func (m *Machine) doOpShlAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } func (m *Machine) doOpShrAssign() { s := m.PopStmt().(*AssignStmt) - m.recordCoverage(s) - rv := m.PopValue() // only one. lv := m.PopAsPointer(s.Lhs[0]) - m.recordCoverage(s.Lhs[0]) - m.recordCoverage(s.Rhs[0]) - // XXX HACK (until value persistence impl'd) if m.ReadOnly { if oo, ok := lv.Base.(Object); ok { @@ -397,6 +290,4 @@ func (m *Machine) doOpShrAssign() { if lv.Base != nil { m.Realm.DidUpdate(lv.Base.(Object), nil, nil) } - - m.recordCoverage(s) } diff --git a/gnovm/pkg/gnolang/op_eval.go b/gnovm/pkg/gnolang/op_eval.go index 42d993e42df..701615fff13 100644 --- a/gnovm/pkg/gnolang/op_eval.go +++ b/gnovm/pkg/gnolang/op_eval.go @@ -19,12 +19,10 @@ var ( func (m *Machine) doOpEval() { x := m.PeekExpr(1) - m.recordCoverage(x) - if debug { debug.Printf("EVAL: (%T) %v\n", x, x) + // fmt.Println(m.String()) } - // This case moved out of switch for performance. // TODO: understand this better. if nx, ok := x.(*NameExpr); ok { @@ -47,7 +45,6 @@ func (m *Machine) doOpEval() { // case NameExpr: handled above case *BasicLitExpr: m.PopExpr() - m.recordCoverage(x) switch x.Kind { case INT: x.Value = strings.ReplaceAll(x.Value, blankIdentifier, "") @@ -227,7 +224,6 @@ func (m *Machine) doOpEval() { panic(fmt.Sprintf("unexpected lit kind %v", x.Kind)) } case *BinaryExpr: - m.recordCoverage(x) switch x.Op { case LAND, LOR: m.PushOp(OpBinary1) @@ -246,7 +242,6 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *CallExpr: - m.recordCoverage(x) m.PushOp(OpPrecall) // Eval args. args := x.Args @@ -258,7 +253,6 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Func) m.PushOp(OpEval) case *IndexExpr: - m.recordCoverage(x) if x.HasOK { m.PushOp(OpIndex2) } else { @@ -271,13 +265,11 @@ func (m *Machine) doOpEval() { m.PushExpr(x.X) m.PushOp(OpEval) case *SelectorExpr: - m.recordCoverage(x) m.PushOp(OpSelector) // evaluate x m.PushExpr(x.X) m.PushOp(OpEval) case *SliceExpr: - m.recordCoverage(x) m.PushOp(OpSlice) // evaluate max if x.Max != nil { @@ -298,48 +290,40 @@ func (m *Machine) doOpEval() { m.PushExpr(x.X) m.PushOp(OpEval) case *StarExpr: - m.recordCoverage(x) m.PopExpr() m.PushOp(OpStar) // evaluate x. m.PushExpr(x.X) m.PushOp(OpEval) case *RefExpr: - m.recordCoverage(x) m.PushOp(OpRef) // evaluate x m.PushForPointer(x.X) case *UnaryExpr: - m.recordCoverage(x) op := word2UnaryOp(x.Op) m.PushOp(op) // evaluate x m.PushExpr(x.X) m.PushOp(OpEval) case *CompositeLitExpr: - m.recordCoverage(x) m.PushOp(OpCompositeLit) // evaluate type m.PushExpr(x.Type) m.PushOp(OpEval) case *FuncLitExpr: - m.recordCoverage(x) m.PushOp(OpFuncLit) // evaluate func type m.PushExpr(&x.Type) m.PushOp(OpEval) case *ConstExpr: - m.recordCoverage(x) m.PopExpr() // push preprocessed value m.PushValue(x.TypedValue) case *constTypeExpr: - m.recordCoverage(x) m.PopExpr() // push preprocessed type as value m.PushValue(asValue(x.Type)) case *FieldTypeExpr: - m.recordCoverage(x) m.PushOp(OpFieldType) // evaluate field type m.PushExpr(x.Type) @@ -350,7 +334,6 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *ArrayTypeExpr: - m.recordCoverage(x) m.PushOp(OpArrayType) // evaluate length if set if x.Len != nil { @@ -361,13 +344,11 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Elt) m.PushOp(OpEval) // OpEvalType? case *SliceTypeExpr: - m.recordCoverage(x) m.PushOp(OpSliceType) // evaluate elem type m.PushExpr(x.Elt) m.PushOp(OpEval) // OpEvalType? case *InterfaceTypeExpr: - m.recordCoverage(x) m.PushOp(OpInterfaceType) // evaluate methods for i := len(x.Methods) - 1; 0 <= i; i-- { @@ -375,7 +356,6 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *FuncTypeExpr: - m.recordCoverage(x) // NOTE params and results are evaluated in // the parent scope. m.PushOp(OpFuncType) @@ -390,7 +370,6 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *MapTypeExpr: - m.recordCoverage(x) m.PopExpr() m.PushOp(OpMapType) // evaluate value type @@ -400,7 +379,6 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Key) m.PushOp(OpEval) // OpEvalType? case *StructTypeExpr: - m.recordCoverage(x) m.PushOp(OpStructType) // evaluate fields for i := len(x.Fields) - 1; 0 <= i; i-- { @@ -408,7 +386,6 @@ func (m *Machine) doOpEval() { m.PushOp(OpEval) } case *TypeAssertExpr: - m.recordCoverage(x) if x.HasOK { m.PushOp(OpTypeAssert2) } else { @@ -430,10 +407,6 @@ func (m *Machine) doOpEval() { m.PushExpr(x.Type) m.PushOp(OpEval) default: - m.recordCoverage(x) // record coverage for unknown type panic(fmt.Sprintf("unexpected expression %#v", x)) } - - // Record coverage after evaluating the expression - m.recordCoverage(x) } diff --git a/gnovm/pkg/gnolang/op_exec.go b/gnovm/pkg/gnolang/op_exec.go index c333a8d1c57..2e75dc3797b 100644 --- a/gnovm/pkg/gnolang/op_exec.go +++ b/gnovm/pkg/gnolang/op_exec.go @@ -730,7 +730,6 @@ EXEC_SWITCH: m.PushOp(OpEval) } case *TypeDecl: // SimpleDeclStmt - m.recordCoverage(cs) m.PushOp(OpTypeDecl) m.PushExpr(cs.Type) m.PushOp(OpEval) @@ -812,7 +811,6 @@ func (m *Machine) doOpIfCond() { m.PushOp(OpBody) m.PushStmt(b.GetBodyStmt()) } - m.recordCoverage(&is.Then) } else { m.recordCoverage(&is.Else) if len(is.Else.Body) != 0 { @@ -829,10 +827,7 @@ func (m *Machine) doOpIfCond() { m.PushOp(OpBody) m.PushStmt(b.GetBodyStmt()) } - m.recordCoverage(&is.Else) } - - m.recordCoverage(is) } func (m *Machine) doOpTypeSwitch() { @@ -920,7 +915,6 @@ func (m *Machine) doOpTypeSwitch() { func (m *Machine) doOpSwitchClause() { ss := m.PeekStmt1().(*SwitchStmt) - m.recordCoverage(ss) // tv := m.PeekValue(1) // switch tag value // caiv := m.PeekValue(2) // switch clause case index (reuse) cliv := m.PeekValue(3) // switch clause index (reuse) @@ -934,7 +928,6 @@ func (m *Machine) doOpSwitchClause() { // done! } else { cl := ss.Clauses[idx] - m.recordCoverage(&cl) if len(cl.Cases) == 0 { // default clause m.PopStmt() // pop switch stmt @@ -961,10 +954,7 @@ func (m *Machine) doOpSwitchClause() { m.PushOp(OpEval) m.PushExpr(cl.Cases[0]) } - m.recordCoverage(&cl) } - - m.recordCoverage(ss) } func (m *Machine) doOpSwitchClauseCase() { From ecd76ba88effff98d25424f4d1022be51bb686a7 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 11 Sep 2024 18:39:21 +0900 Subject: [PATCH 20/37] update CLI --- gnovm/cmd/gno/test.go | 58 ++++++-- gnovm/pkg/gnolang/coverage.go | 258 ++++++++++++++++++++++++++++++---- gnovm/pkg/gnolang/machine.go | 4 +- gnovm/pkg/gnolang/op_exec.go | 3 - 4 files changed, 278 insertions(+), 45 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index b65ae775edc..77b07d4979d 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -38,9 +38,12 @@ type testCfg struct { withNativeFallback bool // coverage flags - coverage bool - showColoredCoverage bool - output string + coverage bool + listFiles bool + viewFile string + showHits bool + output string + htmlOutput string } func newTestCmd(io commands.IO) *commands.Command { @@ -163,10 +166,24 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { ) fs.BoolVar( - &c.showColoredCoverage, - "show-colored-coverage", + &c.listFiles, + "ls", false, - "show colored coverage in terminal", + "list files with coverage results", + ) + + fs.BoolVar( + &c.showHits, + "show-hits", + false, + "show number of times each line was executed", + ) + + fs.StringVar( + &c.viewFile, + "view", + "", + "view coverage for a specific file", ) fs.StringVar( @@ -175,6 +192,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "", "save coverage data as JSON to specified file", ) + + fs.StringVar( + &c.htmlOutput, + "html", + "", + "output coverage report in HTML format", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -414,13 +438,15 @@ func gnoTestPkg( } if cfg.coverage { - coverageData.Report() - if cfg.showColoredCoverage { - for filePath := range coverageData.Files { - if err := coverageData.ColoredCoverage(filePath); err != nil { - io.ErrPrintfln("Error printing colored coverage for %s: %v", filePath, err) - } + if cfg.listFiles { + coverageData.ListFiles(io) + } else if cfg.viewFile != "" { + err := coverageData.ViewFiles(cfg.viewFile, cfg.showHits, io) + if err != nil { + return fmt.Errorf("failed to view file coverage: %w", err) } + } else { + coverageData.Report() } if cfg.output != "" { @@ -430,6 +456,14 @@ func gnoTestPkg( } io.Println("coverage data saved to", cfg.output) } + + if cfg.htmlOutput != "" { + err := coverageData.SaveHTML(cfg.htmlOutput) + if err != nil { + return fmt.Errorf("failed to save coverage data: %w", err) + } + io.Println("coverage report saved to", cfg.htmlOutput) + } } return errs diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 51e1cb3eec2..df8ad79bbbb 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -7,17 +7,24 @@ import ( "go/ast" "go/parser" "go/token" + "html/template" "os" "path/filepath" "sort" "strconv" "strings" + + "github.com/gnolang/gno/tm2/pkg/commands" ) const ( colorReset = "\033[0m" + colorOrange = "\033[38;5;208m" + colorRed = "\033[31m" colorGreen = "\033[32m" colorYellow = "\033[33m" + colorWhite = "\033[37m" + boldText = "\033[1m" ) // CoverageData stores code coverage information @@ -101,36 +108,32 @@ func (c *CoverageData) AddFile(filePath string, totalLines int) { c.Files[filePath] = fileCoverage } -func (c *CoverageData) Report() { - fmt.Println("Coverage Results:") +// region Reporting - // Sort files by name for consistent output - var files []string - for file := range c.Files { - files = append(files, file) +func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) error { + matchingFiles := c.findMatchingFiles(pattern) + if len(matchingFiles) == 0 { + return fmt.Errorf("no files found matching pattern %s", pattern) } - sort.Strings(files) - for _, file := range files { - coverage := c.Files[file] - if !isTestFile(file) && strings.Contains(file, c.PkgPath) { - hitLines := len(coverage.HitLines) - percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 - fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) + for _, path := range matchingFiles { + err := c.viewSingleFileCoverage(path, showHits, io) + if err != nil { + return err } + io.Println() // Add a newline between files } + + return nil } -func (c *CoverageData) ColoredCoverage(filePath string) error { +func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io commands.IO) error { realPath, err := c.determineRealPath(filePath) if err != nil { // skipping invalid file paths return nil } - if isTestFile(filePath) || !strings.Contains(realPath, c.PkgPath) || !strings.HasSuffix(realPath, ".gno") { - return nil - } file, err := os.Open(realPath) if err != nil { return err @@ -139,28 +142,99 @@ func (c *CoverageData) ColoredCoverage(filePath string) error { scanner := bufio.NewScanner(file) lineNumber := 1 - - fileCoverage, exists := c.Files[filePath] + coverage, exists := c.Files[filePath] if !exists { return fmt.Errorf("no coverage data for file %s", filePath) } - fmt.Printf("Coverage Results for %s:\n", filePath) + io.Printfln("%s%s%s:", boldText, filePath, colorReset) for scanner.Scan() { line := scanner.Text() - if _, covered := fileCoverage.HitLines[lineNumber]; covered { - fmt.Printf("%s%4d: %s%s\n", colorGreen, lineNumber, line, colorReset) + hitCount, covered := coverage.HitLines[lineNumber] + + var hitInfo string + if showHits { + if covered { + hitInfo = fmt.Sprintf("%s%d%s ", colorOrange, hitCount, colorReset) + } else { + hitInfo = strings.Repeat(" ", 2) + } + } + + lineNumStr := fmt.Sprintf("%4d", lineNumber) + + if showHits { + if covered { + io.Printfln("%s%s%s %-4s %s%s%s", colorGreen, lineNumStr, colorReset, hitInfo, colorGreen, line, colorReset) + } else if coverage.ExecutableLines[lineNumber] { + io.Printfln("%s%s%s %-4s %s%s%s", colorYellow, lineNumStr, colorReset, hitInfo, colorYellow, line, colorReset) + } else { + io.Printfln("%s%s%s %-4s %s%s", colorWhite, lineNumStr, colorReset, hitInfo, line, colorReset) + } } else { - fmt.Printf("%s%4d: %s%s\n", colorYellow, lineNumber, line, colorReset) + if covered { + io.Printfln("%s%s %s%s", colorGreen, lineNumStr, line, colorReset) + } else if coverage.ExecutableLines[lineNumber] { + io.Printfln("%s%s %s%s", colorYellow, lineNumStr, line, colorReset) + } else { + io.Printfln("%s%s %s%s", colorWhite, lineNumStr, line, colorReset) + } } lineNumber++ } - if err := scanner.Err(); err != nil { - return err + return scanner.Err() +} + +func (c *CoverageData) findMatchingFiles(pattern string) []string { + var files []string + for file := range c.Files { + if strings.Contains(file, pattern) { + files = append(files, file) + } } + return files +} - return nil +func (c *CoverageData) ListFiles(io commands.IO) { + for file, cov := range c.Files { + hitLines := len(cov.HitLines) + totalLines := cov.TotalLines + pct := float64(hitLines) / float64(totalLines) * 100 + color := getCoverageColor(pct) + io.Printfln("%s%3.0f%% [%d/%d] %s%s", color, pct, hitLines, totalLines, file, colorReset) + } +} + +func getCoverageColor(percentage float64) string { + switch { + case percentage >= 80: + return colorGreen + case percentage >= 50: + return colorYellow + default: + return colorRed + } +} + +func (c *CoverageData) Report() { + fmt.Println("Coverage Results:") + + // Sort files by name for consistent output + var files []string + for file := range c.Files { + files = append(files, file) + } + sort.Strings(files) + + for _, file := range files { + coverage := c.Files[file] + if !isTestFile(file) && strings.Contains(file, c.PkgPath) { + hitLines := len(coverage.HitLines) + percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 + fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) + } + } } // Attempts to determine the full real path based on the filePath alone. @@ -229,6 +303,121 @@ func (c *CoverageData) SaveJSON(fileName string) error { return os.WriteFile(fileName, data, 0o644) } +func (c *CoverageData) SaveHTML(outputFileName string) error { + tmpl := ` + + + + + + Coverage Report + + + +

Coverage Report

+ {{range $file, $coverage := .Files}} +
+
{{$file}}
+
{{range $line, $content := $coverage.Lines}}
+{{$line}}{{if $content.Covered}}{{$content.Hits}}{{else}}-{{end}}{{$content.Code}}{{end}}
+        
+
+ {{end}} + +` + + t, err := template.New("coverage").Parse(tmpl) + if err != nil { + return err + } + + data := struct { + Files map[string]struct { + Lines map[int]struct { + Code string + Covered bool + Executable bool + Hits int + } + } + }{ + Files: make(map[string]struct { + Lines map[int]struct { + Code string + Covered bool + Executable bool + Hits int + } + }), + } + + for path, coverage := range c.Files { + realPath, err := c.determineRealPath(path) + if err != nil { + return err + } + content, err := os.ReadFile(realPath) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + fileData := struct { + Lines map[int]struct { + Code string + Covered bool + Executable bool + Hits int + } + }{ + Lines: make(map[int]struct { + Code string + Covered bool + Executable bool + Hits int + }), + } + + for i, line := range lines { + lineNum := i + 1 + hits, covered := coverage.HitLines[lineNum] + executable := coverage.ExecutableLines[lineNum] + + fileData.Lines[lineNum] = struct { + Code string + Covered bool + Executable bool + Hits int + }{ + Code: line, + Covered: covered, + Executable: executable, + Hits: hits, + } + } + + data.Files[path] = fileData + } + + file, err := os.Create(outputFileName) + if err != nil { + return err + } + defer file.Close() + + return t.Execute(file, data) +} + func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { if isTestFile(file) { return @@ -263,6 +452,7 @@ func (m *Machine) recordCoverage(node Node) Location { // region Executable Lines Detection +// countCodeLines counts the number of executable lines in the given source code content. func countCodeLines(content string) int { lines, err := detectExecutableLines(content) if err != nil { @@ -272,14 +462,26 @@ func countCodeLines(content string) int { return len(lines) } -// TODO: use gno Node type +// isExecutableLine determines whether a given AST node represents an +// executable line of code for the purpose of code coverage measurement. +// +// It returns true for statement nodes that typically contain executable code, +// such as assignments, expressions, return statements, and control flow statements. +// +// It returns false for nodes that represent non-executable lines, such as +// declarations, blocks, and function definitions. func isExecutableLine(node ast.Node) bool { switch node.(type) { case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: return true - case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.SelectStmt, *ast.CaseClause: + case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.SelectStmt: return true + case *ast.CaseClause: + // Even if a `case` condition (e.g., `case 1:`) in a `switch` statement is executed, + // the condition itself is not included in the coverage; coverage only recorded for the + // code block inside the corresponding `case` clause. + return false case *ast.FuncDecl: return false case *ast.BlockStmt: diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index a4bf08e02de..33352e7b535 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1689,6 +1689,7 @@ func (m *Machine) PeekStmt1() Stmt { } func (m *Machine) PushStmt(s Stmt) { + m.recordCoverage(s) if debug { m.Printf("+s %v\n", s) } @@ -1707,7 +1708,6 @@ func (m *Machine) PushStmts(ss ...Stmt) { func (m *Machine) PopStmt() Stmt { numStmts := len(m.Stmts) s := m.Stmts[numStmts-1] - m.recordCoverage(s) if debug { m.Printf("-s %v\n", s) } @@ -1740,6 +1740,7 @@ func (m *Machine) PushExpr(x Expr) { if debug { m.Printf("+x %v\n", x) } + m.recordCoverage(x) m.Exprs = append(m.Exprs, x) } @@ -1775,7 +1776,6 @@ func (m *Machine) PushValue(tv TypedValue) { } m.Values[m.NumValues] = tv m.NumValues++ - return } // Resulting reference is volatile. diff --git a/gnovm/pkg/gnolang/op_exec.go b/gnovm/pkg/gnolang/op_exec.go index 2e75dc3797b..eebacf5dd38 100644 --- a/gnovm/pkg/gnolang/op_exec.go +++ b/gnovm/pkg/gnolang/op_exec.go @@ -789,10 +789,7 @@ EXEC_SWITCH: func (m *Machine) doOpIfCond() { is := m.PopStmt().(*IfStmt) m.recordCoverage(is) // start record coverage when IfStmt is popped - b := m.LastBlock() - - m.recordCoverage(is) // record Condition Evaluation Coverage // Test cond and run Body or Else. cond := m.PopValue() if cond.GetBool() { From 8a35fc6159ffa9a5e861de9d82f2c34d33eaeee0 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 12 Sep 2024 19:13:19 +0900 Subject: [PATCH 21/37] fix minor bugs --- gnovm/cmd/gno/test.go | 16 +++------------- gnovm/pkg/gnolang/coverage.go | 30 ++++++++++++++++++------------ gnovm/pkg/gnolang/coverage_test.go | 4 ++-- gnovm/pkg/gnolang/machine.go | 2 +- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 77b07d4979d..61cebe829a0 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -39,7 +39,6 @@ type testCfg struct { // coverage flags coverage bool - listFiles bool viewFile string showHits bool output string @@ -165,13 +164,6 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "enable coverage analysis", ) - fs.BoolVar( - &c.listFiles, - "ls", - false, - "list files with coverage results", - ) - fs.BoolVar( &c.showHits, "show-hits", @@ -438,15 +430,11 @@ func gnoTestPkg( } if cfg.coverage { - if cfg.listFiles { - coverageData.ListFiles(io) - } else if cfg.viewFile != "" { + if cfg.viewFile != "" { err := coverageData.ViewFiles(cfg.viewFile, cfg.showHits, io) if err != nil { return fmt.Errorf("failed to view file coverage: %w", err) } - } else { - coverageData.Report() } if cfg.output != "" { @@ -464,6 +452,8 @@ func gnoTestPkg( } io.Println("coverage report saved to", cfg.htmlOutput) } + + coverageData.ListFiles(io) } return errs diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index df8ad79bbbb..e0115a3cd63 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -67,14 +67,26 @@ func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[i c.Files[filePath] = cov } -func (c *CoverageData) AddHit(pkgPath string, line int) { - if !strings.HasSuffix(pkgPath, ".gno") { +func (c *CoverageData) updateHit(pkgPath string, line int) { + if !c.isValidFile(pkgPath) { return } - if isTestFile(pkgPath) { - return + + fileCoverage := c.getOrCreateFileCoverage(pkgPath) + + if fileCoverage.ExecutableLines[line] { + fileCoverage.HitLines[line]++ + c.Files[pkgPath] = fileCoverage } +} +func (c *CoverageData) isValidFile(pkgPath string) bool { + return strings.HasPrefix(pkgPath, c.PkgPath) && + strings.HasSuffix(pkgPath, ".gno") && + !isTestFile(pkgPath) +} + +func (c *CoverageData) getOrCreateFileCoverage(pkgPath string) FileCoverage { fileCoverage, exists := c.Files[pkgPath] if !exists { fileCoverage = FileCoverage{ @@ -83,13 +95,7 @@ func (c *CoverageData) AddHit(pkgPath string, line int) { } c.Files[pkgPath] = fileCoverage } - - if fileCoverage.ExecutableLines[line] { - fileCoverage.HitLines[line]++ - } - - // Only update the file coverage, without incrementing TotalLines - c.Files[pkgPath] = fileCoverage + return fileCoverage } func (c *CoverageData) AddFile(filePath string, totalLines int) { @@ -440,7 +446,7 @@ func (m *Machine) recordCoverage(node Node) Location { line := node.GetLine() path := filepath.Join(pkgPath, file) - m.Coverage.AddHit(path, line) + m.Coverage.updateHit(path, line) return Location{ PkgPath: pkgPath, diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 280af3b3a00..adb64974279 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAddHit(t *testing.T) { +func TestCoverageDataUpdateHit(t *testing.T) { tests := []struct { name string initialData *CoverageData @@ -73,7 +73,7 @@ func TestAddHit(t *testing.T) { fileCoverage.ExecutableLines = tt.executableLines tt.initialData.Files[tt.pkgPath] = fileCoverage - tt.initialData.AddHit(tt.pkgPath, tt.line) + tt.initialData.updateHit(tt.pkgPath, tt.line) updatedFileCoverage := tt.initialData.Files[tt.pkgPath] // Validate the hit count for the specific line diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 33352e7b535..0f7fbc02440 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1282,7 +1282,7 @@ func (m *Machine) Run() { op := m.PopOp() loc := m.getCurrentLocation() - m.Coverage.AddHit(loc.PkgPath+"/"+loc.File, loc.Line) + m.Coverage.updateHit(loc.PkgPath+"/"+loc.File, loc.Line) // TODO: this can be optimized manually, even into tiers. switch op { From 3ec1df7b9e89a6042c300753c6f46f281c53b44a Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Sat, 14 Sep 2024 14:43:06 +0900 Subject: [PATCH 22/37] fix test --- gnovm/cmd/gno/test.go | 2 +- gnovm/pkg/gnolang/coverage.go | 87 +++++----- gnovm/pkg/gnolang/coverage_test.go | 246 +++++++++++++++++++---------- 3 files changed, 208 insertions(+), 127 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 61cebe829a0..0c173f78b35 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -453,7 +453,7 @@ func gnoTestPkg( io.Println("coverage report saved to", cfg.htmlOutput) } - coverageData.ListFiles(io) + coverageData.Report(io) } return errs diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index e0115a3cd63..fcd36cd9473 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -8,6 +8,7 @@ import ( "go/parser" "go/token" "html/template" + "math" "os" "path/filepath" "sort" @@ -34,6 +35,7 @@ type CoverageData struct { RootDir string CurrentPackage string CurrentFile string + pathCache map[string]string // relative path to absolute path } // FileCoverage stores coverage information for a single file @@ -50,6 +52,7 @@ func NewCoverageData(rootDir string) *CoverageData { RootDir: rootDir, CurrentPackage: "", CurrentFile: "", + pathCache: make(map[string]string), } } @@ -98,7 +101,7 @@ func (c *CoverageData) getOrCreateFileCoverage(pkgPath string) FileCoverage { return fileCoverage } -func (c *CoverageData) AddFile(filePath string, totalLines int) { +func (c *CoverageData) addFile(filePath string, totalLines int) { if isTestFile(filePath) { return } @@ -134,7 +137,7 @@ func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) } func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io commands.IO) error { - realPath, err := c.determineRealPath(filePath) + realPath, err := c.findAbsoluteFilePath(filePath) if err != nil { // skipping invalid file paths return nil @@ -202,16 +205,30 @@ func (c *CoverageData) findMatchingFiles(pattern string) []string { return files } -func (c *CoverageData) ListFiles(io commands.IO) { - for file, cov := range c.Files { +// Report prints the coverage report to the console +func (c *CoverageData) Report(io commands.IO) { + files := make([]string, 0, len(c.Files)) + for file := range c.Files { + files = append(files, file) + } + + sort.Strings(files) + + for _, file := range files { + cov := c.Files[file] hitLines := len(cov.HitLines) totalLines := cov.TotalLines pct := float64(hitLines) / float64(totalLines) * 100 color := getCoverageColor(pct) - io.Printfln("%s%3.0f%% [%d/%d] %s%s", color, pct, hitLines, totalLines, file, colorReset) + io.Printfln("%s%.1f%% [%4d/%d] %s%s", color, floor1(pct), hitLines, totalLines, file, colorReset) } } +// floor1 round down to one decimal place +func floor1(v float64) float64 { + return math.Floor(v*10) / 10 +} + func getCoverageColor(percentage float64) string { switch { case percentage >= 80: @@ -223,46 +240,38 @@ func getCoverageColor(percentage float64) string { } } -func (c *CoverageData) Report() { - fmt.Println("Coverage Results:") - - // Sort files by name for consistent output - var files []string - for file := range c.Files { - files = append(files, file) +// findAbsoluteFilePath finds the absolute path of a file given its relative path. +// It starts searching from root directory and recursively traverses directories. +func (c *CoverageData) findAbsoluteFilePath(filePath string) (string, error) { + if cachedPath, ok := c.pathCache[filePath]; ok { + return cachedPath, nil } - sort.Strings(files) - for _, file := range files { - coverage := c.Files[file] - if !isTestFile(file) && strings.Contains(file, c.PkgPath) { - hitLines := len(coverage.HitLines) - percentage := float64(hitLines) / float64(coverage.TotalLines) * 100 - fmt.Printf("%s: %.2f%% (%d/%d lines)\n", file, percentage, hitLines, coverage.TotalLines) + var result string + var found bool + + err := filepath.Walk(c.RootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(path, filePath) { + result = path + found = true + return filepath.SkipAll } + return nil + }) + if err != nil { + return "", err } -} -// Attempts to determine the full real path based on the filePath alone. -// It dynamically checks if the file exists in either examples or gnovm/stdlibs directories. -func (c *CoverageData) determineRealPath(filePath string) (string, error) { - // Define possible base directories - baseDirs := []string{ - filepath.Join(c.RootDir, "examples"), // p, r packages - filepath.Join(c.RootDir, "gnovm", "stdlibs"), + if !found { + return "", fmt.Errorf("file %s not found", filePath) } - // Try finding the file in each base directory - for _, baseDir := range baseDirs { - realPath := filepath.Join(baseDir, filePath) - - // Check if the file exists - if _, err := os.Stat(realPath); err == nil { - return realPath, nil - } - } + c.pathCache[filePath] = result - return "", fmt.Errorf("file %s not found in known paths", filePath) + return result, nil } func isTestFile(pkgPath string) bool { @@ -368,7 +377,7 @@ func (c *CoverageData) SaveHTML(outputFileName string) error { } for path, coverage := range c.Files { - realPath, err := c.determineRealPath(path) + realPath, err := c.findAbsoluteFilePath(path) if err != nil { return err } @@ -428,7 +437,7 @@ func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { if isTestFile(file) { return } - m.Coverage.AddFile(file, totalLines) + m.Coverage.addFile(file, totalLines) } // recordCoverage records the execution of a specific node in the AST. diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index adb64974279..3591311387e 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -5,8 +5,10 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" + "github.com/gnolang/gno/tm2/pkg/commands" "github.com/stretchr/testify/assert" ) @@ -68,6 +70,7 @@ func TestCoverageDataUpdateHit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() // Set executable lines fileCoverage := tt.initialData.Files[tt.pkgPath] fileCoverage.ExecutableLines = tt.executableLines @@ -134,7 +137,8 @@ func TestAddFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.initialData.AddFile(tt.pkgPath, tt.totalLines) + t.Parallel() + tt.initialData.addFile(tt.pkgPath, tt.totalLines) if tt.pkgPath == "file1_test.gno" && len(tt.initialData.Files) != 0 { t.Errorf("expected no files to be added for test files") } else { @@ -163,6 +167,7 @@ func TestIsTestFile(t *testing.T) { for _, tt := range tests { t.Run(tt.pkgPath, func(t *testing.T) { + t.Parallel() got := isTestFile(tt.pkgPath) if got != tt.want { t.Errorf("isTestFile(%s) = %v, want %v", tt.pkgPath, got, tt.want) @@ -171,54 +176,45 @@ func TestIsTestFile(t *testing.T) { } } -func TestReport(t *testing.T) { - tests := []struct { - name string - initialData *CoverageData - expectedOutput string - }{ - { - name: "Print results with one file", - initialData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": {TotalLines: 100, HitLines: map[int]int{10: 1, 20: 1}}, - }, - }, - expectedOutput: "Coverage Results:\nfile1.gno: 2.00% (2/100 lines)\n", - }, - { - name: "Print results with multiple files", - initialData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": {TotalLines: 100, HitLines: map[int]int{10: 1, 20: 1}}, - "file2.gno": {TotalLines: 200, HitLines: map[int]int{30: 1}}, - }, - }, - expectedOutput: "Coverage Results:\nfile1.gno: 2.00% (2/100 lines)\nfile2.gno: 0.50% (1/200 lines)\n", +type nopCloser struct { + *bytes.Buffer +} + +func (nopCloser) Close() error { return nil } + +func TestCoverageData_GenerateReport(t *testing.T) { + coverageData := &CoverageData{ + Files: map[string]FileCoverage{ + "c.gno": {TotalLines: 100, HitLines: map[int]int{1: 1, 2: 1}}, + "a.gno": {TotalLines: 50, HitLines: map[int]int{1: 1}}, + "b.gno": {TotalLines: 75, HitLines: map[int]int{1: 1, 2: 1, 3: 1}}, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - origStdout := os.Stdout + var buf bytes.Buffer + io := commands.NewTestIO() + io.SetOut(nopCloser{Buffer: &buf}) - r, w, _ := os.Pipe() - os.Stdout = w + coverageData.Report(io) - tt.initialData.Report() + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") - w.Close() - os.Stdout = origStdout + // check if the output is sorted + assert.Equal(t, 3, len(lines)) + assert.Contains(t, lines[0], "a.gno") + assert.Contains(t, lines[1], "b.gno") + assert.Contains(t, lines[2], "c.gno") - var buf bytes.Buffer - buf.ReadFrom(r) - - got := buf.String() - if got != tt.expectedOutput { - t.Errorf("got %q, want %q", got, tt.expectedOutput) - } - }) + // check if the format is correct + for _, line := range lines { + assert.Regexp(t, `^\x1b\[\d+m\d+\.\d+% \[\s*\d+/\d+\] .+\.gno\x1b\[0m$`, line) } + + // check if the coverage percentage is correct + assert.Contains(t, lines[0], "2.0% [ 1/50] a.gno") + assert.Contains(t, lines[1], "4.0% [ 3/75] b.gno") + assert.Contains(t, lines[2], "2.0% [ 2/100] c.gno") } type mockNode struct { @@ -243,64 +239,104 @@ var _ Node = &mockNode{} func TestRecordCoverage(t *testing.T) { tests := []struct { - name string - node Node - currentPackage string - currentFile string - expectedLoc Location - expectedHits map[string]map[int]int + name string + pkgPath string + file string + node *mockNode + initialCoverage *CoverageData + expectedHits map[string]map[int]int }{ { - name: "Basic node coverage", - node: &mockNode{line: 10, column: 5}, - currentPackage: "testpkg", - currentFile: "testfile.gno", - expectedLoc: Location{PkgPath: "testpkg", File: "testfile.gno", Line: 10, Column: 5}, - expectedHits: map[string]map[int]int{"testpkg/testfile.gno": {10: 1}}, + name: "Record coverage for new file and line", + pkgPath: "testpkg", + file: "testfile.gno", + node: &mockNode{ + line: 10, + column: 5, + }, + initialCoverage: &CoverageData{ + Files: map[string]FileCoverage{ + "testpkg/testfile.gno": { + HitLines: make(map[int]int), + ExecutableLines: map[int]bool{10: true}, // Add this line + }, + }, + PkgPath: "testpkg", + CurrentPackage: "testpkg", + CurrentFile: "testfile.gno", + }, + expectedHits: map[string]map[int]int{ + "testpkg/testfile.gno": {10: 1}, + }, }, { - name: "Nil node", - node: nil, - currentPackage: "testpkg", - currentFile: "testfile.gno", - expectedLoc: Location{}, - expectedHits: map[string]map[int]int{}, + name: "Increment hit count for existing line", + pkgPath: "testpkg", + file: "testfile.gno", + node: &mockNode{ + line: 10, + column: 5, + }, + initialCoverage: &CoverageData{ + Files: map[string]FileCoverage{ + "testpkg/testfile.gno": { + HitLines: map[int]int{10: 1}, + ExecutableLines: map[int]bool{10: true}, + }, + }, + PkgPath: "testpkg", + CurrentPackage: "testpkg", + CurrentFile: "testfile.gno", + }, + expectedHits: map[string]map[int]int{ + "testpkg/testfile.gno": {10: 2}, + }, }, { - name: "Multiple hits on same line", - node: &mockNode{line: 15, column: 3}, - currentPackage: "testpkg", - currentFile: "testfile.gno", - expectedLoc: Location{PkgPath: "testpkg", File: "testfile.gno", Line: 15, Column: 3}, - expectedHits: map[string]map[int]int{"testpkg/testfile.gno": {15: 2}}, + name: "Do not record coverage for non-executable line", + pkgPath: "testpkg", + file: "testfile.gno", + node: &mockNode{ + line: 20, + column: 5, + }, + initialCoverage: &CoverageData{ + Files: map[string]FileCoverage{ + "testpkg/testfile.gno": { + HitLines: map[int]int{}, + ExecutableLines: map[int]bool{10: true}, + }, + }, + PkgPath: "testpkg", + CurrentPackage: "testpkg", + CurrentFile: "testfile.gno", + }, + expectedHits: map[string]map[int]int{ + "testpkg/testfile.gno": {}, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - coverage := NewCoverageData("") - coverage.CurrentPackage = tt.currentPackage - coverage.CurrentFile = tt.currentFile + t.Parallel() m := &Machine{ - Coverage: coverage, - } - - // First call to set up initial state for "Multiple hits on same line" test - if tt.name == "Multiple hits on same line" { - m.recordCoverage(tt.node) + Coverage: tt.initialCoverage, } loc := m.recordCoverage(tt.node) - assert.Equal(t, tt.expectedLoc, loc, "Location should match") + // Check if the returned location is correct + assert.Equal(t, tt.pkgPath, loc.PkgPath) + assert.Equal(t, tt.file, loc.File) + assert.Equal(t, tt.node.line, loc.Line) + assert.Equal(t, tt.node.column, loc.Column) - for file, lines := range tt.expectedHits { - for line, hits := range lines { - actualHits, exists := m.Coverage.Files[file].HitLines[line] - assert.True(t, exists, "Line should be recorded in coverage data") - assert.Equal(t, hits, actualHits, "Number of hits should match") - } + // Check if the coverage data has been updated correctly + for file, expectedHits := range tt.expectedHits { + actualHits := m.Coverage.Files[file].HitLines + assert.Equal(t, expectedHits, actualHits) } }) } @@ -379,6 +415,7 @@ func TestToJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() jsonData, err := tt.coverageData.ToJSON() assert.NoError(t, err) @@ -396,7 +433,7 @@ func TestToJSON(t *testing.T) { } } -func TestDetermineRealPath(t *testing.T) { +func TestFindAbsoluteFilePath(t *testing.T) { rootDir := t.TempDir() examplesDir := filepath.Join(rootDir, "examples") @@ -418,9 +455,7 @@ func TestDetermineRealPath(t *testing.T) { t.Fatalf("failed to create stdlib file: %v", err) } - c := &CoverageData{ - RootDir: rootDir, - } + c := NewCoverageData(rootDir) tests := []struct { name string @@ -450,7 +485,8 @@ func TestDetermineRealPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actualPath, err := c.determineRealPath(tt.filePath) + t.Parallel() + actualPath, err := c.findAbsoluteFilePath(tt.filePath) if tt.expectError { if err == nil { @@ -468,6 +504,41 @@ func TestDetermineRealPath(t *testing.T) { } } +func TestFindAbsoluteFilePathCache(t *testing.T) { + t.Parallel() + + tempDir, err := os.MkdirTemp("", "test") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + testFilePath := filepath.Join(tempDir, "example.gno") + if err := os.WriteFile(testFilePath, []byte("test content"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + covData := NewCoverageData(tempDir) + + // 1st run: search from file system + path1, err := covData.findAbsoluteFilePath("example.gno") + if err != nil { + t.Fatalf("failed to find absolute file path: %v", err) + } + assert.Equal(t, testFilePath, path1) + + // 2nd run: use cache + path2, err := covData.findAbsoluteFilePath("example.gno") + if err != nil { + t.Fatalf("failed to find absolute file path: %v", err) + } + + assert.Equal(t, testFilePath, path2) + if len(covData.pathCache) != 1 { + t.Fatalf("expected 1 path in cache, got %d", len(covData.pathCache)) + } +} + func TestDetectExecutableLines(t *testing.T) { tests := []struct { name string @@ -530,7 +601,7 @@ type MyStruct struct { wantErr: false, }, { - name: "Invalid Go code", + name: "Invalid gno code", content: ` This is not valid Go code It should result in an error`, @@ -541,6 +612,7 @@ It should result in an error`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() got, err := detectExecutableLines(tt.content) assert.Equal(t, tt.wantErr, err != nil) assert.Equal(t, tt.want, got) From d9943255c447084ccf0ac8674a4689c8b984a0b8 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Wed, 18 Sep 2024 23:19:20 +0900 Subject: [PATCH 23/37] update docs --- gnovm/cmd/gno/test.go | 2 + gnovm/pkg/gnolang/coverage.go | 104 ++++++++++++++++++----------- gnovm/pkg/gnolang/coverage_test.go | 14 ++++ 3 files changed, 80 insertions(+), 40 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 0c173f78b35..72e771ff3a0 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -296,6 +296,7 @@ func gnoTestPkg( var gnoPkgPath string modfile, err := gnomod.ParseAt(pkgPath) if err == nil { + // TODO: use pkgPathFromRootDir? gnoPkgPath = modfile.Module.Mod.Path coverageData.PkgPath = gnoPkgPath } else { @@ -430,6 +431,7 @@ func gnoTestPkg( } if cfg.coverage { + // TODO: consider cache if cfg.viewFile != "" { err := coverageData.ViewFiles(cfg.viewFile, cfg.showHits, io) if err != nil { diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index fcd36cd9473..c9241a8fbcf 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -18,14 +18,15 @@ import ( "github.com/gnolang/gno/tm2/pkg/commands" ) +// color scheme for coverage report const ( colorReset = "\033[0m" - colorOrange = "\033[38;5;208m" - colorRed = "\033[31m" - colorGreen = "\033[32m" - colorYellow = "\033[33m" - colorWhite = "\033[37m" - boldText = "\033[1m" + colorOrange = "\033[38;5;208m" // orange indicates a number of hits + colorRed = "\033[31m" // red indicates no hits + colorGreen = "\033[32m" // green indicates full coverage, or executed lines + colorYellow = "\033[33m" // yellow indicates partial coverage, or executable but not executed lines + colorWhite = "\033[37m" // white indicates non-executable lines + boldText = "\033[1m" // bold text ) // CoverageData stores code coverage information @@ -56,6 +57,8 @@ func NewCoverageData(rootDir string) *CoverageData { } } +// SetExecutableLines sets the executable lines for a given file path in the coverage data. +// It updates the ExecutableLines map for the given file path with the provided executable lines. func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[int]bool) { cov, exists := c.Files[filePath] if !exists { @@ -70,6 +73,9 @@ func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[i c.Files[filePath] = cov } +// updateHit updates the hit count for a given line in the coverage data. +// This function is used to update the hit count for a specific line in the coverage data. +// It increments the hit count for the given line in the HitLines map for the specified file path. func (c *CoverageData) updateHit(pkgPath string, line int) { if !c.isValidFile(pkgPath) { return @@ -119,6 +125,8 @@ func (c *CoverageData) addFile(filePath string, totalLines int) { // region Reporting +// ViewFiles displays the coverage information for files matching the given pattern. +// It shows hit counts if showHits is true. func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) error { matchingFiles := c.findMatchingFiles(pattern) if len(matchingFiles) == 0 { @@ -139,8 +147,12 @@ func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io commands.IO) error { realPath, err := c.findAbsoluteFilePath(filePath) if err != nil { - // skipping invalid file paths - return nil + return nil // ignore invalid file paths + } + + coverage, exists := c.Files[filePath] + if !exists { + return fmt.Errorf("no coverage data for file %s", filePath) } file, err := os.Open(realPath) @@ -149,52 +161,62 @@ func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io } defer file.Close() + io.Printfln("%s%s%s:", boldText, filePath, colorReset) + + return c.printFileContent(file, coverage, showHits, io) +} + +func (c *CoverageData) printFileContent(file *os.File, coverage FileCoverage, showHits bool, io commands.IO) error { scanner := bufio.NewScanner(file) lineNumber := 1 - coverage, exists := c.Files[filePath] - if !exists { - return fmt.Errorf("no coverage data for file %s", filePath) - } - io.Printfln("%s%s%s:", boldText, filePath, colorReset) for scanner.Scan() { line := scanner.Text() hitCount, covered := coverage.HitLines[lineNumber] - var hitInfo string - if showHits { - if covered { - hitInfo = fmt.Sprintf("%s%d%s ", colorOrange, hitCount, colorReset) - } else { - hitInfo = strings.Repeat(" ", 2) - } - } - - lineNumStr := fmt.Sprintf("%4d", lineNumber) + lineInfo := c.formatLineInfo(lineNumber, line, hitCount, covered, coverage.ExecutableLines[lineNumber], showHits) + io.Printfln(lineInfo) - if showHits { - if covered { - io.Printfln("%s%s%s %-4s %s%s%s", colorGreen, lineNumStr, colorReset, hitInfo, colorGreen, line, colorReset) - } else if coverage.ExecutableLines[lineNumber] { - io.Printfln("%s%s%s %-4s %s%s%s", colorYellow, lineNumStr, colorReset, hitInfo, colorYellow, line, colorReset) - } else { - io.Printfln("%s%s%s %-4s %s%s", colorWhite, lineNumStr, colorReset, hitInfo, line, colorReset) - } - } else { - if covered { - io.Printfln("%s%s %s%s", colorGreen, lineNumStr, line, colorReset) - } else if coverage.ExecutableLines[lineNumber] { - io.Printfln("%s%s %s%s", colorYellow, lineNumStr, line, colorReset) - } else { - io.Printfln("%s%s %s%s", colorWhite, lineNumStr, line, colorReset) - } - } lineNumber++ } return scanner.Err() } +func (c *CoverageData) formatLineInfo(lineNumber int, line string, hitCount int, covered, executable, showHits bool) string { + lineNumStr := fmt.Sprintf("%4d", lineNumber) + + color := c.getLineColor(covered, executable) + + hitInfo := c.getHitInfo(hitCount, covered, showHits) + + format := "%s%s%s %s%s%s%s" + return fmt.Sprintf(format, color, lineNumStr, colorReset, hitInfo, color, line, colorReset) +} + +func (c *CoverageData) getLineColor(covered, executable bool) string { + switch { + case covered: + return colorGreen + case executable: + return colorYellow + default: + return colorWhite + } +} + +func (c *CoverageData) getHitInfo(hitCount int, covered, showHits bool) string { + if !showHits { + return "" + } + + if covered { + return fmt.Sprintf("%s%-4d%s ", colorOrange, hitCount, colorReset) + } + + return strings.Repeat(" ", 5) +} + func (c *CoverageData) findMatchingFiles(pattern string) []string { var files []string for file := range c.Files { @@ -512,6 +534,8 @@ func isExecutableLine(node ast.Node) bool { } } +// detectExecutableLines analyzes the given source code content and returns a map +// of line numbers to boolean values indicating whether each line is executable. func detectExecutableLines(content string) (map[int]bool, error) { fset := token.NewFileSet() node, err := parser.ParseFile(fset, "", content, parser.ParseComments) diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 3591311387e..76fddf645c6 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -13,6 +13,7 @@ import ( ) func TestCoverageDataUpdateHit(t *testing.T) { + t.Parallel() tests := []struct { name string initialData *CoverageData @@ -69,6 +70,7 @@ func TestCoverageDataUpdateHit(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() // Set executable lines @@ -94,6 +96,7 @@ func TestCoverageDataUpdateHit(t *testing.T) { } func TestAddFile(t *testing.T) { + t.Parallel() tests := []struct { name string pkgPath string @@ -136,6 +139,7 @@ func TestAddFile(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() tt.initialData.addFile(tt.pkgPath, tt.totalLines) @@ -155,6 +159,7 @@ func TestAddFile(t *testing.T) { } func TestIsTestFile(t *testing.T) { + t.Parallel() tests := []struct { pkgPath string want bool @@ -166,6 +171,7 @@ func TestIsTestFile(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.pkgPath, func(t *testing.T) { t.Parallel() got := isTestFile(tt.pkgPath) @@ -238,6 +244,7 @@ func (m *mockNode) SetColumn(c int) {} var _ Node = &mockNode{} func TestRecordCoverage(t *testing.T) { + t.Parallel() tests := []struct { name string pkgPath string @@ -318,6 +325,7 @@ func TestRecordCoverage(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -343,6 +351,7 @@ func TestRecordCoverage(t *testing.T) { } func TestToJSON(t *testing.T) { + t.Parallel() tests := []struct { name string coverageData *CoverageData @@ -414,6 +423,7 @@ func TestToJSON(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() jsonData, err := tt.coverageData.ToJSON() @@ -457,6 +467,7 @@ func TestFindAbsoluteFilePath(t *testing.T) { c := NewCoverageData(rootDir) + t.Parallel() tests := []struct { name string filePath string @@ -484,6 +495,7 @@ func TestFindAbsoluteFilePath(t *testing.T) { } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() actualPath, err := c.findAbsoluteFilePath(tt.filePath) @@ -540,6 +552,7 @@ func TestFindAbsoluteFilePathCache(t *testing.T) { } func TestDetectExecutableLines(t *testing.T) { + t.Parallel() tests := []struct { name string content string @@ -611,6 +624,7 @@ It should result in an error`, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got, err := detectExecutableLines(tt.content) From 7efca2c561f740a6e3106afff11c9727010d145c Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 19 Sep 2024 13:18:35 +0900 Subject: [PATCH 24/37] function coverage save --- gnovm/cmd/gno/test.go | 15 +++ gnovm/pkg/gnolang/coverage.go | 89 +++++++++--- gnovm/pkg/gnolang/coverage_func.go | 91 +++++++++++++ gnovm/pkg/gnolang/coverage_test.go | 208 ++++++++++++++++++++++++++++- 4 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 gnovm/pkg/gnolang/coverage_func.go diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 72e771ff3a0..16116696d08 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -43,6 +43,7 @@ type testCfg struct { showHits bool output string htmlOutput string + funcFilter string } func newTestCmd(io commands.IO) *commands.Command { @@ -157,6 +158,8 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "print runtime metrics (gas, memory, cpu cycles)", ) + // test coverage flags + fs.BoolVar( &c.coverage, "cover", @@ -191,6 +194,13 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "", "output coverage report in HTML format", ) + + fs.StringVar( + &c.funcFilter, + "func", + "", + "output coverage profile information for each function (comma separated list or regex)", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -455,6 +465,11 @@ func gnoTestPkg( io.Println("coverage report saved to", cfg.htmlOutput) } + if cfg.funcFilter != "" { + coverageData.ReportFuncCoverage(io, cfg.funcFilter) + return nil + } + coverageData.Report(io) } diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index c9241a8fbcf..38513c6c722 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -11,6 +11,7 @@ import ( "math" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -37,6 +38,7 @@ type CoverageData struct { CurrentPackage string CurrentFile string pathCache map[string]string // relative path to absolute path + Functions map[string][]FuncCoverage } // FileCoverage stores coverage information for a single file @@ -54,6 +56,7 @@ func NewCoverageData(rootDir string) *CoverageData { CurrentPackage: "", CurrentFile: "", pathCache: make(map[string]string), + Functions: make(map[string][]FuncCoverage), } } @@ -87,6 +90,13 @@ func (c *CoverageData) updateHit(pkgPath string, line int) { fileCoverage.HitLines[line]++ c.Files[pkgPath] = fileCoverage } + + for i, fc := range c.Functions[pkgPath] { + if line >= fc.StartLine && line <= fc.EndLine { + c.Functions[pkgPath][i].Covered++ + break + } + } } func (c *CoverageData) isValidFile(pkgPath string) bool { @@ -125,6 +135,61 @@ func (c *CoverageData) addFile(filePath string, totalLines int) { // region Reporting +// Report prints the coverage report to the console +func (c *CoverageData) Report(io commands.IO) { + files := make([]string, 0, len(c.Files)) + for file := range c.Files { + files = append(files, file) + } + + sort.Strings(files) + + for _, file := range files { + cov := c.Files[file] + hitLines := len(cov.HitLines) + totalLines := cov.TotalLines + pct := calculateCoverage(hitLines, totalLines) + color := getCoverageColor(pct) + io.Printfln("%s%.1f%% [%4d/%d] %s%s", color, floor1(pct), hitLines, totalLines, file, colorReset) + } +} + +func (c *CoverageData) ReportFuncCoverage(io commands.IO, funcFilter string) { + io.Printfln("Function Coverage:") + + var regex *regexp.Regexp + var err error + if funcFilter != "" { + regex, err = regexp.Compile(funcFilter) + if err != nil { + io.Printf("error compiling regex: %s\n", err) + return + } + } + + for file, funcs := range c.Functions { + filePrinted := false + for _, fc := range funcs { + if matchesRegexFilter(fc.Name, regex) { + if !filePrinted { + io.Printfln("File %s:", file) + filePrinted = true + } + pct := calculateCoverage(fc.Covered, fc.Total) + color := getCoverageColor(pct) + io.Printfln("%s%-20s %4d/%d %s%.1f%%%s", color, fc.Name, fc.Covered, fc.Total, colorOrange, floor1(pct), colorReset) + } + } + } +} + +func matchesRegexFilter(name string, regex *regexp.Regexp) bool { + if regex == nil { + return true + } + return regex.MatchString(name) +} + // ViewFiles displays the coverage information for files matching the given pattern. // It shows hit counts if showHits is true. func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) error { @@ -227,25 +292,6 @@ func (c *CoverageData) findMatchingFiles(pattern string) []string { return files } -// Report prints the coverage report to the console -func (c *CoverageData) Report(io commands.IO) { - files := make([]string, 0, len(c.Files)) - for file := range c.Files { - files = append(files, file) - } - - sort.Strings(files) - - for _, file := range files { - cov := c.Files[file] - hitLines := len(cov.HitLines) - totalLines := cov.TotalLines - pct := float64(hitLines) / float64(totalLines) * 100 - color := getCoverageColor(pct) - io.Printfln("%s%.1f%% [%4d/%d] %s%s", color, floor1(pct), hitLines, totalLines, file, colorReset) - } -} - // floor1 round down to one decimal place func floor1(v float64) float64 { return math.Floor(v*10) / 10 @@ -262,6 +308,10 @@ func getCoverageColor(percentage float64) string { } } +func calculateCoverage(covered, total int) float64 { + return float64(covered) / float64(total) * 100 +} + // findAbsoluteFilePath finds the absolute path of a file given its relative path. // It starts searching from root directory and recursively traverses directories. func (c *CoverageData) findAbsoluteFilePath(filePath string) (string, error) { @@ -460,6 +510,7 @@ func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { return } m.Coverage.addFile(file, totalLines) + m.Coverage.ParseFile(file) } // recordCoverage records the execution of a specific node in the AST. diff --git a/gnovm/pkg/gnolang/coverage_func.go b/gnovm/pkg/gnolang/coverage_func.go new file mode 100644 index 00000000000..a5241a1ab35 --- /dev/null +++ b/gnovm/pkg/gnolang/coverage_func.go @@ -0,0 +1,91 @@ +package gnolang + +import ( + "go/ast" + "go/parser" + "go/token" +) + +type FuncCoverage struct { + Name string + StartLine int + EndLine int + Covered int + Total int +} + +// findFuncs finds the functions in a file. +func findFuncs(name string) ([]*FuncExtent, error) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + return nil, err + } + + v := &FuncVisitor{ + fset: fset, + name: name, + astFile: parsed, + } + + ast.Walk(v, parsed) + + return v.funcs, nil +} + +// FuncExtent describes a function's extent in the source by file and position. +type FuncExtent struct { + name string + startLine int + startCol int + endLine int + endCol int +} + +// FuncVisitor implements the visitor that builds the function position list for a file. +type FuncVisitor struct { + fset *token.FileSet + name string // name of file + 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 // ignore functions with no body + } + start := v.fset.Position(n.Pos()) + end := v.fset.Position(n.End()) + fe := &FuncExtent{ + name: n.Name.Name, + startLine: start.Line, + startCol: start.Column, + endLine: end.Line, + endCol: end.Column, + } + v.funcs = append(v.funcs, fe) + } + return v +} + +func (c *CoverageData) ParseFile(filePath string) error { + funcs, err := findFuncs(filePath) + if err != nil { + return err + } + + c.Functions[filePath] = make([]FuncCoverage, len(funcs)) + for i, f := range funcs { + c.Functions[filePath][i] = FuncCoverage{ + Name: f.name, + StartLine: f.startLine, + EndLine: f.endLine, + Covered: 0, + Total: f.endLine - f.startLine + 1, + } + } + + return nil +} diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 76fddf645c6..3d5726d5b8a 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" + "regexp" "strings" "testing" @@ -444,6 +446,7 @@ func TestToJSON(t *testing.T) { } func TestFindAbsoluteFilePath(t *testing.T) { + t.Parallel() rootDir := t.TempDir() examplesDir := filepath.Join(rootDir, "examples") @@ -467,7 +470,6 @@ func TestFindAbsoluteFilePath(t *testing.T) { c := NewCoverageData(rootDir) - t.Parallel() tests := []struct { name string filePath string @@ -633,3 +635,207 @@ It should result in an error`, }) } } + +func TestFindFuncs(t *testing.T) { + tests := []struct { + name string + content string + expected []*FuncExtent + }{ + { + name: "Single function", + content: ` +package main + +func TestFunc() { + println("Hello, World!") +}`, + expected: []*FuncExtent{ + { + name: "TestFunc", + startLine: 4, + startCol: 1, + endLine: 6, + endCol: 2, + }, + }, + }, + { + name: "No functions", + content: ` +package main + +var x = 10 +`, + expected: nil, + }, + { + name: "Function without body", + content: ` +package main + +func TestFunc(); +`, + expected: nil, + }, + { + name: "Multiple functions", + content: ` +package main + +func Func1() { + println("Func1") +} + +func Func2() { + println("Func2") +} +`, + expected: []*FuncExtent{ + { + name: "Func1", + startLine: 4, + startCol: 1, + endLine: 6, + endCol: 2, + }, + { + name: "Func2", + startLine: 8, + startCol: 1, + endLine: 10, + endCol: 2, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpfile, err := os.CreateTemp("", "example.go") + assert.NoError(t, err) + defer os.Remove(tmpfile.Name()) + _, err = tmpfile.Write([]byte(tt.content)) + assert.NoError(t, err) + assert.NoError(t, tmpfile.Close()) + + got, err := findFuncs(tmpfile.Name()) + if err != nil { + t.Fatalf("findFuncs failed: %v", err) + } + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("findFuncs() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestCoverageData_ReportFuncCoverage(t *testing.T) { + tests := []struct { + name string + coverage *CoverageData + funcFilter string + expected string + }{ + { + name: "Single file, single function, no filter", + coverage: &CoverageData{ + Functions: map[string][]FuncCoverage{ + "file1.gno": { + {Name: "func1", Covered: 5, Total: 10}, + }, + }, + }, + funcFilter: "", + expected: `Function Coverage: +File file1.gno: +func1 5/10 50.0% +`, + }, + { + name: "Single file, multiple functions, no filter", + coverage: &CoverageData{ + Functions: map[string][]FuncCoverage{ + "file1.gno": { + {Name: "func1", Covered: 5, Total: 10}, + {Name: "func2", Covered: 8, Total: 8}, + }, + }, + }, + funcFilter: "", + expected: `Function Coverage: +File file1.gno: +func1 5/10 50.0% +func2 8/8 100.0% +`, + }, + { + name: "Multiple files, multiple functions, with filter", + coverage: &CoverageData{ + Functions: map[string][]FuncCoverage{ + "file1.gno": { + {Name: "func1", Covered: 5, Total: 10}, + {Name: "func2", Covered: 8, Total: 8}, + }, + "file2.gno": { + {Name: "func3", Covered: 0, Total: 5}, + }, + }, + }, + funcFilter: "func1", + expected: `Function Coverage: +File file1.gno: +func1 5/10 50.0% +`, + }, + { + name: "Multiple files, multiple functions, with regex filter", + coverage: &CoverageData{ + Functions: map[string][]FuncCoverage{ + "file1.gno": { + {Name: "testFunc1", Covered: 5, Total: 10}, + {Name: "testFunc2", Covered: 8, Total: 8}, + }, + "file2.gno": { + {Name: "otherFunc", Covered: 0, Total: 5}, + }, + }, + }, + funcFilter: "^test", + expected: `Function Coverage: +File file1.gno: +testFunc1 5/10 50.0% +testFunc2 8/8 100.0% +`, + }, + { + name: "No coverage", + coverage: &CoverageData{ + Functions: map[string][]FuncCoverage{}, + }, + funcFilter: "", + expected: "Function Coverage:\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + io := commands.NewTestIO() + io.SetOut(nopCloser{Buffer: &buf}) + + tt.coverage.ReportFuncCoverage(io, tt.funcFilter) + + actual := removeANSIEscapeCodes(buf.String()) + expected := removeANSIEscapeCodes(tt.expected) + + assert.Equal(t, expected, actual) + }) + } +} + +func removeANSIEscapeCodes(s string) string { + ansi := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + return ansi.ReplaceAllString(s, "") +} From 19dd2067bed71e57a629973ae33343446b662d23 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 19 Sep 2024 19:10:29 +0900 Subject: [PATCH 25/37] update non-executable elements --- gnovm/cmd/gno/test.go | 3 +++ gnovm/pkg/gnolang/coverage.go | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 16116696d08..81cba78dcaf 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -447,6 +447,7 @@ func gnoTestPkg( if err != nil { return fmt.Errorf("failed to view file coverage: %w", err) } + return nil // prevent printing out coverage report } if cfg.output != "" { @@ -455,6 +456,7 @@ func gnoTestPkg( return fmt.Errorf("failed to save coverage data: %w", err) } io.Println("coverage data saved to", cfg.output) + return nil } if cfg.htmlOutput != "" { @@ -463,6 +465,7 @@ func gnoTestPkg( return fmt.Errorf("failed to save coverage data: %w", err) } io.Println("coverage report saved to", cfg.htmlOutput) + return nil } if cfg.funcFilter != "" { diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 38513c6c722..caa5eb37fbd 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -150,7 +150,9 @@ func (c *CoverageData) Report(io commands.IO) { totalLines := cov.TotalLines pct := calculateCoverage(hitLines, totalLines) color := getCoverageColor(pct) - io.Printfln("%s%.1f%% [%4d/%d] %s%s", color, floor1(pct), hitLines, totalLines, file, colorReset) + if totalLines != 0 { + io.Printfln("%s%.1f%% [%4d/%d] %s%s", color, floor1(pct), hitLines, totalLines, file, colorReset) + } } } @@ -559,7 +561,7 @@ func countCodeLines(content string) int { // It returns false for nodes that represent non-executable lines, such as // declarations, blocks, and function definitions. func isExecutableLine(node ast.Node) bool { - switch node.(type) { + switch n := node.(type) { case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: return true @@ -578,8 +580,15 @@ func isExecutableLine(node ast.Node) bool { return false case *ast.ImportSpec, *ast.TypeSpec, *ast.ValueSpec: return false - case *ast.GenDecl: + case *ast.InterfaceType: return false + case *ast.GenDecl: + switch n.Tok { + case token.VAR, token.CONST, token.TYPE, token.IMPORT: + return false + default: + return true + } default: return false } From a326278b0c7b96ab4a0e1dafa3162f7ba50755e8 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 20 Sep 2024 14:21:27 +0900 Subject: [PATCH 26/37] remove func coverage --- gnovm/cmd/gno/test.go | 14 -- gnovm/pkg/gnolang/coverage.go | 41 +----- gnovm/pkg/gnolang/coverage_func.go | 156 +++++++++++----------- gnovm/pkg/gnolang/coverage_test.go | 206 ----------------------------- 4 files changed, 80 insertions(+), 337 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 81cba78dcaf..54928d8b6f3 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -43,7 +43,6 @@ type testCfg struct { showHits bool output string htmlOutput string - funcFilter string } func newTestCmd(io commands.IO) *commands.Command { @@ -194,13 +193,6 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "", "output coverage report in HTML format", ) - - fs.StringVar( - &c.funcFilter, - "func", - "", - "output coverage profile information for each function (comma separated list or regex)", - ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -467,12 +459,6 @@ func gnoTestPkg( io.Println("coverage report saved to", cfg.htmlOutput) return nil } - - if cfg.funcFilter != "" { - coverageData.ReportFuncCoverage(io, cfg.funcFilter) - return nil - } - coverageData.Report(io) } diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index caa5eb37fbd..dc469e604c0 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -38,7 +38,7 @@ type CoverageData struct { CurrentPackage string CurrentFile string pathCache map[string]string // relative path to absolute path - Functions map[string][]FuncCoverage + // Functions map[string][]FuncCoverage } // FileCoverage stores coverage information for a single file @@ -56,7 +56,7 @@ func NewCoverageData(rootDir string) *CoverageData { CurrentPackage: "", CurrentFile: "", pathCache: make(map[string]string), - Functions: make(map[string][]FuncCoverage), + // Functions: make(map[string][]FuncCoverage), } } @@ -90,13 +90,6 @@ func (c *CoverageData) updateHit(pkgPath string, line int) { fileCoverage.HitLines[line]++ c.Files[pkgPath] = fileCoverage } - - for i, fc := range c.Functions[pkgPath] { - if line >= fc.StartLine && line <= fc.EndLine { - c.Functions[pkgPath][i].Covered++ - break - } - } } func (c *CoverageData) isValidFile(pkgPath string) bool { @@ -156,35 +149,6 @@ func (c *CoverageData) Report(io commands.IO) { } } -func (c *CoverageData) ReportFuncCoverage(io commands.IO, funcFilter string) { - io.Printfln("Function Coverage:") - - var regex *regexp.Regexp - var err error - if funcFilter != "" { - regex, err = regexp.Compile(funcFilter) - if err != nil { - io.Printf("error compiling regex: %s\n", err) - return - } - } - - for file, funcs := range c.Functions { - filePrinted := false - for _, fc := range funcs { - if matchesRegexFilter(fc.Name, regex) { - if !filePrinted { - io.Printfln("File %s:", file) - filePrinted = true - } - pct := calculateCoverage(fc.Covered, fc.Total) - color := getCoverageColor(pct) - io.Printfln("%s%-20s %4d/%d %s%.1f%%%s", color, fc.Name, fc.Covered, fc.Total, colorOrange, floor1(pct), colorReset) - } - } - } -} - func matchesRegexFilter(name string, regex *regexp.Regexp) bool { if regex == nil { return true @@ -512,7 +476,6 @@ func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { return } m.Coverage.addFile(file, totalLines) - m.Coverage.ParseFile(file) } // recordCoverage records the execution of a specific node in the AST. diff --git a/gnovm/pkg/gnolang/coverage_func.go b/gnovm/pkg/gnolang/coverage_func.go index a5241a1ab35..1233933c534 100644 --- a/gnovm/pkg/gnolang/coverage_func.go +++ b/gnovm/pkg/gnolang/coverage_func.go @@ -1,91 +1,91 @@ package gnolang -import ( - "go/ast" - "go/parser" - "go/token" -) +// import ( +// "go/ast" +// "go/parser" +// "go/token" +// ) -type FuncCoverage struct { - Name string - StartLine int - EndLine int - Covered int - Total int -} +// type FuncCoverage struct { +// Name string +// StartLine int +// EndLine int +// Covered map[int]bool +// Total int +// } -// findFuncs finds the functions in a file. -func findFuncs(name string) ([]*FuncExtent, error) { - fset := token.NewFileSet() - parsed, err := parser.ParseFile(fset, name, nil, 0) - if err != nil { - return nil, err - } +// // findFuncs finds the functions in a file. +// func findFuncs(name string) ([]*FuncExtent, error) { +// fset := token.NewFileSet() +// parsed, err := parser.ParseFile(fset, name, nil, 0) +// if err != nil { +// return nil, err +// } - v := &FuncVisitor{ - fset: fset, - name: name, - astFile: parsed, - } +// v := &FuncVisitor{ +// fset: fset, +// name: name, +// astFile: parsed, +// } - ast.Walk(v, parsed) +// ast.Walk(v, parsed) - return v.funcs, nil -} +// return v.funcs, nil +// } -// FuncExtent describes a function's extent in the source by file and position. -type FuncExtent struct { - name string - startLine int - startCol int - endLine int - endCol int -} +// // FuncExtent describes a function's extent in the source by file and position. +// type FuncExtent struct { +// name string +// startLine int +// startCol int +// endLine int +// endCol int +// } -// FuncVisitor implements the visitor that builds the function position list for a file. -type FuncVisitor struct { - fset *token.FileSet - name string // name of file - astFile *ast.File - funcs []*FuncExtent -} +// // FuncVisitor implements the visitor that builds the function position list for a file. +// type FuncVisitor struct { +// fset *token.FileSet +// name string // name of file +// 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 // ignore functions with no body - } - start := v.fset.Position(n.Pos()) - end := v.fset.Position(n.End()) - fe := &FuncExtent{ - name: n.Name.Name, - startLine: start.Line, - startCol: start.Column, - endLine: end.Line, - endCol: end.Column, - } - v.funcs = append(v.funcs, fe) - } - return v -} +// func (v *FuncVisitor) Visit(node ast.Node) ast.Visitor { +// switch n := node.(type) { +// case *ast.FuncDecl: +// if n.Body == nil { +// break // ignore functions with no body +// } +// start := v.fset.Position(n.Pos()) +// end := v.fset.Position(n.End()) +// fe := &FuncExtent{ +// name: n.Name.Name, +// startLine: start.Line, +// startCol: start.Column, +// endLine: end.Line, +// endCol: end.Column, +// } +// v.funcs = append(v.funcs, fe) +// } +// return v +// } -func (c *CoverageData) ParseFile(filePath string) error { - funcs, err := findFuncs(filePath) - if err != nil { - return err - } +// func (c *CoverageData) ParseFile(filePath string) error { +// funcs, err := findFuncs(filePath) +// if err != nil { +// return err +// } - c.Functions[filePath] = make([]FuncCoverage, len(funcs)) - for i, f := range funcs { - c.Functions[filePath][i] = FuncCoverage{ - Name: f.name, - StartLine: f.startLine, - EndLine: f.endLine, - Covered: 0, - Total: f.endLine - f.startLine + 1, - } - } +// c.Functions[filePath] = make([]FuncCoverage, len(funcs)) +// for i, f := range funcs { +// c.Functions[filePath][i] = FuncCoverage{ +// Name: f.name, +// StartLine: f.startLine, +// EndLine: f.endLine, +// Covered: make(map[int]bool), +// Total: f.endLine - f.startLine + 1, +// } +// } - return nil -} +// return nil +// } diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 3d5726d5b8a..510cde33767 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -5,8 +5,6 @@ import ( "encoding/json" "os" "path/filepath" - "reflect" - "regexp" "strings" "testing" @@ -635,207 +633,3 @@ It should result in an error`, }) } } - -func TestFindFuncs(t *testing.T) { - tests := []struct { - name string - content string - expected []*FuncExtent - }{ - { - name: "Single function", - content: ` -package main - -func TestFunc() { - println("Hello, World!") -}`, - expected: []*FuncExtent{ - { - name: "TestFunc", - startLine: 4, - startCol: 1, - endLine: 6, - endCol: 2, - }, - }, - }, - { - name: "No functions", - content: ` -package main - -var x = 10 -`, - expected: nil, - }, - { - name: "Function without body", - content: ` -package main - -func TestFunc(); -`, - expected: nil, - }, - { - name: "Multiple functions", - content: ` -package main - -func Func1() { - println("Func1") -} - -func Func2() { - println("Func2") -} -`, - expected: []*FuncExtent{ - { - name: "Func1", - startLine: 4, - startCol: 1, - endLine: 6, - endCol: 2, - }, - { - name: "Func2", - startLine: 8, - startCol: 1, - endLine: 10, - endCol: 2, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpfile, err := os.CreateTemp("", "example.go") - assert.NoError(t, err) - defer os.Remove(tmpfile.Name()) - _, err = tmpfile.Write([]byte(tt.content)) - assert.NoError(t, err) - assert.NoError(t, tmpfile.Close()) - - got, err := findFuncs(tmpfile.Name()) - if err != nil { - t.Fatalf("findFuncs failed: %v", err) - } - - if !reflect.DeepEqual(got, tt.expected) { - t.Errorf("findFuncs() = %v, want %v", got, tt.expected) - } - }) - } -} - -func TestCoverageData_ReportFuncCoverage(t *testing.T) { - tests := []struct { - name string - coverage *CoverageData - funcFilter string - expected string - }{ - { - name: "Single file, single function, no filter", - coverage: &CoverageData{ - Functions: map[string][]FuncCoverage{ - "file1.gno": { - {Name: "func1", Covered: 5, Total: 10}, - }, - }, - }, - funcFilter: "", - expected: `Function Coverage: -File file1.gno: -func1 5/10 50.0% -`, - }, - { - name: "Single file, multiple functions, no filter", - coverage: &CoverageData{ - Functions: map[string][]FuncCoverage{ - "file1.gno": { - {Name: "func1", Covered: 5, Total: 10}, - {Name: "func2", Covered: 8, Total: 8}, - }, - }, - }, - funcFilter: "", - expected: `Function Coverage: -File file1.gno: -func1 5/10 50.0% -func2 8/8 100.0% -`, - }, - { - name: "Multiple files, multiple functions, with filter", - coverage: &CoverageData{ - Functions: map[string][]FuncCoverage{ - "file1.gno": { - {Name: "func1", Covered: 5, Total: 10}, - {Name: "func2", Covered: 8, Total: 8}, - }, - "file2.gno": { - {Name: "func3", Covered: 0, Total: 5}, - }, - }, - }, - funcFilter: "func1", - expected: `Function Coverage: -File file1.gno: -func1 5/10 50.0% -`, - }, - { - name: "Multiple files, multiple functions, with regex filter", - coverage: &CoverageData{ - Functions: map[string][]FuncCoverage{ - "file1.gno": { - {Name: "testFunc1", Covered: 5, Total: 10}, - {Name: "testFunc2", Covered: 8, Total: 8}, - }, - "file2.gno": { - {Name: "otherFunc", Covered: 0, Total: 5}, - }, - }, - }, - funcFilter: "^test", - expected: `Function Coverage: -File file1.gno: -testFunc1 5/10 50.0% -testFunc2 8/8 100.0% -`, - }, - { - name: "No coverage", - coverage: &CoverageData{ - Functions: map[string][]FuncCoverage{}, - }, - funcFilter: "", - expected: "Function Coverage:\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - io := commands.NewTestIO() - io.SetOut(nopCloser{Buffer: &buf}) - - tt.coverage.ReportFuncCoverage(io, tt.funcFilter) - - actual := removeANSIEscapeCodes(buf.String()) - expected := removeANSIEscapeCodes(tt.expected) - - assert.Equal(t, expected, actual) - }) - } -} - -func removeANSIEscapeCodes(s string) string { - ansi := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - return ansi.ReplaceAllString(s, "") -} From 2fde27e3ab8005c1e485916bb0fa67f6b7597e57 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 20 Sep 2024 14:25:23 +0900 Subject: [PATCH 27/37] lint --- gnovm/pkg/gnolang/coverage.go | 8 --- gnovm/pkg/gnolang/coverage_func.go | 91 ------------------------------ 2 files changed, 99 deletions(-) delete mode 100644 gnovm/pkg/gnolang/coverage_func.go diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index dc469e604c0..9033260b631 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -11,7 +11,6 @@ import ( "math" "os" "path/filepath" - "regexp" "sort" "strconv" "strings" @@ -149,13 +148,6 @@ func (c *CoverageData) Report(io commands.IO) { } } -func matchesRegexFilter(name string, regex *regexp.Regexp) bool { - if regex == nil { - return true - } - return regex.MatchString(name) -} - // ViewFiles displays the coverage information for files matching the given pattern. // It shows hit counts if showHits is true. func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) error { diff --git a/gnovm/pkg/gnolang/coverage_func.go b/gnovm/pkg/gnolang/coverage_func.go deleted file mode 100644 index 1233933c534..00000000000 --- a/gnovm/pkg/gnolang/coverage_func.go +++ /dev/null @@ -1,91 +0,0 @@ -package gnolang - -// import ( -// "go/ast" -// "go/parser" -// "go/token" -// ) - -// type FuncCoverage struct { -// Name string -// StartLine int -// EndLine int -// Covered map[int]bool -// Total int -// } - -// // findFuncs finds the functions in a file. -// func findFuncs(name string) ([]*FuncExtent, error) { -// fset := token.NewFileSet() -// parsed, err := parser.ParseFile(fset, name, nil, 0) -// if err != nil { -// return nil, err -// } - -// v := &FuncVisitor{ -// fset: fset, -// name: name, -// astFile: parsed, -// } - -// ast.Walk(v, parsed) - -// return v.funcs, nil -// } - -// // FuncExtent describes a function's extent in the source by file and position. -// type FuncExtent struct { -// name string -// startLine int -// startCol int -// endLine int -// endCol int -// } - -// // FuncVisitor implements the visitor that builds the function position list for a file. -// type FuncVisitor struct { -// fset *token.FileSet -// name string // name of file -// 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 // ignore functions with no body -// } -// start := v.fset.Position(n.Pos()) -// end := v.fset.Position(n.End()) -// fe := &FuncExtent{ -// name: n.Name.Name, -// startLine: start.Line, -// startCol: start.Column, -// endLine: end.Line, -// endCol: end.Column, -// } -// v.funcs = append(v.funcs, fe) -// } -// return v -// } - -// func (c *CoverageData) ParseFile(filePath string) error { -// funcs, err := findFuncs(filePath) -// if err != nil { -// return err -// } - -// c.Functions[filePath] = make([]FuncCoverage, len(funcs)) -// for i, f := range funcs { -// c.Functions[filePath][i] = FuncCoverage{ -// Name: f.name, -// StartLine: f.startLine, -// EndLine: f.endLine, -// Covered: make(map[int]bool), -// Total: f.endLine - f.startLine + 1, -// } -// } - -// return nil -// } From 1d02c9a6d2c948de4a80e4bc12571d96af240b62 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Sat, 5 Oct 2024 21:21:56 +0900 Subject: [PATCH 28/37] increase coverage --- gnovm/pkg/gnolang/coverage.go | 61 +++-- gnovm/pkg/gnolang/coverage_test.go | 413 +++++++++++++++++++++++++++++ 2 files changed, 458 insertions(+), 16 deletions(-) diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 9033260b631..ccc3bb67644 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -8,12 +8,14 @@ import ( "go/parser" "go/token" "html/template" + "io/fs" "math" "os" "path/filepath" "sort" "strconv" "strings" + "sync" "github.com/gnolang/gno/tm2/pkg/commands" ) @@ -37,7 +39,7 @@ type CoverageData struct { CurrentPackage string CurrentFile string pathCache map[string]string // relative path to absolute path - // Functions map[string][]FuncCoverage + mu sync.RWMutex } // FileCoverage stores coverage information for a single file @@ -55,13 +57,15 @@ func NewCoverageData(rootDir string) *CoverageData { CurrentPackage: "", CurrentFile: "", pathCache: make(map[string]string), - // Functions: make(map[string][]FuncCoverage), } } // SetExecutableLines sets the executable lines for a given file path in the coverage data. // It updates the ExecutableLines map for the given file path with the provided executable lines. func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[int]bool) { + c.mu.Lock() + defer c.mu.Unlock() + cov, exists := c.Files[filePath] if !exists { cov = FileCoverage{ @@ -79,6 +83,8 @@ func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[i // This function is used to update the hit count for a specific line in the coverage data. // It increments the hit count for the given line in the HitLines map for the specified file path. func (c *CoverageData) updateHit(pkgPath string, line int) { + c.mu.Lock() + defer c.mu.Unlock() if !c.isValidFile(pkgPath) { return } @@ -110,6 +116,9 @@ func (c *CoverageData) getOrCreateFileCoverage(pkgPath string) FileCoverage { } func (c *CoverageData) addFile(filePath string, totalLines int) { + c.mu.Lock() + defer c.mu.Unlock() + if isTestFile(filePath) { return } @@ -125,8 +134,6 @@ func (c *CoverageData) addFile(filePath string, totalLines int) { c.Files[filePath] = fileCoverage } -// region Reporting - // Report prints the coverage report to the console func (c *CoverageData) Report(io commands.IO) { files := make([]string, 0, len(c.Files)) @@ -170,7 +177,7 @@ func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io commands.IO) error { realPath, err := c.findAbsoluteFilePath(filePath) if err != nil { - return nil // ignore invalid file paths + return err } coverage, exists := c.Files[filePath] @@ -273,20 +280,20 @@ func calculateCoverage(covered, total int) float64 { // findAbsoluteFilePath finds the absolute path of a file given its relative path. // It starts searching from root directory and recursively traverses directories. func (c *CoverageData) findAbsoluteFilePath(filePath string) (string, error) { - if cachedPath, ok := c.pathCache[filePath]; ok { + c.mu.RLock() + cachedPath, ok := c.pathCache[filePath] + c.mu.RUnlock() + if ok { return cachedPath, nil } var result string - var found bool - - err := filepath.Walk(c.RootDir, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(c.RootDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - if !info.IsDir() && strings.HasSuffix(path, filePath) { + if !d.IsDir() && strings.HasSuffix(path, filePath) { result = path - found = true return filepath.SkipAll } return nil @@ -295,11 +302,13 @@ func (c *CoverageData) findAbsoluteFilePath(filePath string) (string, error) { return "", err } - if !found { + if result == "" { return "", fmt.Errorf("file %s not found", filePath) } + c.mu.Lock() c.pathCache[filePath] = result + c.mu.Unlock() return result, nil } @@ -495,8 +504,6 @@ func (m *Machine) recordCoverage(node Node) Location { } } -// region Executable Lines Detection - // countCodeLines counts the number of executable lines in the given source code content. func countCodeLines(content string) int { lines, err := detectExecutableLines(content) @@ -520,18 +527,32 @@ func isExecutableLine(node ast.Node) bool { case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: return true - case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.SelectStmt: + case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, + *ast.TypeSwitchStmt, *ast.SelectStmt: return true case *ast.CaseClause: // Even if a `case` condition (e.g., `case 1:`) in a `switch` statement is executed, // the condition itself is not included in the coverage; coverage only recorded for the // code block inside the corresponding `case` clause. return false + case *ast.LabeledStmt: + return isExecutableLine(n.Stmt) case *ast.FuncDecl: return false case *ast.BlockStmt: return false case *ast.DeclStmt: + // check inner declarations in the DeclStmt (e.g. `var a, b = 1, 2`) + // if there is a value initialization, then the line is executable + genDecl, ok := n.Decl.(*ast.GenDecl) + if ok && (genDecl.Tok == token.VAR || genDecl.Tok == token.CONST) { + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if ok && len(valueSpec.Values) > 0 { + return true + } + } + } return false case *ast.ImportSpec, *ast.TypeSpec, *ast.ValueSpec: return false @@ -539,7 +560,15 @@ func isExecutableLine(node ast.Node) bool { return false case *ast.GenDecl: switch n.Tok { - case token.VAR, token.CONST, token.TYPE, token.IMPORT: + case token.VAR, token.CONST: + for _, spec := range n.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if ok && len(valueSpec.Values) > 0 { + return true + } + } + return false + case token.TYPE, token.IMPORT: return false default: return true diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 510cde33767..28bf820d63a 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -3,6 +3,7 @@ package gnolang import ( "bytes" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -350,6 +351,188 @@ func TestRecordCoverage(t *testing.T) { } } +func TestViewFilesE2E(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + files := map[string]string{ + "file1.gno": "package main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n", + "file2.gno": "package utils\n\nfunc Add(a, b int) int {\n\treturn a + b\n}\n", + } + + for name, content := range files { + err := os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0o644) + assert.NoError(t, err) + } + + coverage := NewCoverageData(tempDir) + for name, content := range files { + execLines, err := detectExecutableLines(content) + assert.NoError(t, err) + coverage.SetExecutableLines(name, execLines) + coverage.addFile(name, len(strings.Split(content, "\n"))) + coverage.updateHit(name, 4) + } + + var buf bytes.Buffer + io := commands.NewTestIO() + io.SetOut(nopCloser{Buffer: &buf}) + + err := coverage.ViewFiles("", true, io) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "file1.gno") + assert.Contains(t, output, "file2.gno") + assert.Contains(t, output, "println(\"Hello\")") + assert.Contains(t, output, "return a + b") + assert.Contains(t, output, string(colorGreen)) + assert.Contains(t, output, string(colorWhite)) + // colorYellow은 이 테스트 케이스에서는 나타나지 않을 수 있으므로 제거 + + buf.Reset() + err = coverage.ViewFiles("file1", true, io) + assert.NoError(t, err) + output = buf.String() + assert.Contains(t, output, "file1.gno") + assert.NotContains(t, output, "file2.gno") + + err = coverage.ViewFiles("nonexistent", true, io) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no files found matching pattern") + + buf.Reset() + err = coverage.ViewFiles("", false, io) + assert.NoError(t, err) + output = buf.String() + assert.NotContains(t, output, string(colorOrange)) +} + +func TestFormatLineInfoE2E(t *testing.T) { + t.Parallel() + coverage := NewCoverageData("") + + tests := []struct { + name string + lineNumber int + line string + hitCount int + covered bool + executable bool + showHits bool + want string + }{ + { + name: "Covered line with hits", + lineNumber: 1, + line: "println(\"Hello\")", + hitCount: 2, + covered: true, + executable: true, + showHits: true, + want: fmt.Sprintf( + "%s 1%s %s2 %s %sprintln(\"Hello\")%s", + colorGreen, colorReset, colorOrange, colorReset, colorGreen, colorReset, + ), + }, + { + name: "Executable but not covered line", + lineNumber: 2, + line: "if x > 0 {", + hitCount: 0, + covered: false, + executable: true, + showHits: true, + want: fmt.Sprintf( + "%s 2%s %sif x > 0 {%s", + colorYellow, colorReset, colorYellow, colorReset, + ), + }, + { + name: "Non-executable line", + lineNumber: 3, + line: "package main", + hitCount: 0, + covered: false, + executable: false, + showHits: true, + want: fmt.Sprintf( + "%s 3%s %spackage main%s", + colorWhite, colorReset, colorWhite, colorReset, + ), + }, + { + name: "Covered line without showing hits", + lineNumber: 4, + line: "return x + y", + hitCount: 1, + covered: true, + executable: true, + showHits: false, + want: fmt.Sprintf( + "%s 4%s %sreturn x + y%s", + colorGreen, colorReset, colorGreen, colorReset, + ), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := coverage.formatLineInfo(tt.lineNumber, tt.line, tt.hitCount, tt.covered, tt.executable, tt.showHits) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFindMatchingFilesE2E(t *testing.T) { + t.Parallel() + coverage := &CoverageData{ + Files: map[string]FileCoverage{ + "file1.gno": {}, + "file2.gno": {}, + "other_file.go": {}, + }, + } + + tests := []struct { + name string + pattern string + want []string + }{ + { + name: "Match all .gno files", + pattern: ".gno", + want: []string{"file1.gno", "file2.gno"}, + }, + { + name: "Match specific file", + pattern: "file1", + want: []string{"file1.gno"}, + }, + { + name: "Match non-existent pattern", + pattern: "nonexistent", + want: []string{}, + }, + { + name: "Match all files", + pattern: "", + want: []string{"file1.gno", "file2.gno", "other_file.go"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := coverage.findMatchingFiles(tt.pattern) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + func TestToJSON(t *testing.T) { t.Parallel() tests := []struct { @@ -443,6 +626,236 @@ func TestToJSON(t *testing.T) { } } +func createTempFile(t *testing.T, dir, name, content string) string { + t.Helper() + filePath := filepath.Join(dir, name) + err := os.WriteFile(filePath, []byte(content), 0o644) + if err != nil { + t.Fatalf("Failed to create temp file %s: %v", filePath, err) + } + return filePath +} + +func readFileContent(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read file %s: %v", path, err) + } + return string(data) +} + +func TestSaveHTML(t *testing.T) { + tempDir := t.TempDir() + + source1 := `package main + +import "fmt" + +func main() { + fmt.Println("Hello, World!") +}` + + source2 := `package utils + +func Add(a, b int) int { + return a + b +}` + + file1 := createTempFile(t, tempDir, "main.gno", source1) + file2 := createTempFile(t, tempDir, "utils.gno", source2) + + coverage := NewCoverageData(tempDir) + + execLines1, err := detectExecutableLines(source1) + if err != nil { + t.Fatalf("Failed to detect executable lines for %s: %v", file1, err) + } + execLines2, err := detectExecutableLines(source2) + if err != nil { + t.Fatalf("Failed to detect executable lines for %s: %v", file2, err) + } + + // Set executable lines + relPath1, err := filepath.Rel(tempDir, file1) + if err != nil { + t.Fatalf("Failed to get relative path for %s: %v", file1, err) + } + relPath2, err := filepath.Rel(tempDir, file2) + if err != nil { + t.Fatalf("Failed to get relative path for %s: %v", file2, err) + } + coverage.SetExecutableLines(relPath1, execLines1) + coverage.SetExecutableLines(relPath2, execLines2) + + // Add files with total executable lines + totalExecLines1 := len(execLines1) + totalExecLines2 := len(execLines2) + coverage.addFile(relPath1, totalExecLines1) + coverage.addFile(relPath2, totalExecLines2) + + // Simulate hits + coverage.updateHit(relPath1, 6) // fmt.Println line + coverage.updateHit(relPath2, 4) // return a + b + + // Define output HTML file + outputHTML := filepath.Join(tempDir, "coverage.html") + + // Run SaveHTML + err = coverage.SaveHTML(outputHTML) + if err != nil { + t.Fatalf("SaveHTML failed: %v", err) + } + + // Read and verify the HTML content + htmlContent := readFileContent(t, outputHTML) + + // Basic checks + if !strings.Contains(htmlContent, "main.gno") { + t.Errorf("HTML does not contain main.gno") + } + if !strings.Contains(htmlContent, "utils.gno") { + t.Errorf("HTML does not contain utils.gno") + } + + // Check for hit counts + if !strings.Contains(htmlContent, ">1") { + t.Errorf("Expected hit count '1' for main.gno, but not found") + } + if !strings.Contains(htmlContent, ">1") { + t.Errorf("Expected hit count '1' for utils.gno, but not found") + } +} + +func TestSaveHTML_EmptyCoverage(t *testing.T) { + tempDir := t.TempDir() + + coverage := NewCoverageData(tempDir) + + outputHTML := filepath.Join(tempDir, "coverage_empty.html") + + err := coverage.SaveHTML(outputHTML) + if err != nil { + t.Fatalf("SaveHTML failed: %v", err) + } + + htmlContent := readFileContent(t, outputHTML) + if !strings.Contains(htmlContent, "

Coverage Report

") { + t.Errorf("HTML does not contain the main heading") + } + if strings.Contains(htmlContent, "class=\"filename\"") { + t.Errorf("HTML should not contain any filenames, but found some") + } +} + +func TestSaveHTML_MultipleFiles(t *testing.T) { + tempDir := t.TempDir() + + sources := map[string]string{ + "file1.gno": `package a + +func A() { + // Line 3 +}`, + "file2.gno": `package b + +func B() { + // Line 3 + // Line 4 +}`, + "file3.gno": `package c + +func C() { + // Line 3 + // Line 4 + // Line 5 +}`, + } + + for name, content := range sources { + createTempFile(t, tempDir, name, content) + } + + coverage := NewCoverageData(tempDir) + + for name, content := range sources { + relPath := name + execLines, err := detectExecutableLines(content) + if err != nil { + t.Fatalf("Failed to detect executable lines for %s: %v", name, err) + } + coverage.SetExecutableLines(relPath, execLines) + totalExecLines := len(execLines) + coverage.addFile(relPath, totalExecLines) + } + + coverage.updateHit("file1.gno", 3) // Line 3 + coverage.updateHit("file2.gno", 3) // Line 3 + coverage.updateHit("file2.gno", 4) // Line 4 + coverage.updateHit("file3.gno", 3) // Line 3 + + outputHTML := filepath.Join(tempDir, "coverage_multiple.html") + + err := coverage.SaveHTML(outputHTML) + if err != nil { + t.Fatalf("SaveHTML failed: %v", err) + } + + htmlContent := readFileContent(t, outputHTML) + + for name := range sources { + if !strings.Contains(htmlContent, name) { + t.Errorf("HTML does not contain %s", name) + } + } +} + +func TestSaveHTML_FileNotFound(t *testing.T) { + tempDir := t.TempDir() + + coverage := NewCoverageData(tempDir) + coverage.SetExecutableLines("nonexistent.gno", map[int]bool{1: true, 2: true}) + coverage.addFile("nonexistent.gno", 2) + coverage.updateHit("nonexistent.gno", 1) + + outputHTML := filepath.Join(tempDir, "coverage_notfound.html") + + err := coverage.SaveHTML(outputHTML) + if err == nil { + t.Fatalf("Expected SaveHTML to fail due to missing file, but it succeeded") + } + + if !strings.Contains(err.Error(), "file nonexistent.gno not found") { + t.Errorf("Unexpected error message: %v", err) + } +} + +func TestSaveHTML_FileCreationError(t *testing.T) { + tempDir := t.TempDir() + + createTempFile(t, tempDir, "file.gno", `package main + +func main() {}`) + + coverage := NewCoverageData(tempDir) + coverage.Files["file.gno"] = FileCoverage{ + TotalLines: 2, // Assuming 2 executable lines + HitLines: map[int]int{1: 1}, + ExecutableLines: map[int]bool{1: true, 2: true}, + } + + outputHTML := tempDir + + err := coverage.SaveHTML(outputHTML) + if err == nil { + t.Fatalf("Expected SaveHTML to fail due to invalid file path, but it succeeded") + } + + if !strings.Contains(err.Error(), "is a directory") { + t.Errorf("Unexpected error message: %v", err) + } +} + func TestFindAbsoluteFilePath(t *testing.T) { t.Parallel() rootDir := t.TempDir() From c77ed874a1a6398a0a37310ee818b4aae2012b66 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 21 Oct 2024 16:05:01 +0900 Subject: [PATCH 29/37] state handler --- gnovm/cmd/gno/test.go | 7 +++++-- gnovm/pkg/gnolang/coverage.go | 4 ++++ gnovm/pkg/gnolang/machine.go | 8 +++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 54928d8b6f3..63b47af886e 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -291,6 +291,7 @@ func gnoTestPkg( } coverageData := gno.NewCoverageData(cfg.rootDir) + coverageData.SetEnabled(cfg.coverage) // testing with *_test.gno if len(unittestFiles) > 0 { @@ -335,8 +336,10 @@ func gnoTestPkg( } m := tests.TestMachine(testStore, stdout, gnoPkgPath) - m.Coverage = coverageData - m.Coverage.CurrentPackage = memPkg.Path + if coverageData.IsEnabled() { + m.Coverage = coverageData + m.Coverage.CurrentPackage = memPkg.Path + } if printRuntimeMetrics { // from tm2/pkg/sdk/vm/keeper.go diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index ccc3bb67644..84ade9f853e 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -33,6 +33,7 @@ const ( // CoverageData stores code coverage information type CoverageData struct { + Enabled bool Files map[string]FileCoverage PkgPath string RootDir string @@ -60,6 +61,9 @@ func NewCoverageData(rootDir string) *CoverageData { } } +func (c *CoverageData) SetEnabled(state bool) { c.Enabled = state } +func (c *CoverageData) IsEnabled() bool { return c.Enabled } + // SetExecutableLines sets the executable lines for a given file path in the coverage data. // It updates the ExecutableLines map for the given file path with the provided executable lines. func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[int]bool) { diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 33bab08792a..fdb6da58dba 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1276,6 +1276,7 @@ const ( // main run loop. func (m *Machine) Run() { + var currentPath string for { if m.Debugger.enabled { m.Debug() @@ -1283,7 +1284,12 @@ func (m *Machine) Run() { op := m.PopOp() loc := m.getCurrentLocation() - m.Coverage.updateHit(loc.PkgPath+"/"+loc.File, loc.Line) + if m.Coverage.Enabled { + if currentPath != loc.PkgPath+"/"+loc.File { + currentPath = loc.PkgPath + "/" + loc.File + } + m.Coverage.updateHit(currentPath, loc.Line) + } // TODO: this can be optimized manually, even into tiers. switch op { From 6ac042623c0b88e19b3f3dd4fcfce300d0efe19a Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 21 Oct 2024 17:02:17 +0900 Subject: [PATCH 30/37] disable recording when not enabled --- gnovm/pkg/gnolang/coverage.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 84ade9f853e..ea4db3ef23f 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -489,7 +489,7 @@ func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { // Note: This function assumes that CurrentPackage and CurrentFile are correctly set in the Machine // before it's called. These fields provide the context necessary to accurately record the coverage information. func (m *Machine) recordCoverage(node Node) Location { - if node == nil { + if node == nil || !m.Coverage.IsEnabled() { return Location{} } From b3a0413ab620bf58b902ea0d7c15ce49942631d2 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 22 Oct 2024 10:37:35 +0900 Subject: [PATCH 31/37] fix failed test --- gnovm/cmd/gno/test.go | 6 +++++- gnovm/pkg/gnolang/coverage.go | 6 ++++-- gnovm/pkg/gnolang/coverage_test.go | 13 +++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 63b47af886e..f0b684468a8 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -291,7 +291,11 @@ func gnoTestPkg( } coverageData := gno.NewCoverageData(cfg.rootDir) - coverageData.SetEnabled(cfg.coverage) + if cfg.coverage { + coverageData.Enable() + } else { + coverageData.Disable() + } // testing with *_test.gno if len(unittestFiles) > 0 { diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index ea4db3ef23f..73862d191ab 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -33,7 +33,7 @@ const ( // CoverageData stores code coverage information type CoverageData struct { - Enabled bool + Enabled bool // -cover flag activated Files map[string]FileCoverage PkgPath string RootDir string @@ -61,7 +61,9 @@ func NewCoverageData(rootDir string) *CoverageData { } } -func (c *CoverageData) SetEnabled(state bool) { c.Enabled = state } +// func (c *CoverageData) SetEnabled(state bool) { c.Enabled = state } +func (c *CoverageData) Enable() { c.Enabled = true } +func (c *CoverageData) Disable() { c.Enabled = false } func (c *CoverageData) IsEnabled() bool { return c.Enabled } // SetExecutableLines sets the executable lines for a given file path in the coverage data. diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 28bf820d63a..598bb61dbf8 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -263,6 +263,7 @@ func TestRecordCoverage(t *testing.T) { column: 5, }, initialCoverage: &CoverageData{ + Enabled: true, Files: map[string]FileCoverage{ "testpkg/testfile.gno": { HitLines: make(map[int]int), @@ -286,6 +287,7 @@ func TestRecordCoverage(t *testing.T) { column: 5, }, initialCoverage: &CoverageData{ + Enabled: true, Files: map[string]FileCoverage{ "testpkg/testfile.gno": { HitLines: map[int]int{10: 1}, @@ -309,6 +311,7 @@ func TestRecordCoverage(t *testing.T) { column: 5, }, initialCoverage: &CoverageData{ + Enabled: true, Files: map[string]FileCoverage{ "testpkg/testfile.gno": { HitLines: map[int]int{}, @@ -388,7 +391,6 @@ func TestViewFilesE2E(t *testing.T) { assert.Contains(t, output, "return a + b") assert.Contains(t, output, string(colorGreen)) assert.Contains(t, output, string(colorWhite)) - // colorYellow은 이 테스트 케이스에서는 나타나지 않을 수 있으므로 제거 buf.Reset() err = coverage.ViewFiles("file1", true, io) @@ -480,7 +482,14 @@ func TestFormatLineInfoE2E(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := coverage.formatLineInfo(tt.lineNumber, tt.line, tt.hitCount, tt.covered, tt.executable, tt.showHits) + got := coverage.formatLineInfo( + tt.lineNumber, + tt.line, + tt.hitCount, + tt.covered, + tt.executable, + tt.showHits, + ) assert.Equal(t, tt.want, got) }) } From cc0b24a8c22d57016cb311c3a1642d07587f4b45 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Tue, 22 Oct 2024 11:14:10 +0900 Subject: [PATCH 32/37] fix --- gnovm/pkg/gnolang/coverage.go | 74 ++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 73862d191ab..269585407d4 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -23,23 +23,28 @@ import ( // color scheme for coverage report const ( colorReset = "\033[0m" - colorOrange = "\033[38;5;208m" // orange indicates a number of hits - colorRed = "\033[31m" // red indicates no hits - colorGreen = "\033[32m" // green indicates full coverage, or executed lines - colorYellow = "\033[33m" // yellow indicates partial coverage, or executable but not executed lines - colorWhite = "\033[37m" // white indicates non-executable lines - boldText = "\033[1m" // bold text + colorOrange = "\033[38;5;208m" // number of hits + colorRed = "\033[31m" // no hits + colorGreen = "\033[32m" // covered lines + colorYellow = "\033[33m" // partial coverage, or executable but not executed lines + colorWhite = "\033[37m" // non-executable lines + boldText = "\033[1m" +) + +type ( + fileCoverageMap map[string]FileCoverage + pathCache map[string]string ) // CoverageData stores code coverage information type CoverageData struct { Enabled bool // -cover flag activated - Files map[string]FileCoverage PkgPath string RootDir string CurrentPackage string CurrentFile string - pathCache map[string]string // relative path to absolute path + pathCache pathCache + Files fileCoverageMap mu sync.RWMutex } @@ -52,19 +57,18 @@ type FileCoverage struct { func NewCoverageData(rootDir string) *CoverageData { return &CoverageData{ - Files: make(map[string]FileCoverage), - PkgPath: "", RootDir: rootDir, + PkgPath: "", CurrentPackage: "", CurrentFile: "", - pathCache: make(map[string]string), + Files: make(fileCoverageMap), + pathCache: make(pathCache), } } -// func (c *CoverageData) SetEnabled(state bool) { c.Enabled = state } func (c *CoverageData) Enable() { c.Enabled = true } func (c *CoverageData) Disable() { c.Enabled = false } -func (c *CoverageData) IsEnabled() bool { return c.Enabled } +func (c *CoverageData) IsEnabled() bool { return c.Enabled } // SetExecutableLines sets the executable lines for a given file path in the coverage data. // It updates the ExecutableLines map for the given file path with the provided executable lines. @@ -156,7 +160,10 @@ func (c *CoverageData) Report(io commands.IO) { pct := calculateCoverage(hitLines, totalLines) color := getCoverageColor(pct) if totalLines != 0 { - io.Printfln("%s%.1f%% [%4d/%d] %s%s", color, floor1(pct), hitLines, totalLines, file, colorReset) + io.Printfln( + "%s%.1f%% [%4d/%d] %s%s", + color, floor1(pct), hitLines, totalLines, file, colorReset, + ) } } } @@ -180,7 +187,11 @@ func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) return nil } -func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io commands.IO) error { +func (c *CoverageData) viewSingleFileCoverage( + filePath string, + showHits bool, + io commands.IO, +) error { realPath, err := c.findAbsoluteFilePath(filePath) if err != nil { return err @@ -202,7 +213,12 @@ func (c *CoverageData) viewSingleFileCoverage(filePath string, showHits bool, io return c.printFileContent(file, coverage, showHits, io) } -func (c *CoverageData) printFileContent(file *os.File, coverage FileCoverage, showHits bool, io commands.IO) error { +func (c *CoverageData) printFileContent( + file *os.File, + coverage FileCoverage, + showHits bool, + io commands.IO, +) error { scanner := bufio.NewScanner(file) lineNumber := 1 @@ -210,7 +226,14 @@ func (c *CoverageData) printFileContent(file *os.File, coverage FileCoverage, sh line := scanner.Text() hitCount, covered := coverage.HitLines[lineNumber] - lineInfo := c.formatLineInfo(lineNumber, line, hitCount, covered, coverage.ExecutableLines[lineNumber], showHits) + lineInfo := c.formatLineInfo( + lineNumber, + line, + hitCount, + covered, + coverage.ExecutableLines[lineNumber], + showHits, + ) io.Printfln(lineInfo) lineNumber++ @@ -219,14 +242,18 @@ func (c *CoverageData) printFileContent(file *os.File, coverage FileCoverage, sh return scanner.Err() } -func (c *CoverageData) formatLineInfo(lineNumber int, line string, hitCount int, covered, executable, showHits bool) string { +func (c *CoverageData) formatLineInfo( + lineNumber int, + line string, + hitCount int, + covered, executable, showHits bool, +) string { lineNumStr := fmt.Sprintf("%4d", lineNumber) - color := c.getLineColor(covered, executable) - hitInfo := c.getHitInfo(hitCount, covered, showHits) format := "%s%s%s %s%s%s%s" + return fmt.Sprintf(format, color, lineNumStr, colorReset, hitInfo, color, line, colorReset) } @@ -325,8 +352,10 @@ func isTestFile(pkgPath string) bool { strings.HasSuffix(pkgPath, "_filetest.gno") } +type jsonCoverageMap map[string]JSONFileCoverage + type JSONCoverage struct { - Files map[string]JSONFileCoverage `json:"files"` + Files jsonCoverageMap `json:"files"` } type JSONFileCoverage struct { @@ -336,7 +365,7 @@ type JSONFileCoverage struct { func (c *CoverageData) ToJSON() ([]byte, error) { jsonCov := JSONCoverage{ - Files: make(map[string]JSONFileCoverage), + Files: make(jsonCoverageMap), } for file, coverage := range c.Files { @@ -363,6 +392,7 @@ func (c *CoverageData) SaveJSON(fileName string) error { return os.WriteFile(fileName, data, 0o644) } +// TODO: temporary HTML output. need to match with go coverage tool func (c *CoverageData) SaveHTML(outputFileName string) error { tmpl := ` From 0d06a6fd658df1a871609e7f27efa9628d5f3f70 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 25 Oct 2024 18:40:44 +0900 Subject: [PATCH 33/37] fix --- gnovm/cmd/gno/test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 43c0479a6e3..308524ad2ed 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -194,7 +194,7 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "html", "", "output coverage report in HTML format", - ) + ) fs.BoolVar( &c.printEvents, From c59c4a706ba0986b103cb0aa1264bf0b11873303 Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 25 Oct 2024 23:21:05 +0900 Subject: [PATCH 34/37] fix --- gnovm/pkg/gnolang/coverage_test.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 598bb61dbf8..4c8daa07e51 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -229,18 +229,19 @@ type mockNode struct { column int } -func (m *mockNode) assertNode() {} -func (m *mockNode) String() string { return "" } -func (m *mockNode) Copy() Node { return &mockNode{} } -func (m *mockNode) GetLabel() Name { return "mockNode" } -func (m *mockNode) SetLabel(n Name) {} -func (m *mockNode) HasAttribute(n interface{}) bool { return false } -func (m *mockNode) GetAttribute(n interface{}) interface{} { return nil } -func (m *mockNode) SetAttribute(n interface{}, v interface{}) {} -func (m *mockNode) GetLine() int { return m.line } -func (m *mockNode) SetLine(l int) {} -func (m *mockNode) GetColumn() int { return m.column } -func (m *mockNode) SetColumn(c int) {} +func (m *mockNode) assertNode() {} +func (m *mockNode) String() string { return "" } +func (m *mockNode) Copy() Node { return &mockNode{} } +func (m *mockNode) GetLabel() Name { return "mockNode" } +func (m *mockNode) SetLabel(n Name) {} +func (m *mockNode) GetLine() int { return m.line } +func (m *mockNode) SetLine(l int) {} +func (m *mockNode) GetColumn() int { return m.column } +func (m *mockNode) SetColumn(c int) {} +func (m *mockNode) DelAttribute(key GnoAttribute) {} +func (m *mockNode) GetAttribute(key GnoAttribute) interface{} { return nil } +func (m *mockNode) HasAttribute(key GnoAttribute) bool { return false } +func (m *mockNode) SetAttribute(key GnoAttribute, value interface{}) {} var _ Node = &mockNode{} From 56cc61ff724deb3e9427fcbc914baa4b423cd8bf Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Mon, 28 Oct 2024 12:11:52 +0900 Subject: [PATCH 35/37] fix --- gnovm/pkg/gnolang/coverage.go | 22 +++++++-------- gnovm/pkg/gnolang/coverage_test.go | 16 +++++------ gnovm/pkg/gnolang/machine.go | 43 +++++++++++++++++------------- 3 files changed, 43 insertions(+), 38 deletions(-) diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go index 269585407d4..210abd65e33 100644 --- a/gnovm/pkg/gnolang/coverage.go +++ b/gnovm/pkg/gnolang/coverage.go @@ -40,9 +40,9 @@ type ( type CoverageData struct { Enabled bool // -cover flag activated PkgPath string - RootDir string + rootDir string CurrentPackage string - CurrentFile string + currentFile string pathCache pathCache Files fileCoverageMap mu sync.RWMutex @@ -57,10 +57,10 @@ type FileCoverage struct { func NewCoverageData(rootDir string) *CoverageData { return &CoverageData{ - RootDir: rootDir, + rootDir: rootDir, PkgPath: "", CurrentPackage: "", - CurrentFile: "", + currentFile: "", Files: make(fileCoverageMap), pathCache: make(pathCache), } @@ -70,9 +70,9 @@ func (c *CoverageData) Enable() { c.Enabled = true } func (c *CoverageData) Disable() { c.Enabled = false } func (c *CoverageData) IsEnabled() bool { return c.Enabled } -// SetExecutableLines sets the executable lines for a given file path in the coverage data. +// setExecutableLines sets the executable lines for a given file path in the coverage data. // It updates the ExecutableLines map for the given file path with the provided executable lines. -func (c *CoverageData) SetExecutableLines(filePath string, executableLines map[int]bool) { +func (c *CoverageData) setExecutableLines(filePath string, executableLines map[int]bool) { c.mu.Lock() defer c.mu.Unlock() @@ -249,7 +249,7 @@ func (c *CoverageData) formatLineInfo( covered, executable, showHits bool, ) string { lineNumStr := fmt.Sprintf("%4d", lineNumber) - color := c.getLineColor(covered, executable) + color := c.color(covered, executable) hitInfo := c.getHitInfo(hitCount, covered, showHits) format := "%s%s%s %s%s%s%s" @@ -257,7 +257,7 @@ func (c *CoverageData) formatLineInfo( return fmt.Sprintf(format, color, lineNumStr, colorReset, hitInfo, color, line, colorReset) } -func (c *CoverageData) getLineColor(covered, executable bool) string { +func (c *CoverageData) color(covered, executable bool) string { switch { case covered: return colorGreen @@ -321,7 +321,7 @@ func (c *CoverageData) findAbsoluteFilePath(filePath string) (string, error) { } var result string - err := filepath.WalkDir(c.RootDir, func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(c.rootDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -508,7 +508,7 @@ func (c *CoverageData) SaveHTML(outputFileName string) error { return t.Execute(file, data) } -func (m *Machine) AddFileToCodeCoverage(file string, totalLines int) { +func (m *Machine) addFileToCodeCoverage(file string, totalLines int) { if isTestFile(file) { return } @@ -526,7 +526,7 @@ func (m *Machine) recordCoverage(node Node) Location { } pkgPath := m.Coverage.CurrentPackage - file := m.Coverage.CurrentFile + file := m.Coverage.currentFile line := node.GetLine() path := filepath.Join(pkgPath, file) diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go index 4c8daa07e51..535a959acf0 100644 --- a/gnovm/pkg/gnolang/coverage_test.go +++ b/gnovm/pkg/gnolang/coverage_test.go @@ -273,7 +273,7 @@ func TestRecordCoverage(t *testing.T) { }, PkgPath: "testpkg", CurrentPackage: "testpkg", - CurrentFile: "testfile.gno", + currentFile: "testfile.gno", }, expectedHits: map[string]map[int]int{ "testpkg/testfile.gno": {10: 1}, @@ -297,7 +297,7 @@ func TestRecordCoverage(t *testing.T) { }, PkgPath: "testpkg", CurrentPackage: "testpkg", - CurrentFile: "testfile.gno", + currentFile: "testfile.gno", }, expectedHits: map[string]map[int]int{ "testpkg/testfile.gno": {10: 2}, @@ -321,7 +321,7 @@ func TestRecordCoverage(t *testing.T) { }, PkgPath: "testpkg", CurrentPackage: "testpkg", - CurrentFile: "testfile.gno", + currentFile: "testfile.gno", }, expectedHits: map[string]map[int]int{ "testpkg/testfile.gno": {}, @@ -373,7 +373,7 @@ func TestViewFilesE2E(t *testing.T) { for name, content := range files { execLines, err := detectExecutableLines(content) assert.NoError(t, err) - coverage.SetExecutableLines(name, execLines) + coverage.setExecutableLines(name, execLines) coverage.addFile(name, len(strings.Split(content, "\n"))) coverage.updateHit(name, 4) } @@ -695,8 +695,8 @@ func Add(a, b int) int { if err != nil { t.Fatalf("Failed to get relative path for %s: %v", file2, err) } - coverage.SetExecutableLines(relPath1, execLines1) - coverage.SetExecutableLines(relPath2, execLines2) + coverage.setExecutableLines(relPath1, execLines1) + coverage.setExecutableLines(relPath2, execLines2) // Add files with total executable lines totalExecLines1 := len(execLines1) @@ -794,7 +794,7 @@ func C() { if err != nil { t.Fatalf("Failed to detect executable lines for %s: %v", name, err) } - coverage.SetExecutableLines(relPath, execLines) + coverage.setExecutableLines(relPath, execLines) totalExecLines := len(execLines) coverage.addFile(relPath, totalExecLines) } @@ -824,7 +824,7 @@ func TestSaveHTML_FileNotFound(t *testing.T) { tempDir := t.TempDir() coverage := NewCoverageData(tempDir) - coverage.SetExecutableLines("nonexistent.gno", map[int]bool{1: true, 2: true}) + coverage.setExecutableLines("nonexistent.gno", map[int]bool{1: true, 2: true}) coverage.addFile("nonexistent.gno", 2) coverage.updateHit("nonexistent.gno", 1) diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 6d1e72b41bf..6b1a3ba4aec 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -271,23 +271,8 @@ func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { // and corresponding package node, package value, and types to store. Save // is set to false for tests where package values may be native. func (m *Machine) RunMemPackage(memPkg *gnovm.MemPackage, save bool) (*PackageNode, *PackageValue) { - m.Coverage.CurrentPackage = memPkg.Path - - for _, file := range memPkg.Files { - if strings.HasSuffix(file.Name, ".gno") && !isTestFile(file.Name) { - m.Coverage.CurrentFile = file.Name - - totalLines := countCodeLines(file.Body) - path := filepath.Join(m.Coverage.CurrentPackage, m.Coverage.CurrentFile) - - executableLines, err := detectExecutableLines(file.Body) - if err != nil { - continue - } - - m.Coverage.SetExecutableLines(path, executableLines) - m.AddFileToCodeCoverage(path, totalLines) - } + if m.Coverage.Enabled { + m.initCoverage(memPkg) } return m.runMemPackage(memPkg, save, false) } @@ -349,6 +334,26 @@ func (m *Machine) runMemPackage(memPkg *gnovm.MemPackage, save, overrides bool) return pn, pv } +func (m *Machine) initCoverage(memPkg *gnovm.MemPackage) { + m.Coverage.CurrentPackage = memPkg.Path + for _, file := range memPkg.Files { + if strings.HasSuffix(file.Name, ".gno") && !isTestFile(file.Name) { + m.Coverage.currentFile = file.Name + + totalLines := countCodeLines(file.Body) + path := filepath.Join(m.Coverage.CurrentPackage, m.Coverage.currentFile) + + executableLines, err := detectExecutableLines(file.Body) + if err != nil { + continue + } + + m.Coverage.setExecutableLines(path, executableLines) + m.addFileToCodeCoverage(path, totalLines) + } + } +} + type redeclarationErrors []Name func (r redeclarationErrors) Error() string { @@ -1657,7 +1662,7 @@ func (m *Machine) getCurrentLocation() Location { return Location{ PkgPath: m.Coverage.CurrentPackage, - File: m.Coverage.CurrentFile, + File: m.Coverage.currentFile, Line: lastFrame.Source.GetLine(), Column: lastFrame.Source.GetColumn(), } @@ -1965,7 +1970,7 @@ func (m *Machine) PushFrameCall(cx *CallExpr, fv *FuncValue, recv TypedValue) { } m.Coverage.CurrentPackage = fv.PkgPath - m.Coverage.CurrentFile = string(fv.FileName) + m.Coverage.currentFile = string(fv.FileName) } func (m *Machine) PushFrameGoNative(cx *CallExpr, fv *NativeValue) { From 003b798a6393c00f30fc55c79c4decfa26dd5dbf Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Thu, 14 Nov 2024 20:23:58 +0900 Subject: [PATCH 36/37] fix lint error --- gnovm/pkg/gnolang/machine.go | 1 - 1 file changed, 1 deletion(-) diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index aa3f8449cbf..2a4c2835f2b 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "os" "path/filepath" "reflect" "slices" From 88128a47821315525f795af4fc3519093ca4638b Mon Sep 17 00:00:00 2001 From: Lee ByeongJun Date: Fri, 29 Nov 2024 16:17:49 +0900 Subject: [PATCH 37/37] decouple --- gnovm/cmd/gno/test.go | 93 +-- gnovm/pkg/coverage/analyze.go | 108 +++ gnovm/pkg/coverage/analyze_test.go | 90 +++ gnovm/pkg/coverage/coverage.go | 170 +++++ gnovm/pkg/coverage/coverage_test.go | 113 +++ gnovm/pkg/coverage/report.go | 369 ++++++++++ gnovm/pkg/coverage/report_test.go | 253 +++++++ gnovm/pkg/coverage/utils.go | 64 ++ gnovm/pkg/coverage/utils_test.go | 141 ++++ gnovm/pkg/gnolang/coverage.go | 642 ---------------- gnovm/pkg/gnolang/coverage_test.go | 1058 --------------------------- gnovm/pkg/gnolang/machine.go | 102 ++- gnovm/pkg/gnolang/op_exec.go | 10 +- 13 files changed, 1386 insertions(+), 1827 deletions(-) create mode 100644 gnovm/pkg/coverage/analyze.go create mode 100644 gnovm/pkg/coverage/analyze_test.go create mode 100644 gnovm/pkg/coverage/coverage.go create mode 100644 gnovm/pkg/coverage/coverage_test.go create mode 100644 gnovm/pkg/coverage/report.go create mode 100644 gnovm/pkg/coverage/report_test.go create mode 100644 gnovm/pkg/coverage/utils.go create mode 100644 gnovm/pkg/coverage/utils_test.go delete mode 100644 gnovm/pkg/gnolang/coverage.go delete mode 100644 gnovm/pkg/gnolang/coverage_test.go diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index 0aada5fa425..04a3808718d 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -26,13 +26,6 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool printEvents bool - - // coverage flags - coverage bool - viewFile string - showHits bool - output string - htmlOutput string } func newTestCmd(io commands.IO) *commands.Command { @@ -144,43 +137,6 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { "print runtime metrics (gas, memory, cpu cycles)", ) - // test coverage flags - - fs.BoolVar( - &c.coverage, - "cover", - false, - "enable coverage analysis", - ) - - fs.BoolVar( - &c.showHits, - "show-hits", - false, - "show number of times each line was executed", - ) - - fs.StringVar( - &c.viewFile, - "view", - "", - "view coverage for a specific file", - ) - - fs.StringVar( - &c.output, - "out", - "", - "save coverage data as JSON to specified file", - ) - - fs.StringVar( - &c.htmlOutput, - "html", - "", - "output coverage report in HTML format", - ) - fs.BoolVar( &c.printEvents, "print-events", @@ -239,21 +195,11 @@ func execTest(cfg *testCfg, args []string, io commands.IO) error { io.ErrPrintfln("? %s \t[no test files]", pkg.Dir) continue } - - coverageData := gno.NewCoverageData(cfg.rootDir) - if cfg.coverage { - coverageData.Enable() - } else { - coverageData.Disable() - } - // Determine gnoPkgPath by reading gno.mod var gnoPkgPath string modfile, err := gnomod.ParseAt(pkg.Dir) if err == nil { - // TODO: use pkgPathFromRootDir? gnoPkgPath = modfile.Module.Mod.Path - coverageData.PkgPath = gnoPkgPath } else { gnoPkgPath = pkgPathFromRootDir(pkg.Dir, cfg.rootDir) if gnoPkgPath == "" { @@ -261,7 +207,6 @@ func execTest(cfg *testCfg, args []string, io commands.IO) error { io.ErrPrintfln("--- WARNING: unable to read package path from gno.mod or gno root directory; try creating a gno.mod file") gnoPkgPath = gno.RealmPathPrefix + strings.ToLower(random.RandStr(8)) } - coverageData.PkgPath = pkgPath } memPkg := gno.ReadMemPackage(pkg.Dir, gnoPkgPath) @@ -271,12 +216,6 @@ func execTest(cfg *testCfg, args []string, io commands.IO) error { err = test.Test(memPkg, pkg.Dir, opts) }) - m := tests.TestMachine(testStore, stdout, gnoPkgPath) - if coverageData.IsEnabled() { - m.Coverage = coverageData - m.Coverage.CurrentPackage = memPkg.Path - } - duration := time.Since(startedAt) dstr := fmtDuration(duration) @@ -297,37 +236,7 @@ func execTest(cfg *testCfg, args []string, io commands.IO) error { return fmt.Errorf("FAIL: %d build errors, %d test errors", buildErrCount, testErrCount) } - if cfg.coverage { - // TODO: consider cache - if cfg.viewFile != "" { - err := coverageData.ViewFiles(cfg.viewFile, cfg.showHits, io) - if err != nil { - return fmt.Errorf("failed to view file coverage: %w", err) - } - return nil // prevent printing out coverage report - } - - if cfg.output != "" { - err := coverageData.SaveJSON(cfg.output) - if err != nil { - return fmt.Errorf("failed to save coverage data: %w", err) - } - io.Println("coverage data saved to", cfg.output) - return nil - } - - if cfg.htmlOutput != "" { - err := coverageData.SaveHTML(cfg.htmlOutput) - if err != nil { - return fmt.Errorf("failed to save coverage data: %w", err) - } - io.Println("coverage report saved to", cfg.htmlOutput) - return nil - } - coverageData.Report(io) - } - - return errs + return nil } // attempts to determine the full gno pkg path by analyzing the directory. diff --git a/gnovm/pkg/coverage/analyze.go b/gnovm/pkg/coverage/analyze.go new file mode 100644 index 00000000000..ad4cc579819 --- /dev/null +++ b/gnovm/pkg/coverage/analyze.go @@ -0,0 +1,108 @@ +package coverage + +import ( + "go/ast" + "go/parser" + "go/token" +) + +// detectExecutableLines analyzes the given source code content and returns a map +// of line numbers to boolean values indicating whether each line is executable. +func DetectExecutableLines(content string) (map[int]bool, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, "", content, parser.ParseComments) + if err != nil { + return nil, err + } + + executableLines := make(map[int]bool) + + ast.Inspect(node, func(n ast.Node) bool { + if n == nil { + return true + } + + if isExecutableLine(n) { + line := fset.Position(n.Pos()).Line + executableLines[line] = true + } + + return true + }) + + return executableLines, nil +} + +// countCodeLines counts the number of executable lines in the given source code content. +func CountCodeLines(content string) int { + lines, err := DetectExecutableLines(content) + if err != nil { + return 0 + } + + return len(lines) +} + +// isExecutableLine determines whether a given AST node represents an +// executable line of code for the purpose of code coverage measurement. +// +// It returns true for statement nodes that typically contain executable code, +// such as assignments, expressions, return statements, and control flow statements. +// +// It returns false for nodes that represent non-executable lines, such as +// declarations, blocks, and function definitions. +func isExecutableLine(node ast.Node) bool { + switch n := node.(type) { + case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, + *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: + return true + case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, + *ast.TypeSwitchStmt, *ast.SelectStmt: + return true + case *ast.CaseClause: + // Even if a `case` condition (e.g., `case 1:`) in a `switch` statement is executed, + // the condition itself is not included in the coverage; coverage only recorded for the + // code block inside the corresponding `case` clause. + return false + case *ast.LabeledStmt: + return isExecutableLine(n.Stmt) + case *ast.FuncDecl: + return false + case *ast.BlockStmt: + return false + case *ast.DeclStmt: + // check inner declarations in the DeclStmt (e.g. `var a, b = 1, 2`) + // if there is a value initialization, then the line is executable + genDecl, ok := n.Decl.(*ast.GenDecl) + if ok && (genDecl.Tok == token.VAR || genDecl.Tok == token.CONST) { + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if ok && len(valueSpec.Values) > 0 { + return true + } + } + } + return false + case *ast.ImportSpec, *ast.TypeSpec, *ast.ValueSpec: + return false + case *ast.InterfaceType: + return false + case *ast.GenDecl: + switch n.Tok { + case token.VAR, token.CONST: + for _, spec := range n.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if ok && len(valueSpec.Values) > 0 { + return true + } + } + return false + case token.TYPE, token.IMPORT: + return false + default: + return true + } + default: + return false + } +} diff --git a/gnovm/pkg/coverage/analyze_test.go b/gnovm/pkg/coverage/analyze_test.go new file mode 100644 index 00000000000..02165fada30 --- /dev/null +++ b/gnovm/pkg/coverage/analyze_test.go @@ -0,0 +1,90 @@ +package coverage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectExecutableLines(t *testing.T) { + t.Parallel() + tests := []struct { + name string + content string + want map[int]bool + wantErr bool + }{ + { + name: "Simple function", + content: ` +package main + +func main() { + x := 5 + if x > 3 { + println("Greater") + } +}`, + want: map[int]bool{ + 5: true, // x := 5 + 6: true, // if x > 3 + 7: true, // println("Greater") + }, + wantErr: false, + }, + { + name: "Function with loop", + content: ` +package main + +func loopFunction() { + for i := 0; i < 5; i++ { + if i%2 == 0 { + continue + } + println(i) + } +}`, + want: map[int]bool{ + 5: true, // for i := 0; i < 5; i++ + 6: true, // if i%2 == 0 + 7: true, // continue + 9: true, // println(i) + }, + wantErr: false, + }, + { + name: "Only declarations", + content: ` +package main + +import "fmt" + +var x int + +type MyStruct struct { + field int +}`, + want: map[int]bool{}, + wantErr: false, + }, + { + name: "Invalid gno code", + content: ` +This is not valid Go code +It should result in an error`, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := DetectExecutableLines(tt.content) + assert.Equal(t, tt.wantErr, err != nil) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/gnovm/pkg/coverage/coverage.go b/gnovm/pkg/coverage/coverage.go new file mode 100644 index 00000000000..eca0e7db793 --- /dev/null +++ b/gnovm/pkg/coverage/coverage.go @@ -0,0 +1,170 @@ +package coverage + +import ( + "io" + "path/filepath" +) + +// Collector defines the interface for collecting coverage data +type Collector interface { + RecordHit(loc FileLocation) + SetExecutableLines(filePath string, lines map[int]bool) + AddFile(filePath string, totalLines int) +} + +// Coverage implements the Collector interface and manages coverage data +type Coverage struct { + enabled bool + rootDir string + currentPath string + currentFile string + files fileCoverageMap + pathCache pathCache +} + +// FileCoverage stores coverage information for a single file +type FileCoverage struct { + totalLines int + hitLines map[int]int + executableLines map[int]bool +} + +type ( + fileCoverageMap map[string]FileCoverage + pathCache map[string]string +) + +func (m fileCoverageMap) get(path string) (FileCoverage, bool) { + fc, ok := m[path] + return fc, ok +} + +func (m fileCoverageMap) set(path string, fc FileCoverage) { + m[path] = fc +} + +// NewFileCoverage creates a new FileCoverage instance +func NewFileCoverage() FileCoverage { + return FileCoverage{ + totalLines: 0, + hitLines: make(map[int]int), + executableLines: make(map[int]bool), + } +} + +// New creates a new Coverage instance +func New(rootDir string) *Coverage { + return &Coverage{ + rootDir: rootDir, + files: make(fileCoverageMap), + pathCache: make(pathCache), + } +} + +// Configuration methods +func (c *Coverage) Enabled() bool { return c.enabled } +func (c *Coverage) Enable() { c.enabled = true } +func (c *Coverage) Disable() { c.enabled = false } +func (c *Coverage) SetCurrentPath(path string) { c.currentPath = path } +func (c *Coverage) CurrentPath() string { return c.currentPath } +func (c *Coverage) SetCurrentFile(file string) { c.currentFile = file } +func (c *Coverage) CurrentFile() string { return c.currentFile } + +// RecordHit implements Collector.RecordHit +func (c *Coverage) RecordHit(loc FileLocation) { + if !c.enabled { return } + + path := filepath.Join(loc.PkgPath, loc.File) + cov := c.getOrCreateFileCoverage(path) + + if cov.executableLines[loc.Line] { + cov.hitLines[loc.Line]++ + c.files.set(path, cov) + } +} + +// SetExecutableLines implements Collector.SetExecutableLines +func (c *Coverage) SetExecutableLines(filePath string, executableLines map[int]bool) { + cov, exists := c.files.get(filePath) + if !exists { + cov = NewFileCoverage() + } + + cov.executableLines = executableLines + c.files.set(filePath, cov) +} + +// AddFile implements Collector.AddFile +func (c *Coverage) AddFile(filePath string, totalLines int) { + if IsTestFile(filePath) || !isValidFile(c.currentPath, filePath) { + return + } + + cov, exists := c.files.get(filePath) + if !exists { + cov = NewFileCoverage() + } + + cov.totalLines = totalLines + c.files.set(filePath, cov) +} + +// Report generates a coverage report using the given options +func (c *Coverage) Report(opts ReportOpts, w io.Writer) error { + reporter := NewReporter(c, opts) + if opts.pattern != "" { + return reporter.WriteFileDetail(w, opts.pattern, opts.showHits) + } + return reporter.Write(w) +} + +// FileLocation represents a specific location in source code +type FileLocation struct { + PkgPath string + File string + Line int + Column int +} + +// Helper methods +func (c *Coverage) getOrCreateFileCoverage(filePath string) FileCoverage { + cov, exists := c.files.get(filePath) + if !exists { + cov = NewFileCoverage() + } + return cov +} + +// GetStats returns coverage statistics for a file +func (c *Coverage) GetStats(filePath string) (hits, total int, ok bool) { + cov, exists := c.files.get(filePath) + if !exists { + return 0, 0, false + } + return len(cov.hitLines), cov.totalLines, true +} + +// GetFileHits returns the hit counts for a file (primarily for testing) +func (c *Coverage) GetFileHits(filePath string) map[int]int { + if cov, exists := c.files.get(filePath); exists { + return cov.hitLines + } + return nil +} + +// GetExecutableLines returns the executable lines for a file (primarily for testing) +func (c *Coverage) GetExecutableLines(filePath string) map[int]bool { + if cov, exists := c.files.get(filePath); exists { + return cov.executableLines + } + return nil +} + +// GetFiles returns a list of all tracked files +func (c *Coverage) GetFiles() []string { + files := make([]string, 0, len(c.files)) + for file := range c.files { + files = append(files, file) + } + return files +} diff --git a/gnovm/pkg/coverage/coverage_test.go b/gnovm/pkg/coverage/coverage_test.go new file mode 100644 index 00000000000..7b51b54bb52 --- /dev/null +++ b/gnovm/pkg/coverage/coverage_test.go @@ -0,0 +1,113 @@ +package coverage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCollector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCoverage func() *Coverage + location FileLocation + expectedHits map[string]map[int]int + checkLocation bool + }{ + { + name: "Record hit for new file and line", + setupCoverage: func() *Coverage { + c := New("") + c.Enable() + c.SetExecutableLines("testpkg/testfile.gno", map[int]bool{10: true}) + return c + }, + location: FileLocation{ + PkgPath: "testpkg", + File: "testfile.gno", + Line: 10, + Column: 5, + }, + expectedHits: map[string]map[int]int{ + "testpkg/testfile.gno": {10: 1}, + }, + checkLocation: true, + }, + { + name: "Increment hit count for existing line", + setupCoverage: func() *Coverage { + c := New("") + c.Enable() + c.SetExecutableLines("testpkg/testfile.gno", map[int]bool{10: true}) + // pre-record a hit + c.RecordHit(FileLocation{ + PkgPath: "testpkg", + File: "testfile.gno", + Line: 10, + }) + return c + }, + location: FileLocation{ + PkgPath: "testpkg", + File: "testfile.gno", + Line: 10, + Column: 5, + }, + expectedHits: map[string]map[int]int{ + "testpkg/testfile.gno": {10: 2}, + }, + checkLocation: true, + }, + { + name: "Do not record coverage for non-executable line", + setupCoverage: func() *Coverage { + c := New("") + c.Enable() + c.SetExecutableLines("testpkg/testfile.gno", map[int]bool{10: true}) + return c + }, + location: FileLocation{ + PkgPath: "testpkg", + File: "testfile.gno", + Line: 20, + Column: 5, + }, + expectedHits: map[string]map[int]int{ + "testpkg/testfile.gno": {}, + }, + checkLocation: true, + }, + { + name: "Ignore coverage when disabled", + setupCoverage: func() *Coverage { + c := New("") + c.Disable() + return c + }, + location: FileLocation{ + PkgPath: "testpkg", + File: "testfile.gno", + Line: 10, + }, + expectedHits: map[string]map[int]int{}, + checkLocation: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cov := tt.setupCoverage() + cov.RecordHit(tt.location) + + for file, expectedHits := range tt.expectedHits { + actualHits := cov.files[file].hitLines + assert.Equal(t, expectedHits, actualHits) + } + }) + } +} diff --git a/gnovm/pkg/coverage/report.go b/gnovm/pkg/coverage/report.go new file mode 100644 index 00000000000..d27751edcda --- /dev/null +++ b/gnovm/pkg/coverage/report.go @@ -0,0 +1,369 @@ +package coverage + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "io/fs" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + _ "github.com/gnolang/gno/tm2/pkg/commands" +) + +type ansiColor string + +const ( + Reset ansiColor = "\033[0m" + Green ansiColor = "\033[32m" + Yellow ansiColor = "\033[33m" + Red ansiColor = "\033[31m" + White ansiColor = "\033[37m" + Orange ansiColor = "\033[38;5;208m" + Bold ansiColor = "\033[1m" +) + +// color scheme for coverage report +// ColorScheme defines ANSI color codes for terminal output +type ColorScheme struct { + Reset ansiColor + Success ansiColor + Warning ansiColor + Error ansiColor + Info ansiColor + Highlight ansiColor + Bold ansiColor +} + +var defaultColors = ColorScheme{ + Reset: Reset, + Success: Green, + Warning: Yellow, + Error: Red, + Info: White, + Highlight: Orange, + Bold: Bold, +} + +type ReportFormat string + +const ( + Text ReportFormat = "text" + JSON ReportFormat = "json" + HTML ReportFormat = "html" +) + +type Reporter interface { + // Write writes a coverage report to the given writer. + Write(w io.Writer) error + + // WriteFileDetail writes detailed coverage info for specific file. + WriteFileDetail(w io.Writer, pattern string, showHits bool) error +} + +type ReportOpts struct { + format ReportFormat + showHits bool + fileName string + pattern string +} + +type baseReporter struct { + coverage *Coverage + colors ColorScheme +} + +func (base *baseReporter) sortFiles() []string { + files := make([]string, 0, len(base.coverage.files)) + for file := range base.coverage.files { + files = append(files, file) + } + sort.Strings(files) + return files +} + +func (r *baseReporter) calculateStats(cov FileCoverage) (int, int, float64) { + executableLines := 0 + for _, executable := range cov.executableLines { + if executable { + executableLines++ + } + } + + hitLines := len(cov.hitLines) + percentage := float64(0) + if executableLines > 0 { + percentage = float64(hitLines) / float64(executableLines) * 100 + } + + return hitLines, executableLines, percentage +} + +type ConsoleReporter struct { + baseReporter + finder PathFinder +} + +func NewConsoleReporter(c *Coverage, finder PathFinder) *ConsoleReporter { + return &ConsoleReporter{ + baseReporter: baseReporter{ + coverage: c, + colors: defaultColors, + }, + finder: finder, + } +} + +func (r *ConsoleReporter) Write(w io.Writer) error { + files := r.sortFiles() + + for _, file := range files { + cov, exists := r.coverage.files.get(file) + if !exists { + continue + } + + hits, executable, pct := r.calculateStats(cov) + if executable == 0 { + continue + } + + color := r.colorize(r.colors, pct) + _, err := fmt.Fprintf(w, + "%s%.1f%% [%4d/%d] %s%s\n", + color, floor1(pct), hits, executable, file, r.colors.Reset, + ) + if err != nil { + return fmt.Errorf("writing coverage for %s: %w", file, err) + } + } + + return nil +} + +func (r *ConsoleReporter) WriteFileDetail(w io.Writer, pattern string, showHits bool) error { + files := findMatchingFiles(r.coverage.files, pattern) + if len(files) == 0 { + return fmt.Errorf("no files found matching pattern %s", pattern) + } + + for _, path := range files { + absPath, err := r.finder.Find(path) + if err != nil { + return fmt.Errorf("finding file path: %w", err) + } + + relPath := path + + if err := r.writeFileCoverage(w, absPath, relPath, showHits); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + + return nil +} + +func (r *ConsoleReporter) writeFileCoverage(w io.Writer, absPath, relPath string, showHits bool) error { + file, err := os.Open(absPath) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer file.Close() + + // file name + if _, err := fmt.Fprintf(w, "%s%s%s:\n", r.colors.Bold, relPath, r.colors.Reset); err != nil { + return err + } + + cov, exists := r.coverage.files.get(relPath) + if !exists { + return fmt.Errorf("no coverage data for file %s", relPath) + } + + // print file content (line by line) + scanner := bufio.NewScanner(file) + lineNum := 1 + for scanner.Scan() { + line := scanner.Text() + hits, covered := cov.hitLines[lineNum] + executable := cov.executableLines[lineNum] + + lineInfo := r.formatLineInfo(lineNum, line, hits, covered, executable, showHits) + if _, err := fmt.Fprintln(w, lineInfo); err != nil { + return err + } + lineNum++ + } + + return scanner.Err() +} + +func (r *ConsoleReporter) formatLineInfo( + lineNum int, + line string, + hits int, + covered, executable, showHits bool, +) string { + lineNumStr := fmt.Sprintf("%4d", lineNum) + color := r.getLineColor(covered, executable) + hitInfo := r.formatHitInfo(hits, covered, showHits) + + return fmt.Sprintf("%s%s%s %s%s%s%s", + color, lineNumStr, r.colors.Reset, + hitInfo, color, line, r.colors.Reset) +} + +func (r *ConsoleReporter) formatHitInfo(hits int, covered, showHits bool) string { + if !showHits { + return "" + } + if covered { + return fmt.Sprintf("%s%-4d%s ", r.colors.Highlight, hits, r.colors.Reset) + } + return strings.Repeat(" ", 5) +} + +func (r *ConsoleReporter) getLineColor(covered, executable bool) ansiColor { + switch { + case covered: + return r.colors.Success + case executable: + return r.colors.Warning + default: + return r.colors.Info + } +} + +func (r *ConsoleReporter) colorize(scheme ColorScheme, pct float64) ansiColor { + switch { + case pct >= 80: + return scheme.Success + case pct >= 50: + return scheme.Warning + default: + return scheme.Error + } +} + +type PathFinder interface { + Find(path string) (string, error) +} + +type defaultPathFinder struct { + rootDir string + cache map[string]string +} + +func NewDefaultPathFinder(rootDir string) PathFinder { + return &defaultPathFinder{ + rootDir: rootDir, + cache: make(map[string]string), + } +} + +func (f *defaultPathFinder) Find(path string) (string, error) { + if cached, ok := f.cache[path]; ok { + return cached, nil + } + + // try direct path first + direct := filepath.Join(f.rootDir, path) + if _, err := os.Stat(direct); err == nil { + f.cache[path] = direct + return direct, nil + } + + var found string + err := filepath.WalkDir(f.rootDir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Base(p) == filepath.Base(path) { + found = p + return filepath.SkipAll + } + return nil + }) + if err != nil { + return "", fmt.Errorf("finding path %s: %w", path, err) + } + + if found == "" { + return "", fmt.Errorf("file %s not found", path) + } + + f.cache[path] = found + return found, nil +} + +type JSONReporter struct { + baseReporter + fileName string +} + +type jsonCoverage struct { + Files map[string]jsonFileCoverage `json:"files"` +} + +type jsonFileCoverage struct { + TotalLines int `json:"total_lines"` + HitLines map[string]int `json:"hit_lines"` +} + +func NewJSONReporter(cov *Coverage, fname string) *JSONReporter { + return &JSONReporter{ + baseReporter: baseReporter{coverage: cov}, + fileName: fname, + } +} + +func (r *JSONReporter) Write(w io.Writer) error { + data := jsonCoverage{ + Files: make(map[string]jsonFileCoverage), + } + + for file, coverage := range r.coverage.files { + hits := make(map[string]int) + for line, count := range coverage.hitLines { + hits[strconv.Itoa(line)] = count + } + + data.Files[file] = jsonFileCoverage{ + TotalLines: coverage.totalLines, + HitLines: hits, + } + } + + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(data) +} + +func (r *JSONReporter) WriteFileDetail(w io.Writer, pattern string, showHits bool) error { + return fmt.Errorf("file detail view not supported for JSON format") +} + +func NewReporter(cov *Coverage, opts ReportOpts) Reporter { + switch opts.format { + case JSON: + return NewJSONReporter(cov, opts.fileName) + case HTML: + // TODO: implement HTML reporter + return nil + default: + return NewConsoleReporter(cov, NewDefaultPathFinder(cov.rootDir)) + } +} + +func floor1(v float64) float64 { + return math.Floor(v*10) / 10 +} diff --git a/gnovm/pkg/coverage/report_test.go b/gnovm/pkg/coverage/report_test.go new file mode 100644 index 00000000000..da2490f5ae1 --- /dev/null +++ b/gnovm/pkg/coverage/report_test.go @@ -0,0 +1,253 @@ +package coverage + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConsoleReporter_WriteReport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCoverage func() *Coverage + wantContains []string + wantExcludes []string + }{ + { + name: "basic coverage report", + setupCoverage: func() *Coverage { + cov := New("") + cov.Enable() + + filePath := "main/file.gno" + execLines := map[int]bool{ + 4: true, + 5: true, + } + cov.SetExecutableLines(filePath, execLines) + cov.AddFile(filePath, 10) + cov.RecordHit(FileLocation{ + PkgPath: "main", + File: "file.gno", + Line: 4, + }) + + return cov + }, + wantContains: []string{ + "50.0%", + "1/2", + "file.gno", + string(Yellow), + }, + }, + { + name: "high coverage report", + setupCoverage: func() *Coverage { + cov := New("") + cov.Enable() + + filePath := "pkg/high.gno" + execLines := map[int]bool{ + 1: true, + 2: true, + } + cov.SetExecutableLines(filePath, execLines) + cov.AddFile(filePath, 10) + + cov.RecordHit(FileLocation{PkgPath: "pkg", File: "high.gno", Line: 1}) + cov.RecordHit(FileLocation{PkgPath: "pkg", File: "high.gno", Line: 2}) + + return cov + }, + wantContains: []string{ + "100.0%", + "2/2", + "high.gno", + string(Green), + }, + }, + { + name: "low coverage report", + setupCoverage: func() *Coverage { + cov := New("") + cov.Enable() + + filePath := "pkg/low.gno" + execLines := map[int]bool{ + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + } + cov.SetExecutableLines(filePath, execLines) + cov.AddFile(filePath, 10) + + cov.RecordHit(FileLocation{PkgPath: "pkg", File: "low.gno", Line: 1}) + + return cov + }, + wantContains: []string{ + "20.0%", + "1/5", + "low.gno", + string(Red), + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + cov := tt.setupCoverage() + reporter := NewConsoleReporter(cov, NewDefaultPathFinder("")) + + err := reporter.Write(&buf) + require.NoError(t, err) + + output := buf.String() + for _, want := range tt.wantContains { + assert.Contains(t, output, want) + } + for _, exclude := range tt.wantExcludes { + assert.NotContains(t, output, exclude) + } + }) + } +} + +func TestConsoleReporter_WriteFileDetail(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + testFileName := "test.gno" + testPath := filepath.Join(tempDir, testFileName) + testContent := `package test + +func Add(a, b int) int { + return a + b +} +` + require.NoError(t, os.WriteFile(testPath, []byte(testContent), 0o644)) + + tests := []struct { + name string + pattern string + showHits bool + setupCoverage func() *Coverage + wantContains []string + wantErr bool + }{ + { + name: "show file with hits", + pattern: testFileName, + showHits: true, + setupCoverage: func() *Coverage { + cov := New(tempDir) + cov.Enable() + + execLines, _ := DetectExecutableLines(testContent) + cov.SetExecutableLines(testFileName, execLines) + cov.AddFile(testFileName, len(strings.Split(testContent, "\n"))) + cov.RecordHit(FileLocation{File: testFileName, Line: 4}) + + return cov + }, + wantContains: []string{ + testFileName, + "func Add", + "return a + b", + string(Green), // covered line + string(White), // non-executable line + string(Orange), // hit count + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + cov := tt.setupCoverage() + reporter := NewConsoleReporter(cov, NewDefaultPathFinder(tempDir)) + + err := reporter.WriteFileDetail(&buf, tt.pattern, tt.showHits) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + output := buf.String() + for _, want := range tt.wantContains { + assert.Contains(t, output, want) + } + }) + } +} + +func TestJSONReporter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCoverage func() *Coverage + checkOutput func(*testing.T, []byte) + }{ + { + name: "basic json report", + setupCoverage: func() *Coverage { + cov := New("") + cov.Enable() + + filePath := "pkg/file.gno" + cov.AddFile(filePath, 10) + cov.SetExecutableLines(filePath, map[int]bool{1: true}) + cov.RecordHit(FileLocation{PkgPath: "pkg", File: "file.gno", Line: 1}) + + return cov + }, + checkOutput: func(t *testing.T, output []byte) { + var report jsonCoverage + require.NoError(t, json.Unmarshal(output, &report)) + + assert.Contains(t, report.Files, "pkg/file.gno") + fileCov := report.Files["pkg/file.gno"] + assert.Equal(t, 10, fileCov.TotalLines) + assert.Equal(t, 1, fileCov.HitLines["1"]) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + cov := tt.setupCoverage() + reporter := NewJSONReporter(cov, "") + + err := reporter.Write(&buf) + require.NoError(t, err) + + tt.checkOutput(t, buf.Bytes()) + + println(buf.String()) + }) + } +} diff --git a/gnovm/pkg/coverage/utils.go b/gnovm/pkg/coverage/utils.go new file mode 100644 index 00000000000..3be26a70832 --- /dev/null +++ b/gnovm/pkg/coverage/utils.go @@ -0,0 +1,64 @@ +package coverage + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +// findAbsFilePath finds the absolute path of a file given its relative path. +// It starts searching from root directory and recursively traverses directories. +func findAbsFilePath(c *Coverage, fpath string) (string, error) { + cache, ok := c.pathCache[fpath] + if ok { + return cache, nil + } + + var absPath string + err := filepath.WalkDir(c.rootDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && strings.HasSuffix(path, fpath) { + absPath = path + return filepath.SkipAll + } + + return nil + }) + if err != nil { + return "", err + } + + if absPath == "" { + return "", fmt.Errorf("file %s not found", fpath) + } + + c.pathCache[fpath] = absPath + + return absPath, nil +} + +func findMatchingFiles(fileMap fileCoverageMap, pat string) []string { + var files []string + for file := range fileMap { + if strings.Contains(file, pat) { + files = append(files, file) + } + } + return files +} + +func IsTestFile(pkgPath string) bool { + return strings.HasSuffix(pkgPath, "_test.gno") || + strings.HasSuffix(pkgPath, "_testing.gno") || + strings.HasSuffix(pkgPath, "_filetest.gno") +} + +func isValidFile(currentPath, path string) bool { + return strings.HasPrefix(path, currentPath) && + strings.HasSuffix(path, ".gno") && + !IsTestFile(path) +} diff --git a/gnovm/pkg/coverage/utils_test.go b/gnovm/pkg/coverage/utils_test.go new file mode 100644 index 00000000000..4dadad7c297 --- /dev/null +++ b/gnovm/pkg/coverage/utils_test.go @@ -0,0 +1,141 @@ +package coverage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsTestFile(t *testing.T) { + t.Parallel() + tests := []struct { + pkgPath string + want bool + }{ + {"file1_test.gno", true}, + {"file1_testing.gno", true}, + {"file1.gno", false}, + {"random_test.go", false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.pkgPath, func(t *testing.T) { + t.Parallel() + got := IsTestFile(tt.pkgPath) + if got != tt.want { + t.Errorf("isTestFile(%s) = %v, want %v", tt.pkgPath, got, tt.want) + } + }) + } +} + +func TestFindAbsoluteFilePath(t *testing.T) { + t.Parallel() + rootDir := t.TempDir() + + examplesDir := filepath.Join(rootDir, "examples") + stdlibsDir := filepath.Join(rootDir, "gnovm", "stdlibs") + + if err := os.MkdirAll(examplesDir, 0o755); err != nil { + t.Fatalf("failed to create examples directory: %v", err) + } + if err := os.MkdirAll(stdlibsDir, 0o755); err != nil { + t.Fatalf("failed to create stdlibs directory: %v", err) + } + + exampleFile := filepath.Join(examplesDir, "example.gno") + stdlibFile := filepath.Join(stdlibsDir, "stdlib.gno") + if _, err := os.Create(exampleFile); err != nil { + t.Fatalf("failed to create example file: %v", err) + } + if _, err := os.Create(stdlibFile); err != nil { + t.Fatalf("failed to create stdlib file: %v", err) + } + + c := New(rootDir) + + tests := []struct { + name string + filePath string + expectedPath string + expectError bool + }{ + { + name: "File in examples directory", + filePath: "example.gno", + expectedPath: exampleFile, + expectError: false, + }, + { + name: "File in stdlibs directory", + filePath: "stdlib.gno", + expectedPath: stdlibFile, + expectError: false, + }, + { + name: "Non-existent file", + filePath: "nonexistent.gno", + expectedPath: "", + expectError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + actualPath, err := findAbsFilePath(c, tt.filePath) + + if tt.expectError { + if err == nil { + t.Errorf("expected an error but got none") + } + } else { + if err != nil { + t.Errorf("did not expect an error but got: %v", err) + } + if actualPath != tt.expectedPath { + t.Errorf("expected path %s, but got %s", tt.expectedPath, actualPath) + } + } + }) + } +} + +func TestFindAbsoluteFilePathCache(t *testing.T) { + t.Parallel() + + tempDir, err := os.MkdirTemp("", "test") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + testFilePath := filepath.Join(tempDir, "example.gno") + if err := os.WriteFile(testFilePath, []byte("test content"), 0o644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + covData := New(tempDir) + + // 1st run: search from file system + path1, err := findAbsFilePath(covData, "example.gno") + if err != nil { + t.Fatalf("failed to find absolute file path: %v", err) + } + assert.Equal(t, testFilePath, path1) + + // 2nd run: use cache + path2, err := findAbsFilePath(covData, "example.gno") + if err != nil { + t.Fatalf("failed to find absolute file path: %v", err) + } + + assert.Equal(t, testFilePath, path2) + if len(covData.pathCache) != 1 { + t.Fatalf("expected 1 path in cache, got %d", len(covData.pathCache)) + } +} diff --git a/gnovm/pkg/gnolang/coverage.go b/gnovm/pkg/gnolang/coverage.go deleted file mode 100644 index 210abd65e33..00000000000 --- a/gnovm/pkg/gnolang/coverage.go +++ /dev/null @@ -1,642 +0,0 @@ -package gnolang - -import ( - "bufio" - "encoding/json" - "fmt" - "go/ast" - "go/parser" - "go/token" - "html/template" - "io/fs" - "math" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - - "github.com/gnolang/gno/tm2/pkg/commands" -) - -// color scheme for coverage report -const ( - colorReset = "\033[0m" - colorOrange = "\033[38;5;208m" // number of hits - colorRed = "\033[31m" // no hits - colorGreen = "\033[32m" // covered lines - colorYellow = "\033[33m" // partial coverage, or executable but not executed lines - colorWhite = "\033[37m" // non-executable lines - boldText = "\033[1m" -) - -type ( - fileCoverageMap map[string]FileCoverage - pathCache map[string]string -) - -// CoverageData stores code coverage information -type CoverageData struct { - Enabled bool // -cover flag activated - PkgPath string - rootDir string - CurrentPackage string - currentFile string - pathCache pathCache - Files fileCoverageMap - mu sync.RWMutex -} - -// FileCoverage stores coverage information for a single file -type FileCoverage struct { - TotalLines int - HitLines map[int]int - ExecutableLines map[int]bool -} - -func NewCoverageData(rootDir string) *CoverageData { - return &CoverageData{ - rootDir: rootDir, - PkgPath: "", - CurrentPackage: "", - currentFile: "", - Files: make(fileCoverageMap), - pathCache: make(pathCache), - } -} - -func (c *CoverageData) Enable() { c.Enabled = true } -func (c *CoverageData) Disable() { c.Enabled = false } -func (c *CoverageData) IsEnabled() bool { return c.Enabled } - -// setExecutableLines sets the executable lines for a given file path in the coverage data. -// It updates the ExecutableLines map for the given file path with the provided executable lines. -func (c *CoverageData) setExecutableLines(filePath string, executableLines map[int]bool) { - c.mu.Lock() - defer c.mu.Unlock() - - cov, exists := c.Files[filePath] - if !exists { - cov = FileCoverage{ - TotalLines: 0, - HitLines: make(map[int]int), - ExecutableLines: make(map[int]bool), - } - } - - cov.ExecutableLines = executableLines - c.Files[filePath] = cov -} - -// updateHit updates the hit count for a given line in the coverage data. -// This function is used to update the hit count for a specific line in the coverage data. -// It increments the hit count for the given line in the HitLines map for the specified file path. -func (c *CoverageData) updateHit(pkgPath string, line int) { - c.mu.Lock() - defer c.mu.Unlock() - if !c.isValidFile(pkgPath) { - return - } - - fileCoverage := c.getOrCreateFileCoverage(pkgPath) - - if fileCoverage.ExecutableLines[line] { - fileCoverage.HitLines[line]++ - c.Files[pkgPath] = fileCoverage - } -} - -func (c *CoverageData) isValidFile(pkgPath string) bool { - return strings.HasPrefix(pkgPath, c.PkgPath) && - strings.HasSuffix(pkgPath, ".gno") && - !isTestFile(pkgPath) -} - -func (c *CoverageData) getOrCreateFileCoverage(pkgPath string) FileCoverage { - fileCoverage, exists := c.Files[pkgPath] - if !exists { - fileCoverage = FileCoverage{ - TotalLines: 0, - HitLines: make(map[int]int), - } - c.Files[pkgPath] = fileCoverage - } - return fileCoverage -} - -func (c *CoverageData) addFile(filePath string, totalLines int) { - c.mu.Lock() - defer c.mu.Unlock() - - if isTestFile(filePath) { - return - } - - fileCoverage, exists := c.Files[filePath] - if !exists { - fileCoverage = FileCoverage{ - HitLines: make(map[int]int), - } - } - - fileCoverage.TotalLines = totalLines - c.Files[filePath] = fileCoverage -} - -// Report prints the coverage report to the console -func (c *CoverageData) Report(io commands.IO) { - files := make([]string, 0, len(c.Files)) - for file := range c.Files { - files = append(files, file) - } - - sort.Strings(files) - - for _, file := range files { - cov := c.Files[file] - hitLines := len(cov.HitLines) - totalLines := cov.TotalLines - pct := calculateCoverage(hitLines, totalLines) - color := getCoverageColor(pct) - if totalLines != 0 { - io.Printfln( - "%s%.1f%% [%4d/%d] %s%s", - color, floor1(pct), hitLines, totalLines, file, colorReset, - ) - } - } -} - -// ViewFiles displays the coverage information for files matching the given pattern. -// It shows hit counts if showHits is true. -func (c *CoverageData) ViewFiles(pattern string, showHits bool, io commands.IO) error { - matchingFiles := c.findMatchingFiles(pattern) - if len(matchingFiles) == 0 { - return fmt.Errorf("no files found matching pattern %s", pattern) - } - - for _, path := range matchingFiles { - err := c.viewSingleFileCoverage(path, showHits, io) - if err != nil { - return err - } - io.Println() // Add a newline between files - } - - return nil -} - -func (c *CoverageData) viewSingleFileCoverage( - filePath string, - showHits bool, - io commands.IO, -) error { - realPath, err := c.findAbsoluteFilePath(filePath) - if err != nil { - return err - } - - coverage, exists := c.Files[filePath] - if !exists { - return fmt.Errorf("no coverage data for file %s", filePath) - } - - file, err := os.Open(realPath) - if err != nil { - return err - } - defer file.Close() - - io.Printfln("%s%s%s:", boldText, filePath, colorReset) - - return c.printFileContent(file, coverage, showHits, io) -} - -func (c *CoverageData) printFileContent( - file *os.File, - coverage FileCoverage, - showHits bool, - io commands.IO, -) error { - scanner := bufio.NewScanner(file) - lineNumber := 1 - - for scanner.Scan() { - line := scanner.Text() - hitCount, covered := coverage.HitLines[lineNumber] - - lineInfo := c.formatLineInfo( - lineNumber, - line, - hitCount, - covered, - coverage.ExecutableLines[lineNumber], - showHits, - ) - io.Printfln(lineInfo) - - lineNumber++ - } - - return scanner.Err() -} - -func (c *CoverageData) formatLineInfo( - lineNumber int, - line string, - hitCount int, - covered, executable, showHits bool, -) string { - lineNumStr := fmt.Sprintf("%4d", lineNumber) - color := c.color(covered, executable) - hitInfo := c.getHitInfo(hitCount, covered, showHits) - - format := "%s%s%s %s%s%s%s" - - return fmt.Sprintf(format, color, lineNumStr, colorReset, hitInfo, color, line, colorReset) -} - -func (c *CoverageData) color(covered, executable bool) string { - switch { - case covered: - return colorGreen - case executable: - return colorYellow - default: - return colorWhite - } -} - -func (c *CoverageData) getHitInfo(hitCount int, covered, showHits bool) string { - if !showHits { - return "" - } - - if covered { - return fmt.Sprintf("%s%-4d%s ", colorOrange, hitCount, colorReset) - } - - return strings.Repeat(" ", 5) -} - -func (c *CoverageData) findMatchingFiles(pattern string) []string { - var files []string - for file := range c.Files { - if strings.Contains(file, pattern) { - files = append(files, file) - } - } - return files -} - -// floor1 round down to one decimal place -func floor1(v float64) float64 { - return math.Floor(v*10) / 10 -} - -func getCoverageColor(percentage float64) string { - switch { - case percentage >= 80: - return colorGreen - case percentage >= 50: - return colorYellow - default: - return colorRed - } -} - -func calculateCoverage(covered, total int) float64 { - return float64(covered) / float64(total) * 100 -} - -// findAbsoluteFilePath finds the absolute path of a file given its relative path. -// It starts searching from root directory and recursively traverses directories. -func (c *CoverageData) findAbsoluteFilePath(filePath string) (string, error) { - c.mu.RLock() - cachedPath, ok := c.pathCache[filePath] - c.mu.RUnlock() - if ok { - return cachedPath, nil - } - - var result string - err := filepath.WalkDir(c.rootDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() && strings.HasSuffix(path, filePath) { - result = path - return filepath.SkipAll - } - return nil - }) - if err != nil { - return "", err - } - - if result == "" { - return "", fmt.Errorf("file %s not found", filePath) - } - - c.mu.Lock() - c.pathCache[filePath] = result - c.mu.Unlock() - - return result, nil -} - -func isTestFile(pkgPath string) bool { - return strings.HasSuffix(pkgPath, "_test.gno") || - strings.HasSuffix(pkgPath, "_testing.gno") || - strings.HasSuffix(pkgPath, "_filetest.gno") -} - -type jsonCoverageMap map[string]JSONFileCoverage - -type JSONCoverage struct { - Files jsonCoverageMap `json:"files"` -} - -type JSONFileCoverage struct { - TotalLines int `json:"total_lines"` - HitLines map[string]int `json:"hit_lines"` -} - -func (c *CoverageData) ToJSON() ([]byte, error) { - jsonCov := JSONCoverage{ - Files: make(jsonCoverageMap), - } - - for file, coverage := range c.Files { - hitLines := make(map[string]int) - for line, count := range coverage.HitLines { - hitLines[strconv.Itoa(line)] = count - } - - jsonCov.Files[file] = JSONFileCoverage{ - TotalLines: coverage.TotalLines, - HitLines: hitLines, - } - } - - return json.MarshalIndent(jsonCov, "", " ") -} - -func (c *CoverageData) SaveJSON(fileName string) error { - data, err := c.ToJSON() - if err != nil { - return err - } - - return os.WriteFile(fileName, data, 0o644) -} - -// TODO: temporary HTML output. need to match with go coverage tool -func (c *CoverageData) SaveHTML(outputFileName string) error { - tmpl := ` - - - - - - Coverage Report - - - -

Coverage Report

- {{range $file, $coverage := .Files}} -
-
{{$file}}
-
{{range $line, $content := $coverage.Lines}}
-{{$line}}{{if $content.Covered}}{{$content.Hits}}{{else}}-{{end}}{{$content.Code}}{{end}}
-        
-
- {{end}} - -` - - t, err := template.New("coverage").Parse(tmpl) - if err != nil { - return err - } - - data := struct { - Files map[string]struct { - Lines map[int]struct { - Code string - Covered bool - Executable bool - Hits int - } - } - }{ - Files: make(map[string]struct { - Lines map[int]struct { - Code string - Covered bool - Executable bool - Hits int - } - }), - } - - for path, coverage := range c.Files { - realPath, err := c.findAbsoluteFilePath(path) - if err != nil { - return err - } - content, err := os.ReadFile(realPath) - if err != nil { - return err - } - - lines := strings.Split(string(content), "\n") - fileData := struct { - Lines map[int]struct { - Code string - Covered bool - Executable bool - Hits int - } - }{ - Lines: make(map[int]struct { - Code string - Covered bool - Executable bool - Hits int - }), - } - - for i, line := range lines { - lineNum := i + 1 - hits, covered := coverage.HitLines[lineNum] - executable := coverage.ExecutableLines[lineNum] - - fileData.Lines[lineNum] = struct { - Code string - Covered bool - Executable bool - Hits int - }{ - Code: line, - Covered: covered, - Executable: executable, - Hits: hits, - } - } - - data.Files[path] = fileData - } - - file, err := os.Create(outputFileName) - if err != nil { - return err - } - defer file.Close() - - return t.Execute(file, data) -} - -func (m *Machine) addFileToCodeCoverage(file string, totalLines int) { - if isTestFile(file) { - return - } - m.Coverage.addFile(file, totalLines) -} - -// recordCoverage records the execution of a specific node in the AST. -// This function tracking which parts of the code have been executed during the runtime. -// -// Note: This function assumes that CurrentPackage and CurrentFile are correctly set in the Machine -// before it's called. These fields provide the context necessary to accurately record the coverage information. -func (m *Machine) recordCoverage(node Node) Location { - if node == nil || !m.Coverage.IsEnabled() { - return Location{} - } - - pkgPath := m.Coverage.CurrentPackage - file := m.Coverage.currentFile - line := node.GetLine() - - path := filepath.Join(pkgPath, file) - m.Coverage.updateHit(path, line) - - return Location{ - PkgPath: pkgPath, - File: file, - Line: line, - Column: node.GetColumn(), - } -} - -// countCodeLines counts the number of executable lines in the given source code content. -func countCodeLines(content string) int { - lines, err := detectExecutableLines(content) - if err != nil { - return 0 - } - - return len(lines) -} - -// isExecutableLine determines whether a given AST node represents an -// executable line of code for the purpose of code coverage measurement. -// -// It returns true for statement nodes that typically contain executable code, -// such as assignments, expressions, return statements, and control flow statements. -// -// It returns false for nodes that represent non-executable lines, such as -// declarations, blocks, and function definitions. -func isExecutableLine(node ast.Node) bool { - switch n := node.(type) { - case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt, *ast.BranchStmt, - *ast.IncDecStmt, *ast.GoStmt, *ast.DeferStmt, *ast.SendStmt: - return true - case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, - *ast.TypeSwitchStmt, *ast.SelectStmt: - return true - case *ast.CaseClause: - // Even if a `case` condition (e.g., `case 1:`) in a `switch` statement is executed, - // the condition itself is not included in the coverage; coverage only recorded for the - // code block inside the corresponding `case` clause. - return false - case *ast.LabeledStmt: - return isExecutableLine(n.Stmt) - case *ast.FuncDecl: - return false - case *ast.BlockStmt: - return false - case *ast.DeclStmt: - // check inner declarations in the DeclStmt (e.g. `var a, b = 1, 2`) - // if there is a value initialization, then the line is executable - genDecl, ok := n.Decl.(*ast.GenDecl) - if ok && (genDecl.Tok == token.VAR || genDecl.Tok == token.CONST) { - for _, spec := range genDecl.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if ok && len(valueSpec.Values) > 0 { - return true - } - } - } - return false - case *ast.ImportSpec, *ast.TypeSpec, *ast.ValueSpec: - return false - case *ast.InterfaceType: - return false - case *ast.GenDecl: - switch n.Tok { - case token.VAR, token.CONST: - for _, spec := range n.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if ok && len(valueSpec.Values) > 0 { - return true - } - } - return false - case token.TYPE, token.IMPORT: - return false - default: - return true - } - default: - return false - } -} - -// detectExecutableLines analyzes the given source code content and returns a map -// of line numbers to boolean values indicating whether each line is executable. -func detectExecutableLines(content string) (map[int]bool, error) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, "", content, parser.ParseComments) - if err != nil { - return nil, err - } - - executableLines := make(map[int]bool) - - ast.Inspect(node, func(n ast.Node) bool { - if n == nil { - return true - } - - if isExecutableLine(n) { - line := fset.Position(n.Pos()).Line - executableLines[line] = true - } - - return true - }) - - return executableLines, nil -} diff --git a/gnovm/pkg/gnolang/coverage_test.go b/gnovm/pkg/gnolang/coverage_test.go deleted file mode 100644 index 535a959acf0..00000000000 --- a/gnovm/pkg/gnolang/coverage_test.go +++ /dev/null @@ -1,1058 +0,0 @@ -package gnolang - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/stretchr/testify/assert" -) - -func TestCoverageDataUpdateHit(t *testing.T) { - t.Parallel() - tests := []struct { - name string - initialData *CoverageData - pkgPath string - line int - expectedHits int - executableLines map[int]bool - }{ - { - name: "Add hit to existing file and executable line", - initialData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": { - HitLines: map[int]int{10: 1}, - ExecutableLines: map[int]bool{10: true, 20: true}, - }, - }, - }, - pkgPath: "file1.gno", - line: 10, - expectedHits: 2, - executableLines: map[int]bool{10: true, 20: true}, - }, - { - name: "Add hit to new executable line in existing file", - initialData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": { - HitLines: map[int]int{10: 1}, - ExecutableLines: map[int]bool{10: true, 20: true}, - }, - }, - }, - pkgPath: "file1.gno", - line: 20, - expectedHits: 1, - executableLines: map[int]bool{10: true, 20: true}, - }, - { - name: "Add hit to non-executable line", - initialData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": { - HitLines: map[int]int{10: 1}, - ExecutableLines: map[int]bool{10: true}, - }, - }, - }, - pkgPath: "file1.gno", - line: 20, - expectedHits: 0, - executableLines: map[int]bool{10: true}, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - // Set executable lines - fileCoverage := tt.initialData.Files[tt.pkgPath] - fileCoverage.ExecutableLines = tt.executableLines - tt.initialData.Files[tt.pkgPath] = fileCoverage - - tt.initialData.updateHit(tt.pkgPath, tt.line) - updatedFileCoverage := tt.initialData.Files[tt.pkgPath] - - // Validate the hit count for the specific line - actualHits := updatedFileCoverage.HitLines[tt.line] - if actualHits != tt.expectedHits { - t.Errorf("got %d hits for line %d, want %d", actualHits, tt.line, tt.expectedHits) - } - - // Check if non-executable lines are not added to HitLines - if !tt.executableLines[tt.line] && actualHits > 0 { - t.Errorf("non-executable line %d was added to HitLines", tt.line) - } - }) - } -} - -func TestAddFile(t *testing.T) { - t.Parallel() - tests := []struct { - name string - pkgPath string - totalLines int - initialData *CoverageData - expectedTotal int - }{ - { - name: "Add new file", - pkgPath: "file1.gno", - totalLines: 100, - initialData: NewCoverageData(""), - expectedTotal: 100, - }, - { - name: "Do not add test file *_test.gno", - pkgPath: "file1_test.gno", - totalLines: 100, - initialData: NewCoverageData(""), - expectedTotal: 0, - }, - { - name: "Do not add test file *_testing.gno", - pkgPath: "file1_testing.gno", - totalLines: 100, - initialData: NewCoverageData(""), - expectedTotal: 0, - }, - { - name: "Update existing file's total lines", - pkgPath: "file1.gno", - totalLines: 200, - initialData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": {TotalLines: 100, HitLines: map[int]int{10: 1}}, - }, - }, - expectedTotal: 200, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - tt.initialData.addFile(tt.pkgPath, tt.totalLines) - if tt.pkgPath == "file1_test.gno" && len(tt.initialData.Files) != 0 { - t.Errorf("expected no files to be added for test files") - } else { - if fileCoverage, ok := tt.initialData.Files[tt.pkgPath]; ok { - if fileCoverage.TotalLines != tt.expectedTotal { - t.Errorf("got %d total lines, want %d", fileCoverage.TotalLines, tt.expectedTotal) - } - } else if len(tt.initialData.Files) > 0 { - t.Errorf("expected file not added") - } - } - }) - } -} - -func TestIsTestFile(t *testing.T) { - t.Parallel() - tests := []struct { - pkgPath string - want bool - }{ - {"file1_test.gno", true}, - {"file1_testing.gno", true}, - {"file1.gno", false}, - {"random_test.go", false}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.pkgPath, func(t *testing.T) { - t.Parallel() - got := isTestFile(tt.pkgPath) - if got != tt.want { - t.Errorf("isTestFile(%s) = %v, want %v", tt.pkgPath, got, tt.want) - } - }) - } -} - -type nopCloser struct { - *bytes.Buffer -} - -func (nopCloser) Close() error { return nil } - -func TestCoverageData_GenerateReport(t *testing.T) { - coverageData := &CoverageData{ - Files: map[string]FileCoverage{ - "c.gno": {TotalLines: 100, HitLines: map[int]int{1: 1, 2: 1}}, - "a.gno": {TotalLines: 50, HitLines: map[int]int{1: 1}}, - "b.gno": {TotalLines: 75, HitLines: map[int]int{1: 1, 2: 1, 3: 1}}, - }, - } - - var buf bytes.Buffer - io := commands.NewTestIO() - io.SetOut(nopCloser{Buffer: &buf}) - - coverageData.Report(io) - - output := buf.String() - lines := strings.Split(strings.TrimSpace(output), "\n") - - // check if the output is sorted - assert.Equal(t, 3, len(lines)) - assert.Contains(t, lines[0], "a.gno") - assert.Contains(t, lines[1], "b.gno") - assert.Contains(t, lines[2], "c.gno") - - // check if the format is correct - for _, line := range lines { - assert.Regexp(t, `^\x1b\[\d+m\d+\.\d+% \[\s*\d+/\d+\] .+\.gno\x1b\[0m$`, line) - } - - // check if the coverage percentage is correct - assert.Contains(t, lines[0], "2.0% [ 1/50] a.gno") - assert.Contains(t, lines[1], "4.0% [ 3/75] b.gno") - assert.Contains(t, lines[2], "2.0% [ 2/100] c.gno") -} - -type mockNode struct { - line int - column int -} - -func (m *mockNode) assertNode() {} -func (m *mockNode) String() string { return "" } -func (m *mockNode) Copy() Node { return &mockNode{} } -func (m *mockNode) GetLabel() Name { return "mockNode" } -func (m *mockNode) SetLabel(n Name) {} -func (m *mockNode) GetLine() int { return m.line } -func (m *mockNode) SetLine(l int) {} -func (m *mockNode) GetColumn() int { return m.column } -func (m *mockNode) SetColumn(c int) {} -func (m *mockNode) DelAttribute(key GnoAttribute) {} -func (m *mockNode) GetAttribute(key GnoAttribute) interface{} { return nil } -func (m *mockNode) HasAttribute(key GnoAttribute) bool { return false } -func (m *mockNode) SetAttribute(key GnoAttribute, value interface{}) {} - -var _ Node = &mockNode{} - -func TestRecordCoverage(t *testing.T) { - t.Parallel() - tests := []struct { - name string - pkgPath string - file string - node *mockNode - initialCoverage *CoverageData - expectedHits map[string]map[int]int - }{ - { - name: "Record coverage for new file and line", - pkgPath: "testpkg", - file: "testfile.gno", - node: &mockNode{ - line: 10, - column: 5, - }, - initialCoverage: &CoverageData{ - Enabled: true, - Files: map[string]FileCoverage{ - "testpkg/testfile.gno": { - HitLines: make(map[int]int), - ExecutableLines: map[int]bool{10: true}, // Add this line - }, - }, - PkgPath: "testpkg", - CurrentPackage: "testpkg", - currentFile: "testfile.gno", - }, - expectedHits: map[string]map[int]int{ - "testpkg/testfile.gno": {10: 1}, - }, - }, - { - name: "Increment hit count for existing line", - pkgPath: "testpkg", - file: "testfile.gno", - node: &mockNode{ - line: 10, - column: 5, - }, - initialCoverage: &CoverageData{ - Enabled: true, - Files: map[string]FileCoverage{ - "testpkg/testfile.gno": { - HitLines: map[int]int{10: 1}, - ExecutableLines: map[int]bool{10: true}, - }, - }, - PkgPath: "testpkg", - CurrentPackage: "testpkg", - currentFile: "testfile.gno", - }, - expectedHits: map[string]map[int]int{ - "testpkg/testfile.gno": {10: 2}, - }, - }, - { - name: "Do not record coverage for non-executable line", - pkgPath: "testpkg", - file: "testfile.gno", - node: &mockNode{ - line: 20, - column: 5, - }, - initialCoverage: &CoverageData{ - Enabled: true, - Files: map[string]FileCoverage{ - "testpkg/testfile.gno": { - HitLines: map[int]int{}, - ExecutableLines: map[int]bool{10: true}, - }, - }, - PkgPath: "testpkg", - CurrentPackage: "testpkg", - currentFile: "testfile.gno", - }, - expectedHits: map[string]map[int]int{ - "testpkg/testfile.gno": {}, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - m := &Machine{ - Coverage: tt.initialCoverage, - } - - loc := m.recordCoverage(tt.node) - - // Check if the returned location is correct - assert.Equal(t, tt.pkgPath, loc.PkgPath) - assert.Equal(t, tt.file, loc.File) - assert.Equal(t, tt.node.line, loc.Line) - assert.Equal(t, tt.node.column, loc.Column) - - // Check if the coverage data has been updated correctly - for file, expectedHits := range tt.expectedHits { - actualHits := m.Coverage.Files[file].HitLines - assert.Equal(t, expectedHits, actualHits) - } - }) - } -} - -func TestViewFilesE2E(t *testing.T) { - t.Parallel() - tempDir := t.TempDir() - - files := map[string]string{ - "file1.gno": "package main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n", - "file2.gno": "package utils\n\nfunc Add(a, b int) int {\n\treturn a + b\n}\n", - } - - for name, content := range files { - err := os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0o644) - assert.NoError(t, err) - } - - coverage := NewCoverageData(tempDir) - for name, content := range files { - execLines, err := detectExecutableLines(content) - assert.NoError(t, err) - coverage.setExecutableLines(name, execLines) - coverage.addFile(name, len(strings.Split(content, "\n"))) - coverage.updateHit(name, 4) - } - - var buf bytes.Buffer - io := commands.NewTestIO() - io.SetOut(nopCloser{Buffer: &buf}) - - err := coverage.ViewFiles("", true, io) - assert.NoError(t, err) - - output := buf.String() - assert.Contains(t, output, "file1.gno") - assert.Contains(t, output, "file2.gno") - assert.Contains(t, output, "println(\"Hello\")") - assert.Contains(t, output, "return a + b") - assert.Contains(t, output, string(colorGreen)) - assert.Contains(t, output, string(colorWhite)) - - buf.Reset() - err = coverage.ViewFiles("file1", true, io) - assert.NoError(t, err) - output = buf.String() - assert.Contains(t, output, "file1.gno") - assert.NotContains(t, output, "file2.gno") - - err = coverage.ViewFiles("nonexistent", true, io) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no files found matching pattern") - - buf.Reset() - err = coverage.ViewFiles("", false, io) - assert.NoError(t, err) - output = buf.String() - assert.NotContains(t, output, string(colorOrange)) -} - -func TestFormatLineInfoE2E(t *testing.T) { - t.Parallel() - coverage := NewCoverageData("") - - tests := []struct { - name string - lineNumber int - line string - hitCount int - covered bool - executable bool - showHits bool - want string - }{ - { - name: "Covered line with hits", - lineNumber: 1, - line: "println(\"Hello\")", - hitCount: 2, - covered: true, - executable: true, - showHits: true, - want: fmt.Sprintf( - "%s 1%s %s2 %s %sprintln(\"Hello\")%s", - colorGreen, colorReset, colorOrange, colorReset, colorGreen, colorReset, - ), - }, - { - name: "Executable but not covered line", - lineNumber: 2, - line: "if x > 0 {", - hitCount: 0, - covered: false, - executable: true, - showHits: true, - want: fmt.Sprintf( - "%s 2%s %sif x > 0 {%s", - colorYellow, colorReset, colorYellow, colorReset, - ), - }, - { - name: "Non-executable line", - lineNumber: 3, - line: "package main", - hitCount: 0, - covered: false, - executable: false, - showHits: true, - want: fmt.Sprintf( - "%s 3%s %spackage main%s", - colorWhite, colorReset, colorWhite, colorReset, - ), - }, - { - name: "Covered line without showing hits", - lineNumber: 4, - line: "return x + y", - hitCount: 1, - covered: true, - executable: true, - showHits: false, - want: fmt.Sprintf( - "%s 4%s %sreturn x + y%s", - colorGreen, colorReset, colorGreen, colorReset, - ), - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := coverage.formatLineInfo( - tt.lineNumber, - tt.line, - tt.hitCount, - tt.covered, - tt.executable, - tt.showHits, - ) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestFindMatchingFilesE2E(t *testing.T) { - t.Parallel() - coverage := &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": {}, - "file2.gno": {}, - "other_file.go": {}, - }, - } - - tests := []struct { - name string - pattern string - want []string - }{ - { - name: "Match all .gno files", - pattern: ".gno", - want: []string{"file1.gno", "file2.gno"}, - }, - { - name: "Match specific file", - pattern: "file1", - want: []string{"file1.gno"}, - }, - { - name: "Match non-existent pattern", - pattern: "nonexistent", - want: []string{}, - }, - { - name: "Match all files", - pattern: "", - want: []string{"file1.gno", "file2.gno", "other_file.go"}, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := coverage.findMatchingFiles(tt.pattern) - assert.ElementsMatch(t, tt.want, got) - }) - } -} - -func TestToJSON(t *testing.T) { - t.Parallel() - tests := []struct { - name string - coverageData *CoverageData - expectedJSON string - }{ - { - name: "Single file with hits", - coverageData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": { - TotalLines: 100, - HitLines: map[int]int{10: 1, 20: 2}, - }, - }, - }, - expectedJSON: `{ - "files": { - "file1.gno": { - "total_lines": 100, - "hit_lines": { - "10": 1, - "20": 2 - } - } - } -}`, - }, - { - name: "Multiple files with hits", - coverageData: &CoverageData{ - Files: map[string]FileCoverage{ - "file1.gno": { - TotalLines: 100, - HitLines: map[int]int{10: 1, 20: 2}, - }, - "file2.gno": { - TotalLines: 200, - HitLines: map[int]int{30: 3}, - }, - }, - }, - expectedJSON: `{ - "files": { - "file1.gno": { - "total_lines": 100, - "hit_lines": { - "10": 1, - "20": 2 - } - }, - "file2.gno": { - "total_lines": 200, - "hit_lines": { - "30": 3 - } - } - } -}`, - }, - { - name: "No files", - coverageData: &CoverageData{ - Files: map[string]FileCoverage{}, - }, - expectedJSON: `{ - "files": {} -}`, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - jsonData, err := tt.coverageData.ToJSON() - assert.NoError(t, err) - - var got map[string]interface{} - var expected map[string]interface{} - - err = json.Unmarshal(jsonData, &got) - assert.NoError(t, err) - - err = json.Unmarshal([]byte(tt.expectedJSON), &expected) - assert.NoError(t, err) - - assert.Equal(t, expected, got) - }) - } -} - -func createTempFile(t *testing.T, dir, name, content string) string { - t.Helper() - filePath := filepath.Join(dir, name) - err := os.WriteFile(filePath, []byte(content), 0o644) - if err != nil { - t.Fatalf("Failed to create temp file %s: %v", filePath, err) - } - return filePath -} - -func readFileContent(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("Failed to read file %s: %v", path, err) - } - return string(data) -} - -func TestSaveHTML(t *testing.T) { - tempDir := t.TempDir() - - source1 := `package main - -import "fmt" - -func main() { - fmt.Println("Hello, World!") -}` - - source2 := `package utils - -func Add(a, b int) int { - return a + b -}` - - file1 := createTempFile(t, tempDir, "main.gno", source1) - file2 := createTempFile(t, tempDir, "utils.gno", source2) - - coverage := NewCoverageData(tempDir) - - execLines1, err := detectExecutableLines(source1) - if err != nil { - t.Fatalf("Failed to detect executable lines for %s: %v", file1, err) - } - execLines2, err := detectExecutableLines(source2) - if err != nil { - t.Fatalf("Failed to detect executable lines for %s: %v", file2, err) - } - - // Set executable lines - relPath1, err := filepath.Rel(tempDir, file1) - if err != nil { - t.Fatalf("Failed to get relative path for %s: %v", file1, err) - } - relPath2, err := filepath.Rel(tempDir, file2) - if err != nil { - t.Fatalf("Failed to get relative path for %s: %v", file2, err) - } - coverage.setExecutableLines(relPath1, execLines1) - coverage.setExecutableLines(relPath2, execLines2) - - // Add files with total executable lines - totalExecLines1 := len(execLines1) - totalExecLines2 := len(execLines2) - coverage.addFile(relPath1, totalExecLines1) - coverage.addFile(relPath2, totalExecLines2) - - // Simulate hits - coverage.updateHit(relPath1, 6) // fmt.Println line - coverage.updateHit(relPath2, 4) // return a + b - - // Define output HTML file - outputHTML := filepath.Join(tempDir, "coverage.html") - - // Run SaveHTML - err = coverage.SaveHTML(outputHTML) - if err != nil { - t.Fatalf("SaveHTML failed: %v", err) - } - - // Read and verify the HTML content - htmlContent := readFileContent(t, outputHTML) - - // Basic checks - if !strings.Contains(htmlContent, "main.gno") { - t.Errorf("HTML does not contain main.gno") - } - if !strings.Contains(htmlContent, "utils.gno") { - t.Errorf("HTML does not contain utils.gno") - } - - // Check for hit counts - if !strings.Contains(htmlContent, ">1") { - t.Errorf("Expected hit count '1' for main.gno, but not found") - } - if !strings.Contains(htmlContent, ">1") { - t.Errorf("Expected hit count '1' for utils.gno, but not found") - } -} - -func TestSaveHTML_EmptyCoverage(t *testing.T) { - tempDir := t.TempDir() - - coverage := NewCoverageData(tempDir) - - outputHTML := filepath.Join(tempDir, "coverage_empty.html") - - err := coverage.SaveHTML(outputHTML) - if err != nil { - t.Fatalf("SaveHTML failed: %v", err) - } - - htmlContent := readFileContent(t, outputHTML) - if !strings.Contains(htmlContent, "

Coverage Report

") { - t.Errorf("HTML does not contain the main heading") - } - if strings.Contains(htmlContent, "class=\"filename\"") { - t.Errorf("HTML should not contain any filenames, but found some") - } -} - -func TestSaveHTML_MultipleFiles(t *testing.T) { - tempDir := t.TempDir() - - sources := map[string]string{ - "file1.gno": `package a - -func A() { - // Line 3 -}`, - "file2.gno": `package b - -func B() { - // Line 3 - // Line 4 -}`, - "file3.gno": `package c - -func C() { - // Line 3 - // Line 4 - // Line 5 -}`, - } - - for name, content := range sources { - createTempFile(t, tempDir, name, content) - } - - coverage := NewCoverageData(tempDir) - - for name, content := range sources { - relPath := name - execLines, err := detectExecutableLines(content) - if err != nil { - t.Fatalf("Failed to detect executable lines for %s: %v", name, err) - } - coverage.setExecutableLines(relPath, execLines) - totalExecLines := len(execLines) - coverage.addFile(relPath, totalExecLines) - } - - coverage.updateHit("file1.gno", 3) // Line 3 - coverage.updateHit("file2.gno", 3) // Line 3 - coverage.updateHit("file2.gno", 4) // Line 4 - coverage.updateHit("file3.gno", 3) // Line 3 - - outputHTML := filepath.Join(tempDir, "coverage_multiple.html") - - err := coverage.SaveHTML(outputHTML) - if err != nil { - t.Fatalf("SaveHTML failed: %v", err) - } - - htmlContent := readFileContent(t, outputHTML) - - for name := range sources { - if !strings.Contains(htmlContent, name) { - t.Errorf("HTML does not contain %s", name) - } - } -} - -func TestSaveHTML_FileNotFound(t *testing.T) { - tempDir := t.TempDir() - - coverage := NewCoverageData(tempDir) - coverage.setExecutableLines("nonexistent.gno", map[int]bool{1: true, 2: true}) - coverage.addFile("nonexistent.gno", 2) - coverage.updateHit("nonexistent.gno", 1) - - outputHTML := filepath.Join(tempDir, "coverage_notfound.html") - - err := coverage.SaveHTML(outputHTML) - if err == nil { - t.Fatalf("Expected SaveHTML to fail due to missing file, but it succeeded") - } - - if !strings.Contains(err.Error(), "file nonexistent.gno not found") { - t.Errorf("Unexpected error message: %v", err) - } -} - -func TestSaveHTML_FileCreationError(t *testing.T) { - tempDir := t.TempDir() - - createTempFile(t, tempDir, "file.gno", `package main - -func main() {}`) - - coverage := NewCoverageData(tempDir) - coverage.Files["file.gno"] = FileCoverage{ - TotalLines: 2, // Assuming 2 executable lines - HitLines: map[int]int{1: 1}, - ExecutableLines: map[int]bool{1: true, 2: true}, - } - - outputHTML := tempDir - - err := coverage.SaveHTML(outputHTML) - if err == nil { - t.Fatalf("Expected SaveHTML to fail due to invalid file path, but it succeeded") - } - - if !strings.Contains(err.Error(), "is a directory") { - t.Errorf("Unexpected error message: %v", err) - } -} - -func TestFindAbsoluteFilePath(t *testing.T) { - t.Parallel() - rootDir := t.TempDir() - - examplesDir := filepath.Join(rootDir, "examples") - stdlibsDir := filepath.Join(rootDir, "gnovm", "stdlibs") - - if err := os.MkdirAll(examplesDir, 0o755); err != nil { - t.Fatalf("failed to create examples directory: %v", err) - } - if err := os.MkdirAll(stdlibsDir, 0o755); err != nil { - t.Fatalf("failed to create stdlibs directory: %v", err) - } - - exampleFile := filepath.Join(examplesDir, "example.gno") - stdlibFile := filepath.Join(stdlibsDir, "stdlib.gno") - if _, err := os.Create(exampleFile); err != nil { - t.Fatalf("failed to create example file: %v", err) - } - if _, err := os.Create(stdlibFile); err != nil { - t.Fatalf("failed to create stdlib file: %v", err) - } - - c := NewCoverageData(rootDir) - - tests := []struct { - name string - filePath string - expectedPath string - expectError bool - }{ - { - name: "File in examples directory", - filePath: "example.gno", - expectedPath: exampleFile, - expectError: false, - }, - { - name: "File in stdlibs directory", - filePath: "stdlib.gno", - expectedPath: stdlibFile, - expectError: false, - }, - { - name: "Non-existent file", - filePath: "nonexistent.gno", - expectedPath: "", - expectError: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - actualPath, err := c.findAbsoluteFilePath(tt.filePath) - - if tt.expectError { - if err == nil { - t.Errorf("expected an error but got none") - } - } else { - if err != nil { - t.Errorf("did not expect an error but got: %v", err) - } - if actualPath != tt.expectedPath { - t.Errorf("expected path %s, but got %s", tt.expectedPath, actualPath) - } - } - }) - } -} - -func TestFindAbsoluteFilePathCache(t *testing.T) { - t.Parallel() - - tempDir, err := os.MkdirTemp("", "test") - if err != nil { - t.Fatalf("failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - testFilePath := filepath.Join(tempDir, "example.gno") - if err := os.WriteFile(testFilePath, []byte("test content"), 0o644); err != nil { - t.Fatalf("failed to create test file: %v", err) - } - - covData := NewCoverageData(tempDir) - - // 1st run: search from file system - path1, err := covData.findAbsoluteFilePath("example.gno") - if err != nil { - t.Fatalf("failed to find absolute file path: %v", err) - } - assert.Equal(t, testFilePath, path1) - - // 2nd run: use cache - path2, err := covData.findAbsoluteFilePath("example.gno") - if err != nil { - t.Fatalf("failed to find absolute file path: %v", err) - } - - assert.Equal(t, testFilePath, path2) - if len(covData.pathCache) != 1 { - t.Fatalf("expected 1 path in cache, got %d", len(covData.pathCache)) - } -} - -func TestDetectExecutableLines(t *testing.T) { - t.Parallel() - tests := []struct { - name string - content string - want map[int]bool - wantErr bool - }{ - { - name: "Simple function", - content: ` -package main - -func main() { - x := 5 - if x > 3 { - println("Greater") - } -}`, - want: map[int]bool{ - 5: true, // x := 5 - 6: true, // if x > 3 - 7: true, // println("Greater") - }, - wantErr: false, - }, - { - name: "Function with loop", - content: ` -package main - -func loopFunction() { - for i := 0; i < 5; i++ { - if i%2 == 0 { - continue - } - println(i) - } -}`, - want: map[int]bool{ - 5: true, // for i := 0; i < 5; i++ - 6: true, // if i%2 == 0 - 7: true, // continue - 9: true, // println(i) - }, - wantErr: false, - }, - { - name: "Only declarations", - content: ` -package main - -import "fmt" - -var x int - -type MyStruct struct { - field int -}`, - want: map[int]bool{}, - wantErr: false, - }, - { - name: "Invalid gno code", - content: ` -This is not valid Go code -It should result in an error`, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, err := detectExecutableLines(tt.content) - assert.Equal(t, tt.wantErr, err != nil) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 998095e3754..0e70d083aa0 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -15,6 +15,7 @@ import ( "github.com/gnolang/overflow" "github.com/gnolang/gno/gnovm" + "github.com/gnolang/gno/gnovm/pkg/coverage" "github.com/gnolang/gno/tm2/pkg/errors" "github.com/gnolang/gno/tm2/pkg/store" ) @@ -82,7 +83,7 @@ type Machine struct { DeferPanicScope uint // Test Coverage - Coverage *CoverageData + Coverage *coverage.Coverage } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -181,7 +182,7 @@ func NewMachineWithOptions(opts MachineOptions) *Machine { mm.Debugger.enabled = opts.Debug mm.Debugger.in = opts.Input mm.Debugger.out = output - mm.Coverage = NewCoverageData("") + mm.Coverage = coverage.New("") if pv != nil { mm.SetActivePackage(pv) @@ -268,8 +269,8 @@ func (m *Machine) PreprocessAllFilesAndSaveBlockNodes() { // and corresponding package node, package value, and types to store. Save // is set to false for tests where package values may be native. func (m *Machine) RunMemPackage(memPkg *gnovm.MemPackage, save bool) (*PackageNode, *PackageValue) { - if m.Coverage.Enabled { - m.initCoverage(memPkg) + if m.Coverage.Enabled() { + initCoverage(m, memPkg) } return m.runMemPackage(memPkg, save, false) } @@ -331,26 +332,70 @@ func (m *Machine) runMemPackage(memPkg *gnovm.MemPackage, save, overrides bool) return pn, pv } -func (m *Machine) initCoverage(memPkg *gnovm.MemPackage) { - m.Coverage.CurrentPackage = memPkg.Path +func initCoverage(m *Machine, memPkg *gnovm.MemPackage) { + // m.Coverage.CurrentPackage = memPkg.Path + // for _, file := range memPkg.Files { + // if strings.HasSuffix(file.Name, ".gno") && !isTestFile(file.Name) { + // m.Coverage.currentFile = file.Name + + // totalLines := countCodeLines(file.Body) + // path := filepath.Join(m.Coverage.CurrentPackage, m.Coverage.currentFile) + + // executableLines, err := detectExecutableLines(file.Body) + // if err != nil { + // continue + // } + + // m.Coverage.setExecutableLines(path, executableLines) + // m.addFileToCodeCoverage(path, totalLines) + // } + // } + if !m.Coverage.Enabled() { + return + } + + m.Coverage.SetCurrentPath(memPkg.Path) + for _, file := range memPkg.Files { - if strings.HasSuffix(file.Name, ".gno") && !isTestFile(file.Name) { - m.Coverage.currentFile = file.Name + if strings.HasSuffix(file.Name, ".gno") && !coverage.IsTestFile(file.Name) { + m.Coverage.SetCurrentFile(file.Name) - totalLines := countCodeLines(file.Body) - path := filepath.Join(m.Coverage.CurrentPackage, m.Coverage.currentFile) + path := filepath.Join(memPkg.Path, file.Name) - executableLines, err := detectExecutableLines(file.Body) + totalLines := coverage.CountCodeLines(file.Body) + lines, err := coverage.DetectExecutableLines(file.Body) if err != nil { continue } - m.Coverage.setExecutableLines(path, executableLines) - m.addFileToCodeCoverage(path, totalLines) + m.Coverage.SetExecutableLines(path, lines) + m.Coverage.AddFile(path, totalLines) } } } +// recordCoverage records the execution of a specific node in the AST. +// This function tracking which parts of the code have been executed during the runtime. +// +// Note: This function assumes that CurrentPackage and CurrentFile are correctly set in the Machine +// before it's called. These fields provide the context necessary to accurately record the coverage information. +func recordCoverage(m *Machine, node Node) coverage.FileLocation { + if node == nil || !m.Coverage.Enabled() { + return coverage.FileLocation{} + } + + loc := coverage.FileLocation{ + PkgPath: m.Package.PkgPath, + File: m.Coverage.CurrentFile(), + Line: node.GetLine(), + Column: node.GetColumn(), + } + + m.Coverage.RecordHit(loc) + + return loc +} + type redeclarationErrors []Name func (r redeclarationErrors) Error() string { @@ -1169,19 +1214,15 @@ func (m *Machine) Run() { } }() - var currentPath string for { if m.Debugger.enabled { m.Debug() } op := m.PopOp() - loc := m.getCurrentLocation() - if m.Coverage.Enabled { - if currentPath != loc.PkgPath+"/"+loc.File { - currentPath = loc.PkgPath + "/" + loc.File - } - m.Coverage.updateHit(currentPath, loc.Line) + if m.Coverage.Enabled() { + loc := getCurrentLocation(m) + m.Coverage.RecordHit(loc) } // TODO: this can be optimized manually, even into tiers. @@ -1510,19 +1551,19 @@ func (m *Machine) Run() { } } -func (m *Machine) getCurrentLocation() Location { +func getCurrentLocation(m *Machine) coverage.FileLocation { if len(m.Frames) == 0 { - return Location{} + return coverage.FileLocation{} } lastFrame := m.Frames[len(m.Frames)-1] if lastFrame.Source == nil { - return Location{} + return coverage.FileLocation{} } - return Location{ - PkgPath: m.Coverage.CurrentPackage, - File: m.Coverage.currentFile, + return coverage.FileLocation{ + PkgPath: m.Coverage.CurrentPath(), + File: m.Coverage.CurrentFile(), Line: lastFrame.Source.GetLine(), Column: lastFrame.Source.GetColumn(), } @@ -1589,7 +1630,8 @@ func (m *Machine) PeekStmt1() Stmt { } func (m *Machine) PushStmt(s Stmt) { - m.recordCoverage(s) + recordCoverage(m, s) + if debug { m.Printf("+s %v\n", s) } @@ -1640,7 +1682,7 @@ func (m *Machine) PushExpr(x Expr) { if debug { m.Printf("+x %v\n", x) } - m.recordCoverage(x) + recordCoverage(m, x) m.Exprs = append(m.Exprs, x) } @@ -1829,8 +1871,8 @@ func (m *Machine) PushFrameCall(cx *CallExpr, fv *FuncValue, recv TypedValue) { m.Realm = rlm // enter new realm } - m.Coverage.CurrentPackage = fv.PkgPath - m.Coverage.currentFile = string(fv.FileName) + m.Coverage.SetCurrentPath(fv.PkgPath) + m.Coverage.SetCurrentFile(string(fv.FileName)) } func (m *Machine) PushFrameGoNative(cx *CallExpr, fv *NativeValue) { diff --git a/gnovm/pkg/gnolang/op_exec.go b/gnovm/pkg/gnolang/op_exec.go index c38454cbffd..dacc9cdec7f 100644 --- a/gnovm/pkg/gnolang/op_exec.go +++ b/gnovm/pkg/gnolang/op_exec.go @@ -433,7 +433,7 @@ EXEC_SWITCH: } switch cs := s.(type) { case *AssignStmt: - m.recordCoverage(cs) + recordCoverage(m, cs) switch cs.Op { case ASSIGN: m.PushOp(OpAssign) @@ -541,7 +541,7 @@ EXEC_SWITCH: // Push eval operations if needed. m.PushForPointer(cs.X) case *ReturnStmt: - m.recordCoverage(cs) + recordCoverage(m, cs) m.PopStmt() fr := m.MustLastCallFrame(1) ft := fr.Func.GetType(m.Store) @@ -778,12 +778,12 @@ EXEC_SWITCH: func (m *Machine) doOpIfCond() { is := m.PopStmt().(*IfStmt) - m.recordCoverage(is) // start record coverage when IfStmt is popped + recordCoverage(m, is) // start record coverage when IfStmt is popped b := m.LastBlock() // Test cond and run Body or Else. cond := m.PopValue() if cond.GetBool() { - m.recordCoverage(&is.Then) + recordCoverage(m, &is.Then) if len(is.Then.Body) != 0 { // expand block size if nn := is.Then.GetNumNames(); int(nn) > len(b.Values) { @@ -799,7 +799,7 @@ func (m *Machine) doOpIfCond() { m.PushStmt(b.GetBodyStmt()) } } else { - m.recordCoverage(&is.Else) + recordCoverage(m, &is.Else) if len(is.Else.Body) != 0 { // expand block size if nn := is.Else.GetNumNames(); int(nn) > len(b.Values) {