diff --git a/compile_options.go b/compile_options.go index acd365d24..165bcf4bb 100644 --- a/compile_options.go +++ b/compile_options.go @@ -53,6 +53,7 @@ func CompileWithOptions(chunk []ast.Stmt, name string, opts CompileOptions) (pro if len(opts.TypeInfo) > 0 { proto.SetTypeInfo(opts.TypeInfo) } + covRegisterProto(proto) return } diff --git a/coverage.go b/coverage.go new file mode 100644 index 000000000..abcb83107 --- /dev/null +++ b/coverage.go @@ -0,0 +1,142 @@ +package lua + +import ( + "os" + "sort" + "strconv" + "strings" + "sync" +) + +// Line coverage built on the debug hook (hook.go). Enabled by setting +// WIPPY_COVERAGE in the environment before the process starts. When on, every +// LState is armed with a line hook (covArm) so the hook's line-event path in +// callHook records each executing source line — coverage is a hook consumer, +// not a separate instrumentation path. The denominator (coverable lines, +// including never-executed functions) is gathered from every compiled prototype +// at CompileWithOptions time. WriteCoverageLCOV emits a standard LCOV tracefile. +var ( + covOn = os.Getenv("WIPPY_COVERAGE") != "" + covMu sync.Mutex + covHits = map[string]map[int]bool{} + covCoverable = map[string]map[int]bool{} +) + +// CoverageEnabled reports whether coverage collection is active. +func CoverageEnabled() bool { return covOn } + +// covArm turns on the line hook for a freshly created/pooled LState so the hook +// records coverage. No-op unless WIPPY_COVERAGE is set, so non-coverage runs and +// explicit debug.sethook users are unaffected. +func covArm(ls *LState) { + if covOn { + ls.hookMask |= HookMaskLine + ls.hookLastLine = 0 + } +} + +// covRecordHit is called from the hook's line-event path in callHook. +func covRecordHit(src string, line int) { + if line <= 0 || src == "" { + return + } + covMu.Lock() + m := covHits[src] + if m == nil { + m = map[int]bool{} + covHits[src] = m + } + m[line] = true + covMu.Unlock() +} + +func covRegisterProto(p *FunctionProto) { + if !covOn || p == nil { + return + } + covMu.Lock() + covRegisterProtoLocked(p) + covMu.Unlock() +} + +func covRegisterProtoLocked(p *FunctionProto) { + if p == nil { + return + } + if p.SourceName != "" { + m := covCoverable[p.SourceName] + if m == nil { + m = map[int]bool{} + covCoverable[p.SourceName] = m + } + for _, ln := range p.DbgSourcePositions { + if ln > 0 { + m[ln] = true + } + } + } + for _, child := range p.FunctionPrototypes { + covRegisterProtoLocked(child) + } +} + +// WriteCoverageLCOV writes an LCOV tracefile for every registered source whose +// name satisfies filter (all sources if filter is nil). The denominator is the +// set of coverable lines gathered from every compiled prototype; the numerator +// is the set of lines the line hook observed executing. +func WriteCoverageLCOV(path string, filter func(src string) bool) error { + covMu.Lock() + defer covMu.Unlock() + + srcs := make([]string, 0, len(covCoverable)) + for s := range covCoverable { + if filter == nil || filter(s) { + srcs = append(srcs, s) + } + } + sort.Strings(srcs) + + var b strings.Builder + for _, s := range srcs { + cov := covCoverable[s] + hit := covHits[s] + nums := make([]int, 0, len(cov)) + for ln := range cov { + nums = append(nums, ln) + } + sort.Ints(nums) + b.WriteString("SF:" + s + "\n") + lh := 0 + for _, ln := range nums { + c := 0 + if hit != nil && hit[ln] { + c = 1 + lh++ + } + b.WriteString("DA:" + strconv.Itoa(ln) + "," + strconv.Itoa(c) + "\n") + } + b.WriteString("LF:" + strconv.Itoa(len(nums)) + "\n") + b.WriteString("LH:" + strconv.Itoa(lh) + "\n") + b.WriteString("end_of_record\n") + } + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// CoverageSummary returns aggregate (linesFound, linesHit) over filtered sources. +func CoverageSummary(filter func(src string) bool) (lf int, lh int) { + covMu.Lock() + defer covMu.Unlock() + for s, cov := range covCoverable { + if filter != nil && !filter(s) { + continue + } + hit := covHits[s] + for ln := range cov { + lf++ + if hit != nil && hit[ln] { + lh++ + } + } + } + return lf, lh +} diff --git a/hook.go b/hook.go index 810b7eaa8..9826e0b0f 100644 --- a/hook.go +++ b/hook.go @@ -33,6 +33,9 @@ func (ls *LState) callHook(cf *callFrame) { line := int32(positions[pc]) if line != ls.hookLastLine { ls.hookLastLine = line + if covOn { + covRecordHit(cf.Fn.Proto.SourceName, int(line)) + } ls.fireHook("line", line) } } diff --git a/state.go b/state.go index fc259270e..4cc6d5508 100644 --- a/state.go +++ b/state.go @@ -509,6 +509,7 @@ func newLState(options Options) *LState { } ls.Env = ls.G.Global + covArm(ls) return ls } } @@ -536,6 +537,7 @@ func newLState(options Options) *LState { } ls.reg = newRegistry(ls, options.RegistrySize, options.RegistryGrowStep, options.RegistryMaxSize) ls.Env = ls.G.Global + covArm(ls) return ls } diff --git a/state_pool.go b/state_pool.go index 4d00efbcb..390f5600f 100644 --- a/state_pool.go +++ b/state_pool.go @@ -90,6 +90,7 @@ func newLStateWithGlobal(options Options, G *Global, env *LTable) *LState { ls.reg.handler = ls } + covArm(ls) return ls } @@ -118,5 +119,6 @@ func newLStateWithGlobal(options Options, G *Global, env *LTable) *LState { ls.reg = newRegistry(ls, options.RegistrySize, options.RegistryGrowStep, options.RegistryMaxSize) ls.Env = env + covArm(ls) return ls }