Files
five/hbrt/thread.go
Charles KWON OhJun 7629f95235 fix(hbrt): variadic PValue support — snapshot pushed args per frame
Variadic Harbour functions (`FUNCTION foo(...)`) declare 0 params,
so Frame() copied no args into the locals slots PValue/CallerLocal
read from. Result: PValue(n) returned whatever happened to live in
the caller's LOCAL slot n (i.e. the first LOCAL declared after `(...)`,
which loops typically use as a counter). fivenode's AP_RPUTS, AP_ECHO
and any other variadic dispatcher that walks PValue() saw garbage —
fivenode_go shipped a workaround (AP_RPUTS collapsed to a single arg)
that this commit now lets us revert.

Mechanism

  CallFrame now carries `actualArgs []Value`. Frame() snapshots every
  value the caller pushed (the full pendingParams, not the clipped
  declared count) into this slice before moving sp. The locals[]
  declared-param region is unchanged so positional LOCAL access keeps
  working. CallerLocal reads from actualArgs first.

  Stack handling tightens slightly: t.sp now ends at `sp - pushedArgs`
  instead of `sp - localsCopy`, dropping the extra-args slots that
  variadic callers used to leave on the stack. They're no longer needed
  — actualArgs is the canonical home — and leaving them on the stack
  was the root of the original "PValue returns the caller's LOCAL"
  bug because the next push would overwrite them.

  Slice reuse: when capacity permits, we slice down rather than
  reallocating, so the hot path (0/1/few args) keeps its no-alloc
  characteristics.

Verified

  • Variadic 2/1/0-arg case and fixed-arg comparison all print the
    expected values (test_variadic_only.prg).
  • Full suite: go test ./compiler/... ./hbrt/... ./hbrtl/...,
    Compat 56/56, std.ch 17/17, FRB 7/7, FiveSql2 43/43 all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:17:48 +09:00

