feat(codegen): pre-resolve the App callee in MIR; delete is_static_callee + type_home_module (mir.2)

Second re-deriver removal of the typed-MIR milestone (spec
docs/specs/0060-typed-mir.md, plan docs/plans/0116-mir.2-callee-static.md).
Static-callee resolution moves out of codegen and into lower_to_mir:
every Term::App callee is classified once, mirroring check's own synth
Term::Var ladder (lib.rs:3409-3582), and emitted as a resolved Callee.
Codegen reads the callee identity off MIR and routes each kind to the
right lowering. The codegen re-derivers is_static_callee and
type_home_module are deleted, and the lower_app <-> is_static_callee
lockstep pair is struck from CLAUDE.md.

Callee reshape (beyond the spec's original {module, fn_name} sketch —
spec 0060's Concrete-code-shapes block is updated in this iteration):

  pub enum Callee {
      Static  { module, fn_name, sig: Type },  // user/prelude fn -> emit_call, module pre-resolved
      Builtin { name, sig: Type },             // operator/intrinsic -> inline opcode lowering
      Indirect(Box<MTerm>),                    // dynamic: fn-pointer / closure / shadowed name
  }

Two forced refinements, both settled in planning:

- sig:Type on each resolved variant. drop::synth_callee_ret_mode reads
  the callee ret_mode, today off Callee::Indirect(inner).ty(). A
  resolved callee has no sub-term, so the sig (= synth_pure(callee),
  mode-preserving) is the lossless replacement. This is NOT a mir.3
  pull-forward: the MArg param-mode / consume_count fields stay at their
  mir.1 defaults. Without it, every statically-resolved call would lose
  its drop signal and the RawBuf / int_to_str RC-stats pins mir.1b fixed
  would regress.

- Builtin variant. Operators and the str-num builtins (+, not,
  str_concat, ...) are lowered inline by name and have no module/fn_name;
  a separate variant is cleaner than a sentinel module. The classifier
  decides Builtin vs Static from CHECK'S OWN resolution (a name in
  env.globals with no owning module is a builtin), never a copy of
  codegen's old is_static_callee allowlist. The recon's divergence audit
  confirmed check's builtin set and codegen's inline-arm set match
  exactly, so the single-engine rule is mechanically satisfiable with no
  env threading gap.

type_home_module is fully deletable: a workspace-wide grep confirmed no
program references a type-scoped T.fn as a value, so its only other
consumer (resolve_top_level_fn) collapses to the module-name fallback
with no behaviour change. (The spec's "x3 mirrors" wording over-counted;
the live surface was the definition plus two call sites.)

Alternatives rejected during planning: sentinel module in Static
(ugly); builtins left as Indirect (breaks — no fn-pointer for an
operator); name-rewrite Static{name,sig} keeping lower_app's by-name
dispatch (blurs builtin/user and still needs sig). The mir.1b
re-synth-strictness trap (post-mono synth_pure re-unify surfacing
mode-strip / bare-vs-qualified / metavar leaks) did NOT fire under the
new producer path — qualify_local_types mode-preservation and the $u
wildcard normalisation from mir.1b held.

