Files
five/hbrt/debugtui.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

464 lines
13 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// Full-screen TUI debugger for Five — Harbour/Clipper debugger style.
// Uses ANSI escape codes for terminal rendering.
// termSize() and readDebugKey() live in termios_<os>.go because their
// low-level mechanics (ioctls vs Windows console API) don't share code.
package hbrt
import (
"fmt"
"os"
"strings"
)
// TUIDebugger creates a full-screen terminal debugger callback.
func TUIDebugger() DebugCallback {
var sourceCache map[string][]string // file → lines cache
sourceCache = make(map[string][]string)
return func(event *DebugEvent) int {
// Switch to cooked mode for debugger
restoreCooked()
defer reenterRaw()
// Load source file
srcDir := ""
if event.Thread.VM().Debugger != nil {
srcDir = event.Thread.VM().Debugger.SourceDir
}
lines := loadSource(sourceCache, event.Module, srcDir)
// Get terminal size
rows, cols := termSize()
if rows < 10 {
rows = 24
}
if cols < 40 {
cols = 80
}
// Layout:
// Row 1: title bar
// Row 2 ~ srcEnd: source code
// srcEnd+1 ~ panelEnd: locals + stack side by side
// Last row: command bar
srcHeight := rows - 10
if srcHeight < 5 {
srcHeight = 5
}
panelHeight := rows - srcHeight - 3 // title + src + cmdbar
if panelHeight < 3 {
panelHeight = 3
}
for {
// Clear screen
fmt.Print("\033[2J\033[H")
// === Title Bar ===
title := fmt.Sprintf(" Five Debugger - %s:%d %s() ", event.Module, event.Line, event.Function)
if event.Reason == "breakpoint" {
title += "[BREAKPOINT] "
}
fmt.Printf("\033[7m%-*s\033[0m\r\n", cols, title)
// === Source Window ===
drawSourceWindow(lines, event.Line, srcHeight, cols, event.Thread.VM().Debugger)
// === Panels: Locals | Call Stack ===
localW := cols / 2
stackW := cols - localW
drawPanels(event, panelHeight, localW, stackW)
// === Command Bar ===
fmt.Printf("\033[7m%-*s\033[0m", cols,
" F5:Go F7:Into F8:Step F9:Break F10:Over F11:Out U:Until :Cmd P:Print W:Watch D:Diag ESC:Quit")
// Wait for key
key := readDebugKey()
switch key {
case 0x1B, 'q', 'Q': // ESC or Q — quit
fmt.Print("\033[2J\033[H")
restoreCooked()
os.Exit(0)
case 0xF5, 'g', 'G': // F5 or G — Go/Continue
return DbgContinue
case 0xF7: // F7 — Step Into
return DbgStepLine
case 0xF8, 's', 'S', 10, 13: // F8 / s / Enter — Step
return DbgStepLine
case 0xF9, 'b', 'B': // F9 or B — Toggle Breakpoint
dbg := event.Thread.VM().Debugger
found := false
for i, bp := range dbg.Breakpoints {
if bp.Line == event.Line {
dbg.RemoveBreakpoint(i)
found = true
break
}
}
if !found {
dbg.AddBreakpoint(event.Module, event.Line)
}
continue // redraw
case 0xFA, 'n', 'N': // F10 or N — Step Over
return DbgStepOver
case 0xFB, 'o', 'O': // F11 or O — Step Out
return DbgStepOut
case 'c', 'C': // Continue
return DbgContinue
case ':': // Command prompt — any full debugger command
if mode, ok := runTUIPrompt(event, rows, cols, ""); ok {
return mode
}
continue
case 'p', 'P': // Quick print prompt — pre-fills "p "
if mode, ok := runTUIPrompt(event, rows, cols, "p "); ok {
return mode
}
continue
case 'w': // Add watch — pre-fills "w "
if mode, ok := runTUIPrompt(event, rows, cols, "w "); ok {
return mode
}
continue
case 'u', 'U': // Run until line — pre-fills "u "
if mode, ok := runTUIPrompt(event, rows, cols, "u "); ok {
return mode
}
continue
case 'W': // Shift-W — clear watches
event.Thread.VM().Debugger.Watches = nil
continue
case 'D', 'd': // D — diagnostics pop-up (workareas + SET + runtime)
showDiagPopup(event, rows, cols)
continue
case 0xE0, 0xE1, 0xE2, 0xE3: // Arrow keys — ignore
continue
default:
continue // unknown key, redraw
}
}
}
}
// runTUIPrompt pops up a bottom-line `:` prompt, reads a command, feeds
// it to runDebugCmd. Returns (mode, true) if the command resumed
// execution (c/s/n/o/u), or (_, false) to stay in the TUI loop.
//
// Captured output is shown on the line above the prompt for a moment
// so the user can see the result before the redraw.
func runTUIPrompt(event *DebugEvent, rows, cols int, prefill string) (int, bool) {
// Move to the command-bar row, clear it, enter cooked mode for input.
fmt.Printf("\033[%d;1H\033[2K", rows)
restoreCooked()
defer reenterRaw()
fmt.Printf(":%s", prefill)
var buf [1024]byte
reader := os.Stdin
// Use a fresh read — bufio would complicate the one-shot prompt.
n, _ := reader.Read(buf[:])
line := strings.TrimRight(prefill+string(buf[:n]), "\r\n")
// Capture output so we can show it on the status line.
var outLines []string
mode := runDebugCmd(event, line, func(s string) { outLines = append(outLines, s) })
if len(outLines) > 0 {
// Print up to 3 output lines above the prompt row — enough for
// watch listings / breakpoint-set confirmations without
// obscuring the source view above.
show := outLines
if len(show) > 3 {
show = show[len(show)-3:]
}
for i, ln := range show {
fmt.Printf("\033[%d;1H\033[2K%s", rows-1-len(show)+i+1, ln)
}
// Give the user a beat to read, but they can skip with any key.
readDebugKey()
}
return mode, mode != cmdNoMode
}
func drawSourceWindow(lines []string, curLine, height, width int, dbg *Debugger) {
// Calculate visible range centered on current line
start := curLine - height/2
if start < 1 {
start = 1
}
end := start + height - 1
if end > len(lines) {
end = len(lines)
}
// Top border
fmt.Printf("\033[36m\u250C\u2500 Source \u2500%s\u2510\033[0m\r\n", strings.Repeat("\u2500", width-12))
for i := start; i <= end; i++ {
lineText := ""
if i-1 < len(lines) {
lineText = lines[i-1]
}
// Truncate to fit
if len(lineText) > width-10 {
lineText = lineText[:width-10]
}
// Breakpoint marker
bpMark := " "
if dbg != nil {
for _, bp := range dbg.Breakpoints {
if bp.Enabled && bp.Line == i {
bpMark = "\033[31m\u25CF\033[0m" // red dot
break
}
}
}
// Current line marker
if i == curLine {
fmt.Printf("\033[36m\u2502\033[0m%s\033[33m>> %4d:\033[0m \033[7m%-*s\033[0m\033[36m\u2502\033[0m\r\n",
bpMark, i, width-10, lineText)
} else {
fmt.Printf("\033[36m\u2502\033[0m%s %4d: %-*s\033[36m\u2502\033[0m\r\n",
bpMark, i, width-10, lineText)
}
}
// Pad remaining lines
for i := end - start + 1; i < height; i++ {
fmt.Printf("\033[36m\u2502\033[0m%*s\033[36m\u2502\033[0m\r\n", width-2, "")
}
// Bottom border
fmt.Printf("\033[36m\u2514%s\u2518\033[0m\r\n", strings.Repeat("\u2500", width-2))
}
func drawPanels(event *DebugEvent, height, localW, rightW int) {
// The right column splits vertically into Stack (top) and Watches
// (bottom). Split 50/50 but give watches at least 3 rows once any
// exist, else hand the whole column to stack.
dbg := event.Thread.VM().Debugger
nWatches := 0
if dbg != nil {
nWatches = len(dbg.Watches)
}
contentRows := height - 2 // minus header+footer borders on each side
stackRows := contentRows
watchRows := 0
if nWatches > 0 {
watchRows = contentRows / 2
if watchRows < 3 {
watchRows = 3
}
if watchRows > contentRows-2 {
watchRows = contentRows - 2
}
stackRows = contentRows - watchRows - 1 // -1 for the divider
}
// Pre-render rendered watch lines (outside the row loop so we don't
// re-evaluate expressions per row).
watchLines := make([]string, 0, nWatches)
if nWatches > 0 {
for i, expr := range dbg.Watches {
v, evalErr := event.Thread.EvalWithFrameLocals(expr)
var rendered string
if evalErr != "" {
rendered = fmt.Sprintf(" [%d] %s ! %s", i, expr, truncFit(evalErr, 20))
} else {
rendered = fmt.Sprintf(" [%d] %s = %s", i, expr, describeDbgValue(v))
}
watchLines = append(watchLines, rendered)
}
}
// Headers
localHeader := fmt.Sprintf("\u250C\u2500 Locals %s\u2510", strings.Repeat("\u2500", localW-11))
stackHeader := fmt.Sprintf("\u250C\u2500 Stack %s\u2510", strings.Repeat("\u2500", rightW-10))
fmt.Printf("\033[36m%s%s\033[0m\r\n", localHeader, stackHeader)
// Body rows — left is always locals, right alternates Stack then
// (optional) Watches, separated by a mid-panel header.
for i := 0; i < contentRows; i++ {
localLine := ""
if i < len(event.Locals) {
v := event.Locals[i]
val := describeDbgValue(v.Value)
localLine = truncFit(fmt.Sprintf(" %s = %s", v.Name, val), localW-2)
}
rightLine := ""
switch {
case i < stackRows:
if i < len(event.CallStack) {
f := event.CallStack[i]
if f.Module != "" {
rightLine = fmt.Sprintf(" %s() %s:%d", f.Function, f.Module, f.Line)
} else {
rightLine = fmt.Sprintf(" %s()", f.Function)
}
}
case i == stackRows && watchRows > 0:
// Mid-panel divider/header for the Watches sub-section.
rightLine = "─ Watches " + strings.Repeat("─", rightW-13)
default:
widx := i - stackRows - 1
if widx >= 0 && widx < len(watchLines) {
rightLine = watchLines[widx]
}
}
rightLine = truncFit(rightLine, rightW-2)
fmt.Printf("\033[36m\u2502\033[0m%s\033[36m\u2502\033[0m%s\033[36m\u2502\033[0m\r\n",
padRunes(localLine, localW-2), padRunes(rightLine, rightW-2))
}
// Bottom borders
localFooter := fmt.Sprintf("\u2514%s\u2518", strings.Repeat("\u2500", localW-2))
stackFooter := fmt.Sprintf("\u2514%s\u2518", strings.Repeat("\u2500", rightW-2))
fmt.Printf("\033[36m%s%s\033[0m\r\n", localFooter, stackFooter)
}
// showDiagPopup renders the full diagnostic dump (workareas, SET flags,
// runtime) in a scrollable bottom panel. Press any key to dismiss.
// Reuses DebugDiagnosticHook so output matches error.log exactly.
func showDiagPopup(event *DebugEvent, rows, cols int) {
if DebugDiagnosticHook == nil {
return
}
var lines []string
DebugDiagnosticHook(event.Thread, "", func(s string) {
for _, ln := range strings.Split(s, "\n") {
if ln != "" {
lines = append(lines, ln)
}
}
})
// Clear and redraw as a full-screen scrolling view. Simplest: clear
// screen, print lines, show a "-- press any key --" footer.
start := 0
for {
fmt.Print("\033[2J\033[H")
fmt.Printf("\033[7m%s\033[0m\r\n", padRunes(" Diagnostics (q/ESC to close, space/pgdn next, pgup prev) ", cols))
viewH := rows - 2
end := start + viewH
if end > len(lines) {
end = len(lines)
}
for i := start; i < end; i++ {
fmt.Printf("%s\r\n", truncFit(lines[i], cols))
}
// Pad remaining rows
for i := end - start; i < viewH; i++ {
fmt.Print("\r\n")
}
fmt.Printf("\033[7m%s\033[0m", padRunes(
fmt.Sprintf(" line %d-%d of %d ", start+1, end, len(lines)), cols))
key := readDebugKey()
switch key {
case 0x1B, 'q', 'Q':
return
case ' ', 10, 13, 0xE1: // space / enter / down-arrow
start += viewH
if start >= len(lines) {
start = len(lines) - 1
}
case 'b', 'B', 0xE0: // back page / up-arrow
start -= viewH
if start < 0 {
start = 0
}
default:
return
}
}
}
// padRunes right-pads s with spaces so its display width is `width`
// runes. Used instead of Printf's "%-*s" which counts bytes, mangling
// UTF-8 box-drawing / CJK. Assumes each rune is one display column —
// not strictly true for CJK "wide" chars, but good enough for the
// Latin/box-drawing mix this debugger prints.
func padRunes(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return string(runes[:width])
}
return s + strings.Repeat(" ", width-len(runes))
}
// truncFit clamps s to at most width runes, appending "…" on truncation.
// Rune-aware so it doesn't cut UTF-8 box-drawing / CJK characters in
// the middle of a multi-byte sequence (which would render as mojibake).
func truncFit(s string, width int) string {
if width <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= width {
return s
}
if width <= 1 {
return string(runes[:width])
}
return string(runes[:width-1]) + "…"
}
func loadSource(cache map[string][]string, filename string, sourceDir string) []string {
if lines, ok := cache[filename]; ok {
return lines
}
// Try as-is first
data, err := os.ReadFile(filename)
if err != nil && sourceDir != "" {
// Try relative to source directory
joined := sourceDir + "/" + filename
data, err = os.ReadFile(joined)
if err != nil {
// Try just the basename in source dir
base := filename
if idx := strings.LastIndexAny(filename, "/\\"); idx >= 0 {
base = filename[idx+1:]
}
data, err = os.ReadFile(sourceDir + "/" + base)
}
}
if err != nil {
return []string{"(source not available: " + filename + ")"}
}
lines := strings.Split(string(data), "\n")
cache[filename] = lines
return lines
}
// termSize() and readDebugKey() moved to termios_<os>.go — the Unix
// implementations use ioctl TIOCGWINSZ + raw-mode termios, while the
// Windows version uses console APIs.