// 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 [file2.prg ...] [-o output] [-I includedir ...] [--rtl pkg ...] // fnode run [-I includedir ...] [--rtl pkg ...] // fnode version // // --rtl 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 ... [-o out] [-I dir]... [--rtl pkg]... [--go-replace pkg=path]...") fmt.Fprintln(os.Stderr, " fnode run [-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 => ` 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 => ` 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 => // // 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) }