Verification (orchestrator, post-implement inspect): diff matches the
plan; grep-clean for is_static_callee + type_home_module across
ailang-codegen and ail; cargo build --workspace green; cargo test
--workspace 700 passed / 0 failed / 3 ignored (no #[ignore] added this
iteration — the 3 are the pre-existing #49 Str-leg pin + two doctests).
Acceptance witnesses green: #53 (mono_new_over_user_adt_builds_and_runs),
#51 (rawbuf_new_int_size_only_builds_and_runs still builds), show_print
cross-module (print_user_adt_runs_end_to_end), the new mir.2 pins
(mir2_resolved_callee_kinds_run_end_to_end, callee_classification_*,
strengthened new_over_user_adt_carries_node_types asserting Callee::Static).
#49 heap-Str loop binder stays #[ignore] (lifts at mir.4).

Two new examples/ fixtures back the new pins (classify_pin.ail for the
producer classification pin, mir2_callee_kinds.ail for the E2E),
ail-parse / round-trip clean.

Forward note (cycle-close architect): crates/ail/tests/codegen_import_map_fallback_pin.rs:14-15
doc prose names lower_app's cross-module arm and synth_with_extras as
failure-mode targets — both now deleted (mir.2 / mir.1b). The pin's
protected invariant is still valid and green; the prose is stale and
wants a doc-honesty reword, left out of this iteration's footprint.
This commit is contained in:
2026-05-31 19:27:56 +02:00
parent d81ea93de6
commit a6fd93adba
10 changed files with 261 additions and 233 deletions
+65 -225
View File
@@ -60,10 +60,10 @@ use synth::{
/// the comparator names `==` / `!=` / `<` / `<=` / `>` / `>=` are
/// no longer surface-level identifiers (comparison goes through the
/// `eq` / `compare` class methods and the `float_*` named fns), so
/// only the five arithmetic ops live here. Used by `lower_app` to
/// decide whether to call `builtin_binop_typed`, and by
/// `is_static_callee` (in combination with `not`) to recognise
/// built-in callees during the global-resolution pass.
/// only the five arithmetic ops live here. Used by `lower_builtin` to
/// decide whether to call `builtin_binop_typed`. Callee classification
/// (which names are builtins) now lives in `lower_to_mir::classify_callee`,
/// not in codegen.
fn is_arithmetic_op(name: &str) -> bool {
matches!(name, "+" | "-" | "*" | "/" | "%")
}
@@ -1995,44 +1995,47 @@ impl<'a> Emitter<'a> {
}
Ok((phi, then_ty))
}
MTerm::App { callee, args, tail, .. } => {
// callee is `Callee::Indirect(Box<MTerm>)` at mir.1b;
// the static name, if any, is the inner `MTerm::Var`.
// (mir.2 replaces this with `Callee::Static` and deletes
// `is_static_callee`.)
let callee_mterm: &MTerm = match callee {
Callee::Indirect(inner) => inner.as_ref(),
Callee::Static { .. } => unreachable!("callee is Indirect until mir.2"),
};
// Direct call when the callee is a `Var` referring to a
// statically-known target (builtin, current-module fn,
// qualified cross-module fn) AND not shadowed by a local.
// Otherwise we fall through to the indirect-call path,
// which lowers the callee to a fn-pointer and looks up
// its sig in the sidetable.
if let MTerm::Var { name, .. } = callee_mterm {
let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name);
if !shadowed && self.is_static_callee(name) {
return self.lower_app(name, args, *tail);
MTerm::App { callee, args, tail, .. } => match callee {
// mir.2: the callee is pre-resolved in MIR by
// `lower_to_mir::classify_callee`. A builtin is lowered
// inline by name (opcode selection); a static
// user/prelude fn routes to `emit_call` with the module
// already resolved; an indirect callee is lowered to a
// fn-pointer and called via the sidetable sig. Codegen
// re-derives no callee identity and resolves no module.
Callee::Builtin { name, .. } => self.lower_builtin(name, args, *tail),
Callee::Static { module, fn_name, .. } => {
let sig = self
.module_user_fns
.get(module)
.and_then(|m| m.get(fn_name))
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"static call `{module}.{fn_name}`: no FnSig in workspace"
))
})?;
self.emit_call(module, fn_name, &sig, args, *tail)
}
Callee::Indirect(inner) => {
let (callee_ssa, callee_ty) = self.lower_term(inner)?;
if callee_ty != "ptr" {
return Err(CodegenError::Internal(format!(
"indirect call: callee type must be ptr, got {callee_ty}"
)));
}
let sig = self
.ssa_fn_sigs
.get(&callee_ssa)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"indirect call: no FnSig recorded for `{callee_ssa}`"
))
})?;
self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
}
let (callee_ssa, callee_ty) = self.lower_term(callee_mterm)?;
if callee_ty != "ptr" {
return Err(CodegenError::Internal(format!(
"indirect call: callee type must be ptr, got {callee_ty}"
)));
}
let sig = self
.ssa_fn_sigs
.get(&callee_ssa)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"indirect call: no FnSig recorded for `{callee_ssa}`"
))
})?;
self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
}
},
MTerm::Do { op, args, tail, .. } => self.lower_effect_op(op, args, *tail),
MTerm::Ctor { type_name, ctor, args, .. } => {
// pass the term pointer so `lower_ctor` can
@@ -2425,7 +2428,13 @@ impl<'a> Emitter<'a> {
)))
}
fn lower_app(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> {
/// Lower a `Callee::Builtin` call inline (opcode selection). Only
/// the operator / str-num builtins reach here; user/prelude fns are
/// `Callee::Static` and route to `emit_call`. (Pre-mir.2 this was
/// `lower_app`, which also walked the cross-module / current-module
/// / prelude resolution ladder — that ladder moved into
/// `lower_to_mir::classify_callee`.)
fn lower_builtin(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> {
// 2026-05-21 operator-routing-eq-ord: the `if name == "=="`
// short-circuit that routed through `lower_eq` is gone. `==`
// is no longer a surface identifier; the typechecker
@@ -2619,94 +2628,9 @@ impl<'a> Emitter<'a> {
return Ok((dst, "ptr".to_string()));
}
// Cross-module call: exactly one dot in the name → resolve via import map.
// Logic identical to the typechecker (see `synth` for `Term::Var`).
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
// fall back to direct module-name lookup when
// the prefix isn't in the current module's import_map. A
// post-mono synthesised body may carry cross-module
// references to modules its source template didn't import
// (e.g. `prelude.print__<UserType>` synthesised in
// `prelude` calls `show_user_adt.show__<UserType>` even
// though prelude does not import user modules). See the
// matching fallback in `resolve_top_level_fn` for the
// companion change on the fn-pointer Var-resolution path.
let target_module = match self.import_map.get(prefix).cloned() {
Some(m) => m,
None if self.module_user_fns.contains_key(prefix) => prefix.to_string(),
// #53: type-scoped `T.def` callee (e.g. `Counter.new`,
// produced by the monomorphic `Term::New` desugar). When
// `prefix` is not an import alias or module name but
// names a TypeDef in scope, the call resolves to the
// `def` fn in that type's home module — symmetric to the
// checker's TypeDef-first ladder. Keeps the documented
// `lower_app` ↔ `is_static_callee` lockstep honest:
// `is_static_callee` already returns true for every
// single-dot name, so this branch MUST resolve it.
None => match self.type_home_module(prefix) {
Some(home) => home,
None => {
return Err(CodegenError::Internal(format!(
"cross-module call `{name}`: prefix `{prefix}` not in import map"
)))
}
},
};
// iter 23.4: codegen-time poly-call dispatch removed. Post-mono
// every poly call site has been rewritten by `rewrite_mono_calls`
// to a monomorphic symbol, so the lookup-ladder below sees only
// user fns / consts / builtins. The pre-iter-23.4 path checked
// `module_polymorphic_fns` and dispatched to `lower_polymorphic_call`;
// both are gone (and the supporting Emitter fields with them).
let target_fns = self
.module_user_fns
.get(&target_module)
.ok_or_else(|| {
CodegenError::Internal(format!(
"cross-module call `{name}`: target module `{target_module}` not found in workspace"
))
})?;
let sig = target_fns
.get(suffix)
.cloned()
.ok_or_else(|| {
CodegenError::Internal(format!(
"cross-module call `{name}`: def `{suffix}` not in module `{target_module}`"
))
})?;
return self.emit_call(&target_module, suffix, &sig, args, tail);
}
// iter 23.4: codegen-time poly-call dispatch removed (see above).
// User function in the current module?
if let Some(sig) = self
.module_user_fns
.get(self.module_name)
.and_then(|m| m.get(name))
.cloned()
{
return self.emit_call(self.module_name, name, &sig, args, tail);
}
// operator-routing-eq-ord.1: prelude fallback for monomorphic
// prelude fns called bare from a non-prelude module (e.g.
// `(app float_eq …)`). Mirrors the fallback already in
// `is_static_callee` and `resolve_top_level_fn`.
if self.module_name != "prelude" {
if let Some(sig) = self
.module_user_fns
.get("prelude")
.and_then(|m| m.get(name))
.cloned()
{
return self.emit_call("prelude", name, &sig, args, tail);
}
}
Err(CodegenError::Internal(format!(
"unknown callee: `{name}`"
"lower_builtin: `{name}` is not a builtin (should have been \
classified as Static or Indirect in lower_to_mir)"
)))
}
@@ -2886,101 +2810,13 @@ impl<'a> Emitter<'a> {
}
/// #53: returns the module that declares the named TypeDef — the
/// current module first, then imported modules. `None` if no module
/// in scope declares a TypeDef of that name. A type's presence in a
/// module is read from `module_ctor_index` (a TypeDef declares at
/// least one ctor, each `CtorRef` carrying its `type_name`). This
/// mirrors the checker's TypeDef-first ladder so codegen resolves a
/// type-scoped dotted callee (`T.def`) exactly where the checker
/// does. Used by `lower_app` and `resolve_top_level_fn`.
fn type_home_module(&self, type_name: &str) -> Option<String> {
if let Some(ctors) = self.module_ctor_index.get(self.module_name) {
if ctors.values().any(|c| c.type_name == type_name) {
return Some(self.module_name.to_string());
}
}
for target in self.import_map.values() {
if let Some(ctors) = self.module_ctor_index.get(target) {
if ctors.values().any(|c| c.type_name == type_name) {
return Some(target.clone());
}
}
}
None
}
/// is `name` a callee that can be resolved at compile time
/// (no fn-pointer needed)? True for builtin operators, the
/// current-module top-level fns (mono or poly), and qualified
/// `prefix.def`. Locals shadow this — the caller checks for
/// that first.
fn is_static_callee(&self, name: &str) -> bool {
if is_arithmetic_op(name) || name == "not" {
return true;
}
// Floats iter 4.4: new fn-builtins (`neg`, `int_to_float`,
// `float_to_int_truncate`, `is_nan`, `float_to_str`) lower
// inline in `lower_app`, parallel to the operator path. Iter
// hs.4: `int_to_str` joins the list, lowering to
// `@ailang_int_to_str` from `runtime/str.c`.
if matches!(
name,
"neg"
| "int_to_float"
| "float_to_int_truncate"
| "is_nan"
| "float_to_str"
| "int_to_str"
| "bool_to_str"
| "str_clone"
| "str_concat"
) {
return true;
}
if name.matches('.').count() == 1 {
return true;
}
// iter 23.4: poly-def arm dropped — post-mono there are no
// `Type::Forall` defs in `module_user_fns` to begin with, and
// `module_polymorphic_fns` no longer exists.
if self
.module_user_fns
.get(self.module_name)
.is_some_and(|m| m.contains_key(name))
{
return true;
}
// operator-routing-eq-ord.1: the typechecker auto-imports
// prelude into every non-prelude module so a bare reference
// like `(app float_eq …)` typechecks against the prelude's
// monomorphic `float_eq` fn. The codegen needs the matching
// resolution: if the name is not in the current module but
// IS in prelude's monomorphic fn table, treat it as a static
// callee — the call lowers to `@ail_prelude_<name>` directly.
// Without this fallback, post-mono polymorphic-prelude calls
// (e.g. `print`) work because the mono pass synthesises a
// mono arm `print__T` into the user's module, but monomorphic
// prelude fns (no Forall to instantiate) never get a mirror
// arm in user modules and would otherwise fail at codegen
// with `unknown variable`.
if self.module_name != "prelude"
&& self
.module_user_fns
.get("prelude")
.is_some_and(|m| m.contains_key(name))
{
return true;
}
false
}
/// resolve `name` to a top-level fn-value, i.e. the address
/// of its static closure pair `@ail_<m>_<def>_clos`, plus the user-
/// visible FnSig (params/ret WITHOUT the env_ptr — that's added at
/// the call site by the closure ABI). Returns None if the name does
/// not refer to a top-level fn. Operators / `not` are not first-
/// class values; `is_static_callee` filters them earlier.
/// class values and never reach here as a `Callee::Static`/`Builtin`
/// — they are classified in `lower_to_mir` and lowered inline.
fn resolve_top_level_fn(&self, name: &str) -> Option<(String, FnSig)> {
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.')?;
@@ -2996,15 +2832,19 @@ impl<'a> Emitter<'a> {
// a valid post-mono construct because both ends were
// independently typechecked under their original module
// contexts before mono ran).
// #53: a type-scoped `T.def` prefix (e.g. `Counter.new`)
// resolves to the type's home module, symmetric to the
// checker's TypeDef-first ladder and the matching arm in
// `lower_app`'s cross-module branch. Tried after import_map
// and the literal-module-name fallback.
// mir.2: the codegen-side type-home resolver is gone. A
// type-scoped `T.fn` reference in VALUE position (the only
// path that used the type-home fallback here) is unreachable in the current
// corpus — every dotted `T.fn` is a call head, resolved as
// `Callee::Static` in lower_to_mir. The remaining value-
// position dotted forms (`Mod.fn`) resolve through
// `import_map` / the module-name fallback. If a type-scoped
// fn is ever taken as a first-class value, its home-module
// resolution moves into MIR at mir.5 (the last re-derivation
// residue); until then the module-name fallback is correct.
let target: String = match self.import_map.get(prefix) {
Some(m) => m.clone(),
None if self.module_user_fns.contains_key(prefix) => prefix.to_string(),
None => self.type_home_module(prefix).unwrap_or_else(|| prefix.to_string()),
None => prefix.to_string(),
};
let sig = self.module_user_fns.get(&target)?.get(suffix)?.clone();
return Some((format!("@ail_{target}_{suffix}_clos"), sig));