Iter 14h: cross-module parameterised-ADT import (15a unblocked)

The 15a tester surfaced a real compiler limitation: cross-module
type and ctor references were not implemented. Iter 5b only
carried fns + consts via module_globals; types/ctors stayed
module-local with an explicit DESIGN comment. This iter completes
the cross-module mechanism using the Iter-5b convention:
qualified-only access via module.Name.

Implementation:
- ailang-check: Env.module_types populated by build_module_types.
  Qualified resolution in Type::Con, Term::Ctor, with cross-module
  fallback for pat-ctor (local wins, multi-import collision -> new
  ambiguous-ctor diagnostic). Four new unit tests.
- ailang-codegen: workspace-level module_ctor_index replaces
  per-Emitter table. lookup_ctor_by_type / lookup_ctor_in_pattern
  thread qualified type names through box-tag and field-type
  resolution.
- examples/std_maybe_demo.{ailx,ail.json}: type-name slots now
  qualified (std_maybe.Maybe).
- New e2e test cross_module_maybe_demo asserts the demo prints
  ["7","99","true","true","42"].

Net diff ~550 LOC. Tests 80 -> 85. All Iter 14a regressions
(parameterised_box_round_trip, parameterised_maybe_match,
list_map_poly_inc_then_prints, polymorphic_id_at_int_and_bool)
verified green — the 14h derive_substitution change (default
unpinned forall vars to Unit for monomorphiser) sits on a
different layer than 14a's $u-wildcard fix and they coexist.

Hash invariance: all five std_maybe def hashes unchanged. All
80-test-suite fixtures retain bit-identical hashes — cross-module
support is purely additive at the language level.

Process note: the std_maybe.ailx file landed in the 14g commit
via a sloppy git-add-A; should have spotted it before staging.
Not a correctness issue but a hygiene one.

Implementer flagged Unit-default monomorphisation as wasteful-
but-correct; rethink if stdlib grows toward overload-resolution-
style cases needing distinct unconstrained instantiations.

