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>
464 lines
13 KiB
Go
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.
|