Skip to content

Commit ecb8621

Browse files
committed
fix(codegen): #138 — link imported enum constructors into codegen
A module that imports prelude's Option/Result and applies their constructors type-checked but failed to compile: codegen raised UnboundVariable "...Some". Removing the b895374 seeded-builtins band-aid (front-end half of #138) correctly routed Some/None/Ok/Err through the module path, but the backends learn variant tags only from TopType decls, and imported types never reached them. - Codegen.gen_imports (core-Wasm — the path `compile` uses): previously wired up only TopFn (-> wasm import) and TopConst (-> global) and dropped imported types. Now also registers the constructor tags / struct layouts of imported public types, reusing gen_decl's local-type registration so imported and local types share one code path. - Module_loader.flatten_imports (Deno / JS / Julia / C / Rust / ...): now inlines imported public TopType decls (separate namespace from fn/const, local-wins, deduped, ordered before consumers) so the prog_decls-iterating backends register them too. Scope: directly-imported constructors lower on every backend. Transitive re-export stays unimplemented. Unrelated pre-existing core-Wasm gaps (tuple-pattern codegen; mixed-representation match of a zero-arg variant) reproduce with purely local enums and are out of scope. Adds a Wasm-path multi-module regression test (test_stdlib_aot.ml), the counterpart to the #137 Deno-path integration. Closes #138 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lz7pRcec2Z3tVtaAhvB3M8
1 parent 9851215 commit ecb8621

4 files changed

Lines changed: 231 additions & 3 deletions

File tree

