From e089c81bcd685284a9ce9970f5e388e052302a7c Mon Sep 17 00:00:00 2001 From: CharlesKWON Date: Sat, 18 Apr 2026 16:02:16 +0900 Subject: [PATCH] feat(macro): &var / &(expr) runtime compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harbour's macro operator was a stub: hbrt.MacroCompile only resolved bare identifier names to memvars/functions and returned the source string unchanged for any non-trivial expression. The gengo emit was also broken — `t.MacroPush() + t.PushNil()` never pushed the inner expression's value, so MacroPush popped whatever happened to be on the stack. Wire it up properly: 1. Gengo fix: `case *ast.MacroExpr` now emits `emitExpr(e.Expr); t.MacroPush()`. The inner expression produces the source string; MacroPush consumes it and pushes the evaluated result. 2. Hook pattern in hbrt: `SetMacroEvalHook(fn)` lets hbrtl install the real evaluator without creating an import cycle (genpc already imports hbrt). MacroPush delegates to the hook when installed; otherwise falls back to the legacy stub for hbrt unit tests. 3. hbrtl.init registers macroEval, which reuses compileExprSource (factored out of PcCompile) so macro lookups share the same sync.Map-backed pcode cache — repeat evaluations of the same macro source are free after the first hit. 4. ExecPcode leaves the result in retVal; macroEval copies it to the operand stack via PushRetValue. Tested (/tmp/test_macro.prg): &"10 + 20" → 30 &"Sqrt(16)" → 4 &"Upper('hello')" → HELLO &("30 * " + Str(nX, 1)) → 210 (runtime-built source) &"5 > 3 .AND. .T." → .T. &("Str(" + Str(nX*10,2) + ",2)") → 70 FiveSql2 43/43, Harbour compat 56/56, Go test ALL PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- compiler/gengo/gengo.go | 7 ++-- hbrt/macro.go | 27 ++++++++++++++++ hbrtl/pcexpr.go | 72 +++++++++++++++++++++++++---------------- 3 files changed, 77 insertions(+), 29 deletions(-) diff --git a/compiler/gengo/gengo.go b/compiler/gengo/gengo.go index 4b7ddf6..4d43076 100644 --- a/compiler/gengo/gengo.go +++ b/compiler/gengo/gengo.go @@ -2420,8 +2420,11 @@ func (g *Generator) emitExpr(expr ast.Expr) { // Already converted to fmt.Sprintf CallExpr by parser g.emitExpr(e.Parts[0]) // shouldn't reach here normally case *ast.MacroExpr: - g.writeln("t.MacroPush() // runtime macro compilation") - g.writeln("t.PushNil()") + // &ident / &(expr) — evaluate the inner expression to get the + // source string, then MacroPush compiles and runs it via the + // hbrtl-installed evaluator hook. + g.emitExpr(e.Expr) + g.writeln("t.MacroPush()") case *ast.AliasExpr: g.emitAliasExpr(e) case *ast.RefExpr: diff --git a/hbrt/macro.go b/hbrt/macro.go index a8b9165..747339c 100644 --- a/hbrt/macro.go +++ b/hbrt/macro.go @@ -85,9 +85,36 @@ func (t *Thread) MacroCompile(expr string) Value { return MakeString(expr) } +// macroEvalHook is installed by hbrtl at init time. It handles the +// real work: parse the source via compiler/parser, compile to pcode +// via compiler/genpc, execute with ExecPcode. We can't call those +// directly from hbrt because genpc imports hbrt — the hook pattern +// keeps hbrt's core independent of the compiler packages. +// +// Stack contract matches MacroPush: pops the source string value, +// pushes the evaluated result. +var macroEvalHook func(*Thread) + +// SetMacroEvalHook wires in the full macro evaluator. Called by +// hbrtl.init(). Without the hook installed, MacroPush falls back to +// the legacy stub that only resolves bare identifier names. +func SetMacroEvalHook(fn func(*Thread)) { + macroEvalHook = fn +} + // MacroPush compiles a macro and pushes the result on stack. // Harbour: HB_P_MACROPUSH +// +// Stack: [sourceString] → [result]. The caller emits the expression +// that yields the source string first — gengo produces +// `emitExpr(e.Expr); t.MacroPush()` for &. func (t *Thread) MacroPush() { + if macroEvalHook != nil { + macroEvalHook(t) + return + } + // Fallback: legacy simple-ident lookup. Kept so hbrt tests (which + // don't init hbrtl) still function for trivial cases. exprVal := t.pop() result := t.MacroCompile(exprVal.AsString()) t.push(result) diff --git a/hbrtl/pcexpr.go b/hbrtl/pcexpr.go index 9e94eb5..a1096ad 100644 --- a/hbrtl/pcexpr.go +++ b/hbrtl/pcexpr.go @@ -55,70 +55,88 @@ func PcCompile(t *hbrt.Thread) { defer t.EndProc() source := t.Local(1).AsString() - if source == "" { + fn := compileExprSource(source) + if fn == nil { t.RetNil() return } + t.RetPointer(fn) +} - // Cache hit — skip parser/genpc entirely. +// compileExprSource compiles a PRG expression string to a PcodeFunc, +// memoising the result in pcCompileCache. Returns nil on parse or +// empty-source errors. Shared by PcCompile (the RTL function) and +// the macro evaluator (hbrt.MacroPush hook) so both paths benefit +// from the same cache. +func compileExprSource(source string) *hbrt.PcodeFunc { + if source == "" { + return nil + } if cached, ok := pcCompileCache.Load(source); ok { if fn, ok := cached.(*hbrt.PcodeFunc); ok && fn != nil { - t.RetPointer(fn) - return + return fn } } - // Wrap expression in a function stub so the parser can handle it. wrapped := "FUNCTION _EXPR()\nRETURN " + source + "\n" - // Preprocess pre := pp.New() processed, _ := pre.Process("_expr.prg", wrapped) - // Parse file, errs := parser.ParseWithGoDumps("_expr.prg", processed, pre.GoDumps) if len(errs) > 0 { for _, e := range errs { _, _ = os.Stderr.WriteString("PcCompile: " + e.Error() + "\n") } - t.RetNil() - return + return nil } - - // Extract the RETURN expression from the first function if len(file.Decls) == 0 { - t.RetNil() - return + return nil } - // Compile the whole wrapped function to a PcodeModule, then extract - // the _EXPR function. This reuses all of genpc's mature emit logic. mod := genpc.Generate(file) if mod == nil { - t.RetNil() - return + return nil } fn, ok := mod.Funcs["_EXPR"] if !ok { - // Try uppercase / case variations - for name, f := range mod.Funcs { - _ = name + for _, f := range mod.Funcs { fn = f ok = true break } } if !ok || fn == nil { - t.RetNil() - return + return nil } - // Populate the cache. sync.Map.Store handles concurrent writers — - // duplicate compilations of the same source waste a few µs but - // don't corrupt the map; whichever compilation finishes second - // overwrites with an identical value. pcCompileCache.Store(source, fn) - t.RetPointer(fn) + return fn +} + +// init registers the full macro evaluator with hbrt. Without this +// hook, hbrt.MacroPush falls back to a stub that only resolves +// bare identifiers. +func init() { + hbrt.SetMacroEvalHook(macroEval) +} + +// macroEval implements Harbour `&var` / `&(expr)`. Stack: [source] → +// [result]. Compiles the source string to pcode (with the same cache +// PcCompile uses), runs it, pushes the return value. Errors fall +// through as NIL so malformed macros don't crash the VM. +func macroEval(t *hbrt.Thread) { + srcVal := t.Pop2() + source := srcVal.AsString() + fn := compileExprSource(source) + if fn == nil { + t.PushNil() + return + } + hbrt.ExecPcode(t, fn, nil) + // ExecPcode's RetValue opcode stores the result in the retVal slot; + // PushRetValue copies it onto the operand stack for our caller. + t.PushRetValue() } // PcEval(pFunc) → xValue