diff --git a/docs/specs/SPEC.adoc b/docs/specs/SPEC.adoc index 7c3604a..403f0ff 100644 --- a/docs/specs/SPEC.adoc +++ b/docs/specs/SPEC.adoc @@ -757,12 +757,13 @@ under the alias chosen by the import form (§2.1). The order in which import-introduced names enter the environment is *before* every local top-level declaration of the importer. -Status of cross-module flow at v0.1: +Items that flow across module boundaries: -* `fn` and `extern fn` items flow across module boundaries. -* `const` items do not yet flow across module boundaries; a `const` - declared in module `M` and named by `use M::{c}` is a known - restriction, not a language-level prohibition. +* `fn` and `extern fn` — imported as references to the original definition. +* `const` — its initialiser is compiled inline into the importer's + module, so each importer materialises its own copy of the value. + The denotation seen at use sites is the same as the source `const` + in the defining module. === 8.5 Conformance Criteria diff --git a/docs/specs/codegen-environment.adoc b/docs/specs/codegen-environment.adoc index cd490e0..9538ce7 100644 --- a/docs/specs/codegen-environment.adoc +++ b/docs/specs/codegen-environment.adoc @@ -202,7 +202,9 @@ during typechecking and trait-dictionary insertion. `gen_imports : Module_loader.t -> import_decl list -> context -> context result` walks `prog.prog_imports` once at the start of `generate_module`, -*before* any local `gen_decl` call. For every imported function: +*before* any local `gen_decl` call. For every imported item: + +=== 5.1 Imported `TopFn` 1. Load the referenced module via `Module_loader`. 2. Find the matching `TopFn` (or fail silently if absent — the resolver @@ -214,14 +216,29 @@ walks `prog.prog_imports` once at the start of `generate_module`, 5. Register the local alias (or original name) in `func_indices` with the positive `import_func_idx`. -Glob imports (`use M::*`) expand to one entry per public `TopFn` / -`PubCrate TopFn` in `M`'s `prog_decls`. +=== 5.2 Imported `TopConst` + +WASM module-linking for globals isn't standard yet, so cross-module +const support inlines the value into the importer's module: + +1. Load the referenced module via `Module_loader` and locate the + matching `TopConst` (matching on the original name; alias renaming + happens at registration time). +2. Compile the const's initialiser against the importer's context via + `gen_expr` (same lowering as `gen_decl TopConst`, §4.4). +3. Append the resulting `global` entry to `ctx.globals`. +4. Register `(local_name, -(global_idx + 1))` in `func_indices` — the + same negative-sentinel encoding used for locally-declared consts + (§3), so use-site lookup is uniform. + +The importer keeps its own copy of the constant value; cross-module +const identity is by value, not by reference. This is fine because +AffineScript consts are immutable. + +=== 5.3 Glob Imports -`TopConst` items are *not* threaded across module boundaries by -`gen_imports`. This is the cross-module gap acknowledged in -SPEC §8.4 — addressing it requires emitting the constant as a global -in the importing module (or via WASM module-linking) and remains -future work. +Glob imports (`use M::*`) expand to one entry per public `TopFn` AND +per public `TopConst` (`Public` or `PubCrate`) in `M`'s `prog_decls`. == 6. Identifier Resolution at Use Sites @@ -359,10 +376,22 @@ which appends): `[("main", 2); ("withInput", 1); ("inputSuffix", -1)]`. == 10. Closed Issues * https://github.com/hyperpolymath/affinescript/issues/73[#73] — - `Codegen.UnboundVariable` for top-level `const` bindings. **Closed.** - Resolved by the `Some k when k < 0` arm in `ExprVar` (`lib/codegen.ml`, - line 442–445). The negative-sentinel encoding is the load-bearing - invariant; new back-ends adopting `func_indices` must preserve it. + `Codegen.UnboundVariable` for top-level `const` bindings (intra-module). + **Closed.** Resolved by the `Some k when k < 0` arm in `ExprVar` + (`lib/codegen.ml`, line 442–445). The negative-sentinel encoding is + the load-bearing invariant; new back-ends adopting `func_indices` must + preserve it. + +* https://github.com/hyperpolymath/affinescript/issues/107[#107] — + Cross-module `const` imports dropped by `gen_imports` / + `flatten_imports`. **Closed.** Both paths now thread `TopConst`: + - `Codegen.gen_imports` matches `TopConst` alongside `TopFn` and + inlines the initialiser as a fresh global on the importer (§5.2). + - `Module_loader.flatten_imports` includes public consts in its + inlined declaration set, with the same alias-renaming machinery + used for fns, so non-WASM back-ends pick them up unchanged. + Regression tests live in `test/test_e2e.ml` + (`E2E Xmod Other Codegens` group, items 2–3). == 11. References diff --git a/lib/codegen.ml b/lib/codegen.ml index 66e0e2a..c581f2c 100644 --- a/lib/codegen.ml +++ b/lib/codegen.ml @@ -1961,11 +1961,19 @@ let gen_decl (ctx : context) (decl : top_level) : context result = func_indices = (ef.ef_name.name, func_idx) :: ctx_with_type.func_indices } (** Cross-module imports: walk [prog.prog_imports], load each referenced module - via [loader], and for every imported function name register - a WASM [(import "" "" (func ...))] entry plus a - [(local_alias_name -> func_idx)] mapping in [func_indices]. - - Silent on missing modules / non-function items / loader errors: the + via [loader], and for every imported top-level binding register the + appropriate WASM artefact + entry in [func_indices]. + + - Imported [TopFn]: emit a WASM [(import "" "" (func ...))] + entry and bind the local alias to its positive function index. + - Imported [TopConst]: compile its initialiser inline against the + importer's context and append a fresh [global] entry; bind the + local alias under the negative-sentinel convention from §3 of + `docs/specs/codegen-environment.adoc`. (WASM module-linking for + globals isn't standard yet, so each importer keeps its own copy + of the constant — fine for the v0.1 surface.) + + Silent on missing modules / unresolved items / loader errors: the resolver runs before codegen and would have already errored. *) let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : context) : context result = @@ -1973,14 +1981,21 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c match Module_loader.load_module loader mod_path with | Error _ -> Ok ctx | Ok loaded -> - let fn_decl_opt = List.find_map (function - | TopFn fd when fd.fd_name.name = orig_name -> Some fd + let local_name = Option.value alias_opt ~default:orig_name in + (* Look up the imported binding — function or constant — in declaration + order. The first match wins; same-name fn+const in the same module + would be a resolver-level error and never reach codegen. + Inline-record fields (TopConst) are destructured at the match site + so the constructor's anonymous record type does not escape. *) + let item = List.find_map (function + | TopFn fd when fd.fd_name.name = orig_name -> Some (`Fn fd) + | TopConst { tc_name; tc_value; _ } when tc_name.name = orig_name -> + Some (`Const tc_value) | _ -> None ) loaded.mod_program.prog_decls in - match fn_decl_opt with + match item with | None -> Ok ctx - | Some fd -> - let local_name = Option.value alias_opt ~default:orig_name in + | Some (`Fn fd) -> let ft = func_type_of_fn_decl fd in let (type_idx, types_after) = intern_func_type ctx.types ft in let import_func_idx = import_func_count ctx in @@ -1994,6 +2009,18 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c imports = ctx.imports @ [import]; func_indices = (local_name, import_func_idx) :: ctx.func_indices; } + | Some (`Const tc_value) -> + let* (ctx', init_code) = gen_expr ctx tc_value in + let global_idx = List.length ctx'.globals in + let global = { + g_type = I32; + g_mutable = false; + g_init = init_code; + } in + Ok { ctx' with + globals = ctx'.globals @ [global]; + func_indices = (local_name, -(global_idx + 1)) :: ctx'.func_indices; + } in let expand_import imp : (string list * string * string option) list = let path_strs path = List.map (fun (id : ident) -> id.name) path in @@ -2012,6 +2039,9 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c List.filter_map (function | TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate -> Some (p, fd.fd_name.name, None) + | TopConst { tc_vis; tc_name; _ } + when tc_vis = Public || tc_vis = PubCrate -> + Some (p, tc_name.name, None) | _ -> None ) lm.mod_program.prog_decls) in diff --git a/lib/module_loader.ml b/lib/module_loader.ml index 07af6f8..5f01624 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -211,15 +211,19 @@ let clear_cache (loader : t) : unit = earlier ones with the same fn name. Local decls in [prog.prog_decls] always win over imported ones. *) let flatten_imports (loader : t) (prog : program) : program = - let local_fn_names = + (* Local-decl names suppress same-named imports of any kind. *) + let local_names = List.filter_map (function | TopFn fd -> Some fd.fd_name.name + | TopConst { tc_name; _ } -> Some tc_name.name | _ -> None ) prog.prog_decls in let already_in = Hashtbl.create 32 in - List.iter (fun n -> Hashtbl.add already_in n ()) local_fn_names; - let imported_fns = + List.iter (fun n -> Hashtbl.add already_in n ()) local_names; + (* A flattened import is either a function or a constant; both share the + same name-collision rule against [already_in]. *) + let imported_decls = List.concat_map (fun imp -> let path_strs path = List.map (fun (id : ident) -> id.name) path @@ -230,38 +234,59 @@ let flatten_imports (loader : t) (prog : program) : program = match Hashtbl.find_opt loader.loaded mod_path with | None -> [] | Some lm -> - let public_fns = List.filter_map (function + let public_decls = List.filter_map (fun decl -> + match decl with | TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate -> - Some (fd.fd_name.name, fd) + Some (fd.fd_name.name, `Fn fd) + | TopConst { tc_vis; tc_name; _ } + when tc_vis = Public || tc_vis = PubCrate -> + Some (tc_name.name, `Const decl) | _ -> None ) lm.mod_program.prog_decls in - let select : (string * fn_decl) list = match imp with - | ImportGlob _ -> public_fns + let select = match imp with + | ImportGlob _ -> public_decls | ImportSimple _ -> (* `use Foo` brings the namespace into scope but doesn't import specific symbols. For codegens that need them inlined we still - include all public fns — same as glob. The resolver determines - what's referenceable; codegen just needs the bodies present. *) - public_fns + include all public top-level bindings — same as glob. The + resolver determines what's referenceable; codegen just needs + the bodies present. *) + public_decls | ImportList (_, items) -> List.filter_map (fun item -> let target = item.ii_name.name in - List.find_opt (fun (n, _) -> n = target) public_fns - |> Option.map (fun (_, fd) -> + List.find_opt (fun (n, _) -> n = target) public_decls + |> Option.map (fun (_, found) -> let bound_name = match item.ii_alias with | Some a -> a.name - | None -> fd.fd_name.name + | None -> target in - (bound_name, { fd with fd_name = { fd.fd_name with name = bound_name } })) + let renamed = match found with + | `Fn fd -> + `Fn { fd with fd_name = { fd.fd_name with name = bound_name } } + | `Const (TopConst { tc_vis; tc_name; tc_ty; tc_value }) -> + `Const (TopConst { + tc_vis; + tc_name = { tc_name with name = bound_name }; + tc_ty; + tc_value; + }) + | `Const _ -> + (* Unreachable: public_decls only stores TopConst under `Const`. *) + found + in + (bound_name, renamed)) ) items in - List.filter_map (fun (name, fd) -> + List.filter_map (fun (name, decl_kind) -> if Hashtbl.mem already_in name then None else begin Hashtbl.add already_in name (); - Some (TopFn fd) + match decl_kind with + | `Fn fd -> Some (TopFn fd) + | `Const decl -> Some decl end ) select ) prog.prog_imports in - { prog with prog_decls = imported_fns @ prog.prog_decls } + { prog with prog_decls = imported_decls @ prog.prog_decls } diff --git a/test/e2e/fixtures/PortNames.affine b/test/e2e/fixtures/PortNames.affine new file mode 100644 index 0000000..0444f47 --- /dev/null +++ b/test/e2e/fixtures/PortNames.affine @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Cross-module const-export fixture. Regression coverage for +// https://github.com/hyperpolymath/affinescript/issues/107 — the WASM +// codegen previously dropped imported TopConst bindings, breaking any +// `use M::{some_const}` that the typechecker accepted. + +module PortNames; + +pub const input_marker: Int = 256; + +pub fn marker_plus(x: Int) -> Int { + return x + input_marker; +} diff --git a/test/e2e/fixtures/cross_const_caller.affine b/test/e2e/fixtures/cross_const_caller.affine new file mode 100644 index 0000000..b2ec614 --- /dev/null +++ b/test/e2e/fixtures/cross_const_caller.affine @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Caller that imports both a public const and a public fn from another +// module. Regression coverage for affinescript#107: prior to the fix, +// the imported `input_marker` was silently dropped by +// `Codegen.gen_imports`, producing an `UnboundVariable` at WASM codegen. + +use PortNames::{input_marker, marker_plus}; + +pub fn main() -> Int { + return marker_plus(input_marker); +} diff --git a/test/test_e2e.ml b/test/test_e2e.ml index f14c037..abb450d 100644 --- a/test/test_e2e.ml +++ b/test/test_e2e.ml @@ -2926,9 +2926,59 @@ let test_flatten_imports_dedup_local_wins () = Alcotest.(check int) "local consume wins; imported one not duplicated" 1 consume_count +(* Regression for affinescript#107: imported public consts must be + threaded into the importer's environment by both paths (WASM via + gen_imports, non-WASM via flatten_imports). *) + +let test_flatten_imports_inlines_public_const () = + let loader = Module_loader.create { + Module_loader.stdlib_path = "stdlib"; + search_paths = []; + current_dir = fixture_dir; + } in + match parse_fixture (fixture "cross_const_caller.affine") with + | Error e -> Alcotest.failf "parse failed: %s" e + | Ok caller_prog -> + (match Resolve.resolve_program_with_loader caller_prog loader with + | _ -> ()); + let flat = Module_loader.flatten_imports loader caller_prog in + let flat_const_names = List.filter_map (function + | Ast.TopConst { tc_name; _ } -> Some tc_name.name + | _ -> None + ) flat.prog_decls in + let flat_fn_names = List.filter_map (function + | Ast.TopFn fd -> Some fd.fd_name.name + | _ -> None + ) flat.prog_decls in + Alcotest.(check bool) "imported `input_marker` const inlined" + true (List.mem "input_marker" flat_const_names); + Alcotest.(check bool) "imported `marker_plus` fn inlined" + true (List.mem "marker_plus" flat_fn_names); + Alcotest.(check bool) "caller's main fn still present" + true (List.mem "main" flat_fn_names) + +let test_wasm_cross_module_const_compiles () = + match compile_fixture_to_wasm (fixture "PortNames.affine"), + compile_fixture_to_wasm (fixture "cross_const_caller.affine") with + | Ok _, Ok caller -> + (* The imported const must have produced exactly one global on the + caller side, with the same I32Const initialiser as the callee's + value (256). *) + Alcotest.(check bool) "caller emits at least one global for the imported const" + true (List.length caller.globals >= 1); + let has_marker_init = List.exists (fun (g : Wasm.global) -> + List.exists (function Wasm.I32Const n -> Int32.to_int n = 256 | _ -> false) g.g_init + ) caller.globals in + Alcotest.(check bool) "caller has a global initialised to 256 (input_marker value)" + true has_marker_init + | Error e, _ -> Alcotest.fail ("callee compile failed: " ^ e) + | _, Error e -> Alcotest.fail ("caller compile failed (regression for #107): " ^ e) + let cross_module_other_codegens_tests = [ Alcotest.test_case "flatten_imports inlines imported public fns" `Quick test_flatten_imports_inlines_public_fns; Alcotest.test_case "flatten_imports: local def shadows imported, no dup" `Quick test_flatten_imports_dedup_local_wins; + Alcotest.test_case "flatten_imports inlines imported public consts (#107)" `Quick test_flatten_imports_inlines_public_const; + Alcotest.test_case "WASM gen_imports threads imported consts (#107)" `Quick test_wasm_cross_module_const_compiles; ] (* ---- extern declarations (issues-drafts/04) ----