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/machine.go b/gnovm/pkg/gnolang/machine.go index 4f4c7c188f3..0e70d083aa0 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -5,6 +5,7 @@ package gnolang import ( "fmt" "io" + "path/filepath" "reflect" "slices" "strconv" @@ -14,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" ) @@ -79,6 +81,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 *coverage.Coverage } // NewMachine initializes a new gno virtual machine, acting as a shorthand @@ -177,6 +182,7 @@ func NewMachineWithOptions(opts MachineOptions) *Machine { mm.Debugger.enabled = opts.Debug mm.Debugger.in = opts.Input mm.Debugger.out = output + mm.Coverage = coverage.New("") if pv != nil { mm.SetActivePackage(pv) @@ -263,6 +269,9 @@ 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() { + initCoverage(m, memPkg) + } return m.runMemPackage(memPkg, save, false) } @@ -323,6 +332,70 @@ func (m *Machine) runMemPackage(memPkg *gnovm.MemPackage, save, overrides bool) return pn, pv } +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") && !coverage.IsTestFile(file.Name) { + m.Coverage.SetCurrentFile(file.Name) + + path := filepath.Join(memPkg.Path, file.Name) + + totalLines := coverage.CountCodeLines(file.Body) + lines, err := coverage.DetectExecutableLines(file.Body) + if err != nil { + continue + } + + 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 { @@ -1146,6 +1219,12 @@ func (m *Machine) Run() { m.Debug() } op := m.PopOp() + + if m.Coverage.Enabled() { + loc := getCurrentLocation(m) + m.Coverage.RecordHit(loc) + } + // TODO: this can be optimized manually, even into tiers. switch op { /* Control operators */ @@ -1472,6 +1551,24 @@ func (m *Machine) Run() { } } +func getCurrentLocation(m *Machine) coverage.FileLocation { + if len(m.Frames) == 0 { + return coverage.FileLocation{} + } + + lastFrame := m.Frames[len(m.Frames)-1] + if lastFrame.Source == nil { + return coverage.FileLocation{} + } + + return coverage.FileLocation{ + PkgPath: m.Coverage.CurrentPath(), + File: m.Coverage.CurrentFile(), + Line: lastFrame.Source.GetLine(), + Column: lastFrame.Source.GetColumn(), + } +} + //---------------------------------------- // push pop methods. @@ -1533,6 +1630,8 @@ func (m *Machine) PeekStmt1() Stmt { } func (m *Machine) PushStmt(s Stmt) { + recordCoverage(m, s) + if debug { m.Printf("+s %v\n", s) } @@ -1583,6 +1682,7 @@ func (m *Machine) PushExpr(x Expr) { if debug { m.Printf("+x %v\n", x) } + recordCoverage(m, x) m.Exprs = append(m.Exprs, x) } @@ -1618,7 +1718,6 @@ func (m *Machine) PushValue(tv TypedValue) { } m.Values[m.NumValues] = tv m.NumValues++ - return } // Resulting reference is volatile. @@ -1771,6 +1870,9 @@ func (m *Machine) PushFrameCall(cx *CallExpr, fv *FuncValue, recv TypedValue) { if rlm != nil && m.Realm != rlm { m.Realm = rlm // enter new realm } + + 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 900b5f8e9bb..dacc9cdec7f 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: + recordCoverage(m, 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: + recordCoverage(m, cs) m.PopStmt() fr := m.MustLastCallFrame(1) ft := fr.Func.GetType(m.Store) @@ -776,10 +778,12 @@ EXEC_SWITCH: func (m *Machine) doOpIfCond() { is := m.PopStmt().(*IfStmt) + 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() { + recordCoverage(m, &is.Then) if len(is.Then.Body) != 0 { // expand block size if nn := is.Then.GetNumNames(); int(nn) > len(b.Values) { @@ -795,6 +799,7 @@ func (m *Machine) doOpIfCond() { m.PushStmt(b.GetBodyStmt()) } } else { + recordCoverage(m, &is.Else) if len(is.Else.Body) != 0 { // expand block size if nn := is.Else.GetNumNames(); int(nn) > len(b.Values) {