Files
fivenode_go/cmd/fnode/main.go
Charles KWON OhJun b213f594aa feat(dispatch): file-name routing via auto-renamed Main symbols (AOT)
Lets app/api/foo.prg keep its idiomatic `FUNCTION Main()` shape while
multiple such files compile into one binary. fnode auto-renames each
library file's Main into a unique symbol derived from the basename:

  app/api/hello.prg       -> HELLO__MAIN
  app/api/admin-stats.prg -> ADMIN_STATS__MAIN  (hyphen -> underscore)

Three moving parts:

  cmd/fnode/main.go
    parseOne for every PRG, then rename Main on every file except
    the first (the entry). crossFile map updated so the analyzer
    treats the renamed symbol as declared.

  hbrtl_ext/dispatch/dispatch.go
    New HB_FUNC FNODE_CALL(cFuncName) that does VM.FindSymbol +
    PushSymbol/Function dance and discards the return value. Same
    pattern pgserver's callPRG helper uses internally.

  app/bridge_server.prg
    BridgeDispatch now derives the symbol name from hReq["path"]
    ( /api/foo[.prg] -> FOO__MAIN ), invokes FNODE_CALL, and
    maps "not found" errors to HTTP 404 (other errors -> 500).
    Hardcoded /api/hello and /api/echo handlers replaced by the
    path-driven model.

Verified end-to-end with app/api/hello.prg and app/api/admin-stats.prg:
  GET /api/hello.prg                  -> 200 + JSON from HELLO__MAIN
  GET /api/hello                      -> 200 (extension optional)
  GET /api/admin-stats.prg?from=2026  -> 200 from ADMIN_STATS__MAIN
                                          with query string echoed
  GET /api/nope                       -> 404 "function not found"

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

424 lines
11 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"
"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",
}
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]...")
fmt.Fprintln(os.Stderr, " fnode run <file.prg> [-I dir]... [--rtl pkg]...")
fmt.Fprintln(os.Stderr, " fnode version")
}
type buildOpts struct {
prgFiles []string
output string
includes []string
rtlPkgs []string
}
func parseCommon(args []string) buildOpts {
var o buildOpts
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++
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)
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)
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) {
five := mustAbs(fiveRoot())
fnode := mustAbs(fnodeRoot())
mod := fmt.Sprintf(`module fnode_build
go 1.21
require (
five v0.0.0
fivenode_go v0.0.0
)
replace five => %s
replace fivenode_go => %s
`, five, fnode)
writeFile(filepath.Join(tmpDir, "go.mod"), mod)
}
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 (walk up from this binary or
// from cwd until a directory with module name "five" is found).
func fiveRoot() string {
if d := walkUpForModule("five"); d != "" {
return d
}
return "../../fivedev/five"
}
// 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)
}