Files
five/hbrt/thread.go
CharlesKWON 3adc9d7d59 fix: PCount, Break/RECOVER, SET INDEX TO — 3 Harbour compat fixes
Release-blocking compatibility issues discovered during the 258-test
pre-release validation suite (100 syntax + 44 RDD + 114 RTL).

1. PCount() always returned 0 in PRG code

   Root cause: ParamCount() returned t.pendingParams, which is
   overwritten by every nested Function() call. By the time the
   PCount() RTL's Frame() executes, pendingParams is already 0.

   Fix: Frame() now stores pendingParams in frame.paramCount.
   PCount() RTL uses CallerParamCount() which reads callSP-2
   (the PRG caller's frame), while RTL functions still use
   ParamCount() (reads pendingParams before their own Frame).

   Verified: PCount(1,2,3)=3, PCount(1)=1, PCount()=0

2. Break("string") panicked instead of being caught by RECOVER USING

   Root cause: Generated SEQUENCE code only caught *HbError panics.
   Break() panics with BreakValue (a different type), which fell
   through to EndProc's "runtime error" message and re-panic.

   Fix (two parts):
   a) gengo emitBeginSequence: recover closure now catches any
      panic (interface{}), then dispatches via type switch:
      - *HbError → extract .Error() string
      - hasValue interface (BreakValue) → extract .GetValue()
      - other → static "error" string
   b) hbrtl/error.go: BreakValue gets GetValue() method for
      duck-type detection without import cycles
   c) hbrt/thread.go EndProc: BreakValue type name check added
      so it re-panics silently (no stderr noise)

3. SET INDEX TO a, b, c only opened the last file

   Root cause: Parser's parseSet() called parseExpr() once for
   INDEX setting, stopping at the first comma. Remaining file
   names were consumed by the "eat rest of line" loop.

   Fix: Parser now collects comma-separated identifiers into a
   single string literal "a,b,c". gengo splits on comma and
   calls OrderListAdd() for each file.

   Verified: SET INDEX TO si_name, si_city → OrdCount=2

All tests pass:
  go test ./...          14 packages OK
  FiveSql2               43/43  100%
  compat_harbour         51/51
  Syntax test           100/100
  RDD test               44/44
  RTL test              114/114
  Windows cross-compile  OK
  Linux cross-compile    OK

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:06:28 +09:00