855 lines
24 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
module string // current PRG source file (updated by DebugLine)
line int // current PRG source line
localNames []string // PRG-source names of params+locals (nil = none registered)
// actualArgs snapshots every value the caller pushed, regardless of
// the callee's declared param count. PValue / CallerLocal read this
// so variadic functions (declared params = 0) can see their args —
// the locals[] slots are reserved for declared LOCALs in that case,
// so the args have nowhere else to live. Reused across calls when
// capacity allows.
actualArgs []Value
}
// CurFrame returns the current call frame (for closure capture).
func (t *Thread) CurFrame() *CallFrame { return t.curFrame }
// CurBlock returns the *HbBlock for the codeblock currently executing
// (or nil outside a block body). Used by pcode dispatch to resolve
// detached-local opcodes against the running block's capture slice.
func (t *Thread) CurBlock() *HbBlock { return t.curBlock }
// SetCurBlock installs the executing block. The pcode block wrapper
// pairs Set(block) with a deferred Set(prev) to nest correctly across
// blocks that call other blocks (`{|| eval(b1) + eval(b2) }`).
func (t *Thread) SetCurBlock(b *HbBlock) { t.curBlock = b }
// 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.
// The TID is VM-unique and assigned at construction time for debugger
// thread listing.
// TraceEntry captures one step of execution history — module+line where
// DebugLine fired. Populated only when the debugger is attached so
// regular runs don't pay the ring-buffer cost.
type TraceEntry struct {
Module string
Line int
}
// Size of the per-thread execution trace ring buffer. 256 entries gives
// enough runway to answer "how did we get here?" across most loops
// without meaningfully bloating per-thread memory.
const TraceRingSize = 256
type Thread struct {
tid uint32
// traceRing is a ring buffer of recent DebugLine hits. traceHead
// points at the slot for the next write. Total recorded entries
// across the program's lifetime for this thread is tracked via
// traceCount so the debugger can render "N lines ago".
traceRing []TraceEntry
traceHead int
traceCount uint64
// 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).
// Depth=1 is the common case (non-nested call) and gets a scalar
// fast slot to skip slice append/trim; nested calls fall back to
// the heap slice. Balanced push/pop keeps the invariant:
// pendingSymFast set → slice empty
// slice non-empty → pendingSymFast may or may not be set.
pendingSymFast *Symbol
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
// Current code block (set while a block body is executing).
// Pcode opcodes PcOpPushDetached / PcOpPopDetached read/write
// Detached[i] through this pointer. The block's wrapper Fn
// sets it before ExecPcode and restores on exit. Stays nil
// outside block bodies; nil-checking opcodes fall back to NIL.
curBlock *HbBlock
// 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
// FastFieldGetter is a hot-path closure set by SqlScan (or any other
// scan loop) to short-circuit PcOpFieldGet. When non-nil, the pcode
// interpreter calls this instead of going through PushSymbol +
// Function dispatch + FieldGet RTL's own Frame/EndProc. Caller is
// responsible for setting and clearing it around a scan.
FastFieldGetter func(int) Value
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()) }
// SP returns the current data-stack depth. Paired with SetSP for
// callers that need to clamp the stack across an inner function
// dispatch — used by FrbDo to neutralise pcode-body stack leaks.
func (t *Thread) SP() int { return t.sp }
// SetSP forcibly resets the data-stack depth. Truncates if newSP < sp;
// extends with NIL if newSP > sp (defensive — should never grow here
// in practice). Bounds-checked against the underlying slice so a
// negative or out-of-range value can't corrupt the runtime.
func (t *Thread) SetSP(newSP int) {
if newSP < 0 {
newSP = 0
}
if newSP > len(t.stack) {
newSP = len(t.stack)
}
// Clear any slots being abandoned so stale values can't surface
// later through Dup/peek paths.
for i := newSP; i < t.sp; i++ {
t.stack[i] = cachedNil
}
t.sp = newSP
}
// --- 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
// pushedArgs is the actual count the caller put on the stack.
// localsCopy is how many of those map onto the callee's declared
// LOCAL slot indexes (clipped at the declared param count).
pushedArgs := t.pendingParams
if pushedArgs > t.sp {
pushedArgs = t.sp
}
localsCopy := pushedArgs
if localsCopy > params {
localsCopy = params
}
frame := &t.calls[t.callSP]
frame.base = t.sp - localsCopy // unchanged: only declared args bind to LOCAL slots
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
// Snapshot every pushed arg BEFORE we move sp — extras would be
// overwritten as soon as this frame's body started pushing for its
// own expression evaluation. Reuse the slice when capacity allows
// to keep the no-arg / few-arg path allocation-free.
if pushedArgs > 0 {
snapStart := t.sp - pushedArgs
if cap(frame.actualArgs) >= pushedArgs {
frame.actualArgs = frame.actualArgs[:pushedArgs]
} else {
frame.actualArgs = make([]Value, pushedArgs)
}
copy(frame.actualArgs, t.stack[snapStart:snapStart+pushedArgs])
} else if frame.actualArgs != nil {
frame.actualArgs = frame.actualArgs[:0]
}
// Copy actual parameters from stack to locals (declared-param portion).
for i := 0; i < localsCopy; i++ {
t.locals[localBase+i] = t.stack[frame.base+i]
}
// Initialize missing params and locals to NIL
for i := localsCopy; i < params+locals; i++ {
t.locals[localBase+i] = MakeNil()
}
// Pop args from stack (they're now in locals + actualArgs).
// Extras beyond declared params (variadic) live only in actualArgs;
// drop the stack slots so the caller can't accidentally re-read.
t.sp = t.sp - pushedArgs
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
Stack []DebugStackFrame // snapshot at panic site (pre-unwind)
}
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",
Stack: t.DebugCallStack(),
}
}
func (t *Thread) argError(op string, args ...Value) *HbError {
return &HbError{
Description: "argument error",
Operation: op,
Args: args,
SubSystem: "BASE",
GenCode: 1,
Stack: t.DebugCallStack(),
}
}
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
}
// CallerLocal returns the n-th parameter of the calling PRG function
// (1-based). Returns NIL if no caller frame exists or n is out of range.
// Pairs with CallerParamCount for implementing the PValue() RTL.
//
// Reads from the caller frame's actualArgs snapshot — works for both
// plain and variadic callers. The locals[] slots would only carry the
// first declared-param-count values, which fails for FUNCTION foo(...)
// where declared params == 0.
func (t *Thread) CallerLocal(n int) Value {
if t.callSP >= 2 {
caller := &t.calls[t.callSP-2]
if n >= 1 && n <= len(caller.actualArgs) {
return caller.actualArgs[n-1]
}
}
return MakeNil()
}
// 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) {
// Fast path for depth=1 nesting — store in scalar slot without
// touching the slice. A nil sym (unresolved symbol, caught later
// in Function() with a descriptive error) must not use the fast
// path because `pendingSymFast == nil` already means "empty";
// falling back to the slice preserves distinguishability.
if sym != nil && t.pendingSymFast == nil && len(t.pendingSyms) == 0 {
t.pendingSymFast = sym
return
}
if t.pendingSymFast != nil {
t.pendingSyms = append(t.pendingSyms, t.pendingSymFast)
t.pendingSymFast = nil
}
t.pendingSyms = append(t.pendingSyms, sym)
}
func (t *Thread) popPendingSym() *Symbol {
if n := len(t.pendingSyms); n > 0 {
sym := t.pendingSyms[n-1]
t.pendingSyms = t.pendingSyms[:n-1]
return sym
}
if sym := t.pendingSymFast; sym != nil {
t.pendingSymFast = nil
return sym
}
return nil
}
// 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) ---
//
// The waSel interfaces below use CurrentNum() (uint16 area index), NOT
// Current() (which returns the Area interface on WorkAreaManager). An
// earlier version required `Current() uint16` which silently failed the
// type assertion on the real hbrdd.WorkAreaManager implementation —
// `alias->(expr)` expressions appeared to "work" on the first area but
// collapsed to no-op as soon as a sibling area was opened, because the
// switch/save/restore block was skipped entirely. See repro in
// /tmp/repro_xarea.prg.
func (t *Thread) WASaveAndSelect(areaNum int) {
type waSel interface {
SelectByNum(uint16)
CurrentNum() uint16
}
if wam, ok := t.WA.(waSel); ok {
t.waStack = append(t.waStack, wam.CurrentNum())
wam.SelectByNum(uint16(areaNum))
}
}
func (t *Thread) WASaveAndSelectAlias(alias string) {
type waSel interface {
SelectByAlias(string) bool
SelectByNum(uint16)
CurrentNum() uint16
}
if wam, ok := t.WA.(waSel); ok {
t.waStack = append(t.waStack, wam.CurrentNum())
if !wam.SelectByAlias(alias) {
// Alias not open: switch to the no-area sentinel so the
// inner expression's Current() returns nil and ops like
// DbCloseArea/FieldGet/RecCount short-circuit. Without
// this, the inner expression silently runs against the
// originally-selected WA — which led to `CLOSE bad_alias`
// closing the *current* area.
wam.SelectByNum(0)
}
}
}
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)
}
}
}