Skip to content
Open
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
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 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
}
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
156 changes: 156 additions & 0 deletions hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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, but only when L.hookMask != 0. It emits the "count"
// and "line" debug events. It is a no-op while a hook is already running
// (L.inHook) so a hook cannot recursively trigger itself.
func (ls *LState) callHook(cf *callFrame) {
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 && cf.Fn != nil {
positions := cf.Fn.Proto.DbgSourcePositions
pc := int(cf.Pc - 1)
if pc >= 0 && pc < len(positions) {
line := int32(positions[pc])
if line != ls.hookLastLine {
ls.hookLastLine = line
if covOn {
covRecordHit(cf.Fn.Proto.SourceName, int(line))
}
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
}

// 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.hook = LNil
L.hookMask = 0
L.hookCount = 0
L.hookCounter = 0
return 0
}

hook := L.Get(1)
if hook == LNil {
L.hook = LNil
L.hookMask = 0
L.hookCount = 0
L.hookCounter = 0
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