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
-1
View File
@@ -308,7 +308,6 @@ the cross-references column of a file-map.
| Pair | Failure mode | | Pair | Failure mode |
|---|---| |---|---|
| `Pattern::Lit::*` typecheck rejects in `crates/ailang-check/src/lib.rs` ↔ pre-desugar walkers in `crates/ailang-check/src/pre_desugar_validation.rs` | A reject arm placed AFTER `desugar_module` in the call chain is unreachable through the public `check` API if the desugar pass rewrites that pattern shape first (e.g. `desugar::build_eq` rewrites `Pattern::Lit::Float` into `(== scrut lit)` before typecheck runs). New rejects on a `Pattern::Lit::*` shape MUST live in `pre_desugar_validation.rs`, or be paired with a written guarantee that no desugar pass eats the shape. | | `Pattern::Lit::*` typecheck rejects in `crates/ailang-check/src/lib.rs` ↔ pre-desugar walkers in `crates/ailang-check/src/pre_desugar_validation.rs` | A reject arm placed AFTER `desugar_module` in the call chain is unreachable through the public `check` API if the desugar pass rewrites that pattern shape first (e.g. `desugar::build_eq` rewrites `Pattern::Lit::Float` into `(== scrut lit)` before typecheck runs). New rejects on a `Pattern::Lit::*` shape MUST live in `pre_desugar_validation.rs`, or be paired with a written guarantee that no desugar pass eats the shape. |
| `lower_app` arms in `crates/ailang-codegen/src/lib.rs::lower_app` (line ~1749) ↔ name recognition in `crates/ailang-codegen/src/lib.rs::is_static_callee` (line ~2189) | A name lowered by a direct `lower_app` arm but unrecognised by `is_static_callee` falls through to the indirect-call path with `UnknownVar` (`UnknownVar` panic in worst case). Every new builtin lowering arm in `lower_app` must have a matching `is_static_callee` entry. |
| `INTERCEPTS` entries in `crates/ailang-codegen/src/intercepts.rs``(intrinsic)` markers (`Term::Intrinsic` bodies) in the kernel-tier sources (`examples/prelude.ail`, `crates/ailang-kernel/src/kernel_stub/source.ail`) | A registry entry without a marker is a compiler-supplied body no source declares; a marker without an entry is an `(intrinsic)` fn codegen cannot emit. The bijection (modulo the optimisation-only allowlist — registry entries that intercept the monomorphised `__Int` specialisation of a real-bodied polyfn) is enforced by `intercepts::tests::intercepts_bijection_with_intrinsic_markers`. Every new intrinsic-backed intercept needs both an `INTERCEPTS` entry and an `(intrinsic)` marker; every new optimisation-only intercept needs an `OPTIMISATION_ONLY` allowlist entry. | | `INTERCEPTS` entries in `crates/ailang-codegen/src/intercepts.rs``(intrinsic)` markers (`Term::Intrinsic` bodies) in the kernel-tier sources (`examples/prelude.ail`, `crates/ailang-kernel/src/kernel_stub/source.ail`) | A registry entry without a marker is a compiler-supplied body no source declares; a marker without an entry is an `(intrinsic)` fn codegen cannot emit. The bijection (modulo the optimisation-only allowlist — registry entries that intercept the monomorphised `__Int` specialisation of a real-bodied polyfn) is enforced by `intercepts::tests::intercepts_bijection_with_intrinsic_markers`. Every new intrinsic-backed intercept needs both an `INTERCEPTS` entry and an `(intrinsic)` marker; every new optimisation-only intercept needs an `OPTIMISATION_ONLY` allowlist entry. |
Walk procedure: for each milestone-scope commit-range arm Walk procedure: for each milestone-scope commit-range arm
+20 -1
View File
@@ -2748,7 +2748,8 @@ fn ail_run_accepts_ail_source_with_same_stdout_as_ail_json() {
/// `int_to_str(42)` prints "42\n" through the standard /// `int_to_str(42)` prints "42\n" through the standard
/// `do io/print_str(...)` path. Smoke pin for the heap-Str builtin /// `do io/print_str(...)` path. Smoke pin for the heap-Str builtin
/// wired in hs.4 — exercises checker (signature install), codegen /// wired in hs.4 — exercises checker (signature install), codegen
/// (lower_app arm + IR-header declare + is_static_callee whitelist), /// (lower_builtin arm + IR-header declare + `Callee::Builtin`
/// classification in lower_to_mir),
/// runtime (str.c's `ailang_int_to_str` allocates a heap-Str slab, /// runtime (str.c's `ailang_int_to_str` allocates a heap-Str slab,
/// @fputs reads from the bytes pointer at offset 8 with @stdout as /// @fputs reads from the bytes pointer at offset 8 with @stdout as
/// the FILE*), and the /// the FILE*), and the
@@ -2761,6 +2762,24 @@ fn int_to_str_smoke() {
assert_eq!(out, "42\n"); assert_eq!(out, "42\n");
} }
/// mir.2 end-to-end: a program whose `main` exercises all three
/// resolved-callee kinds compiles and prints the correct result
/// through codegen's post-mir.2 `MTerm::App` consumer. `bump`'s body
/// `(app + n 1)` is a `Callee::Builtin` (inline opcode), `(app bump
/// 41)` is a same-module `Callee::Static`, and `(app int_to_str r)`
/// is a cross-module (prelude-fallback) `Callee::Static`. The
/// property protected: codegen reads the callee identity off MIR and
/// routes each kind to the right lowering — builtins inline, static
/// fns to `emit_call` with the module pre-resolved, dynamic callees
/// to the fn-pointer path — deriving nothing about the callee itself.
/// A regression that mis-resolves any kind surfaces as a wrong result
/// or a build failure here. `bump(41) = 42`, printed with no newline.
#[test]
fn mir2_resolved_callee_kinds_run_end_to_end() {
let out = build_and_run("mir2_callee_kinds.ail");
assert_eq!(out, "42");
}
/// `float_to_str(3.5)` prints a libc-%g-conformant /// `float_to_str(3.5)` prints a libc-%g-conformant
/// rendering of 3.5. The runtime's `ailang_float_to_str` uses /// rendering of 3.5. The runtime's `ailang_float_to_str` uses
/// `snprintf(..., "%g", x)` with no locale call, so the C locale /// `snprintf(..., "%g", x)` with no locale call, so the C locale
+84 -2
View File
@@ -106,6 +106,75 @@ fn arg(term: MTerm) -> MArg {
MArg { term, mode: Mode::Owned, consume_count: 1 } MArg { term, mode: Mode::Owned, consume_count: 1 }
} }
/// The resolved identity of an App callee, mirroring synth's
/// `Term::Var` resolution ladder (`lib.rs:3409-3582`). `lower_term`
/// turns this into the matching `Callee` variant. Resolution uses
/// check's own env (the single engine) — never a copy of codegen's
/// `is_static_callee` allowlist.
enum CalleeClass {
Builtin { name: String },
Static { module: String, fn_name: String },
Indirect,
}
fn classify_callee(ctx: &Ctx, callee: &Term) -> CalleeClass {
// A non-`Var` callee (lambda, applied expression, …) is dynamic.
let name = match callee {
Term::Var { name } => name.clone(),
_ => return CalleeClass::Indirect,
};
// rung 1: shadowed by a local binder → dynamic (synth `lib.rs:3409`).
if ctx.locals.contains_key(&name) {
return CalleeClass::Indirect;
}
// rung 5: dotted `T.fn` / `Mod.fn` — the TypeDef-first ladder
// (synth `lib.rs:3557-3582`). `T.fn` resolves to T's home module;
// `Mod.fn` to the imported module. This replaces codegen's
// `type_home_module`.
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
let type_home = ctx
.env
.module_types
.iter()
.find_map(|(m, types)| types.contains_key(prefix).then(|| m.clone()));
let target = type_home.or_else(|| ctx.env.imports.get(prefix).cloned());
return match target {
Some(module) => CalleeClass::Static { module, fn_name: suffix.to_string() },
// Unreachable for typechecked input (check would have
// raised TypeScopedReceiverNotAType); defensive dynamic.
None => CalleeClass::Indirect,
};
}
// rung 2: same-module global. It is a user fn IFF this module's
// declared globals carry the name; otherwise it is a builtin —
// both live in `env.globals` (synth `lib.rs:3416-3425`).
if ctx.env.globals.contains_key(&name) {
let is_user_fn = ctx
.env
.module_globals
.get(&ctx.env.current_module)
.is_some_and(|m| m.contains_key(&name));
return if is_user_fn {
CalleeClass::Static { module: ctx.env.current_module.clone(), fn_name: name }
} else {
CalleeClass::Builtin { name }
};
}
// rung 3: implicit-import (prelude-fallback) fn (synth `lib.rs:3428-3435`).
if let Some(module) = ctx.env.imports.values().find_map(|m| {
ctx.env
.module_globals
.get(m)
.filter(|g| g.contains_key(&name))
.map(|_| m.clone())
}) {
return CalleeClass::Static { module, fn_name: name };
}
// Typechecked input always resolves above; defensive dynamic.
CalleeClass::Indirect
}
/// Lower one term to `MTerm`, filling `ty` from `synth_pure`. /// Lower one term to `MTerm`, filling `ty` from `synth_pure`.
fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> { fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
let ty = ctx.synth_pure(t)?; let ty = ctx.synth_pure(t)?;
@@ -118,9 +187,22 @@ fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
Term::Var { name } => MTerm::Var { name: name.clone(), ty }, Term::Var { name } => MTerm::Var { name: name.clone(), ty },
// callee is Indirect at mir.1 (mir.2 resolves Static). // mir.2: classify the callee against check's own resolution
// ladder and emit the resolved `Callee`. `sig` is the callee's
// fn-type (mode-preserving via `synth_pure`), carried so
// codegen's drop path reads the callee `ret_mode`.
Term::App { callee, args, tail } => { Term::App { callee, args, tail } => {
let m_callee = Callee::Indirect(Box::new(lower_term(ctx, callee)?)); let class = classify_callee(ctx, callee);
let sig = ctx.synth_pure(callee)?;
let m_callee = match class {
CalleeClass::Builtin { name } => Callee::Builtin { name, sig },
CalleeClass::Static { module, fn_name } => {
Callee::Static { module, fn_name, sig }
}
CalleeClass::Indirect => {
Callee::Indirect(Box::new(lower_term(ctx, callee)?))
}
};
let m_args = args let m_args = args
.iter() .iter()
.map(|a| Ok(arg(lower_term(ctx, a)?))) .map(|a| Ok(arg(lower_term(ctx, a)?)))
+48 -1
View File
@@ -6,7 +6,7 @@
//! pins protect the producer; codegen consumption arrives in mir.1b. //! pins protect the producer; codegen consumption arrives in mir.1b.
use ailang_check::elaborate_workspace; use ailang_check::elaborate_workspace;
use ailang_mir::{MTerm, MirWorkspace, StrRep}; use ailang_mir::{Callee, MTerm, MirWorkspace, StrRep};
use ailang_surface::load_workspace; use ailang_surface::load_workspace;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -70,6 +70,22 @@ fn new_over_user_adt_carries_node_types() {
ty_str.contains("Counter"), ty_str.contains("Counter"),
"lowered (new Counter 42) init node ty is Counter, got {ty_str}" "lowered (new Counter 42) init node ty is Counter, got {ty_str}"
); );
// mir.2: the `(new Counter 42)` desugars to `App{callee:
// Var{"Counter.new"}}`; lower_to_mir must resolve it to a
// `Callee::Static` whose module is `Counter`'s home (its own
// module, spelled bare per the own-module-types-stay-bare rule),
// NOT leave it `Indirect` for codegen's deleted ladder to resolve.
let MTerm::App { callee, .. } = init.as_ref() else {
panic!("let init is the (new Counter 42) App node");
};
match callee {
ailang_mir::Callee::Static { module, fn_name, .. } => {
assert_eq!(module, "new_counter_user_adt", "Counter.new home module");
assert_eq!(fn_name, "new", "Counter.new fn_name");
}
other => panic!("expected Callee::Static for Counter.new, got {other:?}"),
}
} }
#[test] #[test]
@@ -102,6 +118,37 @@ fn poly_free_fn_accumulating_into_own_adt_elaborates() {
assert!(mir.modules.contains_key("std_list")); assert!(mir.modules.contains_key("std_list"));
} }
#[test]
fn callee_classification_builtin_and_static() {
// mir.2: `lower_to_mir::classify_callee` resolves each App callee
// against check's own synth ladder, so codegen reads the resolved
// identity off MIR. This pins two of the three legs from the
// `classify_pin` witness: an arithmetic op (`+`) is a `Builtin`
// (no owning module), a bare same-module user fn (`bump`) is a
// `Static` carrying its own module name. The `Indirect` leg (a
// fn-typed local) is covered by the existing closure e2e tests.
let m = "classify_pin";
let mir = elaborate_fixture(m);
// `bump`'s body is `(app + n 1)` → the `+` callee is a Builtin.
match body(&mir, m, "bump") {
MTerm::App { callee: Callee::Builtin { name, .. }, .. } => {
assert_eq!(name, "+", "arithmetic op classified as Builtin");
}
other => panic!("expected Builtin `+`, got {other:?}"),
}
// `main`'s body is `(app bump 41)` → `bump` is a same-module user
// fn → Static{module: "classify_pin", fn_name: "bump"}.
match body(&mir, m, "main") {
MTerm::App { callee: Callee::Static { module, fn_name, .. }, .. } => {
assert_eq!(module, "classify_pin", "own-module callee module");
assert_eq!(fn_name, "bump", "own-module callee fn_name");
}
other => panic!("expected Static `bump`, got {other:?}"),
}
}
/// Recursively search for a `MTerm::Str { rep: Static }` reachable /// Recursively search for a `MTerm::Str { rep: Static }` reachable
/// from a loop binder init (the seed). /// from a loop binder init (the seed).
fn find_static_str_seed(t: &MTerm) -> bool { fn find_static_str_seed(t: &MTerm) -> bool {
+5 -1
View File
@@ -508,9 +508,13 @@ impl<'a> Emitter<'a> {
/// [`Self::drop_symbol_for_binder`] App-arm to decide both /// [`Self::drop_symbol_for_binder`] App-arm to decide both
/// trackability and the drop-fn symbol. /// trackability and the drop-fn symbol.
fn synth_callee_ret_mode(&self, callee: &Callee) -> Option<ParamMode> { fn synth_callee_ret_mode(&self, callee: &Callee) -> Option<ParamMode> {
// A resolved callee carries its fn-type as `sig`; an indirect
// one carries it on the boxed sub-term. Both yield the callee
// `ret_mode` identically — there is no behaviour change from
// mir.1b, only a different place the same fn-type is read from.
let cty = match callee { let cty = match callee {
Callee::Indirect(inner) => inner.ty(), Callee::Indirect(inner) => inner.ty(),
Callee::Static { .. } => return None, Callee::Static { sig, .. } | Callee::Builtin { sig, .. } => sig.clone(),
}; };
match cty { match cty {
Type::Fn { ret_mode, .. } => Some(ret_mode), Type::Fn { ret_mode, .. } => Some(ret_mode),
+65 -225
View File
@@ -60,10 +60,10 @@ use synth::{
/// the comparator names `==` / `!=` / `<` / `<=` / `>` / `>=` are /// the comparator names `==` / `!=` / `<` / `<=` / `>` / `>=` are
/// no longer surface-level identifiers (comparison goes through the /// no longer surface-level identifiers (comparison goes through the
/// `eq` / `compare` class methods and the `float_*` named fns), so /// `eq` / `compare` class methods and the `float_*` named fns), so
/// only the five arithmetic ops live here. Used by `lower_app` to /// only the five arithmetic ops live here. Used by `lower_builtin` to
/// decide whether to call `builtin_binop_typed`, and by /// decide whether to call `builtin_binop_typed`. Callee classification
/// `is_static_callee` (in combination with `not`) to recognise /// (which names are builtins) now lives in `lower_to_mir::classify_callee`,
/// built-in callees during the global-resolution pass. /// not in codegen.
fn is_arithmetic_op(name: &str) -> bool { fn is_arithmetic_op(name: &str) -> bool {
matches!(name, "+" | "-" | "*" | "/" | "%") matches!(name, "+" | "-" | "*" | "/" | "%")
} }
@@ -1995,44 +1995,47 @@ impl<'a> Emitter<'a> {
} }
Ok((phi, then_ty)) Ok((phi, then_ty))
} }
MTerm::App { callee, args, tail, .. } => { MTerm::App { callee, args, tail, .. } => match callee {
// callee is `Callee::Indirect(Box<MTerm>)` at mir.1b; // mir.2: the callee is pre-resolved in MIR by
// the static name, if any, is the inner `MTerm::Var`. // `lower_to_mir::classify_callee`. A builtin is lowered
// (mir.2 replaces this with `Callee::Static` and deletes // inline by name (opcode selection); a static
// `is_static_callee`.) // user/prelude fn routes to `emit_call` with the module
let callee_mterm: &MTerm = match callee { // already resolved; an indirect callee is lowered to a
Callee::Indirect(inner) => inner.as_ref(), // fn-pointer and called via the sidetable sig. Codegen
Callee::Static { .. } => unreachable!("callee is Indirect until mir.2"), // re-derives no callee identity and resolves no module.
}; Callee::Builtin { name, .. } => self.lower_builtin(name, args, *tail),
// Direct call when the callee is a `Var` referring to a Callee::Static { module, fn_name, .. } => {
// statically-known target (builtin, current-module fn, let sig = self
// qualified cross-module fn) AND not shadowed by a local. .module_user_fns
// Otherwise we fall through to the indirect-call path, .get(module)
// which lowers the callee to a fn-pointer and looks up .and_then(|m| m.get(fn_name))
// its sig in the sidetable. .cloned()
if let MTerm::Var { name, .. } = callee_mterm { .ok_or_else(|| {
let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name); CodegenError::Internal(format!(
if !shadowed && self.is_static_callee(name) { "static call `{module}.{fn_name}`: no FnSig in workspace"
return self.lower_app(name, args, *tail); ))
})?;
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::Do { op, args, tail, .. } => self.lower_effect_op(op, args, *tail),
MTerm::Ctor { type_name, ctor, args, .. } => { MTerm::Ctor { type_name, ctor, args, .. } => {
// pass the term pointer so `lower_ctor` can // 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 == "=="` // 2026-05-21 operator-routing-eq-ord: the `if name == "=="`
// short-circuit that routed through `lower_eq` is gone. `==` // short-circuit that routed through `lower_eq` is gone. `==`
// is no longer a surface identifier; the typechecker // is no longer a surface identifier; the typechecker
@@ -2619,94 +2628,9 @@ impl<'a> Emitter<'a> {
return Ok((dst, "ptr".to_string())); 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!( 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 /// resolve `name` to a top-level fn-value, i.e. the address
/// of its static closure pair `@ail_<m>_<def>_clos`, plus the user- /// of its static closure pair `@ail_<m>_<def>_clos`, plus the user-
/// visible FnSig (params/ret WITHOUT the env_ptr — that's added at /// 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 /// 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- /// 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)> { fn resolve_top_level_fn(&self, name: &str) -> Option<(String, FnSig)> {
if name.matches('.').count() == 1 { if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.')?; let (prefix, suffix) = name.split_once('.')?;
@@ -2996,15 +2832,19 @@ impl<'a> Emitter<'a> {
// a valid post-mono construct because both ends were // a valid post-mono construct because both ends were
// independently typechecked under their original module // independently typechecked under their original module
// contexts before mono ran). // contexts before mono ran).
// #53: a type-scoped `T.def` prefix (e.g. `Counter.new`) // mir.2: the codegen-side type-home resolver is gone. A
// resolves to the type's home module, symmetric to the // type-scoped `T.fn` reference in VALUE position (the only
// checker's TypeDef-first ladder and the matching arm in // path that used the type-home fallback here) is unreachable in the current
// `lower_app`'s cross-module branch. Tried after import_map // corpus — every dotted `T.fn` is a call head, resolved as
// and the literal-module-name fallback. // `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) { let target: String = match self.import_map.get(prefix) {
Some(m) => m.clone(), Some(m) => m.clone(),
None if self.module_user_fns.contains_key(prefix) => prefix.to_string(), None => prefix.to_string(),
None => self.type_home_module(prefix).unwrap_or_else(|| prefix.to_string()),
}; };
let sig = self.module_user_fns.get(&target)?.get(suffix)?.clone(); let sig = self.module_user_fns.get(&target)?.get(suffix)?.clone();
return Some((format!("@ail_{target}_{suffix}_clos"), sig)); return Some((format!("@ail_{target}_{suffix}_clos"), sig));
+16 -1
View File
@@ -132,9 +132,24 @@ pub struct MArg {
pub consume_count: u32, pub consume_count: u32,
} }
/// A resolved App callee. `Static`/`Builtin` are filled by mir.2's
/// `lower_to_mir::classify_callee`, which mirrors check's `synth`
/// `Term::Var` ladder; `Indirect` is a genuinely dynamic callee
/// (fn-typed local/param, closure value, or a name shadowed by a
/// local). `sig` is the callee's fn-type — the lossless replacement
/// for the sub-term's `ty()` that codegen's drop path reads for the
/// callee `ret_mode`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Callee { pub enum Callee {
Static { module: String, fn_name: String }, /// A statically-resolved user/prelude fn: codegen routes it to
/// `emit_call(module, fn_name, …)` with `module` pre-resolved
/// (replacing codegen's `type_home_module` ladder).
Static { module: String, fn_name: String, sig: Type },
/// An operator / intrinsic builtin (`+`, `not`, `str_concat`, …):
/// codegen lowers it inline by `name` (opcode selection), never
/// via `emit_call`.
Builtin { name: String, sig: Type },
/// A dynamic callee — lower the boxed term to a fn-pointer.
Indirect(Box<MTerm>), Indirect(Box<MTerm>),
} }
+12 -1
View File
@@ -196,7 +196,7 @@ pub enum MTerm {
// … one variant per Term node, each with `ty` // … one variant per Term node, each with `ty`
} }
pub struct MArg { pub term: MTerm, pub mode: Mode, pub consume_count: u32 } // (3) pub struct MArg { pub term: MTerm, pub mode: Mode, pub consume_count: u32 } // (3)
pub enum Callee { Static { module: String, fn_name: String }, Indirect(Box<MTerm>) } // (2) pub enum Callee { Static { module: String, fn_name: String, sig: Type }, Builtin { name: String, sig: Type }, Indirect(Box<MTerm>) } // (2) — sig carries the callee fn-type for drop ret_mode; Builtin for inline-lowered operators/intrinsics
pub enum StrRep { Heap, Static } // (4) pub enum StrRep { Heap, Static } // (4)
pub enum Mode { Owned, Borrow } pub enum Mode { Owned, Borrow }
``` ```
@@ -311,6 +311,17 @@ class and deletes the matching codegen re-derivation.
| **mir.4** | `StrRep` (loop-carried `Str``Heap` via `str_clone` at loop entry) | the `!is_str` `recur` dec gate | #49 `live=0`; `#[ignore]` lifted | | **mir.4** | `StrRep` (loop-carried `Str``Heap` via `str_clone` at loop entry) | the `!is_str` `recur` dec gate | #49 `live=0`; `#[ignore]` lifted |
| **mir.5** | element-type / `Term::New` fully on MIR + **ledger** | last re-derivation residue | #51 guard green via MIR; ledger consistent | | **mir.5** | element-type / `Term::New` fully on MIR + **ledger** | last re-derivation residue | #51 guard green via MIR; ledger consistent |
> mir.2 refinement (discovered in planning, `docs/plans/0116-mir.2-callee-static.md`):
> `Callee` gains a `Builtin { name, sig }` variant (operators / str-num
> intrinsics have no `module`/`fn_name`) and a `sig: Type` on each
> resolved variant (codegen's drop path reads the callee `ret_mode` off
> it — the lossless replacement for the pre-mir.2 `Indirect(inner).ty()`
> read; this is NOT a mir.3 pull-forward, the `MArg` param-modes stay at
> mir.1 defaults). `type_home_module` is fully deletable: its
> value-position consumer (`resolve_top_level_fn`) is unreachable for
> type-scoped names in the current corpus, so the acceptance
> "type_home_module grep-clean" holds.
mir.5 carries the ledger work as part of the milestone (per CLAUDE.md: mir.5 carries the ledger work as part of the milestone (per CLAUDE.md:
contract changes ship with the iteration that needs them): contract changes ship with the iteration that needs them):
+5
View File
@@ -0,0 +1,5 @@
(module classify_pin
(fn bump (type (fn-type (params (con Int)) (ret (con Int)))) (params n)
(body (app + n 1)))
(fn main (type (fn-type (params) (ret (con Int)))) (params)
(body (app bump 41))))
+6
View File
@@ -0,0 +1,6 @@
(module mir2_callee_kinds
(fn bump (type (fn-type (params (con Int)) (ret (con Int)))) (params n)
(body (app + n 1)))
(fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
(body (let r (app bump 41)
(do io/print_str (app int_to_str r))))))