Skip to content

Commit a0af020

Browse files
feat(codegen): thread imported TopConst across module boundaries (closes #107) (#109)
Closes #107. Before this commit, `Codegen.gen_imports` and `Module_loader.flatten_imports` both pattern-matched only `TopFn` in the imported module's `prog_decls`, silently dropping public `TopConst` items. A `use M::{some_const}` that the typechecker accepted would then fail at WASM codegen with `Codegen.UnboundVariable some_const`. ## Changes ### `lib/codegen.ml` — `gen_imports` - Match `TopFn` and `TopConst` in declaration order; first match wins (same-name fn+const in one module would be a resolver error). - For an imported `TopFn`: unchanged (emits the WASM `(import "<mod>" "<fn>" ...)` and registers the positive function index). - For an imported `TopConst`: compile the initialiser against the importer's context via `gen_expr`, append a fresh global, register `(local_name, -(global_idx + 1))` in `func_indices` — same negative-sentinel encoding as locally-declared consts, so use-site lookup is uniform. - Glob expansion (`use M::*`) likewise includes public consts. WASM module-linking for globals isn't standard yet, so the const value is inlined per importer. Identity is by value, which matches AffineScript's immutability guarantee. ### `lib/module_loader.ml` — `flatten_imports` - Local-decl name suppression set now covers fns AND consts. - Public consts get inlined into the importer's `prog_decls` alongside public fns, with the same alias-renaming machinery for `use M::{c as d}`. - Non-WASM back-ends (Rust, JS, OCaml, Lua, …) see imported consts as if they were locally declared, so they inherit the fix without per-target changes. ### Tests — `test/test_e2e.ml` Two regression tests in the `E2E Xmod Other Codegens` group: - `flatten_imports inlines imported public consts (#107)` — parses `cross_const_caller.affine`, runs the loader, asserts the imported `input_marker` const and `marker_plus` fn both appear in the flattened `prog_decls`. - `WASM gen_imports threads imported consts (#107)` — compiles `PortNames.affine` then `cross_const_caller.affine`; asserts the caller emits at least one global initialised to the const's value (256). The earlier failure mode would have errored out at `compile_fixture_to_wasm` before the assertion. Two new fixtures: - `test/e2e/fixtures/PortNames.affine` — exports `pub const input_marker` + a fn that uses it. - `test/e2e/fixtures/cross_const_caller.affine` — imports and uses both. ### Docs - `docs/specs/SPEC.adoc` §8.4 — drops the "do not yet flow" bullet for consts; the cross-module flow table now lists const-as-inlined-value as supported. - `docs/specs/codegen-environment.adoc` §5 — split into 5.1 (TopFn), 5.2 (TopConst inlining algorithm + identity-by-value note), 5.3 (glob expansion). §10 records #107 as closed and points at the regression tests. ## Inline-record-escape note `TopConst` is declared with an inline record. The OCaml compiler rejects `| TopConst tc when ...` followed by using `tc` as a value ("the type of the inlined record could escape"), so the match destructures the needed fields directly: `| TopConst { tc_name; tc_value; _ } when tc_name.name = orig_name -> Some (`Const tc_value)`. The glob path destructures `{ tc_vis; tc_name; _ }`. Reconstruction in the alias-rename path uses inline-literal construction, which is allowed.
1 parent d49a712 commit a0af020

7 files changed

Lines changed: 207 additions & 44 deletions

File tree

docs/specs/SPEC.adoc

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -757,12 +757,13 @@ under the alias chosen by the import form (§2.1). The order in which
757757
import-introduced names enter the environment is *before* every
758758
local top-level declaration of the importer.
759759

760-
Status of cross-module flow at v0.1:
760+
Items that flow across module boundaries:
761761

762-
* `fn` and `extern fn` items flow across module boundaries.
763-
* `const` items do not yet flow across module boundaries; a `const`
764-
declared in module `M` and named by `use M::{c}` is a known
765-
restriction, not a language-level prohibition.
762+
* `fn` and `extern fn` — imported as references to the original definition.
763+
* `const` — its initialiser is compiled inline into the importer's
764+
module, so each importer materialises its own copy of the value.
765+
The denotation seen at use sites is the same as the source `const`
766+
in the defining module.
766767

767768
=== 8.5 Conformance Criteria
768769

docs/specs/codegen-environment.adoc

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,9 @@ during typechecking and trait-dictionary insertion.
202202

203203
`gen_imports : Module_loader.t -> import_decl list -> context -> context result`
204204
walks `prog.prog_imports` once at the start of `generate_module`,
205-
*before* any local `gen_decl` call. For every imported function:
205+
*before* any local `gen_decl` call. For every imported item:
206+
207+
=== 5.1 Imported `TopFn`
206208

207209
1. Load the referenced module via `Module_loader`.
208210
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`,
214216
5. Register the local alias (or original name) in `func_indices` with
215217
the positive `import_func_idx`.
216218

217-
Glob imports (`use M::*`) expand to one entry per public `TopFn` /
218-
`PubCrate TopFn` in `M`'s `prog_decls`.
219+
=== 5.2 Imported `TopConst`
220+
221+
WASM module-linking for globals isn't standard yet, so cross-module
222+
const support inlines the value into the importer's module:
223+
224+
1. Load the referenced module via `Module_loader` and locate the
225+
matching `TopConst` (matching on the original name; alias renaming
226+
happens at registration time).
227+
2. Compile the const's initialiser against the importer's context via
228+
`gen_expr` (same lowering as `gen_decl TopConst`, §4.4).
229+
3. Append the resulting `global` entry to `ctx.globals`.
230+
4. Register `(local_name, -(global_idx + 1))` in `func_indices` — the
231+
same negative-sentinel encoding used for locally-declared consts
232+
(§3), so use-site lookup is uniform.
233+
234+
The importer keeps its own copy of the constant value; cross-module
235+
const identity is by value, not by reference. This is fine because
236+
AffineScript consts are immutable.
237+
238+
=== 5.3 Glob Imports
219239

220-
`TopConst` items are *not* threaded across module boundaries by
221-
`gen_imports`. This is the cross-module gap acknowledged in
222-
SPEC §8.4 — addressing it requires emitting the constant as a global
223-
in the importing module (or via WASM module-linking) and remains
224-
future work.
240+
Glob imports (`use M::*`) expand to one entry per public `TopFn` AND
241+
per public `TopConst` (`Public` or `PubCrate`) in `M`'s `prog_decls`.
225242

226243
== 6. Identifier Resolution at Use Sites
227244

@@ -359,10 +376,22 @@ which appends): `[("main", 2); ("withInput", 1); ("inputSuffix", -1)]`.
359376
== 10. Closed Issues
360377

361378
* https://github.com/hyperpolymath/affinescript/issues/73[#73] —
362-
`Codegen.UnboundVariable` for top-level `const` bindings. **Closed.**
363-
Resolved by the `Some k when k < 0` arm in `ExprVar` (`lib/codegen.ml`,
364-
line 442–445). The negative-sentinel encoding is the load-bearing
365-
invariant; new back-ends adopting `func_indices` must preserve it.
379+
`Codegen.UnboundVariable` for top-level `const` bindings (intra-module).
380+
**Closed.** Resolved by the `Some k when k < 0` arm in `ExprVar`
381+
(`lib/codegen.ml`, line 442–445). The negative-sentinel encoding is
382+
the load-bearing invariant; new back-ends adopting `func_indices` must
383+
preserve it.
384+
385+
* https://github.com/hyperpolymath/affinescript/issues/107[#107] —
386+
Cross-module `const` imports dropped by `gen_imports` /
387+
`flatten_imports`. **Closed.** Both paths now thread `TopConst`:
388+
- `Codegen.gen_imports` matches `TopConst` alongside `TopFn` and
389+
inlines the initialiser as a fresh global on the importer (§5.2).
390+
- `Module_loader.flatten_imports` includes public consts in its
391+
inlined declaration set, with the same alias-renaming machinery
392+
used for fns, so non-WASM back-ends pick them up unchanged.
393+
Regression tests live in `test/test_e2e.ml`
394+
(`E2E Xmod Other Codegens` group, items 2–3).
366395

367396
== 11. References
368397

lib/codegen.ml

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,26 +1961,41 @@ let gen_decl (ctx : context) (decl : top_level) : context result =
19611961
func_indices = (ef.ef_name.name, func_idx) :: ctx_with_type.func_indices }
19621962

19631963
(** Cross-module imports: walk [prog.prog_imports], load each referenced module
1964-
via [loader], and for every imported function name register
1965-
a WASM [(import "<mod>" "<fn>" (func ...))] entry plus a
1966-
[(local_alias_name -> func_idx)] mapping in [func_indices].
1967-
1968-
Silent on missing modules / non-function items / loader errors: the
1964+
via [loader], and for every imported top-level binding register the
1965+
appropriate WASM artefact + entry in [func_indices].
1966+
1967+
- Imported [TopFn]: emit a WASM [(import "<mod>" "<fn>" (func ...))]
1968+
entry and bind the local alias to its positive function index.
1969+
- Imported [TopConst]: compile its initialiser inline against the
1970+
importer's context and append a fresh [global] entry; bind the
1971+
local alias under the negative-sentinel convention from §3 of
1972+
`docs/specs/codegen-environment.adoc`. (WASM module-linking for
1973+
globals isn't standard yet, so each importer keeps its own copy
1974+
of the constant — fine for the v0.1 surface.)
1975+
1976+
Silent on missing modules / unresolved items / loader errors: the
19691977
resolver runs before codegen and would have already errored. *)
19701978
let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : context)
19711979
: context result =
19721980
let process_one ctx (mod_path, orig_name, alias_opt) =
19731981
match Module_loader.load_module loader mod_path with
19741982
| Error _ -> Ok ctx
19751983
| Ok loaded ->
1976-
let fn_decl_opt = List.find_map (function
1977-
| TopFn fd when fd.fd_name.name = orig_name -> Some fd
1984+
let local_name = Option.value alias_opt ~default:orig_name in
1985+
(* Look up the imported binding — function or constant — in declaration
1986+
order. The first match wins; same-name fn+const in the same module
1987+
would be a resolver-level error and never reach codegen.
1988+
Inline-record fields (TopConst) are destructured at the match site
1989+
so the constructor's anonymous record type does not escape. *)
1990+
let item = List.find_map (function
1991+
| TopFn fd when fd.fd_name.name = orig_name -> Some (`Fn fd)
1992+
| TopConst { tc_name; tc_value; _ } when tc_name.name = orig_name ->
1993+
Some (`Const tc_value)
19781994
| _ -> None
19791995
) loaded.mod_program.prog_decls in
1980-
match fn_decl_opt with
1996+
match item with
19811997
| None -> Ok ctx
1982-
| Some fd ->
1983-
let local_name = Option.value alias_opt ~default:orig_name in
1998+
| Some (`Fn fd) ->
19841999
let ft = func_type_of_fn_decl fd in
19852000
let (type_idx, types_after) = intern_func_type ctx.types ft in
19862001
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
19942009
imports = ctx.imports @ [import];
19952010
func_indices = (local_name, import_func_idx) :: ctx.func_indices;
19962011
}
2012+
| Some (`Const tc_value) ->
2013+
let* (ctx', init_code) = gen_expr ctx tc_value in
2014+
let global_idx = List.length ctx'.globals in
2015+
let global = {
2016+
g_type = I32;
2017+
g_mutable = false;
2018+
g_init = init_code;
2019+
} in
2020+
Ok { ctx' with
2021+
globals = ctx'.globals @ [global];
2022+
func_indices = (local_name, -(global_idx + 1)) :: ctx'.func_indices;
2023+
}
19972024
in
19982025
let expand_import imp : (string list * string * string option) list =
19992026
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
20122039
List.filter_map (function
20132040
| TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate ->
20142041
Some (p, fd.fd_name.name, None)
2042+
| TopConst { tc_vis; tc_name; _ }
2043+
when tc_vis = Public || tc_vis = PubCrate ->
2044+
Some (p, tc_name.name, None)
20152045
| _ -> None
20162046
) lm.mod_program.prog_decls)
20172047
in

lib/module_loader.ml

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,19 @@ let clear_cache (loader : t) : unit =
211211
earlier ones with the same fn name. Local decls in [prog.prog_decls]
212212
always win over imported ones. *)
213213
let flatten_imports (loader : t) (prog : program) : program =
214-
let local_fn_names =
214+
(* Local-decl names suppress same-named imports of any kind. *)
215+
let local_names =
215216
List.filter_map (function
216217
| TopFn fd -> Some fd.fd_name.name
218+
| TopConst { tc_name; _ } -> Some tc_name.name
217219
| _ -> None
218220
) prog.prog_decls
219221
in
220222
let already_in = Hashtbl.create 32 in
221-
List.iter (fun n -> Hashtbl.add already_in n ()) local_fn_names;
222-
let imported_fns =
223+
List.iter (fun n -> Hashtbl.add already_in n ()) local_names;
224+
(* A flattened import is either a function or a constant; both share the
225+
same name-collision rule against [already_in]. *)
226+
let imported_decls =
223227
List.concat_map (fun imp ->
224228
let path_strs path =
225229
List.map (fun (id : ident) -> id.name) path
@@ -230,38 +234,59 @@ let flatten_imports (loader : t) (prog : program) : program =
230234
match Hashtbl.find_opt loader.loaded mod_path with
231235
| None -> []
232236
| Some lm ->
233-
let public_fns = List.filter_map (function
237+
let public_decls = List.filter_map (fun decl ->
238+
match decl with
234239
| TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate ->
235-
Some (fd.fd_name.name, fd)
240+
Some (fd.fd_name.name, `Fn fd)
241+
| TopConst { tc_vis; tc_name; _ }
242+
when tc_vis = Public || tc_vis = PubCrate ->
243+
Some (tc_name.name, `Const decl)
236244
| _ -> None
237245
) lm.mod_program.prog_decls in
238-
let select : (string * fn_decl) list = match imp with
239-
| ImportGlob _ -> public_fns
246+
let select = match imp with
247+
| ImportGlob _ -> public_decls
240248
| ImportSimple _ ->
241249
(* `use Foo` brings the namespace into scope but doesn't import
242250
specific symbols. For codegens that need them inlined we still
243-
include all public fns — same as glob. The resolver determines
244-
what's referenceable; codegen just needs the bodies present. *)
245-
public_fns
251+
include all public top-level bindings — same as glob. The
252+
resolver determines what's referenceable; codegen just needs
253+
the bodies present. *)
254+
public_decls
246255
| ImportList (_, items) ->
247256
List.filter_map (fun item ->
248257
let target = item.ii_name.name in
249-
List.find_opt (fun (n, _) -> n = target) public_fns
250-
|> Option.map (fun (_, fd) ->
258+
List.find_opt (fun (n, _) -> n = target) public_decls
259+
|> Option.map (fun (_, found) ->
251260
let bound_name = match item.ii_alias with
252261
| Some a -> a.name
253-
| None -> fd.fd_name.name
262+
| None -> target
254263
in
255-
(bound_name, { fd with fd_name = { fd.fd_name with name = bound_name } }))
264+
let renamed = match found with
265+
| `Fn fd ->
266+
`Fn { fd with fd_name = { fd.fd_name with name = bound_name } }
267+
| `Const (TopConst { tc_vis; tc_name; tc_ty; tc_value }) ->
268+
`Const (TopConst {
269+
tc_vis;
270+
tc_name = { tc_name with name = bound_name };
271+
tc_ty;
272+
tc_value;
273+
})
274+
| `Const _ ->
275+
(* Unreachable: public_decls only stores TopConst under `Const`. *)
276+
found
277+
in
278+
(bound_name, renamed))
256279
) items
257280
in
258-
List.filter_map (fun (name, fd) ->
281+
List.filter_map (fun (name, decl_kind) ->
259282
if Hashtbl.mem already_in name then None
260283
else begin
261284
Hashtbl.add already_in name ();
262-
Some (TopFn fd)
285+
match decl_kind with
286+
| `Fn fd -> Some (TopFn fd)
287+
| `Const decl -> Some decl
263288
end
264289
) select
265290
) prog.prog_imports
266291
in
267-
{ prog with prog_decls = imported_fns @ prog.prog_decls }
292+
{ prog with prog_decls = imported_decls @ prog.prog_decls }

