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
29 changes: 28 additions & 1 deletion docs/history/MODULE-SYSTEM-PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ type context = {
| Visibility checking | ✅ | Public/PubCrate filtering |
| Symbol registration | ✅ | Symbols added to table |
| Type information transfer | ✅ | **FIXED** |
| Re-exports | ❌ | Not implemented |
| Cross-module constructor codegen | ✅ | Directly-imported enum constructors (`use prelude::{Option, Some, None}`) lower on every backend (#138) |
| Re-exports (transitive) | ❌ | A module surfacing names it itself imported (`use option` → prelude's `Option`) — not implemented |
| Nested modules | ❌ | Not implemented |

## Known Limitations
Expand Down Expand Up @@ -400,3 +401,29 @@ actual objective of #128.
| #138 | Delete the b895374 seeded-builtins band-aid once the prelude re-export module exists. |

No code change in #132 (decision + documentation only).

### #138 codegen follow-up (2026-06-20)

Removing the `b895374` seeded `Some/None/Ok/Err` builtins (front-end half of
#138) correctly routed those constructors through the module path, so `check`
passes — but it surfaced a codegen gap: a consumer that imports prelude's
`Option`/`Result` and applies their constructors type-checked yet failed to
compile, because the backends learn variant tags only from `TopType` decls and
imported types never reached them.

- **Core-Wasm backend** (`Codegen.gen_imports`): wired up only `TopFn`
(→ wasm import) and `TopConst` (→ global); imported types were dropped. It now
also registers the constructor tags / struct layouts of imported public types,
reusing the local-type registration in `gen_decl`.
- **Other backends** (Deno / JS / Julia / C / Rust / …): `Module_loader.flatten_imports`
now inlines imported public `TopType` decls (a separate namespace from
fn/const, local-wins, deduped) so the `prog_decls`-iterating codegens see them.

Scope: **directly-imported** constructors lower on every backend. **Transitive
re-export** (a module re-exposing constructors it itself imported) remains
unimplemented — see the status table above. Unrelated and still open: the
core-Wasm pattern-codegen gap for tuple patterns (`UnsupportedFeature "Only
variable and wildcard patterns supported in tuple patterns"`, which
`stdlib/option.affine` / `result.affine` hit) and the mixed-representation match
of a zero-arg variant against a constructor-with-args arm — both reproduce with
purely local enums and are independent of cross-module linking.
58 changes: 58 additions & 0 deletions lib/codegen.ml
Original file line number Diff line number Diff line change
Expand Up @@ -3339,7 +3339,65 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c
| _ -> None
) lm.mod_program.prog_decls)
in
(* #138: register the constructor tags (and struct field layouts) of
imported PUBLIC types. [gen_imports] is the WASM backend's native
cross-module import path; it historically wired up only [TopFn]
(-> wasm import) and [TopConst] (-> global), silently dropping imported
TYPES. Codegen learns variant tags ONLY from [TopType] decls (see
[gen_decl]/[variant_tags]), and the WASM compile path feeds the original
(un-flattened) [prog] to [generate_module], so applying an imported
constructor such as [Some]/[None] from `use prelude::{Option, Some, None}`
raised [UnboundVariable] at codegen even though resolve + typecheck
passed. We reuse the local-type registration in [gen_decl] so imported
and local types share exactly one code path; the non-WASM backends get
the same decls inlined via [Module_loader.flatten_imports]. *)
let register_imported_types ctx =
(* Dedup imported types by name across all imports (a type may be reachable
through more than one path). Local [TopType]s — registered afterwards by
the [prog_decls] fold in [generate_module] — are prepended after these,
so they win on the first-match [List.assoc] lookup. *)
let seen = Hashtbl.create 8 in
let path_strs path = List.map (fun (id : ident) -> id.name) path in
List.fold_left (fun acc imp ->
let* ctx = acc in
let p = match imp with
| ImportSimple (path, _) | ImportList (path, _) | ImportGlob path ->
path_strs path
in
match Module_loader.load_module loader p with
| Error _ -> Ok ctx
| Ok lm ->
let public_types = List.filter_map (function
| TopType td when td.td_vis = Public || td.td_vis = PubCrate -> Some td
| _ -> None
) lm.mod_program.prog_decls in
(* `use M::{..}` selects a type when the list names the type itself or
any of its constructors (so `use prelude::{Some}` works without also
naming `Option`); `use M` / `use M::*` bring all public types. *)
let selected = match imp with
| ImportGlob _ | ImportSimple _ -> public_types
| ImportList (_, items) ->
let wanted = List.map (fun (it : import_item) -> it.ii_name.name) items in
List.filter (fun td ->
List.mem td.td_name.name wanted ||
(match td.td_body with
| TyEnum variants ->
List.exists (fun vd -> List.mem vd.vd_name.name wanted) variants
| _ -> false)
) public_types
in
List.fold_left (fun acc td ->
let* ctx = acc in
if Hashtbl.mem seen td.td_name.name then Ok ctx
else begin
Hashtbl.add seen td.td_name.name ();
gen_decl ctx (TopType td)
end
) (Ok ctx) selected
) (Ok ctx) imports
in
let entries = List.concat_map expand_import imports in
let* ctx = register_imported_types ctx in
List.fold_left (fun acc e ->
let* ctx = acc in
process_one ctx e
Expand Down
59 changes: 58 additions & 1 deletion lib/module_loader.ml
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,61 @@ let flatten_imports (loader : t) (prog : program) : program =
) select
) prog.prog_imports
in
{ prog with prog_decls = imported_decls @ prog.prog_decls }
(* #138: type declarations are a SEPARATE namespace from fn/const bindings,
so they get their own dedup table — a local `fn Foo` must not suppress an
imported `type Foo`, and vice versa. Imported public types are inlined too
so the prog_decls-iterating backends (Deno / JS / Julia / C / Rust / ...)
register their constructors exactly as they would for a local type;
without this, applying an imported constructor (`Some`/`None` from
`use prelude::{Option, Some, None}`) can reach codegen with no type in
scope. Local types win over imported ones, and a type reachable through
more than one path is carried only once. The Wasm backend gets the same
registration natively in [Codegen.gen_imports]. *)
let local_type_names =
List.filter_map (function
| TopType td -> Some td.td_name.name
| _ -> None
) prog.prog_decls
in
let type_already_in = Hashtbl.create 16 in
List.iter (fun n -> Hashtbl.add type_already_in n ()) local_type_names;
let imported_types =
List.concat_map (fun imp ->
let path_strs path = List.map (fun (id : ident) -> id.name) path in
let mod_path = match imp with
| ImportSimple (p, _) | ImportList (p, _) | ImportGlob p -> path_strs p
in
match Hashtbl.find_opt loader.loaded mod_path with
| None -> []
| Some lm ->
let public_types = List.filter_map (function
| TopType td when td.td_vis = Public || td.td_vis = PubCrate -> Some td
| _ -> None
) lm.mod_program.prog_decls in
(* `use M::{..}` selects a type when the list names the type itself or
any of its constructors; `use M` / `use M::*` bring all public
types (the resolver still gates what is referenceable). *)
let selected = match imp with
| ImportGlob _ | ImportSimple _ -> public_types
| ImportList (_, items) ->
let wanted = List.map (fun (it : import_item) -> it.ii_name.name) items in
List.filter (fun td ->
List.mem td.td_name.name wanted ||
(match td.td_body with
| TyEnum variants ->
List.exists (fun vd -> List.mem vd.vd_name.name wanted) variants
| _ -> false)
) public_types
in
List.filter_map (fun td ->
if Hashtbl.mem type_already_in td.td_name.name then None
else begin
Hashtbl.add type_already_in td.td_name.name ();
Some (TopType td)
end
) selected
) prog.prog_imports
in
(* Types precede imported fns/consts and all local decls so the single-pass
codegen registers an imported type before any function that uses it. *)
{ prog with prog_decls = imported_types @ imported_decls @ prog.prog_decls }
88 changes: 87 additions & 1 deletion test/test_stdlib_aot.ml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,37 @@ let pipeline_to_deno (prog : Ast.program) : (string, string) result =
| Error e -> Error (Printf.sprintf "deno-codegen: %s" e)
| Ok js -> Ok js)))

