// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. // Shared debugger command dispatch. Both the CLI (gdb-style prompt) // and the TUI (full-screen F-keys + `:` prompt) funnel parsed input // strings through runDebugCmd so the surface area stays in one place. package hbrt import ( "fmt" "os" "strconv" "strings" ) // cmdNoMode is the sentinel returned by runDebugCmd when a command // printed output and the debug loop should keep prompting — i.e. no // mode transition is requested. const cmdNoMode = -1 // runDebugCmd interprets one line of debugger input against the given // event. Returns a DbgContinue / DbgStepLine / ... constant when the // command resumes execution, or cmdNoMode to stay in the prompt loop. // Prints results/errors using the supplied `out` writer so the TUI can // buffer them for its status area while the CLI writes to stdout. func runDebugCmd(event *DebugEvent, line string, out func(string)) int { line = strings.TrimSpace(line) if line == "" { return cmdNoMode } if out == nil { out = func(s string) { fmt.Println(s) } } parts := strings.Fields(line) cmd := parts[0] dbg := event.Thread.VM().Debugger switch cmd { case "s", "step": return DbgStepLine case "n", "next": return DbgStepOver case "o", "out": return DbgStepOut case "c", "cont", "continue": return DbgContinue case "b", "break": mod, lineNo, cond, ok := parseBreakArgs(line, parts, event.Module) if !ok { out(" Usage: b [module] [if ]") return cmdNoMode } idx := dbg.AddBreakpoint(mod, lineNo) if cond != "" { dbg.Breakpoints[idx].Condition = cond out(fmt.Sprintf(" Breakpoint %d at %s:%d if %s", idx, mod, lineNo, cond)) } else { out(fmt.Sprintf(" Breakpoint %d at %s:%d", idx, mod, lineNo)) } case "u", "until": if len(parts) >= 2 { if lineNo, err := strconv.Atoi(parts[1]); err == nil && lineNo > 0 { dbg.ToCursorMod = event.Module dbg.ToCursorLine = lineNo if len(parts) >= 3 { dbg.ToCursorMod = parts[2] } return DbgToCursor } } out(" Usage: u [module]") case "w", "watch": if len(parts) < 2 { if len(dbg.Watches) == 0 { out(" No watches. Usage: w ") } else { for i, e := range dbg.Watches { out(fmt.Sprintf(" [%d] %s", i, e)) } } return cmdNoMode } expr := strings.TrimSpace(line[len(parts[0]):]) dbg.Watches = append(dbg.Watches, expr) out(fmt.Sprintf(" Watch %d: %s", len(dbg.Watches)-1, expr)) case "wd", "unwatch": if len(parts) >= 2 { if idx, err := strconv.Atoi(parts[1]); err == nil { if idx >= 0 && idx < len(dbg.Watches) { dbg.Watches = append(dbg.Watches[:idx], dbg.Watches[idx+1:]...) out(fmt.Sprintf(" Watch %d removed", idx)) return cmdNoMode } } } out(" Usage: wd ") case "d", "del", "delete": if len(parts) >= 2 { if idx, err := strconv.Atoi(parts[1]); err == nil { dbg.RemoveBreakpoint(idx) out(fmt.Sprintf(" Breakpoint %d removed", idx)) return cmdNoMode } } out(" Usage: d ") case "bl", "breakpoints": if len(dbg.Breakpoints) == 0 { out(" No breakpoints") } else { for i, bp := range dbg.Breakpoints { status := "ON " if !bp.Enabled { status = "OFF" } cond := "" if bp.Condition != "" { cond = " if " + bp.Condition } out(fmt.Sprintf(" %d: [%s] %s:%d%s (hits: %d)", i, status, bp.Module, bp.Line, cond, bp.HitCount)) } } case "l", "locals": if len(event.Locals) == 0 { out(" No local variables") } else { for _, v := range event.Locals { out(fmt.Sprintf(" %s [%d] %s = %s", v.Scope, v.Index, v.Name, describeDbgValue(v.Value))) } } case "p", "print": if len(parts) < 2 { out(" Usage: p ") return cmdNoMode } expr := strings.TrimSpace(line[len(parts[0]):]) v, evalErr := event.Thread.EvalWithFrameLocals(expr) if evalErr != "" { out(fmt.Sprintf(" eval failed: %s", evalErr)) } else { out(fmt.Sprintf(" %s = %s", expr, describeDbgValue(v))) } case "diag", "d!": // Full error.log-style dump at the break point — workareas, // SET flags, runtime memory. Same renderer our DefaultErrorHook // uses, so what you see here is what you'd get if the program // had crashed instead of stopped. if DebugDiagnosticHook == nil { out(" (diagnostics unavailable — hook not installed)") } else { DebugDiagnosticHook(event.Thread, "", func(s string) { for _, ln := range strings.Split(s, "\n") { if ln != "" { out(ln) } } }) } case "wa", "workareas": if DebugDiagnosticHook == nil { out(" (workarea info unavailable)") } else { DebugDiagnosticHook(event.Thread, "wa", func(s string) { for _, ln := range strings.Split(s, "\n") { if ln != "" { out(ln) } } }) } case "set": if DebugDiagnosticHook == nil { out(" (SET state unavailable)") } else { DebugDiagnosticHook(event.Thread, "set", func(s string) { for _, ln := range strings.Split(s, "\n") { if ln != "" { out(ln) } } }) } case "mem": if DebugDiagnosticHook == nil { out(" (memory stats unavailable)") } else { DebugDiagnosticHook(event.Thread, "mem", func(s string) { for _, ln := range strings.Split(s, "\n") { if ln != "" { out(ln) } } }) } case "hist", "trace": // Execution trace — last N PRG lines the current thread stepped // through. Most useful for "how did control reach here?" when // you break inside a deeply-nested helper and want to see the // LOOP/IF/Call chain that led to it. Deduplicates adjacent // repeats (tight FOR loops compress to "line X ×27") so the // output stays readable in hot code. trace, total := event.Thread.Trace() if len(trace) == 0 { out(" (no trace data — debugger just attached?)") return cmdNoMode } // How many to show — default 50, or "hist N" limit := 50 if len(parts) >= 2 { if n, err := strconv.Atoi(parts[1]); err == nil && n > 0 { limit = n } } if limit > len(trace) { limit = len(trace) } view := trace[len(trace)-limit:] // Collapse adjacent duplicates. type run struct { e TraceEntry count int } var runs []run for _, e := range view { if n := len(runs); n > 0 && runs[n-1].e == e { runs[n-1].count++ continue } runs = append(runs, run{e, 1}) } out(fmt.Sprintf(" trace (last %d of %d) — newest last:", len(view), total)) for _, r := range runs { suffix := "" if r.count > 1 { suffix = fmt.Sprintf(" ×%d", r.count) } out(fmt.Sprintf(" %s:%d%s", r.e.Module, r.e.Line, suffix)) } case "threads", "ts": // Lists every live Thread managed by the VM with its current // PRG source position. Useful for diagnosing multi-thread PRG // programs (hb_Thread*, GoLaunch) where the debugger is // currently attached to one thread but others may be blocked, // looping, or crashed. Position is read from each thread's // current frame — may show an older line for threads that // haven't executed a DebugLine recently. threads := event.Thread.VM().Threads() if len(threads) == 0 { out(" (no threads tracked)") return cmdNoMode } for _, th := range threads { marker := " " if th == event.Thread { marker = "=>" } mod, line := "", 0 if f := th.CurFrame(); f != nil { mod = f.module line = f.line } name := "MAIN" if f := th.CurFrame(); f != nil && f.symbol != nil { name = f.symbol.Name } if mod == "" { out(fmt.Sprintf(" %s [%d] %s", marker, th.TID(), name)) } else { out(fmt.Sprintf(" %s [%d] %s at %s:%d", marker, th.TID(), name, mod, line)) } } case "bt", "backtrace", "stack": if len(event.CallStack) == 0 { out(" Empty call stack") } else { for i, frame := range event.CallStack { marker := " " if i == 0 { marker = "=>" } if frame.Module != "" { out(fmt.Sprintf(" %s #%d %s() at %s:%d", marker, frame.Level, frame.Function, frame.Module, frame.Line)) } else { out(fmt.Sprintf(" %s #%d %s()", marker, frame.Level, frame.Function)) } } } case "q", "quit": out(" Debugger quit.") os.Exit(0) case "h", "help", "?": for _, ln := range debugCmdHelp { out(ln) } default: out(fmt.Sprintf(" Unknown command: %s (type 'h' for help)", cmd)) } return cmdNoMode } var debugCmdHelp = []string{ " Five Debugger Commands:", " s, step — step to next line", " n, next — step over function calls", " o, out — step out of current function", " c, cont — continue (run to next breakpoint)", " u — run until in current module", " b [if E] — set breakpoint, optional condition", " d — delete breakpoint n", " bl — list all breakpoints", " w — add watch expression", " wd — remove watch n", " w — list watches", " l — show local variables", " p — evaluate and print expression", " bt — show call stack", " wa — list open workareas + active index", " set — SET state (DELETED, DATEFORMAT, ...)", " mem — runtime memory / GC stats", " diag — full diag dump (wa + set + mem)", " threads, ts — list all live threads", " hist, trace [N] — last N lines executed (how did we get here?)", " q — quit", } // describeDbgValue is now shared across platforms. It lives in // debugcmd.go because debugcli.go is !windows-only and runDebugCmd // needs this regardless of platform. func describeDbgValue(v Value) string { switch { case v.IsNil(): return "NIL" case v.IsString(): return fmt.Sprintf("%q", v.AsString()) } return v.String() } // parseBreakArgs accepts: // // b // b // b if // b if func parseBreakArgs(rawLine string, parts []string, defaultMod string) (module string, line int, cond string, ok bool) { if len(parts) < 2 { return "", 0, "", false } lineNo, err := strconv.Atoi(parts[1]) if err != nil || lineNo <= 0 { return "", 0, "", false } module = defaultMod lowered := strings.ToLower(rawLine) if idx := strings.Index(lowered, " if "); idx > 0 { cond = strings.TrimSpace(rawLine[idx+4:]) rawLine = rawLine[:idx] parts = strings.Fields(rawLine) } if len(parts) >= 3 { module = parts[2] } return module, lineNo, cond, true }