Two CLI knobs that together let PRG #pragma BEGINDUMP blocks import
private Go modules and their internal/* packages:
--go-replace pkg=path
Adds `require pkg v0.0.0` + `replace pkg => path` to the temp
go.mod, so `go mod tidy` doesn't try (and fail) to fetch a
module that lives only on local disk or behind a private
forge (Gitea, etc.).
--module <name>
Overrides the temp-build module name (default fnode_build).
Use when the BEGINDUMP block needs to reach into another
module's internal/ packages — Go's internal-visibility rule
requires the importer to share a parent path with the
importee, so the build module needs to live somewhere under
that parent.
Worked example (solmade integration PoC):
fnode build /tmp/poc_dartapi.prg \\
--go-replace gitea.fivego.org/kwon_ai/solmade=/Users/charleskwon/solmade \\
--module gitea.fivego.org/kwon_ai/solmade/_fnode_build \\
-o poc_dart
A 4-line BEGINDUMP block now imports
"gitea.fivego.org/kwon_ai/solmade/internal/dartapi", links its
AllAliases() function, and returns the real count (3) at run time.
Single 23 MB Go binary, no Node, no FFI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
525 lines
14 KiB
Go
525 lines
14 KiB
Go
// fnode — fivenode_go build/run CLI.
|
|
//
|
|
// Reuses Five's compiler packages to turn PRG sources into Go, then
|
|
// builds them together with fivenode_go's own hbrtl_ext/* packages into
|
|
// a single self-contained binary. No FFI, no Apache, no Node.js.
|
|
//
|
|
// Usage:
|
|
//
|
|
// fnode build <file.prg> [file2.prg ...] [-o output] [-I includedir ...] [--rtl pkg ...]
|
|
// fnode run <file.prg> [-I includedir ...] [--rtl pkg ...]
|
|
// fnode version
|
|
//
|
|
// --rtl <pkg> blank-imports that fivenode_go package so its init()
|
|
// registers RTL functions. Default is the full hbrtl_ext set (currently
|
|
// just "hello"); pass --rtl explicitly to override.
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"five/compiler/analyzer"
|
|
"five/compiler/ast"
|
|
"five/compiler/gengo"
|
|
"five/compiler/parser"
|
|
"five/compiler/pp"
|
|
)
|
|
|
|
const version = "fnode 0.1.0 — fivenode_go builder for Five"
|
|
|
|
// defaultRTL is the blank-import list used when --rtl is not specified.
|
|
// Order is irrelevant — each package's init() is independent.
|
|
var defaultRTL = []string{
|
|
"fivenode_go/hbrtl_ext/hello",
|
|
"fivenode_go/hbrtl_ext/httpserver",
|
|
"fivenode_go/hbrtl_ext/bridge_capi",
|
|
"fivenode_go/hbrtl_ext/pgrtl",
|
|
"fivenode_go/hbrtl_ext/dispatch",
|
|
"fivenode_go/hbrtl_ext/labdb_state",
|
|
"fivenode_go/hbrtl_ext/labdb_static",
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
switch os.Args[1] {
|
|
case "build":
|
|
cmdBuild(os.Args[2:])
|
|
case "run":
|
|
cmdRun(os.Args[2:])
|
|
case "version", "-v", "--version":
|
|
fmt.Println(version)
|
|
case "help", "-h", "--help":
|
|
printUsage()
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "fnode: unknown command %q\n", os.Args[1])
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Fprintln(os.Stderr, version)
|
|
fmt.Fprintln(os.Stderr, "")
|
|
fmt.Fprintln(os.Stderr, "Usage:")
|
|
fmt.Fprintln(os.Stderr, " fnode build <file.prg>... [-o out] [-I dir]... [--rtl pkg]... [--go-replace pkg=path]...")
|
|
fmt.Fprintln(os.Stderr, " fnode run <file.prg> [-I dir]... [--rtl pkg]... [--go-replace pkg=path]...")
|
|
fmt.Fprintln(os.Stderr, " fnode version")
|
|
}
|
|
|
|
type buildOpts struct {
|
|
prgFiles []string
|
|
output string
|
|
includes []string
|
|
rtlPkgs []string
|
|
// goReplace maps a Go module path to a local filesystem path that
|
|
// gets emitted as `replace <path> => <local>` in the temp go.mod.
|
|
// Lets PRG #pragma BEGINDUMP blocks import private modules that
|
|
// `go mod tidy` would otherwise fail to fetch (e.g. a sibling
|
|
// project living outside any module proxy).
|
|
goReplace map[string]string
|
|
// goModule overrides the default temp-build module name
|
|
// ("fnode_build"). Set this to a path inside an existing module
|
|
// when the BEGINDUMP block needs to import that module's
|
|
// internal/* packages — Go's internal-visibility rule only lets
|
|
// callers whose own module path lives under the same parent see
|
|
// internal/.
|
|
// --module gitea.fivego.org/kwon_ai/solmade/_fnode_build
|
|
goModule string
|
|
}
|
|
|
|
func parseCommon(args []string) buildOpts {
|
|
o := buildOpts{goReplace: map[string]string{}}
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
switch {
|
|
case a == "-o" && i+1 < len(args):
|
|
o.output = args[i+1]
|
|
i++
|
|
case a == "-I" && i+1 < len(args):
|
|
o.includes = append(o.includes, args[i+1])
|
|
i++
|
|
case strings.HasPrefix(a, "-I"):
|
|
o.includes = append(o.includes, a[2:])
|
|
case a == "--rtl" && i+1 < len(args):
|
|
o.rtlPkgs = append(o.rtlPkgs, args[i+1])
|
|
i++
|
|
case a == "--go-replace" && i+1 < len(args):
|
|
// --go-replace gitea.fivego.org/kwon_ai/solmade=/abs/path
|
|
spec := args[i+1]
|
|
i++
|
|
eq := strings.IndexByte(spec, '=')
|
|
if eq <= 0 || eq == len(spec)-1 {
|
|
fatal("--go-replace expects pkg=path, got: " + spec)
|
|
}
|
|
o.goReplace[spec[:eq]] = spec[eq+1:]
|
|
case a == "--module" && i+1 < len(args):
|
|
o.goModule = args[i+1]
|
|
i++
|
|
default:
|
|
o.prgFiles = append(o.prgFiles, a)
|
|
}
|
|
}
|
|
if len(o.rtlPkgs) == 0 {
|
|
o.rtlPkgs = append([]string{}, defaultRTL...)
|
|
}
|
|
return o
|
|
}
|
|
|
|
func cmdBuild(args []string) {
|
|
o := parseCommon(args)
|
|
if len(o.prgFiles) == 0 {
|
|
fatal("build: no PRG files given")
|
|
}
|
|
if o.output == "" {
|
|
base := strings.TrimSuffix(filepath.Base(o.prgFiles[0]), filepath.Ext(o.prgFiles[0]))
|
|
o.output = base
|
|
}
|
|
absOut, err := filepath.Abs(o.output)
|
|
if err != nil {
|
|
fatal("cannot resolve output path: " + err.Error())
|
|
}
|
|
tmpDir := mkBuildDir()
|
|
defer cleanupBuildDir(tmpDir)
|
|
|
|
emitGeneratedSources(tmpDir, o)
|
|
writeRootGo(tmpDir, o.rtlPkgs)
|
|
writeGoMod(tmpDir, o.goReplace, o.goModule)
|
|
|
|
runGo(tmpDir, "mod", "tidy")
|
|
runGo(tmpDir, "build", "-o", absOut, ".")
|
|
fmt.Fprintf(os.Stderr, "Built: %s\n", absOut)
|
|
}
|
|
|
|
func cmdRun(args []string) {
|
|
o := parseCommon(args)
|
|
if len(o.prgFiles) == 0 {
|
|
fatal("run: no PRG file given")
|
|
}
|
|
tmpDir := mkBuildDir()
|
|
defer cleanupBuildDir(tmpDir)
|
|
|
|
emitGeneratedSources(tmpDir, o)
|
|
writeRootGo(tmpDir, o.rtlPkgs)
|
|
writeGoMod(tmpDir, o.goReplace, o.goModule)
|
|
|
|
runGo(tmpDir, "mod", "tidy")
|
|
runGoInteractive(tmpDir, "run", ".")
|
|
}
|
|
|
|
// emitGeneratedSources preprocesses, parses, analyzes, and emits Go for
|
|
// every PRG file. The first file is treated as the entry module
|
|
// (gengo.Generate emits a main()), the rest as library modules.
|
|
func emitGeneratedSources(tmpDir string, o buildOpts) {
|
|
// Parse all files first so cross-file function references resolve.
|
|
type parsed struct {
|
|
file *ast.File
|
|
prgFile string
|
|
}
|
|
var ps []parsed
|
|
crossFile := map[string]bool{}
|
|
for _, f := range o.prgFiles {
|
|
file := parseOne(f, o.includes)
|
|
ps = append(ps, parsed{file: file, prgFile: f})
|
|
for _, d := range file.Decls {
|
|
switch decl := d.(type) {
|
|
case *ast.FuncDecl:
|
|
crossFile[strings.ToUpper(decl.Name)] = true
|
|
case *ast.ClassDecl:
|
|
crossFile[strings.ToUpper(decl.Name)] = true
|
|
}
|
|
}
|
|
}
|
|
// Auto-rename the Main() of each library file (i > 0) to a
|
|
// unique name derived from its file basename so multiple .prg
|
|
// files that all define `FUNCTION Main()` can be linked into
|
|
// one binary. The dispatcher (FNODE_CALL or HTTP path router)
|
|
// looks up the renamed symbol by the same convention:
|
|
// app/api/admin-stats.prg → ADMIN_STATS__MAIN
|
|
for i := 1; i < len(ps); i++ {
|
|
newName := mainNameFor(ps[i].prgFile)
|
|
renameMain(ps[i].file, newName)
|
|
crossFile[newName] = true
|
|
delete(crossFile, "MAIN") // only the entry file owns MAIN
|
|
}
|
|
// Re-add MAIN if the entry file declares it (it almost always
|
|
// does); without this the analyzer would warn about the entry
|
|
// file's Main looking undeclared.
|
|
for _, d := range ps[0].file.Decls {
|
|
if fd, ok := d.(*ast.FuncDecl); ok && strings.EqualFold(fd.Name, "Main") {
|
|
crossFile["MAIN"] = true
|
|
break
|
|
}
|
|
}
|
|
|
|
for i, p := range ps {
|
|
diags := analyzer.Analyze(p.file, crossFile)
|
|
for _, d := range diags {
|
|
if d.Severity <= analyzer.SevWarning {
|
|
fmt.Fprintf(os.Stderr, "%s\n", d)
|
|
}
|
|
}
|
|
var code string
|
|
if i == 0 {
|
|
code = gengo.Generate(p.file)
|
|
} else {
|
|
code = gengo.GenerateLibrary(p.file)
|
|
}
|
|
path := filepath.Join(tmpDir, fmt.Sprintf("prg_%d.go", i))
|
|
writeFile(path, code)
|
|
}
|
|
}
|
|
|
|
// renameMain finds the file's top-level FUNCTION Main (case-insensitive)
|
|
// and rewrites its Name to newName. Other functions / classes are left
|
|
// alone so cross-file calls keep working. Returns whether a Main was
|
|
// found and renamed.
|
|
func renameMain(f *ast.File, newName string) bool {
|
|
for _, d := range f.Decls {
|
|
if fd, ok := d.(*ast.FuncDecl); ok && strings.EqualFold(fd.Name, "Main") {
|
|
fd.Name = newName
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mainNameFor derives the unique Main-symbol name from a .prg path.
|
|
// `app/api/admin-stats.prg` → `ADMIN_STATS__MAIN`. Hyphens become
|
|
// underscores so Harbour's identifier rules accept the result; the
|
|
// "__MAIN" suffix lets the dispatcher recognise these as entry points
|
|
// rather than helper functions.
|
|
func mainNameFor(prgPath string) string {
|
|
base := strings.TrimSuffix(filepath.Base(prgPath), filepath.Ext(prgPath))
|
|
base = strings.ReplaceAll(base, "-", "_")
|
|
return strings.ToUpper(base) + "__MAIN"
|
|
}
|
|
|
|
func parseOne(prgFile string, includes []string) *ast.File {
|
|
src, err := os.ReadFile(prgFile)
|
|
if err != nil {
|
|
fatal("cannot read " + prgFile + ": " + err.Error())
|
|
}
|
|
pre := pp.New()
|
|
pre.AddIncludeDir(filepath.Dir(prgFile))
|
|
pre.AddIncludeDir(filepath.Join(filepath.Dir(prgFile), "include"))
|
|
pre.AddIncludeDir(filepath.Join(fiveRoot(), "include"))
|
|
for _, d := range includes {
|
|
pre.AddIncludeDir(d)
|
|
}
|
|
if hbInc := os.Getenv("HB_INC"); hbInc != "" {
|
|
pre.AddIncludeDir(hbInc)
|
|
}
|
|
for _, p := range []string{"/usr/local/include/harbour", "/usr/include/harbour"} {
|
|
if _, err := os.Stat(p); err == nil {
|
|
pre.AddIncludeDir(p)
|
|
}
|
|
}
|
|
processed, ppErrs := pre.Process(prgFile, string(src))
|
|
for _, e := range ppErrs {
|
|
fmt.Fprintf(os.Stderr, "pp: %s\n", e)
|
|
}
|
|
if len(ppErrs) > 0 {
|
|
fatal(fmt.Sprintf("%d preprocessor error(s) in %s", len(ppErrs), prgFile))
|
|
}
|
|
file, parseErrs := parser.ParseWithGoDumps(prgFile, processed, pre.GoDumps)
|
|
if len(parseErrs) > 0 {
|
|
for _, e := range parseErrs {
|
|
fmt.Fprintf(os.Stderr, "%s\n", e)
|
|
}
|
|
fatal(fmt.Sprintf("%d parse error(s) in %s", len(parseErrs), prgFile))
|
|
}
|
|
return file
|
|
}
|
|
|
|
// writeRootGo writes the file that anchors the build's blank imports of
|
|
// hbrtl_ext packages. Each import's init() registers its RTL functions
|
|
// before the generated PRG main runs.
|
|
func writeRootGo(tmpDir string, rtlPkgs []string) {
|
|
var sb strings.Builder
|
|
sb.WriteString("package main\n\n")
|
|
if len(rtlPkgs) > 0 {
|
|
sb.WriteString("import (\n")
|
|
for _, p := range rtlPkgs {
|
|
fmt.Fprintf(&sb, "\t_ %q\n", p)
|
|
}
|
|
sb.WriteString(")\n")
|
|
}
|
|
writeFile(filepath.Join(tmpDir, "fnode_rtl.go"), sb.String())
|
|
}
|
|
|
|
func writeGoMod(tmpDir string, extraReplace map[string]string, modName string) {
|
|
five := mustAbs(fiveRoot())
|
|
fnode := mustAbs(fnodeRoot())
|
|
if modName == "" {
|
|
modName = "fnode_build"
|
|
}
|
|
var sb strings.Builder
|
|
fmt.Fprintf(&sb, `module %s
|
|
|
|
go 1.21
|
|
|
|
require (
|
|
five v0.0.0
|
|
fivenode_go v0.0.0
|
|
)
|
|
|
|
replace five => %s
|
|
|
|
replace fivenode_go => %s
|
|
`, modName, five, fnode)
|
|
// Sorted iteration for stable go.mod output.
|
|
keys := make([]string, 0, len(extraReplace))
|
|
for k := range extraReplace {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, pkg := range keys {
|
|
path := extraReplace[pkg]
|
|
if !filepath.IsAbs(path) {
|
|
path = mustAbs(path)
|
|
}
|
|
fmt.Fprintf(&sb, "\nrequire %s v0.0.0\n", pkg)
|
|
fmt.Fprintf(&sb, "replace %s => %s\n", pkg, path)
|
|
}
|
|
writeFile(filepath.Join(tmpDir, "go.mod"), sb.String())
|
|
}
|
|
|
|
func mustAbs(p string) string {
|
|
a, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return p
|
|
}
|
|
return a
|
|
}
|
|
|
|
func mkBuildDir() string {
|
|
dir, err := os.MkdirTemp("", "fnode-build-*")
|
|
if err != nil {
|
|
fatal("mkdir temp: " + err.Error())
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func cleanupBuildDir(dir string) {
|
|
if os.Getenv("FNODE_KEEP_BUILD") != "" {
|
|
fmt.Fprintln(os.Stderr, "[FNODE_KEEP_BUILD] keeping:", dir)
|
|
return
|
|
}
|
|
os.RemoveAll(dir)
|
|
}
|
|
|
|
func runGo(dir string, args ...string) {
|
|
cmd := exec.Command(goBin(), args...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fatal("go " + strings.Join(args, " ") + " failed: " + err.Error())
|
|
}
|
|
}
|
|
|
|
func runGoInteractive(dir string, args ...string) {
|
|
cmd := exec.Command(goBin(), args...)
|
|
cmd.Dir = dir
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
os.Exit(ee.ExitCode())
|
|
}
|
|
fatal("go " + strings.Join(args, " ") + " failed: " + err.Error())
|
|
}
|
|
}
|
|
|
|
func goBin() string {
|
|
if p, err := exec.LookPath("go"); err == nil {
|
|
return p
|
|
}
|
|
for _, p := range []string{"/usr/local/go/bin/go", "/usr/bin/go", "/opt/homebrew/bin/go"} {
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return "go"
|
|
}
|
|
|
|
// fiveRoot resolves to Five's source tree.
|
|
//
|
|
// Order of resolution:
|
|
// 1. Walk up looking for a go.mod with `module five` (works when
|
|
// fnode was launched from inside the Five source tree).
|
|
// 2. Read fnodeRoot's go.mod for a `replace five => <path>` line —
|
|
// this is the common case when fnode is invoked from somewhere
|
|
// else (e.g. /Users/.../solmade) with the binary living in
|
|
// fivenode_go's tree; the replace pin in fivenode_go/go.mod is
|
|
// the source of truth for where Five lives.
|
|
// 3. Hard-coded relative fallback, resolved against the current
|
|
// working directory. Only useful when fnode is run from inside
|
|
// fivenode_go itself.
|
|
func fiveRoot() string {
|
|
if d := walkUpForModule("five"); d != "" {
|
|
return d
|
|
}
|
|
if p := fiveFromFnodeGoMod(); p != "" {
|
|
return p
|
|
}
|
|
return "../../fivedev/five"
|
|
}
|
|
|
|
// fiveFromFnodeGoMod parses fivenode_go's own go.mod for a
|
|
//
|
|
// replace five => <path>
|
|
//
|
|
// directive and returns the path resolved against fnodeRoot when it's
|
|
// relative. Empty when not found.
|
|
func fiveFromFnodeGoMod() string {
|
|
fr := fnodeRoot()
|
|
if fr == "" {
|
|
return ""
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(fr, "go.mod"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, raw := range strings.Split(string(data), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if !strings.HasPrefix(line, "replace") {
|
|
continue
|
|
}
|
|
// Match either `replace five => path` or `replace five v0.0.0 => path`.
|
|
if !strings.Contains(line, " five ") && !strings.Contains(line, " five\t") &&
|
|
!strings.HasSuffix(strings.TrimSpace(strings.SplitN(line, "=>", 2)[0]), " five") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=>", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
p := strings.TrimSpace(parts[1])
|
|
if filepath.IsAbs(p) {
|
|
return p
|
|
}
|
|
if abs, err := filepath.Abs(filepath.Join(fr, p)); err == nil {
|
|
return abs
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fnodeRoot resolves to fivenode_go's source tree.
|
|
func fnodeRoot() string {
|
|
if d := walkUpForModule("fivenode_go"); d != "" {
|
|
return d
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
return cwd
|
|
}
|
|
|
|
func walkUpForModule(name string) string {
|
|
starts := []string{}
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
starts = append(starts, cwd)
|
|
}
|
|
if exe, err := os.Executable(); err == nil {
|
|
starts = append(starts, filepath.Dir(exe))
|
|
}
|
|
for _, start := range starts {
|
|
dir := start
|
|
for {
|
|
gm := filepath.Join(dir, "go.mod")
|
|
if data, err := os.ReadFile(gm); err == nil {
|
|
first := strings.SplitN(string(data), "\n", 2)[0]
|
|
if strings.TrimSpace(first) == "module "+name {
|
|
return dir
|
|
}
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func writeFile(path, content string) {
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
fatal("write " + path + ": " + err.Error())
|
|
}
|
|
}
|
|
|
|
func fatal(msg string) {
|
|
fmt.Fprintln(os.Stderr, "fnode:", msg)
|
|
os.Exit(1)
|
|
}
|