(** Full AOT pipeline to the core-Wasm backend: resolve -> typecheck ->
borrow -> [Codegen.generate_module] (loader-aware). Mirrors
[pipeline_to_deno] but targets the backend whose cross-module constructor
linking (#138) the test below regression-locks. The Wasm path feeds the
original (un-flattened) [prog] to codegen and resolves imported decls
natively via [Codegen.gen_imports]. *)
let pipeline_to_wasm (prog : Ast.program) : (Wasm.wasm_module, string) result =
let ld = loader () in
match Resolve.resolve_program_with_loader prog ld with
| Error (e, sp) ->
Error (Printf.sprintf "resolve: %s @ %s"
(Resolve.show_resolve_error e) (Span.show sp))
| Ok (rctx, itc) ->
(match
Typecheck.check_program
~import_types:itc.Typecheck.name_types rctx.symbols prog
with
| Error e ->
Error (Printf.sprintf "typecheck: %s" (Typecheck.format_type_error e))
| Ok _ ->
(match Borrow.check_program rctx.symbols prog with
| Error e ->
Error (Printf.sprintf "borrow: %s" (Borrow.format_borrow_error e))
| Ok () ->
let optimized = Opt.fold_constants_program prog in
(match Codegen.generate_module ~loader:ld optimized with
| Error e ->
Error (Printf.sprintf "wasm-codegen: %s"
(Codegen.show_codegen_error e))
| Ok m -> Ok m)))

let parse_file_safe path =
try Ok (Parse_driver.parse_file path)
with
Expand Down Expand Up @@ -140,6 +171,61 @@ let integration_tests =
[ Alcotest.test_case "string+option+collections together" `Quick
test_multi_module_integration ]

(* ---- #138: cross-module constructor linking on the core-Wasm backend ----

A consumer that imports prelude's Option/Result and APPLIES their
constructors must reach a runnable artifact. The front-end resolves
`Some`/`None`/`Ok`/`Err` through the module path, so `check` passes; before
the #138 codegen fix [Codegen.gen_imports] dropped the imported TYPE decls,
so the Wasm backend had no variant tag for `Some` and raised
[UnboundVariable "...Some"] at codegen. This feeds the imported-constructor
decl shape to all three [variant_tags] consumers in [Codegen]: construction
with an argument (`Some(x)`, `Ok`/`Err`), the zero-arg form (`None`), and
constructor patterns (`match`). It is the Wasm counterpart to the #137
Deno-path integration above. *)
let imported_ctors_src = {|
module xmod_ctors;
use prelude::{ Option, Some, None, Result, Ok, Err };

pub fn wrap(x: Int) -> Option<Int> { Some(x) }
pub fn empty() -> Option<Int> { None }

pub fn unwrap_or(o: Option<Int>, d: Int) -> Int {
match o {
Some(v) => v,
None => d,
}
}

pub fn divide(a: Int, b: Int) -> Result<Int, Int> {
if b == 0 { Err(0) } else { Ok(a / b) }
}
|}

let test_imported_constructors_wasm () =
match Parse_driver.parse_string ~file:"<xmod_ctors>" imported_ctors_src with
| exception e ->
Alcotest.failf "imported-ctors parse raised: %s" (Printexc.to_string e)
| prog ->
(match pipeline_to_wasm prog with
| Ok m ->
let names =
List.map (fun (e : Wasm.export) -> e.Wasm.e_name) m.Wasm.exports in
List.iter
(fun fn ->
Alcotest.(check bool)
(Printf.sprintf "Wasm module exports %s" fn)
true (List.mem fn names))
[ "wrap"; "empty"; "unwrap_or"; "divide" ]
| Error m ->
Alcotest.failf
"imported prelude constructors must codegen to Wasm (#138): %s" m)

let xmod_constructor_tests =
[ Alcotest.test_case "imported Option/Result constructors -> Wasm" `Quick
test_imported_constructors_wasm ]

let tests =
[ ("STAGE-A AOT smoke (#136)", aot_smoke_tests);
("STAGE-A multi-module integration (#137)", integration_tests) ]
("STAGE-A multi-module integration (#137)", integration_tests);
("cross-module constructor linking, Wasm (#138)", xmod_constructor_tests) ]
Loading