// 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" "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 } // 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 // 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 --- // DebugLine is called by generated code at each PRG source line. // This is the main debug hook — gengo emits t.DebugLine("file.prg", 42) func (t *Thread) DebugLine(module string, line int) { vm := t.VM() if vm.Debugger == nil || !vm.Debugger.Enabled { return } 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" } } } 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, Level: i, }) } return stack } // DebugLocals returns local variables for the current frame. func (t *Thread) DebugLocals() []DebugVarInfo { if t.curFrame == nil { return nil } 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" } vars = append(vars, DebugVarInfo{ Name: fmt.Sprintf("_%d", i+1), // placeholder name Value: t.locals[idx], Scope: scope, Index: i + 1, }) } } return vars } // 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" }