std_maybe stdlib effectively ships: module + four combinators +
e2e-tested consumer demo. Plan 15b: std_list importing std_maybe,
exercising Maybe-returning head/tail and tail-call-marked
fold_left.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:08:56 +02:00
parent 41d406bcbb
commit 12e9a9c0cc
8 changed files with 837 additions and 70 deletions
+249 -51
View File
@@ -182,10 +182,16 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
let mut module_user_fns: BTreeMap<String, BTreeMap<String, FnSig>> = BTreeMap::new();
let mut module_def_ail_types: BTreeMap<String, BTreeMap<String, Type>> = BTreeMap::new();
let mut module_polymorphic_fns: BTreeMap<String, BTreeMap<String, FnDef>> = BTreeMap::new();
// Iter 15a: cross-module ctor table. Maps module name → ctor name →
// CtorRef (with `type_name` *unqualified*, since the ctor is defined
// in that module). Cross-module ctor lookups resolve through this
// table instead of the per-Emitter `ctor_index`.
let mut module_ctor_index: BTreeMap<String, BTreeMap<String, CtorRef>> = BTreeMap::new();
for (mname, m) in &ws.modules {
let mut user_fns = BTreeMap::new();
let mut ail_types = BTreeMap::new();
let mut poly_fns = BTreeMap::new();
let mut ctors = BTreeMap::new();
for def in &m.defs {
if let Def::Fn(f) = def {
ail_types.insert(f.name.clone(), f.ty.clone());
@@ -205,10 +211,30 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
_ => {}
}
}
if let Def::Type(td) = def {
for (i, c) in td.ctors.iter().enumerate() {
let fields: Vec<String> = c
.fields
.iter()
.map(|t| llvm_type(t).unwrap_or_else(|_| "ptr".into()))
.collect();
ctors.insert(
c.name.clone(),
CtorRef {
type_name: td.name.clone(),
tag: i as u32,
fields,
ail_fields: c.fields.clone(),
type_vars: td.vars.clone(),
},
);
}
}
}
module_user_fns.insert(mname.clone(), user_fns);
module_def_ail_types.insert(mname.clone(), ail_types);
module_polymorphic_fns.insert(mname.clone(), poly_fns);
module_ctor_index.insert(mname.clone(), ctors);
}
// Pass 2: lower per module. Globals/strings are accumulated per module,
@@ -229,6 +255,7 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
&module_user_fns,
&module_def_ail_types,
&module_polymorphic_fns,
&module_ctor_index,
import_map,
);
emitter
@@ -349,13 +376,18 @@ struct Emitter<'a> {
/// Import map of the current module (alias/module name → actual module name).
import_map: BTreeMap<String, String>,
/// ADT table: type_name -> list of ctors in definition order.
/// Tag of a ctor = index in this list. Replicated in `ctor_index`;
/// kept around for future tools (pretty-printer for ADT values,
/// Tag of a ctor = index in this list.
/// Kept around for future tools (pretty-printer for ADT values,
/// decision-tree optimization).
#[allow(dead_code)]
types: BTreeMap<String, Vec<CtorInfo>>,
/// Inverse index: ctor name -> (type_name, tag, field_llvm_types).
ctor_index: BTreeMap<String, CtorRef>,
/// Iter 15a: cross-module ctor index, keyed by module name. Used by
/// `lookup_ctor_by_type` (for `Term::Ctor.type_name`) and
/// `lookup_ctor_in_pattern` (for `Pattern::Ctor.ctor`). Built once
/// per workspace and shared by every Emitter. Replaces the per-
/// emitter `ctor_index` of pre-15a — that table only knew the
/// current module's ctors and broke on cross-module references.
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
/// Current basic block label. Set by `start_block` and is
/// the single source of truth for `phi` operands.
current_block: String,
@@ -426,14 +458,14 @@ impl<'a> Emitter<'a> {
module_user_fns: &'a BTreeMap<String, BTreeMap<String, FnSig>>,
module_def_ail_types: &'a BTreeMap<String, BTreeMap<String, Type>>,
module_polymorphic_fns: &'a BTreeMap<String, BTreeMap<String, FnDef>>,
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
import_map: BTreeMap<String, String>,
) -> Self {
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
let mut ctor_index: BTreeMap<String, CtorRef> = BTreeMap::new();
for def in &module.defs {
if let Def::Type(td) = def {
let mut infos = Vec::new();
for (i, c) in td.ctors.iter().enumerate() {
for c in td.ctors.iter() {
// Iter 13b: precomputed LLVM field types are only
// meaningful for monomorphic ADTs. For parameterised
// ADTs the field types reference free `Type::Var`s
@@ -450,18 +482,8 @@ impl<'a> Emitter<'a> {
.collect();
infos.push(CtorInfo {
name: c.name.clone(),
fields: fields.clone(),
fields,
});
ctor_index.insert(
c.name.clone(),
CtorRef {
type_name: td.name.clone(),
tag: i as u32,
fields,
ail_fields: c.fields.clone(),
type_vars: td.vars.clone(),
},
);
}
types.insert(td.name.clone(), infos);
}
@@ -483,7 +505,7 @@ impl<'a> Emitter<'a> {
mono_emitted: BTreeSet::new(),
import_map,
types,
ctor_index,
module_ctor_index,
current_block: String::new(),
block_terminated: false,
ssa_fn_sigs: BTreeMap::new(),
@@ -948,6 +970,122 @@ impl<'a> Emitter<'a> {
}
}
/// Iter 15a: resolves a ctor reference by `type_name` (which may be
/// qualified `module.T` or bare `T`) plus a bare ctor name. Returns
/// the same `CtorRef` shape used by the local `ctor_index`. The
/// returned `CtorRef.type_name` is always the bare type name as
/// declared in the owning module. The current `module_name` is the
/// authority for "bare" — that lets a specialised fn body emitted
/// under a swapped `module_name` (see `emit_specialised_fn`)
/// resolve its bare ctor references against the *owner* module's
/// ctor table, not the consumer's.
fn lookup_ctor_by_type(
&self,
type_name: &str,
ctor_name: &str,
) -> Result<CtorRef> {
if type_name.matches('.').count() == 1 {
let (prefix, suffix) = type_name.split_once('.').expect("checked");
let target_module = self.import_map.get(prefix).cloned().ok_or_else(|| {
CodegenError::Internal(format!(
"qualified ctor `{type_name}/{ctor_name}`: prefix `{prefix}` not in import map"
))
})?;
let cref = self
.module_ctor_index
.get(&target_module)
.and_then(|m| m.get(ctor_name))
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"qualified ctor `{type_name}/{ctor_name}` not in module `{target_module}`"
))
})?;
if cref.type_name != suffix {
return Err(CodegenError::Internal(format!(
"ctor `{ctor_name}` belongs to `{}`, not `{type_name}`",
cref.type_name
)));
}
Ok(cref)
} else {
let cref = self
.module_ctor_index
.get(self.module_name)
.and_then(|m| m.get(ctor_name))
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"unknown ctor `{ctor_name}` in module `{}`",
self.module_name
))
})?;
if cref.type_name != type_name {
return Err(CodegenError::Internal(format!(
"ctor `{ctor_name}` belongs to `{}`, not `{type_name}`",
cref.type_name
)));
}
Ok(cref)
}
}
/// Iter 15a: collects the set of type names declared in `owner_module`.
/// Used to mirror the typechecker's `qualify_local_types` rewrite
/// when reading a polymorphic fn's signature pulled across the
/// import boundary.
fn collect_owner_local_types(&self, owner_module: &str) -> BTreeSet<String> {
self.module_ctor_index
.get(owner_module)
.map(|m| {
m.values()
.map(|c| c.type_name.clone())
.collect::<BTreeSet<_>>()
})
.unwrap_or_default()
}
/// Iter 15a: resolves a ctor in pattern position. The current
/// `module_name`'s ctor table is consulted first; on miss, the
/// imported modules are scanned (the typechecker has already
/// vetted unambiguity, so the first hit wins — local always
/// shadows imported on conflict). Using `module_name` rather than
/// `self.ctor_index` matters when emitting a specialised fn body
/// in the owner's module context (see `emit_specialised_fn`).
fn lookup_ctor_in_pattern(&self, ctor_name: &str) -> Result<CtorRef> {
if let Some(cref) = self
.module_ctor_index
.get(self.module_name)
.and_then(|m| m.get(ctor_name))
.cloned()
{
return Ok(cref);
}
// Walk the *current* module's imports for fallback. When
// emitting a specialised fn body in another module, the
// emitter's `import_map` is still the consumer's; we want the
// owner's. Look up the owner module's import map indirectly
// through `self.module` whenever it equals `self.module_name`,
// and fall back to the active `import_map` only when we are
// genuinely emitting in the consumer module. Since
// `emit_specialised_fn` swaps only `module_name`, not
// `import_map`, the fallback below covers both cases by
// additionally searching every module in `module_ctor_index`
// — that's cheap (number of modules in a workspace is small)
// and the typechecker has already pinned uniqueness.
for (mname, ctors) in self.module_ctor_index.iter() {
if mname == self.module_name {
continue;
}
if let Some(cref) = ctors.get(ctor_name).cloned() {
return Ok(cref);
}
}
Err(CodegenError::Internal(format!(
"unknown ctor in pattern: `{ctor_name}`"
)))
}
/// Heap box layout: 8 bytes tag (i64) followed by 8 bytes per field.
/// i1 and i8 fields also occupy a full 8-byte slot — the typed
/// load/store instructions write/read only the required size.
@@ -957,21 +1095,7 @@ impl<'a> Emitter<'a> {
ctor_name: &str,
args: &[Term],
) -> Result<(String, String)> {
let cref = self
.ctor_index
.get(ctor_name)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"unknown ctor `{ctor_name}`"
))
})?;
if cref.type_name != type_name {
return Err(CodegenError::Internal(format!(
"ctor `{ctor_name}` belongs to `{}`, not `{type_name}`",
cref.type_name
)));
}
let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
if args.len() != cref.ail_fields.len() {
return Err(CodegenError::Internal(format!(
"ctor `{type_name}/{ctor_name}` arity"
@@ -1068,15 +1192,9 @@ impl<'a> Emitter<'a> {
open_var = Some(name.clone());
}
Pattern::Ctor { ctor, fields } => {
let cref = self
.ctor_index
.get(ctor)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"unknown ctor in pattern: `{ctor}`"
))
})?;
// Iter 15a: lookup falls back to imported modules when
// a bare ctor name doesn't resolve locally.
let cref = self.lookup_ctor_in_pattern(ctor)?;
let bindings: Vec<Option<String>> = fields
.iter()
.map(|p| match p {
@@ -1386,6 +1504,23 @@ impl<'a> Emitter<'a> {
)));
}
};
// Iter 15a: when we're calling into another module, the fn's
// params and ret reference local type names that — from this
// call site's perspective — are qualified `module.T`. Qualify
// before deriving the substitution so the unification mirrors
// what the typechecker has already validated.
let (params, ret) = if owner_module != self.module_name {
let owner_types = self.collect_owner_local_types(owner_module);
(
params
.iter()
.map(|p| qualify_local_types_codegen(p, owner_module, &owner_types))
.collect::<Vec<_>>(),
qualify_local_types_codegen(&ret, owner_module, &owner_types),
)
} else {
(params, ret)
};
// Derive the substitution by comparing the declared param
// types against the actual arg types.
@@ -2118,7 +2253,18 @@ impl<'a> Emitter<'a> {
.get(target)
.and_then(|m| m.get(suffix))
{
return Ok(ty.clone());
// Iter 15a: qualify any bare type-cons that
// refer to types declared in `target` so the
// returned signature lines up with the
// qualified ctors / type names produced
// elsewhere in the consumer module. Mirrors
// the typechecker's `qualify_local_types`.
let owner_local_types = self.collect_owner_local_types(target);
return Ok(qualify_local_types_codegen(
ty,
target,
&owner_local_types,
));
}
}
}
@@ -2188,11 +2334,10 @@ impl<'a> Emitter<'a> {
// types. For monomorphic ADTs (`type_vars.is_empty()`)
// we keep the pre-13b shape `Type::Con { args: vec![] }`
// — matching what the typechecker produces.
let cref = self.ctor_index.get(ctor).cloned().ok_or_else(|| {
CodegenError::Internal(format!(
"synth_arg_type: unknown ctor `{ctor}`"
))
})?;
// Iter 15a: a qualified `type_name` resolves through the
// cross-module ctor index. The result `Type::Con.name`
// stays qualified to match what the typechecker emits.
let cref = self.lookup_ctor_by_type(type_name, ctor)?;
if cref.type_vars.is_empty() {
return Ok(Type::Con {
name: type_name.clone(),
@@ -2359,11 +2504,17 @@ fn derive_substitution(
// through return-type unification, but at the call site we only
// see args; if needed, callers can extend this with expected-ret
// info.
// Iter 15a: a forall var that the args couldn't pin (e.g.
// `is_none(Nothing) : forall a. (Maybe a) -> Bool` — `a` is
// genuinely unobservable from the args alone) defaults to `Unit`.
// The specialised body must not actually read an `a`-typed value,
// or it would have failed type-checking; a dummy concrete type is
// sound and lets monomorphisation proceed deterministically. The
// descriptor uses the same default, so all such call sites
// converge on a single specialisation.
for v in vars {
if !subst.contains_key(v) {
return Err(CodegenError::Internal(format!(
"monomorphisation: type var `{v}` not pinned by call args"
)));
subst.insert(v.clone(), Type::unit());
}
}
Ok(subst)
@@ -2436,6 +2587,53 @@ fn unify_for_subst(
}
}
/// Iter 15a: rewrites bare `Type::Con` references that resolve against
/// `owner_local_types` into qualified `module.Type` form. Mirrors
/// `ailang_check::qualify_local_types`. Used when the codegen pulls a
/// polymorphic fn signature across the import boundary; without this
/// the substitution derived from the call site's qualified args
/// (`std_maybe.Maybe<Int>`) would fail to unify against the bare
/// signature (`Maybe<a>`).
fn qualify_local_types_codegen(
t: &Type,
owner_module: &str,
owner_local_types: &BTreeSet<String>,
) -> Type {
match t {
Type::Con { name, args } => {
let qualified = if name.contains('.') {
name.clone()
} else if matches!(name.as_str(), "Int" | "Bool" | "Unit" | "Str") {
name.clone()
} else if owner_local_types.contains(name) {
format!("{owner_module}.{name}")
} else {
name.clone()
};
Type::Con {
name: qualified,
args: args
.iter()
.map(|a| qualify_local_types_codegen(a, owner_module, owner_local_types))
.collect(),
}
}
Type::Fn { params, ret, effects } => Type::Fn {
params: params
.iter()
.map(|p| qualify_local_types_codegen(p, owner_module, owner_local_types))
.collect(),
ret: Box::new(qualify_local_types_codegen(ret, owner_module, owner_local_types)),
effects: effects.clone(),
},
Type::Forall { vars, body } => Type::Forall {
vars: vars.clone(),
body: Box::new(qualify_local_types_codegen(body, owner_module, owner_local_types)),
},
Type::Var { .. } => t.clone(),
}
}
/// Iter 12b: substitute rigid type vars in `t` according to `subst`.
/// Used to specialise the type of a polymorphic def for a given
/// instantiation.