Files
five/hbrt/debugcmd.go
CharlesKWON f4ed42556b checkpoint: season-wide bug fix campaign + infra
Cumulative season's silent-bug hunting (~62 fixes) across the FiveSql2
SQL engine, the Five compiler/runtime, and the hbrdd RDD layer. Saved
as a single checkpoint before refactoring the parser to delegate xBase
command translation to the preprocessor.

Highlights:

FiveSql2 engine (_FiveSql2/src/)
- prefix-glob index attach -> explicit convention (<table>_pk.ntx,
  <table>_uq.ntx, <table>.cdx) — fixes silent multi-row INSERT row-drop
- DROP/CREATE TABLE FErase chain extended (.cdx, .fsc, .fsv, .dbt, .fpt)
- COUNT(DISTINCT col) parsed + aggregated via hSeen hash
- UNION column-count mismatch returns SQL_ERR_GRAMMAR (was silent)
- DISTINCT + ORDER BY hidden-col leak fixed (trim before DISTINCT)
- Derived table FROM (SELECT...) + JOIN right-side derived
- Self-FK CASCADE depth 2+ via SqlGetSingleColPK pre-collect
- LAG/LEAD default arg uses SqlEvalRowExpr (handles -N const exprs)
- DATE literal round-trip validation (Feb 29 non-leap rejected)
- CREATE OR REPLACE VIEW; CREATE VIEW errors on already-exists
- AlterTable type dispatcher comma-wrapped (1-char type "A" no longer
  matches CHARACTER)

Compiler / runtime
- gengo: HB_ -> FV_ prefix on emitted Go function names (Five identity)
- gengo split: emit_block.go, emit_stmt.go, folding.go extracted
- parser/stmtreg.go nudges
- hbrt: debug TUI/CLI restructure (debugcmd, debugkey, termios_*),
  windows debug stubs collapsed
- thread/vm/value/class/pcinterp tightening from panic traces

RDD layer (hbrdd/)
- dbf: null bitmap support (null.go + null_test.go), mmap split
  (mmap_posix.go / mmap_windows.go), byte-level numeric parse
- ntx/cdx: windows mmap parity
- workarea + mem RDD: cross-area state-bleed fixes

RTL (hbrtl/)
- errorlog rewrite with platform-specific FD (errorlog_fd_unix /
  errorlog_fd_other)
- sqlscan, sqlhelpers, indexrtl, datetime extensions

Gates green at checkpoint:
- go test ./...        : PASS
- FiveSql2 SQL:1999    : 43/43
- Harbour compat       : 56/56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:26:25 +09:00

388 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <line> [module] [if <expr>]")
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 <line> [module]")
case "w", "watch":
if len(parts) < 2 {
if len(dbg.Watches) == 0 {
out(" No watches. Usage: w <expr>")
} 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 <watch_number>")
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 <breakpoint_number>")
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 <expr>")
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 <line> — run until <line> in current module",
" b <line> [if E] — set breakpoint, optional condition",
" d <n> — delete breakpoint n",
" bl — list all breakpoints",
" w <expr> — add watch expression",
" wd <n> — remove watch n",
" w — list watches",
" l — show local variables",
" p <expr> — 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 <line>
// b <line> <module>
// b <line> if <expr>
// b <line> <module> if <expr>
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
}