test/e2e/fixtures/PortNames.affine

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// Cross-module const-export fixture. Regression coverage for
5+
// https://github.com/hyperpolymath/affinescript/issues/107 — the WASM
6+
// codegen previously dropped imported TopConst bindings, breaking any
7+
// `use M::{some_const}` that the typechecker accepted.
8+
9+
module PortNames;
10+
11+
pub const input_marker: Int = 256;
12+
13+
pub fn marker_plus(x: Int) -> Int {
14+
return x + input_marker;
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// Caller that imports both a public const and a public fn from another
5+
// module. Regression coverage for affinescript#107: prior to the fix,
6+
// the imported `input_marker` was silently dropped by
7+
// `Codegen.gen_imports`, producing an `UnboundVariable` at WASM codegen.
8+
9+
use PortNames::{input_marker, marker_plus};
10+
11+
pub fn main() -> Int {
12+
return marker_plus(input_marker);
13+
}

test/test_e2e.ml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2926,9 +2926,59 @@ let test_flatten_imports_dedup_local_wins () =
29262926
Alcotest.(check int) "local consume wins; imported one not duplicated"
29272927
1 consume_count
29282928

2929+
(* Regression for affinescript#107: imported public consts must be
2930+
threaded into the importer's environment by both paths (WASM via
2931+
gen_imports, non-WASM via flatten_imports). *)
2932+
2933+
let test_flatten_imports_inlines_public_const () =
2934+
let loader = Module_loader.create {
2935+
Module_loader.stdlib_path = "stdlib";
2936+
search_paths = [];
2937+
current_dir = fixture_dir;
2938+
} in
2939+
match parse_fixture (fixture "cross_const_caller.affine") with
2940+
| Error e -> Alcotest.failf "parse failed: %s" e
2941+
| Ok caller_prog ->
2942+
(match Resolve.resolve_program_with_loader caller_prog loader with
2943+
| _ -> ());
2944+
let flat = Module_loader.flatten_imports loader caller_prog in
2945+
let flat_const_names = List.filter_map (function
2946+
| Ast.TopConst { tc_name; _ } -> Some tc_name.name
2947+
| _ -> None
2948+
) flat.prog_decls in
2949+
let flat_fn_names = List.filter_map (function
2950+
| Ast.TopFn fd -> Some fd.fd_name.name
2951+
| _ -> None
2952+
) flat.prog_decls in
2953+
Alcotest.(check bool) "imported `input_marker` const inlined"
2954+
true (List.mem "input_marker" flat_const_names);
2955+
Alcotest.(check bool) "imported `marker_plus` fn inlined"
2956+
true (List.mem "marker_plus" flat_fn_names);
2957+
Alcotest.(check bool) "caller's main fn still present"
2958+
true (List.mem "main" flat_fn_names)
2959+
2960+
let test_wasm_cross_module_const_compiles () =
2961+
match compile_fixture_to_wasm (fixture "PortNames.affine"),
2962+
compile_fixture_to_wasm (fixture "cross_const_caller.affine") with
2963+
| Ok _, Ok caller ->
2964+
(* The imported const must have produced exactly one global on the
2965+
caller side, with the same I32Const initialiser as the callee's
2966+
value (256). *)
2967+
Alcotest.(check bool) "caller emits at least one global for the imported const"
2968+
true (List.length caller.globals >= 1);
2969+
let has_marker_init = List.exists (fun (g : Wasm.global) ->
2970+
List.exists (function Wasm.I32Const n -> Int32.to_int n = 256 | _ -> false) g.g_init
2971+
) caller.globals in
2972+
Alcotest.(check bool) "caller has a global initialised to 256 (input_marker value)"
2973+
true has_marker_init
2974+
| Error e, _ -> Alcotest.fail ("callee compile failed: " ^ e)
2975+
| _, Error e -> Alcotest.fail ("caller compile failed (regression for #107): " ^ e)
2976+
29292977
let cross_module_other_codegens_tests = [
29302978
Alcotest.test_case "flatten_imports inlines imported public fns" `Quick test_flatten_imports_inlines_public_fns;
29312979
Alcotest.test_case "flatten_imports: local def shadows imported, no dup" `Quick test_flatten_imports_dedup_local_wins;
2980+
Alcotest.test_case "flatten_imports inlines imported public consts (#107)" `Quick test_flatten_imports_inlines_public_const;
2981+
Alcotest.test_case "WASM gen_imports threads imported consts (#107)" `Quick test_wasm_cross_module_const_compiles;
29322982
]
29332983

29342984
(* ---- extern declarations (issues-drafts/04) ----

0 commit comments

Comments
 (0)