docs/history/MODULE-SYSTEM-PROGRESS.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ type context = {
210210
| Visibility checking || Public/PubCrate filtering |
211211
| Symbol registration || Symbols added to table |
212212
| Type information transfer || **FIXED** |
213-
| Re-exports || Not implemented |
213+
| Cross-module constructor codegen || Directly-imported enum constructors (`use prelude::{Option, Some, None}`) lower on every backend (#138) |
214+
| Re-exports (transitive) || A module surfacing names it itself imported (`use option` → prelude's `Option`) — not implemented |
214215
| Nested modules || Not implemented |
215216

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

402403
No code change in #132 (decision + documentation only).
404+
405+
### #138 codegen follow-up (2026-06-20)
406+
407+
Removing the `b895374` seeded `Some/None/Ok/Err` builtins (front-end half of
408+
#138) correctly routed those constructors through the module path, so `check`
409+
passes — but it surfaced a codegen gap: a consumer that imports prelude's
410+
`Option`/`Result` and applies their constructors type-checked yet failed to
411+
compile, because the backends learn variant tags only from `TopType` decls and
412+
imported types never reached them.
413+
414+
- **Core-Wasm backend** (`Codegen.gen_imports`): wired up only `TopFn`
415+
(→ wasm import) and `TopConst` (→ global); imported types were dropped. It now
416+
also registers the constructor tags / struct layouts of imported public types,
417+
reusing the local-type registration in `gen_decl`.
418+
- **Other backends** (Deno / JS / Julia / C / Rust / …): `Module_loader.flatten_imports`
419+
now inlines imported public `TopType` decls (a separate namespace from
420+
fn/const, local-wins, deduped) so the `prog_decls`-iterating codegens see them.
421+
422+
Scope: **directly-imported** constructors lower on every backend. **Transitive
423+
re-export** (a module re-exposing constructors it itself imported) remains
424+
unimplemented — see the status table above. Unrelated and still open: the
425+
core-Wasm pattern-codegen gap for tuple patterns (`UnsupportedFeature "Only
426+
variable and wildcard patterns supported in tuple patterns"`, which
427+
`stdlib/option.affine` / `result.affine` hit) and the mixed-representation match
428+
of a zero-arg variant against a constructor-with-args arm — both reproduce with
429+
purely local enums and are independent of cross-module linking.

lib/codegen.ml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3339,7 +3339,65 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c
33393339
| _ -> None
33403340
) lm.mod_program.prog_decls)
33413341
in
3342+
(* #138: register the constructor tags (and struct field layouts) of
3343+
imported PUBLIC types. [gen_imports] is the WASM backend's native
3344+
cross-module import path; it historically wired up only [TopFn]
3345+
(-> wasm import) and [TopConst] (-> global), silently dropping imported
3346+
TYPES. Codegen learns variant tags ONLY from [TopType] decls (see
3347+
[gen_decl]/[variant_tags]), and the WASM compile path feeds the original
3348+
(un-flattened) [prog] to [generate_module], so applying an imported
3349+
constructor such as [Some]/[None] from `use prelude::{Option, Some, None}`
3350+
raised [UnboundVariable] at codegen even though resolve + typecheck
3351+
passed. We reuse the local-type registration in [gen_decl] so imported
3352+
and local types share exactly one code path; the non-WASM backends get
3353+
the same decls inlined via [Module_loader.flatten_imports]. *)
3354+
let register_imported_types ctx =
3355+
(* Dedup imported types by name across all imports (a type may be reachable
3356+
through more than one path). Local [TopType]s — registered afterwards by
3357+
the [prog_decls] fold in [generate_module] — are prepended after these,
3358+
so they win on the first-match [List.assoc] lookup. *)
3359+
let seen = Hashtbl.create 8 in
3360+
let path_strs path = List.map (fun (id : ident) -> id.name) path in
3361+
List.fold_left (fun acc imp ->
3362+
let* ctx = acc in
3363+
let p = match imp with
3364+
| ImportSimple (path, _) | ImportList (path, _) | ImportGlob path ->
3365+
path_strs path
3366+
in
3367+
match Module_loader.load_module loader p with
3368+
| Error _ -> Ok ctx
3369+
| Ok lm ->
3370+
let public_types = List.filter_map (function
3371+
| TopType td when td.td_vis = Public || td.td_vis = PubCrate -> Some td
3372+
| _ -> None
3373+
) lm.mod_program.prog_decls in
3374+
(* `use M::{..}` selects a type when the list names the type itself or
3375+
any of its constructors (so `use prelude::{Some}` works without also
3376+
naming `Option`); `use M` / `use M::*` bring all public types. *)
3377+
let selected = match imp with
3378+
| ImportGlob _ | ImportSimple _ -> public_types
3379+
| ImportList (_, items) ->
3380+
let wanted = List.map (fun (it : import_item) -> it.ii_name.name) items in
3381+
List.filter (fun td ->
3382+
List.mem td.td_name.name wanted ||
3383+
(match td.td_body with
3384+
| TyEnum variants ->
3385+
List.exists (fun vd -> List.mem vd.vd_name.name wanted) variants
3386+
| _ -> false)
3387+
) public_types
3388+
in
3389+
List.fold_left (fun acc td ->
3390+
let* ctx = acc in
3391+
if Hashtbl.mem seen td.td_name.name then Ok ctx
3392+
else begin
3393+
Hashtbl.add seen td.td_name.name ();
3394+
gen_decl ctx (TopType td)
3395+
end
3396+
) (Ok ctx) selected
3397+
) (Ok ctx) imports
3398+
in
33423399
let entries = List.concat_map expand_import imports in
3400+
let* ctx = register_imported_types ctx in
33433401
List.fold_left (fun acc e ->
33443402
let* ctx = acc in
33453403
process_one ctx e

lib/module_loader.ml

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,61 @@ let flatten_imports (loader : t) (prog : program) : program =
345345
) select
346346
) prog.prog_imports
347347
in
348-
{ prog with prog_decls = imported_decls @ prog.prog_decls }
348+
(* #138: type declarations are a SEPARATE namespace from fn/const bindings,
349+
so they get their own dedup table — a local `fn Foo` must not suppress an
350+
imported `type Foo`, and vice versa. Imported public types are inlined too
351+
so the prog_decls-iterating backends (Deno / JS / Julia / C / Rust / ...)
352+
register their constructors exactly as they would for a local type;
353+
without this, applying an imported constructor (`Some`/`None` from
354+
`use prelude::{Option, Some, None}`) can reach codegen with no type in
355+
scope. Local types win over imported ones, and a type reachable through
356+
more than one path is carried only once. The Wasm backend gets the same
357+
registration natively in [Codegen.gen_imports]. *)
358+
let local_type_names =
359+
List.filter_map (function
360+
| TopType td -> Some td.td_name.name
361+
| _ -> None
362+
) prog.prog_decls
363+
in
364+
let type_already_in = Hashtbl.create 16 in
365+
List.iter (fun n -> Hashtbl.add type_already_in n ()) local_type_names;
366+
let imported_types =
367+
List.concat_map (fun imp ->
368+
let path_strs path = List.map (fun (id : ident) -> id.name) path in
369+
let mod_path = match imp with
370+
| ImportSimple (p, _) | ImportList (p, _) | ImportGlob p -> path_strs p
371+
in
372+
match Hashtbl.find_opt loader.loaded mod_path with
373+
| None -> []
374+
| Some lm ->
375+
let public_types = List.filter_map (function
376+
| TopType td when td.td_vis = Public || td.td_vis = PubCrate -> Some td
377+
| _ -> None
378+
) lm.mod_program.prog_decls in
379+
(* `use M::{..}` selects a type when the list names the type itself or
380+
any of its constructors; `use M` / `use M::*` bring all public
381+
types (the resolver still gates what is referenceable). *)
382+
let selected = match imp with
383+
| ImportGlob _ | ImportSimple _ -> public_types
384+
| ImportList (_, items) ->
385+
let wanted = List.map (fun (it : import_item) -> it.ii_name.name) items in
386+
List.filter (fun td ->
387+
List.mem td.td_name.name wanted ||
388+
(match td.td_body with
389+
| TyEnum variants ->
390+
List.exists (fun vd -> List.mem vd.vd_name.name wanted) variants
391+
| _ -> false)
392+
) public_types
393+
in
394+
List.filter_map (fun td ->
395+
if Hashtbl.mem type_already_in td.td_name.name then None
396+
else begin
397+
Hashtbl.add type_already_in td.td_name.name ();
398+
Some (TopType td)
399+
end
400+
) selected
401+
) prog.prog_imports
402+
in
403+
(* Types precede imported fns/consts and all local decls so the single-pass
404+
codegen registers an imported type before any function that uses it. *)
405+
{ prog with prog_decls = imported_types @ imported_decls @ prog.prog_decls }

test/test_stdlib_aot.ml

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,37 @@ let pipeline_to_deno (prog : Ast.program) : (string, string) result =
6161
| Error e -> Error (Printf.sprintf "deno-codegen: %s" e)
6262
| Ok js -> Ok js)))
6363

