Compiling _FiveSql2/test/test_sql_extreme.prg + a sweep of the FRB
demos surfaced four real bugs in the dynamic-compilation pipeline.
All fixes shipped together because they were on the same critical
path; each is independently revertible.
* **pcode FOR loop ignored STEP and direction.** emitFor in
compiler/genpc emitted a fixed `<= to` comparison and a hardcoded
`+1` increment, then deleted the actual step expression with
slice arithmetic on the byte buffer. Result: `FOR 5 TO 1 STEP
-1` exited on the first iteration; `FOR 1 TO 10 STEP 2` summed
1..10 (55) instead of 1+3+5+7+9 (25). Rewritten to mirror
gengo's emitFor: detect negative step from a literal `-N` or
unary MINUS, pick `<=` vs `>=` accordingly, and emit a clean
`var := var + step` increment per iteration.
* **pcode compound `+=` operator stored only the RHS.** emitAssign
looked at AssignExpr.Op only for the := case; +=/-=/etc.
silently took the same path, so `n += i` compiled as `n := i`,
discarding the accumulator. Loop reduces were wrong: `Reverse`
returned "" and `n := 0; FOR i ... n += i; NEXT` returned only
the last increment. New compoundBinOp helper maps PLUSEQ /
MINUSEQ / STAREQ / SLASHEQ / PERCENTEQ / POWEREQ to their
matching binary opcode; emitAssign emits `local + rhs ; pop
local` for compound forms.
* **Pcode body stack leaks polluted the caller's frame.** A pcode
function whose body left intermediate values on the data stack
(FOR control values, etc.) returned with extra entries past
its declared retVal. FrbDoFunc / FrbExecFunc / FrbRunFunc then
pushed retVal on top of those leaks, so the caller saw the
leaked values where its own preceding arguments should have
been: `? "Fibonacci(10) =", FrbDo(...), "(expect 55)"` printed
`1 55 (expect 55)` because the FOR loop's `1` lived in arg-1's
slot. Two new Thread methods (`SP()` / `SetSP(int)`) let the
three FRB dispatchers snapshot stack depth before the inner
call and clamp it back afterward, so the leaks evaporate before
they reach the caller's frame.
* **FrbExec / FrbRun recursed into the host's Main forever.** Both
looked up "MAIN" via t.VM().FindSymbol, which always resolved
to the OUTER program's Main since FRB modules deliberately keep
Main local. Compile + run + unload became compile + recurse +
OOM. Both now look up Main via mod.FindFunc("MAIN") (module
scope) — Frbload's policy of leaving Main module-local now
actually has the intended effect.
Plus an architectural improvement: in-memory compilation no longer
depends on shelling out to an external `five` binary. New
hbrtl.frbCompileInProc parses + preprocesses + generates pcode in
process, building a FrbModule directly. FrbCompile and FrbExec use
this exclusively, which means dynamic compilation works from any
directory regardless of PATH and without a second process. The
plugin-mode path (with its runtime-version-mismatch fragility) is
left available via hbrt.FrbCompileSource for callers that want it,
but FrbCompile no longer reaches for it by default.
Test suite: tests/frb/ holds five fixtures + a runner. 5/5 pass:
test_frb_simple / test_frb_pcode_load / test_frb_compile /
test_frb_loop / test_frb_step.
Other gates green:
go test ./... : PASS
FiveSql2 SQL:1999 : 43/43
Harbour compat : 56/56
std.ch suite : 14/14
FRB suite : 5/5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
7.1 KiB
Go
253 lines
7.1 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
// FRB in-memory compilation — compile PRG source at runtime and execute.
|
|
// This is Five's equivalent of Harbour's hb_compileFromBuf() + hb_hrbRun().
|
|
//
|
|
// Usage from PRG:
|
|
// pMod := FrbCompile(cPrgSource) // compile PRG string → FRB in memory
|
|
// result := FrbDo(pMod, "MYFUNC", args) // call compiled function
|
|
// FrbUnload(pMod)
|
|
//
|
|
// // Or one-shot:
|
|
// result := FrbExec(cPrgSource) // compile + run Main() + unload
|
|
|
|
package hbrt
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"plugin"
|
|
)
|
|
|
|
// FrbCompileSource compiles PRG source code to an FRB module in memory.
|
|
// Strategy:
|
|
// 1. If Go isn't installed, go straight to pcode mode.
|
|
// 2. Try the native Go-plugin path first (faster, native speed).
|
|
// 3. If the plugin build fails, OR if loading the resulting plugin
|
|
// fails (the most common failure: "plugin was built with a
|
|
// different version of package runtime", which fires whenever
|
|
// the host binary and the plugin weren't compiled byte-for-byte
|
|
// against the same Go runtime — happens routinely after `go
|
|
// build` rebuilds), fall back to pcode mode. Pcode is a few x
|
|
// slower but always works.
|
|
func FrbCompileSource(vm *VM, prgSource string, fiveExe string) (*FrbModule, error) {
|
|
if !isGoAvailable() {
|
|
return frbCompilePcode(vm, prgSource, fiveExe)
|
|
}
|
|
|
|
mod, err := frbCompilePlugin(vm, prgSource, fiveExe)
|
|
if err == nil {
|
|
return mod, nil
|
|
}
|
|
// Plugin path failed — try pcode. Don't surface the plugin
|
|
// error: pcode either works (return its module) or doesn't
|
|
// (return its error).
|
|
return frbCompilePcode(vm, prgSource, fiveExe)
|
|
}
|
|
|
|
// frbCompilePlugin is the original native-plugin path, factored out
|
|
// of FrbCompileSource so the latter can pick a fallback on failure.
|
|
func frbCompilePlugin(vm *VM, prgSource string, fiveExe string) (*FrbModule, error) {
|
|
tmpDir, err := os.MkdirTemp("", "frb-mem-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Write PRG source to temp file with unique name
|
|
prgFile := filepath.Join(tmpDir, fmt.Sprintf("dynamic_%d.prg", frbSeq))
|
|
frbSeq++
|
|
if err := os.WriteFile(prgFile, []byte(prgSource), 0644); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, err
|
|
}
|
|
|
|
// Find five executable
|
|
if fiveExe == "" {
|
|
fiveExe, _ = os.Executable()
|
|
}
|
|
|
|
// Compile PRG → FRB using five frb command
|
|
frbFile := filepath.Join(tmpDir, "dynamic.frb")
|
|
cmd := exec.Command(fiveExe, "frb", prgFile, "-o", frbFile)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, fmt.Errorf("compile failed: %s\n%w", string(output), err)
|
|
}
|
|
|
|
// Load FRB
|
|
mod, err := FrbLoad(vm, frbFile)
|
|
if err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, err
|
|
}
|
|
// Override TempDir to clean up everything
|
|
mod.TempDir = tmpDir
|
|
|
|
return mod, nil
|
|
}
|
|
|
|
// FrbCompileDirect compiles PRG source directly to a Go plugin without
|
|
// going through the five CLI. Uses the compiler packages directly.
|
|
// This is faster than FrbCompileSource for hot compilation.
|
|
func FrbCompileDirect(vm *VM, prgSource string) (*FrbModule, error) {
|
|
tmpDir, err := os.MkdirTemp("", "frb-direct-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// We need the Five project root for go.mod replace directive
|
|
fiveRoot := findFiveRoot()
|
|
if fiveRoot == "" {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, fmt.Errorf("cannot find Five project root (go.mod)")
|
|
}
|
|
|
|
// Write Go source — import compiler packages inline
|
|
// This uses exec to run a helper that does the compilation
|
|
helperSrc := fmt.Sprintf(`package main
|
|
import (
|
|
"five/compiler/gengo"
|
|
"five/compiler/parser"
|
|
"five/compiler/pp"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
func main() {
|
|
source := %q
|
|
pre := pp.New()
|
|
processed, _ := pre.Process("dynamic.prg", source)
|
|
file, errs := parser.Parse("dynamic.prg", processed)
|
|
if len(errs) > 0 {
|
|
for _, e := range errs { fmt.Fprintln(os.Stderr, e) }
|
|
os.Exit(1)
|
|
}
|
|
goSrc := gengo.GenerateLibrary(file)
|
|
fmt.Print(goSrc)
|
|
}
|
|
`, prgSource)
|
|
|
|
helperFile := filepath.Join(tmpDir, "helper.go")
|
|
os.WriteFile(helperFile, []byte(helperSrc), 0644)
|
|
|
|
// Write go.mod for helper
|
|
goMod := fmt.Sprintf("module frbhelper\n\ngo 1.21\n\nrequire five v0.0.0\nreplace five => %s\n", fiveRoot)
|
|
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0644)
|
|
|
|
// Run go mod tidy + generate
|
|
tidyCmd := exec.Command("go", "mod", "tidy")
|
|
tidyCmd.Dir = tmpDir
|
|
tidyCmd.CombinedOutput()
|
|
|
|
genCmd := exec.Command("go", "run", "helper.go")
|
|
genCmd.Dir = tmpDir
|
|
goSrcBytes, err := genCmd.Output()
|
|
if err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, fmt.Errorf("codegen failed: %w", err)
|
|
}
|
|
|
|
// Write generated module.go
|
|
os.WriteFile(filepath.Join(tmpDir, "module.go"), goSrcBytes, 0644)
|
|
os.Remove(helperFile) // remove helper, keep module.go
|
|
|
|
// Build plugin
|
|
soFile := filepath.Join(tmpDir, "module.so")
|
|
buildCmd := exec.Command("go", "build", "-buildmode=plugin", "-o", soFile, "module.go")
|
|
buildCmd.Dir = tmpDir
|
|
if output, err := buildCmd.CombinedOutput(); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, fmt.Errorf("plugin build failed: %s\n%w", string(output), err)
|
|
}
|
|
|
|
// Load plugin
|
|
p, err := plugin.Open(soFile)
|
|
if err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, fmt.Errorf("plugin load failed: %w", err)
|
|
}
|
|
|
|
vm.RegisterLibModules()
|
|
|
|
return &FrbModule{
|
|
Name: "<dynamic>",
|
|
Plugin: p,
|
|
TempDir: tmpDir,
|
|
}, nil
|
|
}
|
|
|
|
// findFiveRoot locates the Five project root by searching for go.mod
|
|
func findFiveRoot() string {
|
|
// Try executable location first
|
|
if exe, err := os.Executable(); err == nil {
|
|
dir := filepath.Dir(exe)
|
|
for d := dir; ; d = filepath.Dir(d) {
|
|
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
|
|
return d
|
|
}
|
|
if d == filepath.Dir(d) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Try current directory
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
for d := cwd; ; d = filepath.Dir(d) {
|
|
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
|
|
return d
|
|
}
|
|
if d == filepath.Dir(d) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var frbSeq int // sequence number for unique module names
|
|
|
|
// isGoAvailable checks if the Go compiler is installed.
|
|
func isGoAvailable() bool {
|
|
for _, p := range []string{"go", "/usr/local/go/bin/go", "/usr/bin/go"} {
|
|
if _, err := exec.LookPath(p); err == nil {
|
|
return true
|
|
}
|
|
if _, err := os.Stat(p); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// frbCompilePcode compiles PRG source to pcode FRB (no Go needed).
|
|
func frbCompilePcode(vm *VM, prgSource string, fiveExe string) (*FrbModule, error) {
|
|
tmpDir, err := os.MkdirTemp("", "frb-pcode-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prgFile := filepath.Join(tmpDir, fmt.Sprintf("dynamic_%d.prg", frbSeq))
|
|
frbSeq++
|
|
os.WriteFile(prgFile, []byte(prgSource), 0644)
|
|
|
|
frbFile := filepath.Join(tmpDir, "dynamic.frb")
|
|
cmd := exec.Command(fiveExe, "frb", prgFile, "-o", frbFile, "--pcode")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, fmt.Errorf("pcode compile failed: %s\n%w", string(output), err)
|
|
}
|
|
|
|
mod, err := FrbLoad(vm, frbFile)
|
|
if err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
return nil, err
|
|
}
|
|
mod.TempDir = tmpDir
|
|
return mod, nil
|
|
}
|
|
|
|
var _ = binary.LittleEndian // keep import
|