// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. // Five Debugger — line-level debugging with breakpoints, stepping, // variable inspection, and call stack display. // // Architecture: // gengo emits t.DebugLine(file, line) at each PRG source line. // Thread.DebugLine() checks breakpoints and invokes the debugger callback. // The debugger callback can inspect variables, call stack, and control flow. // // Unlike Harbour's 370KB debug system, Five's debugger is ~300 lines of Go // that leverages Go's runtime introspection. package hbrt import ( "fmt" "os" "strings" "sync" ) // DebugMode constants const ( DbgContinue = 0 // free run, stop at breakpoints only DbgStepLine = 1 // stop at every line DbgStepOver = 2 // stop when returning to same or shallower level DbgStepOut = 3 // stop when returning from current function DbgToCursor = 4 // run until specific line ) // Breakpoint represents a source-level breakpoint. type Breakpoint struct { Module string // source file name Line int // line number (1-based) Function string // optional function name filter Enabled bool HitCount int // Condition is an optional PRG expression. The breakpoint only // stops when the expression evaluates truthy at hit-time. Empty // string = unconditional. Evaluation runs through the same macro // hook the `p` command uses, so LOCALs/fields/function calls all // work. Condition string } // DebugVarInfo describes a variable visible in the current scope. type DebugVarInfo struct { Name string // variable name Value Value // current value Scope string // "LOCAL", "STATIC", "PARAM" Index int // local index (1-based) } // DebugStackFrame describes one frame in the call stack. type DebugStackFrame struct { Function string // function name Module string // source file Line int // current line Level int // stack depth } // DebugCallback is called when the debugger activates. // The callback can inspect state and return the next debug mode. type DebugCallback func(info *DebugEvent) int // DebugEvent contains all information available at a debug stop. type DebugEvent struct { Module string // current source file Line int // current line number Function string // current function name Reason string // "breakpoint", "step", "entry" Thread *Thread // the executing thread CallStack []DebugStackFrame // call stack Locals []DebugVarInfo // local variables Breakpoint *Breakpoint // which breakpoint hit (or nil) } // Debugger manages debug state for the VM. type Debugger struct { mu sync.Mutex Enabled bool Mode int // DbgContinue, DbgStepLine, etc. Breakpoints []*Breakpoint Callback DebugCallback // called when debugger activates StepLevel int // call stack depth for step-over ToCursorMod string // target module for run-to-cursor ToCursorLine int // target line for run-to-cursor Watches []string // PRG expressions auto-evaluated at each stop // Debug info tables (populated by generated code) LineInfo map[string]map[int]bool // module → set of valid lines FuncInfo map[string]string // "MODULE:LINE" → function name LocalInfo map[string][]string // function → local var names SourceDir string // base directory for resolving source paths } // NewDebugger creates a new debugger instance. func NewDebugger() *Debugger { return &Debugger{ Enabled: true, Mode: DbgStepLine, // start in step mode LineInfo: make(map[string]map[int]bool), FuncInfo: make(map[string]string), LocalInfo: make(map[string][]string), } } // AddBreakpoint adds a breakpoint. func (d *Debugger) AddBreakpoint(module string, line int) int { d.mu.Lock() defer d.mu.Unlock() bp := &Breakpoint{ Module: strings.ToUpper(module), Line: line, Enabled: true, } d.Breakpoints = append(d.Breakpoints, bp) return len(d.Breakpoints) - 1 } // RemoveBreakpoint removes a breakpoint by index. func (d *Debugger) RemoveBreakpoint(idx int) { d.mu.Lock() defer d.mu.Unlock() if idx >= 0 && idx < len(d.Breakpoints) { d.Breakpoints = append(d.Breakpoints[:idx], d.Breakpoints[idx+1:]...) } } // ToggleBreakpoint enables/disables a breakpoint. func (d *Debugger) ToggleBreakpoint(idx int) { d.mu.Lock() defer d.mu.Unlock() if idx >= 0 && idx < len(d.Breakpoints) { d.Breakpoints[idx].Enabled = !d.Breakpoints[idx].Enabled } } // FindBreakpoint checks if there's an active breakpoint at module:line. func (d *Debugger) FindBreakpoint(module string, line int) *Breakpoint { upper := strings.ToUpper(module) for _, bp := range d.Breakpoints { if bp.Enabled && bp.Line == line { if bp.Module == upper || strings.HasSuffix(upper, bp.Module) { bp.HitCount++ return bp } } } return nil } // RegisterLine records that a line exists in a module (for valid breakpoint checking). func (d *Debugger) RegisterLine(module string, line int) { upper := strings.ToUpper(module) if d.LineInfo[upper] == nil { d.LineInfo[upper] = make(map[int]bool) } d.LineInfo[upper][line] = true } // IsValidLine checks if a line is a valid stop point. func (d *Debugger) IsValidLine(module string, line int) bool { upper := strings.ToUpper(module) if lines, ok := d.LineInfo[upper]; ok { return lines[line] } return false } // --- Thread debug integration --- // DebugLineFast records the current PRG source position on the active // frame — nothing more. gengo emits a call to this at every statement // in non-debug builds so that error.log / panic traces still carry a // line number, without paying for a full DebugLine dispatch (VM lookup // + debugger flag check) per statement. The body is small enough that // Go inlines it across the call boundary; in practice this compiles to // a nil check + two word-sized stores. // // Keep the symbol name stable — gengo emits it by string. func (t *Thread) DebugLineFast(module string, line int) { if t.curFrame != nil { t.curFrame.module = module t.curFrame.line = line } } // DebugLine is the full debugger hook — line recording + trace ring + // breakpoint/step dispatch. gengo only emits this when compiled with // debug info (five debug ...), so the expensive path is off by default. func (t *Thread) DebugLine(module string, line int) { // Always record on the current frame so panic sites know where we were. if t.curFrame != nil { t.curFrame.module = module t.curFrame.line = line } vm := t.VM() if vm.Debugger == nil || !vm.Debugger.Enabled { return } // Record in this thread's execution trace ring so "hist" can show // the path taken to reach the break. Only under an attached // debugger — keeps production runs allocation-free. if t.traceRing == nil { t.traceRing = make([]TraceEntry, TraceRingSize) } t.traceRing[t.traceHead] = TraceEntry{Module: module, Line: line} t.traceHead = (t.traceHead + 1) % TraceRingSize t.traceCount++ dbg := vm.Debugger dbg.mu.Lock() mode := dbg.Mode dbg.mu.Unlock() shouldStop := false var hitBP *Breakpoint reason := "" switch mode { case DbgContinue: // Only stop at breakpoints hitBP = dbg.FindBreakpoint(module, line) if hitBP != nil { shouldStop = true reason = "breakpoint" } case DbgStepLine: shouldStop = true reason = "step" case DbgStepOver: if t.callSP <= dbg.StepLevel { shouldStop = true reason = "step" } else { // Check breakpoints even during step-over hitBP = dbg.FindBreakpoint(module, line) if hitBP != nil { shouldStop = true reason = "breakpoint" } } case DbgStepOut: if t.callSP < dbg.StepLevel { shouldStop = true reason = "step" } case DbgToCursor: if strings.EqualFold(module, dbg.ToCursorMod) && line == dbg.ToCursorLine { shouldStop = true reason = "cursor" } else { hitBP = dbg.FindBreakpoint(module, line) if hitBP != nil { shouldStop = true reason = "breakpoint" } } } // Conditional breakpoint: evaluate the expression with the current // frame visible. If it's not truthy, pretend we didn't hit anything. if hitBP != nil && hitBP.Condition != "" { if !evalBPCondition(t, hitBP.Condition) { shouldStop = false hitBP = nil reason = "" } } if !shouldStop { return } // Build debug event event := &DebugEvent{ Module: module, Line: line, Function: t.currentFuncName(), Reason: reason, Thread: t, Breakpoint: hitBP, } // Build call stack event.CallStack = t.DebugCallStack() // Build locals event.Locals = t.DebugLocals() // Invoke callback if dbg.Callback != nil { newMode := dbg.Callback(event) dbg.mu.Lock() dbg.Mode = newMode if newMode == DbgStepOver { dbg.StepLevel = t.callSP } else if newMode == DbgStepOut { dbg.StepLevel = t.callSP } dbg.mu.Unlock() } } // DebugCallStack returns the current call stack for debugging. func (t *Thread) DebugCallStack() []DebugStackFrame { var stack []DebugStackFrame for i := t.callSP - 1; i >= 0; i-- { frame := &t.calls[i] name := "unknown" if frame.symbol != nil { name = frame.symbol.Name } stack = append(stack, DebugStackFrame{ Function: name, Module: frame.module, Line: frame.line, Level: i, }) } return stack } // DebugLocals returns local variables for the current frame. If the // emitter registered PRG-level names via SetLocalNames, those are used; // otherwise falls back to "_1" / "_2" / ... placeholders. func (t *Thread) DebugLocals() []DebugVarInfo { if t.curFrame == nil { return nil } names := t.curFrame.localNames var vars []DebugVarInfo for i := 0; i < t.curFrame.localCount; i++ { idx := t.curFrame.localBase + i if idx < len(t.locals) { scope := "LOCAL" if i < t.curFrame.paramCount { scope = "PARAM" } name := "" if i < len(names) { name = names[i] } if name == "" { name = fmt.Sprintf("_%d", i+1) } vars = append(vars, DebugVarInfo{ Name: name, Value: t.locals[idx], Scope: scope, Index: i + 1, }) } } return vars } // EvalWithFrameLocals evaluates a PRG expression string with the // current call frame's LOCALs visible as PRIVATEs. Used by both the // debugger's `p` command and conditional-breakpoint checks. Returns // the resulting Value plus an error string (empty on success). Any // panic during eval is captured so a malformed expression can't crash // the debug loop. func (t *Thread) EvalWithFrameLocals(expr string) (result Value, evalErr string) { defer func() { if pv := recover(); pv != nil { evalErr = fmt.Sprintf("%v", pv) } }() if macroEvalHook == nil { return MakeNil(), "macro hook not installed" } // Install LOCALs as PRIVATEs for the eval scope so bare-name // references in the expression resolve to the current frame. if t.Memvars != nil && t.curFrame != nil { names := t.curFrame.localNames t.Memvars.BeginPrivateScope(t.callSP) defer t.Memvars.EndPrivateScope() for i := 0; i < t.curFrame.localCount; i++ { if i >= len(names) || names[i] == "" { continue } idx := t.curFrame.localBase + i if idx < len(t.locals) { t.Memvars.SetPrivate(names[i], t.locals[idx], t.callSP) } } } t.PushString(expr) macroEvalHook(t) result = t.Pop2() return } // evalBPCondition returns true when a breakpoint's Condition string // evaluates truthy in the current frame. Non-logical results are // coerced: NIL/.F./0/"" → false, everything else → true. Eval errors // are reported to stderr and treated as "not hit" so a broken condition // silently skips the stop instead of crashing. func evalBPCondition(t *Thread, expr string) bool { val, evalErr := t.EvalWithFrameLocals(expr) if evalErr != "" { fmt.Fprintf(os.Stderr, "debug: breakpoint condition %q failed: %s\n", expr, evalErr) return false } switch { case val.IsNil(): return false case val.IsLogical(): return val.AsBool() case val.IsNumeric(): return val.AsNumInt() != 0 || val.AsNumDouble() != 0 case val.IsString(): return len(val.AsString()) > 0 } return true } // Trace returns the last up-to-TraceRingSize PRG (module, line) pairs // this thread executed while under the debugger, in chronological // order (oldest first). Also returns the total count since the // debugger attached — callers can compute "how many lines ago" as // totalCount - index. func (t *Thread) Trace() ([]TraceEntry, uint64) { if t.traceRing == nil || t.traceCount == 0 { return nil, 0 } n := len(t.traceRing) have := int(t.traceCount) if have > n { have = n } out := make([]TraceEntry, have) // Start index in the ring depends on whether we've wrapped. var start int if int(t.traceCount) <= n { start = 0 } else { start = t.traceHead } for i := 0; i < have; i++ { out[i] = t.traceRing[(start+i)%n] } return out, t.traceCount } // SetLocalNames attaches the PRG-source variable names for params+locals // to the current call frame. gengo emits a call to this right after // Frame() so the debugger/error.log can show real names ("i", "nSum") // instead of slot numbers. // // The names slice is expected to be function-lifetime immutable (gengo // emits a package-level [...]string), so we store the pointer, not a // copy. func (t *Thread) SetLocalNames(names []string) { if t.curFrame != nil { t.curFrame.localNames = names } } // currentFuncName returns the name of the currently executing function. func (t *Thread) currentFuncName() string { if t.callSP > 0 { frame := &t.calls[t.callSP-1] if frame.symbol != nil { return frame.symbol.Name } } return "MAIN" }