64+
(** Full AOT pipeline to the core-Wasm backend: resolve -> typecheck ->
65+
borrow -> [Codegen.generate_module] (loader-aware). Mirrors
66+
[pipeline_to_deno] but targets the backend whose cross-module constructor
67+
linking (#138) the test below regression-locks. The Wasm path feeds the
68+
original (un-flattened) [prog] to codegen and resolves imported decls
69+
natively via [Codegen.gen_imports]. *)
70+
let pipeline_to_wasm (prog : Ast.program) : (Wasm.wasm_module, string) result =
71+
let ld = loader () in
72+
match Resolve.resolve_program_with_loader prog ld with
73+
| Error (e, sp) ->
74+
Error (Printf.sprintf "resolve: %s @ %s"
75+
(Resolve.show_resolve_error e) (Span.show sp))
76+
| Ok (rctx, itc) ->
77+
(match
78+
Typecheck.check_program
79+
~import_types:itc.Typecheck.name_types rctx.symbols prog
80+
with
81+
| Error e ->
82+
Error (Printf.sprintf "typecheck: %s" (Typecheck.format_type_error e))
83+
| Ok _ ->
84+
(match Borrow.check_program rctx.symbols prog with
85+
| Error e ->
86+
Error (Printf.sprintf "borrow: %s" (Borrow.format_borrow_error e))
87+
| Ok () ->
88+
let optimized = Opt.fold_constants_program prog in
89+
(match Codegen.generate_module ~loader:ld optimized with
90+
| Error e ->
91+
Error (Printf.sprintf "wasm-codegen: %s"
92+
(Codegen.show_codegen_error e))
93+
| Ok m -> Ok m)))
94+
6495
let parse_file_safe path =
6596
try Ok (Parse_driver.parse_file path)
6697
with
@@ -140,6 +171,61 @@ let integration_tests =
140171
[ Alcotest.test_case "string+option+collections together" `Quick
141172
test_multi_module_integration ]
142173

174+
(* ---- #138: cross-module constructor linking on the core-Wasm backend ----
175+
176+
A consumer that imports prelude's Option/Result and APPLIES their
177+
constructors must reach a runnable artifact. The front-end resolves
178+
`Some`/`None`/`Ok`/`Err` through the module path, so `check` passes; before
179+
the #138 codegen fix [Codegen.gen_imports] dropped the imported TYPE decls,
180+
so the Wasm backend had no variant tag for `Some` and raised
181+
[UnboundVariable "...Some"] at codegen. This feeds the imported-constructor
182+
decl shape to all three [variant_tags] consumers in [Codegen]: construction
183+
with an argument (`Some(x)`, `Ok`/`Err`), the zero-arg form (`None`), and
184+
constructor patterns (`match`). It is the Wasm counterpart to the #137
185+
Deno-path integration above. *)
186+
let imported_ctors_src = {|
187+
module xmod_ctors;
188+
use prelude::{ Option, Some, None, Result, Ok, Err };
189+
190+
pub fn wrap(x: Int) -> Option<Int> { Some(x) }
191+
pub fn empty() -> Option<Int> { None }
192+
193+
pub fn unwrap_or(o: Option<Int>, d: Int) -> Int {
194+
match o {
195+
Some(v) => v,
196+
None => d,
197+
}
198+
}
199+
200+
pub fn divide(a: Int, b: Int) -> Result<Int, Int> {
201+
if b == 0 { Err(0) } else { Ok(a / b) }
202+
}
203+
|}
204+
205+
let test_imported_constructors_wasm () =
206+
match Parse_driver.parse_string ~file:"<xmod_ctors>" imported_ctors_src with
207+
| exception e ->
208+
Alcotest.failf "imported-ctors parse raised: %s" (Printexc.to_string e)
209+
| prog ->
210+
(match pipeline_to_wasm prog with
211+
| Ok m ->
212+
let names =
213+
List.map (fun (e : Wasm.export) -> e.Wasm.e_name) m.Wasm.exports in
214+
List.iter
215+
(fun fn ->
216+
Alcotest.(check bool)
217+
(Printf.sprintf "Wasm module exports %s" fn)
218+
true (List.mem fn names))
219+
[ "wrap"; "empty"; "unwrap_or"; "divide" ]
220+
| Error m ->
221+
Alcotest.failf
222+
"imported prelude constructors must codegen to Wasm (#138): %s" m)
223+
224+
let xmod_constructor_tests =
225+
[ Alcotest.test_case "imported Option/Result constructors -> Wasm" `Quick
226+
test_imported_constructors_wasm ]
227+
143228
let tests =
144229
[ ("STAGE-A AOT smoke (#136)", aot_smoke_tests);
145-
("STAGE-A multi-module integration (#137)", integration_tests) ]
230+
("STAGE-A multi-module integration (#137)", integration_tests);
231+
("cross-module constructor linking, Wasm (#138)", xmod_constructor_tests) ]

0 commit comments

Comments
 (0)