686 lines
18 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
package hbrt
import (
"fmt"
"os"
)
// Default stack/frame sizes
const (
DefaultStackSize = 2048 // initial eval stack capacity
MaxStackSize = 65536
MaxCallDepth = 256
InitialCallDepth = 32 // start small, grow if needed
)
// CallFrame saves the state of a function call.
// Harbour equivalent: HB_STACK_STATE
type CallFrame struct {
symbol *Symbol // function symbol (for debugging/profiling)
base int // stack base (start of this frame's args)
localBase int // where locals start in the locals slice
localCount int // number of locals in this frame
paramCount int // number of parameters passed
retVal Value // return value
}
// CurFrame returns the current call frame (for closure capture).
func (t *Thread) CurFrame() *CallFrame { return t.curFrame }
// LocalsSlice returns the underlying locals array (for closure capture).
func (t *Thread) LocalsSlice() []Value { return t.locals }
// GetLocal reads a local variable from a captured frame (1-based index).
func (f *CallFrame) GetLocal(n int, locals []Value) Value {
idx := f.localBase + n - 1
return locals[idx]
}
// SetLocal writes a local variable in a captured frame (1-based index).
func (f *CallFrame) SetLocal(n int, v Value, locals []Value) {
idx := f.localBase + n - 1
locals[idx] = v
}
// Thread is the per-goroutine execution context.
// Harbour equivalent: HB_STACK (thread-local stack)
//
// Each goroutine that runs Harbour code gets its own Thread.
// No locking needed for stack/locals/calls — they are goroutine-local.
type Thread struct {
// Eval stack (goroutine-local, no lock needed)
stack []Value
sp int // stack pointer (next free slot)
// Local variables: flat array, each frame gets a slice via localBase+localCount
locals []Value
// Call stack
calls []CallFrame
callSP int // call stack pointer
curFrame *CallFrame
// Return value (passed between caller/callee)
retVal Value
// Pending function call stack (PushSymbol pushes, Function pops)
// Stack needed for nested calls: Double(Add(3,4))
pendingSyms []*Symbol
pendingParams int // number of params for next Frame call
pendingCallSym *Symbol // symbol for next Frame (for PROCNAME)
// STATIC variables (per-module, shared but rarely written)
// Accessed via PushStatic/PopStatic with module reference
statics map[string][]Value
// OOP: current Self object (set during method dispatch)
self Value
// Error handling: last error from BEGIN SEQUENCE
lastError *HbError
// MEMVAR: PUBLIC/PRIVATE variables (shared across call stack)
Memvars *MemvarTable
// WorkArea manager (goroutine-local, no locks needed)
WA interface{} // *hbrdd.WorkAreaManager — set by caller to avoid import cycle
waStack []uint16 // saved workarea numbers for (expr)->(expr) context switching
// VM reference (shared, read-mostly)
vm *VM
}
// NewThread creates a new execution thread.
func NewThread(vm *VM) *Thread {
t := &Thread{
stack: make([]Value, DefaultStackSize),
sp: 0,
locals: make([]Value, 256), // will grow as needed
calls: make([]CallFrame, InitialCallDepth),
callSP: 0,
statics: make(map[string][]Value),
Memvars: NewMemvarTable(),
vm: vm,
}
return t
}
// --- Stack operations ---
func (t *Thread) push(v Value) {
if t.sp >= len(t.stack) {
if t.sp >= MaxStackSize {
panic(t.runtimeError("stack overflow"))
}
newStack := make([]Value, len(t.stack)*2)
copy(newStack, t.stack[:t.sp])
t.stack = newStack
}
t.stack[t.sp] = v
t.sp++
}
func (t *Thread) pop() Value {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow"))
}
t.sp--
v := t.stack[t.sp]
t.stack[t.sp] = cachedNil
return v
}
func (t *Thread) peek() Value {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow (peek)"))
}
return t.stack[t.sp-1]
}
func (t *Thread) peekPtr() *Value {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow (peekPtr)"))
}
return &t.stack[t.sp-1]
}
func (t *Thread) setTop(v Value) {
if t.sp <= 0 {
panic(t.runtimeError("stack underflow (setTop)"))
}
t.stack[t.sp-1] = v
}
// stackAt returns a pointer to stack item at offset from top.
// 0 = top, -1 = second from top, etc.
func (t *Thread) stackAt(offset int) *Value {
idx := t.sp - 1 + offset
if idx < 0 || idx >= t.sp {
panic(t.runtimeError("stack access out of range"))
}
return &t.stack[idx]
}
// --- Push convenience methods (used by generated code) ---
func (t *Thread) PushNil() { t.push(MakeNil()) }
func (t *Thread) PushBool(b bool) { t.push(MakeBool(b)) }
func (t *Thread) PushInt(n int) { t.push(MakeInt(n)) }
func (t *Thread) PushLong(n int64) { t.push(MakeLong(n)) }
func (t *Thread) PushDouble(v float64, length, decimal uint16) {
t.push(MakeDouble(v, length, decimal))
}
func (t *Thread) PushString(s string) { t.push(MakeString(s)) }
func (t *Thread) PushValue(v Value) { t.push(v) }
func (t *Thread) Pop() { t.pop() }
func (t *Thread) Pop2() Value { return t.pop() } // pop and return
func (t *Thread) Dup() { t.push(t.peek()) }
// --- Frame management ---
// Harbour: hb_xvmFrame(params, locals)
// Called at the start of every function.
func (t *Thread) Frame(params, locals int) {
if t.callSP >= MaxCallDepth {
panic(t.runtimeError("call stack overflow"))
}
// Grow call stack dynamically if needed
if t.callSP >= len(t.calls) {
newSize := len(t.calls) * 2
if newSize > MaxCallDepth {
newSize = MaxCallDepth
}
newCalls := make([]CallFrame, newSize)
copy(newCalls, t.calls)
t.calls = newCalls
}
// Ensure locals slice has enough space
localBase := 0
if t.curFrame != nil {
localBase = t.curFrame.localBase + t.curFrame.localCount
}
needed := localBase + params + locals
if needed > len(t.locals) {
newLocals := make([]Value, needed*2)
copy(newLocals, t.locals)
t.locals = newLocals
}
// Save frame
// Handle case where fewer args were pushed than declared params
actual := t.pendingParams
if actual > params {
actual = params
}
if actual > t.sp {
actual = t.sp
}
frame := &t.calls[t.callSP]
frame.base = t.sp - actual // only actual args on stack
frame.localBase = localBase
frame.localCount = params + locals
frame.paramCount = t.pendingParams // actual args passed by caller (not declared count)
frame.retVal = MakeNil()
frame.symbol = t.pendingCallSym
t.pendingCallSym = nil
// Copy actual parameters from stack to locals
for i := 0; i < actual; i++ {
t.locals[localBase+i] = t.stack[frame.base+i]
}
// Initialize missing params and locals to NIL
for i := actual; i < params+locals; i++ {
t.locals[localBase+i] = MakeNil()
}
// Pop args from stack (they're now in locals)
t.sp = frame.base
t.curFrame = frame
t.callSP++
}
// EndProc is called via defer at the end of every function.
// Handles recover for BEGIN SEQUENCE and restores frame.
// All panics are re-panicked so the generated SEQUENCE/RECOVER handler
// can catch them. HbError + BreakValue (from Break() in hbrtl) are
// re-panicked silently; unknown panics also re-panic but with a
// diagnostic message on stderr.
func (t *Thread) EndProc() {
if r := recover(); r != nil {
t.endFrame()
if _, ok := r.(*HbError); ok {
panic(r) // HbError — re-panic silently
}
// Check for BreakValue from hbrtl.Break() via duck typing.
// We can't import hbrtl (cycle), so we check the type name.
rType := fmt.Sprintf("%T", r)
if rType == "hbrtl.BreakValue" {
panic(r) // BreakValue — re-panic silently for RECOVER USING
}
fmt.Fprintf(os.Stderr, "Five runtime error: %v [recovered, repanicked]\n", r)
panic(r)
}
t.endFrame()
}
// EndProcFast is called by RTL functions that don't need recover().
// ~3x faster than EndProc (no defer recover overhead).
func (t *Thread) EndProcFast() {
t.endFrame()
}
// endFrame restores the previous call frame.
func (t *Thread) endFrame() {
if t.callSP > 0 {
t.callSP--
if t.callSP > 0 {
t.curFrame = &t.calls[t.callSP-1]
} else {
t.curFrame = nil
}
}
}
// EndProcNoRecover cleans up the frame without recover (used by Break).
func (t *Thread) EndProcNoRecover() {
if t.callSP > 0 {
t.callSP--
if t.callSP > 0 {
t.curFrame = &t.calls[t.callSP-1]
} else {
t.curFrame = nil
}
}
}
// --- Local variable access ---
// Harbour convention: local index 1-based (1 = first param or local)
func (t *Thread) PushLocal(n int) {
idx := t.localIndex(n)
v := t.locals[idx]
if v.Type() == tByref {
t.push((*HbRefCell)(v.ptr).V)
} else {
t.push(v)
}
}
func (t *Thread) PopLocal(n int) {
idx := t.localIndex(n)
val := t.pop()
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = val
} else {
t.locals[idx] = val
}
}
func (t *Thread) Local(n int) Value {
v := t.locals[t.localIndex(n)]
if v.Type() == tByref {
return (*HbRefCell)(v.ptr).V
}
return v
}
func (t *Thread) SetLocal(n int, v Value) {
idx := t.localIndex(n)
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = v
} else {
t.locals[idx] = v
}
}
// Fast variants — no bounds checking (gengo guarantees valid indices).
// Byref-aware: transparently dereference/write-through RefCell.
func (t *Thread) PushLocalFast(n int) {
v := t.locals[t.curFrame.localBase+n-1]
if v.Type() == tByref {
t.push((*HbRefCell)(v.ptr).V)
} else {
t.push(v)
}
}
func (t *Thread) PopLocalFast(n int) {
idx := t.curFrame.localBase + n - 1
val := t.pop()
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = val
} else {
t.locals[idx] = val
}
}
func (t *Thread) LocalFast(n int) Value {
v := t.locals[t.curFrame.localBase+n-1]
if v.Type() == tByref {
return (*HbRefCell)(v.ptr).V
}
return v
}
func (t *Thread) SetLocalFast(n int, v Value) {
idx := t.curFrame.localBase + n - 1
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = v
} else {
t.locals[idx] = v
}
}
// PushLocalRef creates a shared RefCell and pushes it for @param.
// Both caller's local and callee's param point to the same cell.
func (t *Thread) PushLocalRef(n int) {
idx := t.localIndex(n)
v := t.locals[idx]
if v.Type() == tByref {
t.push(v) // already a RefCell — share it
return
}
cell := &HbRefCell{V: v}
ref := MakeByref(cell)
t.locals[idx] = ref // caller's local becomes RefCell
t.push(ref) // callee gets same RefCell
}
func (t *Thread) LocalAsString(n int) string {
return t.Local(n).AsString()
}
// EnsureLocalRef converts a local to a RefCell if it isn't one already.
// Used by closure capture to enable shared mutable access.
func (t *Thread) EnsureLocalRef(n int) {
idx := t.curFrame.localBase + n - 1
v := t.locals[idx]
if v.Type() != tByref {
cell := &HbRefCell{V: v}
t.locals[idx] = MakeByref(cell)
}
}
// LocalRaw returns the raw Value at local slot (including RefCell wrapper).
// Used by closure capture to grab the RefCell itself, not the dereferenced value.
func (t *Thread) LocalRaw(n int) Value {
return t.locals[t.curFrame.localBase+n-1]
}
// SetLocalRaw sets a local slot to the raw Value (including RefCell wrapper).
// Used by closure to inject shared RefCell into block locals.
func (t *Thread) SetLocalRaw(n int, v Value) {
t.locals[t.curFrame.localBase+n-1] = v
}
// LocalSetInt is an optimization: set local directly without stack. Byref-aware.
func (t *Thread) LocalSetInt(n int, val int) {
idx := t.localIndex(n)
if e := t.locals[idx]; e.Type() == tByref {
(*HbRefCell)(e.ptr).V = MakeInt(val)
} else {
t.locals[idx] = MakeInt(val)
}
}
func (t *Thread) localIndex(n int) int {
if t.curFrame == nil {
panic(t.runtimeError("no active frame"))
}
idx := t.curFrame.localBase + n - 1 // 1-based to 0-based
if idx < t.curFrame.localBase || idx >= t.curFrame.localBase+t.curFrame.localCount {
panic(t.runtimeError(fmt.Sprintf("local variable index out of range: %d", n)))
}
return idx
}
// --- Memvar access ---
// PushMemvar pushes a memvar value onto the stack. Harbour: M->varname
func (t *Thread) PushMemvar(name string) {
if v, ok := t.Memvars.Get(name); ok {
t.push(v)
} else {
t.push(MakeNil())
}
}
// PopMemvar pops stack and stores into a memvar. Harbour: M->varname := expr
func (t *Thread) PopMemvar(name string) {
val := t.pop()
if !t.Memvars.Set(name, val) {
// Auto-create as PRIVATE if not exists
t.Memvars.SetPrivate(name, val, t.callSP)
}
}
// DeclarePublic creates a PUBLIC memvar with NIL value.
func (t *Thread) DeclarePublic(name string) {
if !t.Memvars.Exists(name) {
t.Memvars.SetPublic(name, MakeNil())
}
}
// DeclarePrivate creates a PRIVATE memvar with NIL value.
func (t *Thread) DeclarePrivate(name string) {
t.Memvars.SetPrivate(name, MakeNil(), t.callSP)
}
// --- Return value ---
func (t *Thread) RetValue() {
t.retVal = t.pop()
}
func (t *Thread) RetInt(n int64) {
t.retVal = MakeNumInt(n)
}
func (t *Thread) RetNil() {
t.retVal = MakeNil()
}
func (t *Thread) RetString(s string) {
t.retVal = MakeString(s)
}
func (t *Thread) RetBool(b bool) {
t.retVal = MakeBool(b)
}
func (t *Thread) RetLong(n int64) {
t.retVal = MakeLong(n)
}
func (t *Thread) RetDouble(v float64, length, decimal uint16) {
t.retVal = MakeDouble(v, length, decimal)
}
func (t *Thread) RetPointer(val interface{}) {
t.retVal = MakePointer(val)
}
func (t *Thread) RetVal(v Value) {
t.retVal = v
}
// PushRetValue pushes the return value from the last call onto the stack.
func (t *Thread) PushRetValue() {
t.push(t.retVal)
}
// GetRetValue returns the current return value.
func (t *Thread) GetRetValue() Value {
return t.retVal
}
// --- Error handling ---
// HbError represents a Harbour runtime error.
type HbError struct {
Description string
Operation string
Args []Value
SubSystem string
GenCode int
}
func (e *HbError) Error() string {
return fmt.Sprintf("Five runtime error: %s (op: %s)", e.Description, e.Operation)
}
func (t *Thread) runtimeError(msg string) *HbError {
return &HbError{
Description: msg,
SubSystem: "BASE",
}
}
func (t *Thread) argError(op string, args ...Value) *HbError {
return &HbError{
Description: "argument error",
Operation: op,
Args: args,
SubSystem: "BASE",
GenCode: 1,
}
}
func (t *Thread) handleSequenceError(err *HbError) {
// BEGIN SEQUENCE / RECOVER: store error for RECOVER USING
t.lastError = err
// The recover block is handled by the generated code's defer/recover pattern.
// EndProc catches the panic and this function stores the error value.
}
// VM returns the VM this thread belongs to.
func (t *Thread) VM() *VM {
return t.vm
}
// ParamCount returns the number of parameters passed to the current call.
// Used by RTL functions that call ParamCount() BEFORE Frame() — returns
// pendingParams set by Function(nArgs). This is the original behavior
// that all existing RTL functions depend on.
//
// For PRG-level PCount(), use CallerParamCount() instead (via PCount RTL).
func (t *Thread) ParamCount() int {
return t.pendingParams
}
// CallerParamCount returns the param count of the calling PRG function
// (one frame below the current). Used by PCount() RTL which needs the
// caller's count, not its own.
func (t *Thread) CallerParamCount() int {
if t.callSP >= 2 {
return t.calls[t.callSP-2].paramCount
}
return 0
}
// PendingParams2 sets pending param count for direct block calls (AEval, ASort etc.)
func (t *Thread) PendingParams2(n int) {
t.pendingParams = n
}
func (t *Thread) pushPendingSym(sym *Symbol) {
t.pendingSyms = append(t.pendingSyms, sym)
}
func (t *Thread) popPendingSym() *Symbol {
n := len(t.pendingSyms)
if n == 0 {
return nil
}
sym := t.pendingSyms[n-1]
t.pendingSyms = t.pendingSyms[:n-1]
return sym
}
// PushAliasField pushes a field value from a named alias workarea.
// Harbour: alias->field
func (t *Thread) PushAliasField(alias, field string) {
// Delegate to WorkAreaManager via WA interface
if t.WA != nil {
// Use reflection-free interface assertion
type aliasGetter interface {
GetAliasField(alias, field string) Value
}
if ag, ok := t.WA.(aliasGetter); ok {
t.push(ag.GetAliasField(alias, field))
return
}
}
t.push(MakeNil())
}
// PushDynAliasField pushes a field from dynamic alias: (expr)->field
func (t *Thread) PushDynAliasField(alias, field string) {
t.PushAliasField(alias, field)
}
// GetLastError returns the last error from BEGIN SEQUENCE.
func (t *Thread) GetLastError() *HbError {
return t.lastError
}
// --- STATIC variable access ---
func (t *Thread) RegisterStatics(module string, statics []Value) {
t.statics[module] = statics
}
func (t *Thread) PushStatic(module string, n int) {
statics := t.statics[module]
if n < 1 || n > len(statics) {
panic(t.runtimeError(fmt.Sprintf("static index out of range: %s[%d]", module, n)))
}
t.push(statics[n-1])
}
func (t *Thread) PopStatic(module string, n int) {
statics := t.statics[module]
if n < 1 || n > len(statics) {
panic(t.runtimeError(fmt.Sprintf("static index out of range: %s[%d]", module, n)))
}
statics[n-1] = t.pop()
}
// --- Workarea context switching for (alias)->(expr) ---
func (t *Thread) WASaveAndSelect(areaNum int) {
type waSel interface{ SelectByNum(uint16); Current() uint16 }
if wam, ok := t.WA.(waSel); ok {
t.waStack = append(t.waStack, wam.Current())
wam.SelectByNum(uint16(areaNum))
}
}
func (t *Thread) WASaveAndSelectAlias(alias string) {
type waSel interface{ SelectByAlias(string); Current() uint16 }
if wam, ok := t.WA.(waSel); ok {
t.waStack = append(t.waStack, wam.Current())
wam.SelectByAlias(alias)
}
}
func (t *Thread) WARestore() {
if n := len(t.waStack); n > 0 {
saved := t.waStack[n-1]
t.waStack = t.waStack[:n-1]
type waSel interface{ SelectByNum(uint16) }
if wam, ok := t.WA.(waSel); ok {
wam.SelectByNum(saved)
}
}
}