From f3e0ffe10cfd07a1c46319125ade532fc0c24be0 Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 17:27:06 +0900 Subject: [PATCH] feat(static): file-local scoping for STATIC FUNCTION / PROCEDURE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harbour STATIC FUNCTION / PROCEDURE is scoped to its source file — multiple .prg files can each declare a `STATIC FUNCTION fn_HGet()` without colliding. Five previously dropped them into the global VM symbol table by their plain name, so multi-file builds (e.g. labdb's 22 .prg files where seven each defined their own STATIC fn_HGet) either failed with redeclaration or silently linked every caller to whichever definition won. fivenode_go's `sed` rename workaround can now go away. Mechanism * ast.FuncDecl gains IsStatic. parser.go sets it whenever the top-level STATIC keyword precedes FUNCTION / PROCEDURE. * gengo records every same-file STATIC FUNCTION name in g.staticFuncs. The symbol-table entry and the Go function name for those declarations are mangled to __STATIC____ so two files declaring `helper()` register two distinct symbols. * emitPushSymbol rewrites call sites that match a name in g.staticFuncs to the same mangled form, so same-file references still resolve while cross-file references would look for a symbol that doesn't exist. * cmd/five/main.go's buildMultiPRG excludes STATIC FUNCTIONs from the cross-file analyzer table — a foreign file calling another file's STATIC now triggers a clean "undeclared variable" warning instead of a runtime "function not found" deep inside vm.Run. Verified /tmp/a.prg + /tmp/b.prg each define `STATIC FUNCTION helper()` returning their own string. Building both into one binary shows each file calling its own helper: a says: alpha (from a.prg) (call B): beta (from b.prg) Misuse (file X calling file Y's STATIC) now warns at compile time. Full regression: go test ./compiler/... ./hbrt/... ./hbrtl/..., Compat 56/56, std.ch 17/17, FRB 7/7, FiveSql2 43/43. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/five/main.go | 10 ++++++- compiler/ast/ast.go | 1 + compiler/gengo/gengo.go | 59 +++++++++++++++++++++++++++++++++++---- compiler/parser/parser.go | 6 +++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/cmd/five/main.go b/cmd/five/main.go index d6e0f56..0d4b2a3 100644 --- a/cmd/five/main.go +++ b/cmd/five/main.go @@ -259,7 +259,15 @@ func buildMultiPRG(prgFiles []string, output string) { for _, d := range f.Decls { switch decl := d.(type) { case *ast.FuncDecl: - crossFileFuncs[strings.ToUpper(decl.Name)] = true + // STATIC FUNCTION is file-local: don't expose to + // other files via the cross-file table so the + // analyzer warns on misuse and the runtime fails + // with a clean "undeclared" rather than a + // mismatched lookup that silently resolves to + // some other file's STATIC. + if !decl.IsStatic { + crossFileFuncs[strings.ToUpper(decl.Name)] = true + } case *ast.ClassDecl: crossFileFuncs[strings.ToUpper(decl.Name)] = true } diff --git a/compiler/ast/ast.go b/compiler/ast/ast.go index 291abe9..41821f9 100644 --- a/compiler/ast/ast.go +++ b/compiler/ast/ast.go @@ -85,6 +85,7 @@ type FuncDecl struct { FuncPos token.Position Name string IsProc bool // PROCEDURE (no return value) + IsStatic bool // STATIC FUNCTION/PROCEDURE — file-local visibility Params []*ParamDecl // declared parameters Decls []Decl // LOCAL, STATIC, FIELD — must come first Body []Stmt // executable statements — after declarations diff --git a/compiler/gengo/gengo.go b/compiler/gengo/gengo.go index 201f85a..28e72e8 100644 --- a/compiler/gengo/gengo.go +++ b/compiler/gengo/gengo.go @@ -52,6 +52,24 @@ type Generator struct { // Class name of the currently-emitting method body, used to resolve // ::super: at compile time against the defining class's Parent. curMethodClass string + // staticFuncs holds the uppercase names of STATIC FUNCTION / + // PROCEDURE declared in THIS file. emitPushSymbol uses it to + // rewrite same-file references to the mangled symbol so two + // files declaring `STATIC FUNCTION foo()` don't collide in the + // global VM symbol table. + staticFuncs map[string]bool +} + +// staticSymName returns the mangled VM symbol / Go function name used +// for a STATIC FUNCTION declared in this file. Format keeps it within +// what Harbour-style identifiers allow and unique across files: +// +// __STATIC____ +// +// The leading underscores keep these out of the user-callable PUBLIC +// namespace. +func (g *Generator) staticSymName(name string) string { + return "__STATIC__" + g.fileKey() + "__" + strings.ToUpper(name) } type symbolEntry struct { @@ -85,6 +103,15 @@ func doGenerate(file *ast.File, debug, library bool) string { IsLibrary: library, } + // First pass: index every STATIC FUNCTION in this file so the rest + // of the generator can rewrite local references to the mangled name. + g.staticFuncs = map[string]bool{} + for _, d := range file.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && fd.IsStatic { + g.staticFuncs[strings.ToUpper(fd.Name)] = true + } + } + // Collect symbols from declarations for _, d := range file.Decls { switch decl := d.(type) { @@ -93,10 +120,20 @@ func doGenerate(file *ast.File, debug, library bool) string { if !library && (decl.Name == "Main" || decl.Name == "MAIN") { scope += "|hbrt.FsFirst" } + symName := strings.ToUpper(decl.Name) + goName := "FV_" + symName + if decl.IsStatic { + // Mangle so two files defining the same STATIC name + // register distinct symbols. The mangled name is also + // what same-file callers look up via emitPushSymbol, + // so cross-file resolution genuinely can't find it. + symName = g.staticSymName(decl.Name) + goName = "FV_" + symName + } g.symbols = append(g.symbols, symbolEntry{ - name: strings.ToUpper(decl.Name), + name: symName, scope: scope, - fn: "FV_" + strings.ToUpper(decl.Name), + fn: goName, }) case *ast.ClassDecl: className := strings.ToUpper(decl.Name) @@ -233,9 +270,17 @@ func (g *Generator) fileKey() string { // emitPushSymbol writes PushSymbol using the lazy-cached package-level // variable. First call resolves via VM; subsequent calls skip the // RWMutex + map lookup. +// +// Same-file STATIC FUNCTION names get rewritten to their mangled +// form so two files defining `STATIC FUNCTION foo()` each see only +// their own version (Harbour file-local STATIC semantics). func (g *Generator) emitPushSymbol(name string) { - v := g.symVar(name) - g.writeln(fmt.Sprintf("t.PushSymbol(t.GetSym(&%s, %q))", v, name)) + target := name + if g.staticFuncs[strings.ToUpper(name)] { + target = g.staticSymName(name) + } + v := g.symVar(target) + g.writeln(fmt.Sprintf("t.PushSymbol(t.GetSym(&%s, %q))", v, target)) } @@ -511,7 +556,11 @@ func (g *Generator) emitTopLevelStatic(vd *ast.VarDecl) { } func (g *Generator) emitFuncDecl(fn *ast.FuncDecl) { - goName := "FV_" + strings.ToUpper(fn.Name) + symName := strings.ToUpper(fn.Name) + if fn.IsStatic { + symName = g.staticSymName(fn.Name) + } + goName := "FV_" + symName // Emit function-level STATIC variables as package-level Go vars. // Harbour: STATIC inside FUNCTION persists across calls but is diff --git a/compiler/parser/parser.go b/compiler/parser/parser.go index 98a72b2..1399bfc 100644 --- a/compiler/parser/parser.go +++ b/compiler/parser/parser.go @@ -236,7 +236,11 @@ func (p *Parser) parseFile() *ast.File { // STATIC FUNCTION/PROCEDURE → function declaration with static scope if p.peekAt(1) == token.FUNCTION_KW || p.peekAt(1) == token.PROCEDURE { p.advance() // skip STATIC - file.Decls = append(file.Decls, p.parseFuncDecl()) + fd := p.parseFuncDecl() + if fd != nil { + fd.IsStatic = true + } + file.Decls = append(file.Decls, fd) } else { // Top-level STATIC declaration (module-level variable) file.Decls = append(file.Decls, p.parseVarDecl())