diff --git a/CLAUDE.md b/CLAUDE.md index f92e2c3..822db92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -308,7 +308,6 @@ the cross-references column of a file-map. | 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. | -| `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. | Walk procedure: for each milestone-scope commit-range arm diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 2f41bd5..4420917 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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 /// `do io/print_str(...)` path. Smoke pin for the heap-Str builtin /// 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, /// @fputs reads from the bytes pointer at offset 8 with @stdout as /// the FILE*), and the @@ -2761,6 +2762,24 @@ fn int_to_str_smoke() { 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 /// rendering of 3.5. The runtime's `ailang_float_to_str` uses /// `snprintf(..., "%g", x)` with no locale call, so the C locale diff --git a/crates/ailang-check/src/lower_to_mir.rs b/crates/ailang-check/src/lower_to_mir.rs index 56aa298..24b438e 100644 --- a/crates/ailang-check/src/lower_to_mir.rs +++ b/crates/ailang-check/src/lower_to_mir.rs @@ -106,6 +106,75 @@ fn arg(term: MTerm) -> MArg { 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`. fn lower_term(ctx: &mut Ctx, t: &Term) -> Result { let ty = ctx.synth_pure(t)?; @@ -118,9 +187,22 @@ fn lower_term(ctx: &mut Ctx, t: &Term) -> Result { 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 } => { - 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 .iter() .map(|a| Ok(arg(lower_term(ctx, a)?))) diff --git a/crates/ailang-check/tests/lower_to_mir_ty.rs b/crates/ailang-check/tests/lower_to_mir_ty.rs index b8e576d..17b4ca1 100644 --- a/crates/ailang-check/tests/lower_to_mir_ty.rs +++ b/crates/ailang-check/tests/lower_to_mir_ty.rs @@ -6,7 +6,7 @@ //! pins protect the producer; codegen consumption arrives in mir.1b. use ailang_check::elaborate_workspace; -use ailang_mir::{MTerm, MirWorkspace, StrRep}; +use ailang_mir::{Callee, MTerm, MirWorkspace, StrRep}; use ailang_surface::load_workspace; use std::path::{Path, PathBuf}; @@ -70,6 +70,22 @@ fn new_over_user_adt_carries_node_types() { ty_str.contains("Counter"), "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] @@ -102,6 +118,37 @@ fn poly_free_fn_accumulating_into_own_adt_elaborates() { 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 /// from a loop binder init (the seed). fn find_static_str_seed(t: &MTerm) -> bool { diff --git a/crates/ailang-codegen/src/drop.rs b/crates/ailang-codegen/src/drop.rs index 1e77d06..73e573d 100644 --- a/crates/ailang-codegen/src/drop.rs +++ b/crates/ailang-codegen/src/drop.rs @@ -508,9 +508,13 @@ impl<'a> Emitter<'a> { /// [`Self::drop_symbol_for_binder`] App-arm to decide both /// trackability and the drop-fn symbol. fn synth_callee_ret_mode(&self, callee: &Callee) -> Option { + // 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 { Callee::Indirect(inner) => inner.ty(), - Callee::Static { .. } => return None, + Callee::Static { sig, .. } | Callee::Builtin { sig, .. } => sig.clone(), }; match cty { Type::Fn { ret_mode, .. } => Some(ret_mode), diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 5ecbacc..53e7717 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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)` 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__` synthesised in - // `prelude` calls `show_user_adt.show__` 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 { - 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_` 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___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)); diff --git a/crates/ailang-mir/src/lib.rs b/crates/ailang-mir/src/lib.rs index d1be9e2..45a155d 100644 --- a/crates/ailang-mir/src/lib.rs +++ b/crates/ailang-mir/src/lib.rs @@ -132,9 +132,24 @@ pub struct MArg { 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)] 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), } diff --git a/docs/specs/0060-typed-mir.md b/docs/specs/0060-typed-mir.md index 40884b4..ec8354f 100644 --- a/docs/specs/0060-typed-mir.md +++ b/docs/specs/0060-typed-mir.md @@ -196,7 +196,7 @@ pub enum MTerm { // … one variant per Term node, each with `ty` } 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) } // (2) +pub enum Callee { Static { module: String, fn_name: String, sig: Type }, Builtin { name: String, sig: Type }, Indirect(Box) } // (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 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.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: contract changes ship with the iteration that needs them): diff --git a/examples/classify_pin.ail b/examples/classify_pin.ail new file mode 100644 index 0000000..835a41b --- /dev/null +++ b/examples/classify_pin.ail @@ -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)))) diff --git a/examples/mir2_callee_kinds.ail b/examples/mir2_callee_kinds.ail new file mode 100644 index 0000000..57add90 --- /dev/null +++ b/examples/mir2_callee_kinds.ail @@ -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))))))