Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ jobs:
# several minutes to finish on CI runners.
run: go test -timeout 25m ./...

- name: Coverage collector regressions
env:
WIPPY_COVERAGE: "1"
GOCACHE: ${{ runner.temp }}/go-build
run: go test -count=1 -run '^TestReviewCoverage' .

race:
name: Race Tests (${{ matrix.shard }})
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions compile_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
142 changes: 142 additions & 0 deletions coverage.go
Original file line number Diff line number Diff line change
@@ -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 enters the shared hook dispatcher, where
// 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 }

// 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)
}
}

// coverageSnapshot publishes one coherent view without holding the collector
// lock across caller callbacks or filesystem I/O.
func coverageSnapshot() map[string]map[int]bool {
covMu.Lock()
defer covMu.Unlock()
snapshot := make(map[string]map[int]bool, len(covCoverable))
for source, coverable := range covCoverable {
lines := make(map[int]bool, len(coverable))
for line := range coverable {
lines[line] = covHits[source][line]
}
snapshot[source] = lines
}
return snapshot
}

// 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 shared dispatcher observed executing.
func WriteCoverageLCOV(path string, filter func(src string) bool) error {
snapshot := coverageSnapshot()
srcs := make([]string, 0, len(snapshot))
for s := range snapshot {
if filter == nil || filter(s) {
srcs = append(srcs, s)
}
}
sort.Strings(srcs)

var b strings.Builder
for _, s := range srcs {
cov := snapshot[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 cov[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) {
for s, cov := range coverageSnapshot() {
if filter != nil && !filter(s) {
continue
}
for ln := range cov {
lf++
if cov[ln] {
lh++
}
}
}
return lf, lh
}
2 changes: 2 additions & 0 deletions debuglib.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (

var debugFuncs = map[string]LGoFunc{
"getinfo": debugGetInfo,
"gethook": debugGetHook,
"getlocal": debugGetLocal,
"getmetatable": debugGetMetatable,
"getupvalue": debugGetUpvalue,
"sethook": debugSetHook,
"setlocal": debugSetLocal,
"setmetatable": debugSetMetatable,
"setupvalue": debugSetUpvalue,
Expand Down
171 changes: 171 additions & 0 deletions hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package lua

// Debug hook masks, matching the C API constants (lua.h LUA_MASKxxx).
const (
HookMaskCall uint8 = 1 << iota // "c"
HookMaskReturn // "r"
HookMaskLine // "l"
HookMaskCount // set when a count > 0 is given
)

// callHook is invoked from the interpreter dispatch loop before each
// instruction executes when a debug hook or coverage is active. It records
// coverage before dispatching "count" and "line" debug events. User callbacks
// cannot recursively trigger themselves while L.inHook is set.
func (ls *LState) callHook(cf *callFrame) {
// Coverage consumes the same dispatch as debug hooks, including dispatches
// inside a callback. User hook masks and reentrancy must not disable it.
pc := int(cf.Pc - 1)
line := int32(-1)
if (covOn || ls.hookMask&HookMaskLine != 0) && cf.Fn != nil {
positions := cf.Fn.Proto.DbgSourcePositions
if pc >= 0 && pc < len(positions) {
line = int32(positions[pc])
if covOn {
covRecordHit(cf.Fn.Proto.SourceName, int(line))
}
}
}
if ls.inHook {
return
}

if ls.hookMask&HookMaskCount != 0 && ls.hookCount > 0 {
ls.hookCounter++
if ls.hookCounter >= ls.hookCount {
ls.hookCounter = 0
ls.fireHook("count", -1)
cf = ls.currentFrame
}
}

if ls.hookMask&HookMaskLine != 0 && line >= 0 {
// A source line alone cannot identify a dispatch location: two
// chunks can use the same line number, and loops revisit a line.
fire := cf.Fn.Proto != ls.hookLastProto || pc <= ls.hookLastPC || line != ls.hookLastLine
ls.hookLastProto = cf.Fn.Proto
ls.hookLastPC = pc
ls.hookLastLine = line
if fire {
ls.fireHook("line", line)
}
}
}

// fireHook calls the registered hook function as hook(event, line). For events
// without a line ("count") the second argument is nil. Reentrancy is blocked
// via ls.inHook for the duration of the call. The register top is saved and
// restored so the hook is transparent to the interrupted instruction.
func (ls *LState) fireHook(event string, line int32) {
if ls.hook == nil || ls.hook == LNil {
return
}

top := ls.reg.Top()
ls.inHook = true
defer func() {
ls.inHook = false
ls.reg.SetTop(top)
}()

ls.Push(ls.hook)
ls.Push(LString(event))
if line >= 0 {
ls.Push(LNumber(line))
} else {
ls.Push(LNil)
}
ls.Call(2, 0)
}

func parseHookMask(mask string, count int) uint8 {
var m uint8
for _, c := range mask {
switch c {
case 'c':
m |= HookMaskCall
case 'r':
m |= HookMaskReturn
case 'l':
m |= HookMaskLine
}
}
if count > 0 {
m |= HookMaskCount
}
return m
}

func hookMaskString(mask uint8) string {
s := ""
if mask&HookMaskCall != 0 {
s += "c"
}
if mask&HookMaskReturn != 0 {
s += "r"
}
if mask&HookMaskLine != 0 {
s += "l"
}
return s
}

// clearHook releases callbacks and dispatch history before disabling or pooling a state.
func (ls *LState) clearHook() {
ls.hook = nil
ls.hookMask = 0
ls.hookCount = 0
ls.hookCounter = 0
ls.hookLastLine = 0
ls.hookLastProto = nil
ls.hookLastPC = 0
}

// debugSetHook implements debug.sethook([hook, mask [, count]]).
// Only "line" and "count" events are emitted; requesting "call"/"return"
// hooks is rejected rather than silently ignored.
func debugSetHook(L *LState) int {
if L.GetTop() == 0 {
L.clearHook()
return 0
}

hook := L.Get(1)
if hook == LNil {
L.clearHook()
return 0
}
if _, ok := hook.(*LFunction); !ok {
L.ArgError(1, "function expected")
}

mask := L.CheckString(2)
count := L.OptInt(3, 0)
for _, c := range mask {
if c == 'c' || c == 'r' {
L.RaiseError("debug.sethook: call/return hooks are not supported (line/count only)")
}
}

L.hook = hook
L.hookMask = parseHookMask(mask, count)
L.hookCount = count
L.hookCounter = 0
L.hookLastLine = 0
return 0
}

// debugGetHook implements debug.gethook(), returning
// (hook function, mask string, count).
func debugGetHook(L *LState) int {
if L.hookMask == 0 || L.hook == nil {
L.Push(LNil)
L.Push(LString(""))
L.Push(LNumber(0))
return 3
}
L.Push(L.hook)
L.Push(LString(hookMaskString(L.hookMask)))
L.Push(LNumber(L.hookCount))
return 3
}
Loading
Loading