Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/specs/SPEC.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 41 additions & 12 deletions docs/specs/codegen-environment.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
50 changes: 40 additions & 10 deletions lib/codegen.ml
Original file line number Diff line number Diff line change
Expand Up @@ -1961,26 +1961,41 @@ 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 "<mod>" "<fn>" (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 "<mod>" "<fn>" (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 =
let process_one ctx (mod_path, orig_name, alias_opt) =
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
Expand All @@ -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
Expand All @@ -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
Expand Down
59 changes: 42 additions & 17 deletions lib/module_loader.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
15 changes: 15 additions & 0 deletions test/e2e/fixtures/PortNames.affine
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions test/e2e/fixtures/cross_const_caller.affine
Original file line number Diff line number Diff line change
@@ -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);
}
50 changes: 50 additions & 0 deletions test/test_e2e.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----
Expand Down