//! LLVM IR text emitter for AILang (MVP). //! //! Third stage of the compiler pipeline (`core` → `check` → `codegen` //! → `ail` CLI). Consumes a fully type-checked [`Module`] (single-file //! mode) or [`Workspace`] (multi-module mode) and produces LLVM IR as //! a UTF-8 string ready to be written as a `.ll` file and handed to //! `clang`. The two entry points are [`emit_ir`] (single module) and //! [`lower_workspace`] (multi-module); both share the same mangling //! scheme and ABI. //! //! Strategy: we generate LLVM IR as a string, write it as `.ll`, and //! link it with `clang`. No binding to a specific libllvm version. //! //! Type mapping: //! - `Int` -> `i64` //! - `Bool` -> `i1` //! - `Unit` -> `i8` (value always 0) //! //! Mangling scheme (Iter 5c): //! - **All** AILang functions become `@ail__`. This holds //! even for single-module programs. The old form `@ail_` is gone. //! - Global string/constant symbols are mangled per module: //! `@.str__` and `@ail__` for constant globals. //! - The entry point remains `main` (LLVM/C ABI). [`lower_workspace`] //! emits `define i64 @main() { call @ail__main() ... }` //! as a trampoline to the entry module's `main`. If missing, the //! build fails with [`CodegenError::MissingEntryMain`]. //! - `source_filename` appears exactly once at the top, with //! `.ail` as value (per workspace). //! //! **Precondition.** Neither [`emit_ir`] nor [`lower_workspace`] runs //! the typechecker. Callers must have run `ailang_check::check_module` //! (or `check_workspace`) first; codegen will panic or emit malformed //! IR if invariants the checker enforces (resolved metavars, declared //! effects, ctor arity) are violated. use ailang_core::ast::*; use ailang_core::Workspace; use ailang_mir::{Callee, MArg, MTerm, MirDef, MirWorkspace, Mode, StrRep}; use std::collections::{BTreeMap, BTreeSet}; mod drop; mod dropmono; mod escape; mod intercepts; mod lambda; mod match_lower; mod subst; mod synth; use escape::NonEscapeSet; use synth::{ builtin_binop_typed, c_byte_len, default_triple, fn_sig_from_type, ll_string_literal, llvm_type, }; /// Classifies a builtin name as a polymorphic arithmetic op (the /// set `+`, `-`, `*`, `/`, `%`). After iter operator-routing-eq-ord.1 /// 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_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, "+" | "-" | "*" | "/" | "%") } /// Failure modes of [`emit_ir`] / [`lower_workspace`]. /// /// Most variants signal a compiler invariant violation rather than a /// user-facing diagnostic — by the time a module reaches codegen the /// typechecker has already accepted it. The exceptions are /// [`CodegenError::MissingEntryMain`] (a workspace-level shape check /// that the typechecker doesn't enforce) and the wrapping variants /// [`CodegenError::Def`] / [`CodegenError::InModule`] which add path /// context to an inner error. #[derive(Debug, thiserror::Error)] pub enum CodegenError { /// Wraps an inner error with the name of the def being lowered. /// Attached by the per-def lowering loop in [`lower_workspace`] so /// the failing definition is named in the message even when the /// underlying error is structural (e.g. an [`CodegenError::Internal`] /// from deep inside `lower_term`). #[error("def `{0}`: {1}")] Def(String, Box), /// Wraps an inner error with the name of the module being lowered. /// Attached by [`lower_workspace`]'s per-module loop so multi-module /// builds report which module failed without requiring the caller /// to thread a module name through every call site. #[error("module `{0}`: {1}")] InModule(String, Box), /// `llvm_type` was asked to lower an AILang [`Type`] it does not /// know how to represent. In the MVP this fires for an unresolved /// rigid `Type::Var` reaching codegen (a substitution bug: a /// parameterised-ADT type argument that monomorphisation should /// have substituted to a concrete type before codegen) or for any /// non-`Con`/`Fn`/`Var` shape that has not yet been wired through. #[error("unsupported type: {0}")] UnsupportedType(String), /// A `Term::Var { name }` could not be resolved against the local /// SSA stack, the current module's top-level fns, or a qualified /// import. A correctly type-checked module never produces this; if /// it does, the typechecker and the codegen-side resolver have /// drifted out of sync. #[error("unknown variable: `{0}`")] UnknownVar(String), /// A `Def::Fn` was reached whose `ty` is not a `Type::Fn`. The /// typechecker ([`ailang_check::CheckError::FnTypeRequired`]) should /// have rejected this case before us; emit_fn re-checks defensively /// because a stale typechecker contract would otherwise produce /// malformed IR. #[error("expected fn type, got {0}")] NotFnType(String), /// The entry module of the workspace has no `main : () -> Unit !IO` /// def, so [`lower_workspace`] cannot emit the C-ABI trampoline. /// This is a workspace-level shape requirement that the typechecker /// does not enforce (a library module is well-typed without a main), /// so it surfaces here instead. #[error("entry module `{0}` has no `main` def")] MissingEntryMain(String), /// Catch-all for codegen-side invariant violations: missing /// ctor entry, lambda environment shape mismatch, mono-queue /// inconsistency, etc. The string carries the precise diagnostic; /// a user-facing build never produces this if the workspace /// type-checks cleanly. #[error("internal: {0}")] Internal(String), } type Result = std::result::Result; /// Which heap-allocation runtime the emitted IR targets. /// /// `Rc` is the canonical production allocator (reference counting + /// uniqueness inference); allocations go through `@ailang_rc_alloc` /// from `runtime/rc.c`, which prefixes every payload with an 8-byte /// refcount header, and codegen emits `inc`/`dec` calls at the points /// dictated by linearity. /// `Bump` is a raw-alloc bench-floor: every allocation site lowers to /// `@bump_malloc`, supplied by `runtime/bump.c` — a no-free, statically- /// sized arena allocator used purely to measure RC overhead against the /// structurally cheapest allocator. Bump is bench-only, not a production /// target. /// The IR is otherwise byte-identical between the two strategies modulo /// the allocator symbol name (and the RC-only inc/dec instrumentation). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AllocStrategy { Bump, Rc, } /// Embedding-ABI M1: codegen output shape. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Target { /// Default: emit the `@main` trampoline; require an entry `main` /// (`MissingEntryMain` if absent). Whole-program executable. Executable, /// `--emit=staticlib`: suppress the `@main` trampoline AND the /// `MissingEntryMain` shape-check (a kernel module is a library); /// emit one external C entrypoint per `(export …)` fn forwarding /// to the internal `@ail__`. M2: the entrypoint takes /// a mandatory leading `ptr %ctx` (per-thread embedding context) /// published via `@__ail_tls_ctx` around the unchanged internal /// call. The ctx-threaded C signature is the M2 shape; the /// value/record layout is frozen as of M3 /// (design/contracts/0006-frozen-value-layout.md). StaticLib, } impl AllocStrategy { /// LLVM IR-level name of the allocator fn (without leading `@`). fn fn_name(self) -> &'static str { match self { AllocStrategy::Bump => "bump_malloc", AllocStrategy::Rc => "ailang_rc_alloc", } } } /// Single-module entry point. Lowers `m` to a `.ll` string with `m` /// itself as the entry module. Returns the full LLVM IR text, ready to /// be written to disk and handed to `clang`. /// /// Used by tests, by `ail emit-ir`, and by `ail build`/`run` whenever /// the input is a single `.ail.json` file rather than a workspace. /// Internally builds a trivial [`Workspace`] containing only `m` and /// delegates to [`lower_workspace`] — the mangling scheme, /// `source_filename`, and the `@main` trampoline are therefore /// identical between the two entry points. /// /// **Precondition.** `m` must already type-check. This function does /// **not** call `ailang_check`; passing a module with unresolved /// metavars, undeclared effects, or arity mismatches will produce /// either a [`CodegenError`] or malformed IR. For any input that came /// from disk, run `ailang_check::check_module(m)` first and only call /// `emit_ir` when the diagnostic list is empty. /// /// Use [`lower_workspace`] instead when the program spans multiple /// modules (cross-module calls, transitive imports) — `emit_ir` is the /// short-cut for the single-file demo case. pub fn emit_ir(m: &Module) -> Result { // nested ctor patterns are desugared inside // `lower_workspace`, so single-module callers go through the // same code path with no extra work here. let mut modules = BTreeMap::new(); modules.insert(m.name.clone(), m.clone()); let ws = Workspace { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), // emit_ir is the single-module shortcut. The // empty registry is fine for 22b.1 because codegen does not // yet read it; once 22b.3 monomorphisation runs, the queue // is built from class-method-call sites in already-typechecked // module bodies, not from the registry. registry: ailang_core::workspace::Registry::default(), }; // build the MIR internally (OQ1/Step 9): emit_ir keeps its // single-module `&Module` surface, runs the full front-end // (check → desugar+lift → mono → lower_to_mir) via // `elaborate_workspace`, and converts a type-error `Err` into a // `CodegenError`. Its in-source test callers use builtins only, so // the internal check passes without a prelude. let mir = ailang_check::elaborate_workspace(&ws).map_err(|diags| { CodegenError::Internal(format!( "elaborate failed: {}", diags .iter() .map(|d| d.message.clone()) .collect::>() .join("; ") )) })?; lower_workspace(&mir) } /// Variant of [`lower_workspace`] that selects the heap allocator at /// codegen time. `AllocStrategy::Rc` is the canonical production path /// (matches [`lower_workspace`]); `AllocStrategy::Bump` swaps every /// runtime-allocator site for `@bump_malloc` (supplied by /// `runtime/bump.c`) so the bench harness can measure RC overhead /// against the raw-alloc floor. pub fn lower_workspace_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result { lower_workspace_inner(mir, alloc, Target::Executable) } /// Embedding-ABI M1 entry point. Lowers a [`Workspace`] for the /// static-library target: no `@main`, no `MissingEntryMain`, one /// external `@` forwarder per `(export …)` fn. pub fn lower_workspace_staticlib_with_alloc( mir: &MirWorkspace, alloc: AllocStrategy, ) -> Result { lower_workspace_inner(mir, alloc, Target::StaticLib) } /// Multi-module entry point. Lowers an entire [`Workspace`] (entry /// module plus its transitive imports, as produced by /// `ailang_core::load_workspace`) to a single `.ll` string and emits /// the C-ABI `@main` trampoline pointing at the entry module's `main`. /// This is what `ail build` and `ail run` call for any real /// multi-module program. /// /// Module order is alphabetic (BTreeMap order = deterministic). Within /// a module, def order matches the AST. /// /// Cross-module calls: `Term::Var { name }` with exactly one dot /// (`.`) is resolved via the calling module's import map /// to `@ail__`. Local var lookups (no dot) stay /// stack locals or local top-level defs of the current module. /// /// **Precondition.** Every module in `ws.modules` must already /// type-check (`ailang_check::check_workspace(ws)` returns no errors). /// `lower_workspace` does **not** invoke the typechecker itself; /// running it on an unchecked workspace is a caller bug. Beyond /// type-checking, this function additionally requires that the entry /// module declares `main : () -> Unit !IO` — otherwise it returns /// [`CodegenError::MissingEntryMain`]. /// /// Use [`emit_ir`] for the single-file shortcut when there are no /// imports. pub fn lower_workspace(mir: &MirWorkspace) -> Result { lower_workspace_inner(mir, AllocStrategy::Rc, Target::Executable) } /// Embedding-ABI M1 single-call entry point symmetric with /// [`lower_workspace`]: lowers a [`Workspace`] for the static-library /// target with the default `AllocStrategy::Rc`. This is what /// `ail emit-ir --emit=staticlib` calls so an author can read a /// `main`-free kernel's IR (the external `@` forwarders, no /// `@main`) — the Decision-5 IR-readability affordance for the /// artefact M1 introduced. Equivalent to /// `lower_workspace_staticlib_with_alloc(ws, AllocStrategy::Rc)`. pub fn lower_workspace_staticlib(mir: &MirWorkspace) -> Result { lower_workspace_inner(mir, AllocStrategy::Rc, Target::StaticLib) } fn lower_workspace_inner(mir: &MirWorkspace, alloc: AllocStrategy, target: Target) -> Result { // The post-mono AST is read structurally off `mir_module.ast` // (Def::Type / Def::Const / intrinsic markers / symbol tables); // each non-intrinsic fn body is the typed `MTerm` in // `mir_module.defs`. `elaborate_workspace` already ran the // desugar + lift + monomorphise passes before producing the MIR, // so no desugar happens here. let mut header = String::new(); let mut body = String::new(); let mut all_strings: BTreeMap> = BTreeMap::new(); // ^ per module: list of (global-name, content). Order = insertion order. // parallel aggregation for language `Str`-literal globals // (packed-struct shape). Same map shape; emitted by a parallel loop // alongside the existing one. let mut all_str_literals: BTreeMap> = BTreeMap::new(); // Pass 1: per-module top-level symbol tables. // - `module_user_fns`: LLVM-typed FnSig for monomorphic fns. Used by // the call resolver. Post-iter-23.4 the workspace contains ONLY // monomorphic `Def::Fn`s (the typecheck-time mono pass synthesises // one per concrete instantiation); any residual `Type::Forall` ty // is a stale source def kept for round-trip / `ail diff` purposes // and is intentionally not lowered. let mut module_user_fns: BTreeMap> = BTreeMap::new(); // 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> = BTreeMap::new(); // per-module const table. Used to resolve `Term::Var` // references to const defs (literal or non-literal) at lowering // time. Literal consts emit a global and are loaded; non-literal // consts (e.g. ctor expressions) are inlined at every reference // site since check_const guarantees their bodies are pure. let mut module_consts: BTreeMap> = BTreeMap::new(); // typed bodies of non-literal consts, keyed by owner module then // const name. Read by the `Var` arm's const-inlining path so the // inlined body is the checker-typed MTerm, not a re-derived one. let mut module_mir_consts: BTreeMap> = BTreeMap::new(); for (mname, mir_module) in &mir.modules { let m = &mir_module.ast; let mut mir_const_bodies: BTreeMap = BTreeMap::new(); for c in &mir_module.consts { mir_const_bodies.insert(c.name.clone(), c.body.clone()); } module_mir_consts.insert(mname.clone(), mir_const_bodies); let mut user_fns = BTreeMap::new(); let mut ctors = BTreeMap::new(); for def in &m.defs { if let Def::Fn(f) = def { if let Type::Fn { params, ret, .. } = &f.ty { let psig: Result> = params.iter().map(llvm_type).collect(); let rsig = llvm_type(ret); if let (Ok(params), Ok(ret)) = (psig, rsig) { user_fns.insert(f.name.clone(), FnSig { params, ret }); } } // iter 23.4: `Type::Forall`-quantified defs are // intentionally skipped — the mono pass has already // produced their monomorphic counterparts. } if let Def::Type(td) = def { for (i, c) in td.ctors.iter().enumerate() { let fields: Vec = 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(), }, ); } } } // collect const defs for this module so non-literal // consts can be inlined at `Term::Var` reference sites. let mut consts: BTreeMap = BTreeMap::new(); for def in &m.defs { if let Def::Const(c) = def { consts.insert(c.name.clone(), c.clone()); } } module_user_fns.insert(mname.clone(), user_fns); module_ctor_index.insert(mname.clone(), ctors); module_consts.insert(mname.clone(), consts); } // Leg C: workspace-global per-monomorph drop metadata. Built once // here (after the symbol-table pass, before lowering) and shared by // reference with every `Emitter`. Computes which polymorphic ADTs // take a per-instantiation drop fn and the concrete arg-tuples each // is used at, so the emission loop and the call-site manglers agree // on every `drop____` symbol. let drop_monos = dropmono::collect_drop_monos(mir); // Pass 2: lower per module. Globals/strings are accumulated per module, // because they are mangled per module. for (mname, mir_module) in &mir.modules { let m = &mir_module.ast; // Import map for cross-module resolution. Identical to the // logic in the typechecker (see `check_in_workspace`): alias or // module name as key, actual module name as value. let mut import_map: BTreeMap = BTreeMap::new(); for imp in &m.imports { let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone()); import_map.insert(key, imp.module.clone()); } // mirror the typechecker's implicit-prelude import // injection (crates/ailang-check/src/lib.rs:1198 and :1300). // User modules that call prelude-resident fns synthesised by // monomorphisation (e.g. `prelude.eq__Int` from a user calling // `eq x y` on Ints) need the codegen-side // import_map to resolve the `prelude.` prefix at the call // site (`lower_call`'s prefix lookup). Post-canonical-type-form ctor lookup // is canonical (bare = local, qualified routes via import_map); // this entry serves fn-name resolution only. if m.name != "prelude" { import_map .entry("prelude".to_string()) .or_insert_with(|| "prelude".to_string()); } let mut emitter = Emitter::new( m, &mir_module.defs, &module_mir_consts, mname, &module_user_fns, &module_ctor_index, &module_consts, import_map, &drop_monos, alloc, ); emitter .emit_module() .map_err(|e| CodegenError::InModule(mname.clone(), Box::new(e)))?; header.push_str(&emitter.header); body.push_str(&emitter.body); // Collect strings in insertion order. let mut entries: Vec<(String, String)> = Vec::new(); for (content, (name, _)) in &emitter.strings { entries.push((name.clone(), content.clone())); } // sort by global name to stay deterministic across runs (intern_string // uses a monotonic counter, so alphabetic is enough). entries.sort_by(|a, b| a.0.cmp(&b.0)); all_strings.insert(mname.clone(), entries); // parallel collection of `Str`-literal globals. let mut lit_entries: Vec<(String, String)> = Vec::new(); for (content, (name, _)) in &emitter.str_literals { lit_entries.push((name.clone(), content.clone())); } lit_entries.sort_by(|a, b| a.0.cmp(&b.0)); all_str_literals.insert(mname.clone(), lit_entries); } // Trampoline: verify that the entry module has a // `main : () -> Unit !IO`. Suppressed for the staticlib target — // a kernel module is a library and has no `main`. if target == Target::Executable { let entry_module = mir .modules .get(&mir.entry) .ok_or_else(|| CodegenError::Internal(format!("entry module `{}` not in workspace", mir.entry)))?; let has_main = entry_module .ast .defs .iter() .any(|d| matches!(d, Def::Fn(f) if f.name == "main" && main_is_void(&f.ty))); if !has_main { return Err(CodegenError::MissingEntryMain(mir.entry.clone())); } } let mut out = String::new(); out.push_str("; AILang generated workspace; entry: "); out.push_str(&mir.entry); out.push('\n'); out.push_str("source_filename = \""); out.push_str(&mir.entry); out.push_str(".ail\"\n"); out.push_str("target triple = \""); out.push_str(default_triple()); out.push_str("\"\n\n"); // Globals: per module, alphabetically over module names (BTreeMap order), // then insertion order per module. let mut emitted_global = false; for entries in all_strings.values() { for (name, content) in entries { let escaped = ll_string_literal(content); let len = c_byte_len(content); out.push_str(&format!( "@{name} = private unnamed_addr constant [{len} x i8] c\"{escaped}\", align 1\n", )); emitted_global = true; } } // packed-struct globals for language `Str` literals. // First `i64` is the byte length (excluding the trailing NUL); the // `[N+1 x i8]` carries the bytes followed by the terminating NUL. // The IR-`Str` pointer that flows through the rest of codegen lands // on the `len`-field via constexpr-GEP at the `Literal::Str` arms // (see emit_const_def / lower_term). Static-Str pointers are kept // out of `ailang_rc_dec` by codegen-level elision (move-tracking // from iter 18d.3 + non-escape lowering from iter 18b), so no // sentinel rc-header slot is needed at the global. for entries in all_str_literals.values() { for (name, content) in entries { let escaped = ll_string_literal(content); let total = c_byte_len(content); // bytes + NUL let bytes_len = total - 1; // bytes only out.push_str(&format!( "@{name} = private unnamed_addr constant <{{ i64, [{total} x i8] }}> <{{ i64 {bytes_len}, [{total} x i8] c\"{escaped}\" }}>, align 8\n", )); emitted_global = true; } } if emitted_global { out.push('\n'); } out.push_str("declare i32 @printf(ptr, ...)\n"); // `io/print_str` lowers to `fputs(s, stdout)` so the runtime emits // exactly the bytes of `s` with NO implicit trailing newline. The // earlier `@puts(ptr)` shape de-facto turned `io/print_str` into // `println_str` (caught empirically in the cross-model-authoring // naming-A/B run, Gitea #29). `@stdout` is a libc global FILE*; // POSIX exposes it as an `extern FILE *stdout` symbol, which LLVM // IR sees as an opaque pointer to a pointer. out.push_str("declare i32 @fputs(ptr, ptr)\n"); out.push_str("@stdout = external global ptr\n"); // The allocator declaration name follows `alloc`. `Rc` declares // `@ailang_rc_alloc` (canonical); `Bump` declares `@bump_malloc` // (raw-alloc bench-floor), supplied by `runtime/bump.c`. out.push_str(&format!("declare ptr @{}(i64)\n", alloc.fn_name())); // under `--alloc=rc`, also declare the inc/dec ABI from // `runtime/rc.c` so codegen can emit refcount calls at every // `Term::Clone` site and at end-of-scope of trackable RC binders. // `Bump` keeps its leak-only IR shape — nothing to declare. if matches!(alloc, AllocStrategy::Rc) { out.push_str("declare void @ailang_rc_inc(ptr)\n"); out.push_str("declare void @ailang_rc_dec(ptr)\n"); // drop-worklist ABI for `(drop-iterative)` types. // Declared unconditionally under `--alloc=rc` (the linker // drops symbols if no emitted fn references them); symmetric // with inc/dec above. See `runtime/rc.c` for the strategy. out.push_str("declare ptr @ailang_drop_worklist_new()\n"); out.push_str("declare void @ailang_drop_worklist_push(ptr, ptr)\n"); out.push_str("declare ptr @ailang_drop_worklist_pop(ptr)\n"); out.push_str("declare void @ailang_drop_worklist_free(ptr)\n"); } // `==` on `Str` lowers to `@strcmp` followed by // `icmp eq i32 0`. NUL-terminated strings make this a one-liner; // libc supplies `strcmp` so no extra link flag is needed. out.push_str("declare i32 @strcmp(ptr, ptr)\n"); // `runtime/str.c::ail_str_eq` backs the prelude's // `eq__Str` mono symbol (see `try_emit_primitive_instance_body`). // Declared unconditionally — the codegen intercept emits a call // to it whenever the monomorphiser synthesises `eq__Str` in any // module; the .o supplying the symbol is linked unconditionally // by `ail build` (see `crates/ail/src/main.rs::build_to`). The // `zeroext i1` return matches clang's lowering of C `_Bool`. out.push_str("declare zeroext i1 @ail_str_eq(ptr, ptr)\n"); // `runtime/str.c::ail_str_compare` backs the prelude's // `compare__Str` mono symbol (see `try_emit_primitive_instance_body` // `compare__Str` arm below). Declared unconditionally on the same // rationale as `@ail_str_eq` — the .o is supplied by the // unconditionally-linked `runtime/str.c`, and clang -O2 dead- // strips the symbol when no caller exists. Returns i32 normalised // to {-1, 0, +1} so the branch ladder in the intercept can compare // against constant 0 directly. out.push_str("declare i32 @ail_str_compare(ptr, ptr)\n"); // heap-Str formatter externs from `runtime/str.c`. // Both return a heap-allocated Str pointer (rc_header at offset // -8; consumer ABI shared with static-Str — len at offset 0, // bytes at offset 8). Declared unconditionally on the same // rationale as `@ail_str_eq` / `@ail_str_compare`: the .o is // supplied by the unconditionally-linked `runtime/str.c`, and // clang -O2 dead-strips the declarations when no caller exists. out.push_str("declare ptr @ailang_int_to_str(i64)\n"); out.push_str("declare ptr @ailang_float_to_str(double)\n"); // heap-Str primitive externs for the `show` instances // — `ailang_bool_to_str` (Show Bool) and `ailang_str_clone` // (Show Str). Same dual-realisation ABI as int_to_str / // float_to_str: rc_header at offset -8 on the heap-Str output; // consumer ABI shared with static-Str (len at offset 0, bytes // at offset 8). Declared unconditionally on the same rationale // — runtime/str.c is unconditionally linked and clang -O2 dead- // strips when no caller exists. out.push_str("declare ptr @ailang_bool_to_str(i1)\n"); out.push_str("declare ptr @ailang_str_clone(ptr)\n"); out.push_str("declare ptr @ailang_str_concat(ptr, ptr)\n"); // Floats iter 4.4: saturating fp-to-int intrinsic for // float_to_int_truncate. NaN → 0, +Inf → i64::MAX, -Inf → // i64::MIN, finite-out-of-range saturates, finite-in-range // truncates toward zero. LLVM 12+, always available with // clang 22. out.push_str("declare i64 @llvm.fptosi.sat.i64.f64(double)\n\n"); out.push_str(&header); out.push_str(&body); match target { Target::Executable => { // Trampoline @main → @ail__main. out.push_str(&format!( "\ndefine i32 @main() {{\n call i8 @ail_{}_main()\n ret i32 0\n}}\n", mir.entry )); } Target::StaticLib => { // One external C entrypoint per `(export …)` fn, // forwarding to the internal `@ail__`. // The check-side export gate guarantees every param and // the ret are `Int`/`Float` or a single-constructor // record of those (M3); map Int→i64, Float→double, a // record → ptr. Scalar fns have no captured env, so // the internal callee takes the scalars positionally // (confirmed against `emit_fn`'s signature emission, // `lib.rs:1087`). // M2: the per-thread embedding ctx slot. Defined by // runtime/rc.c (__thread ailang_ctx_t *__ail_tls_ctx); // the forwarder publishes its explicit ctx param here for // the synchronous duration of the internal call. out.push_str("\n@__ail_tls_ctx = external thread_local global ptr\n"); for (mname, mir_module) in &mir.modules { for d in &mir_module.ast.defs { if let Def::Fn(f) = d { if let Some(sym) = &f.export { let (param_tys, ret_ty) = match fn_scalar_sig(&f.ty) { Some(sig) => sig, None => return Err(CodegenError::Internal(format!( "export `{sym}`: non-scalar signature reached codegen \ (check-side gate should have rejected it)"))), }; let cret = llvm_scalar(&ret_ty); let args: Vec = param_tys.iter().enumerate() .map(|(i, t)| format!("{} %a{i}", llvm_scalar(t))) .collect(); let mut sig_params = vec!["ptr %ctx".to_string()]; sig_params.extend( param_tys.iter().enumerate() .map(|(i, t)| format!("{} %a{i}", llvm_scalar(t)))); out.push_str(&format!( "\ndefine {cret} @{sym}({}) {{\n \ %saved = load ptr, ptr @__ail_tls_ctx\n \ store ptr %ctx, ptr @__ail_tls_ctx\n \ %r = call {cret} @ail_{mname}_{}({})\n \ store ptr %saved, ptr @__ail_tls_ctx\n \ ret {cret} %r\n}}\n", sig_params.join(", "), f.name, args.join(", "), )); } } } } } } Ok(out) } /// Scalar-signature view of an export fn's type. `None` if not the /// flat `Type::Fn` of scalars the M1 gate guarantees. fn fn_scalar_sig(t: &Type) -> Option<(Vec, Type)> { let inner = match t { Type::Forall { body, .. } => body.as_ref(), other => other, }; match inner { // M3: params/ret may include a single-ctor scalar record type; // llvm_scalar maps it to `ptr`. fn_scalar_sig is type-shape only. Type::Fn { params, ret, .. } => Some((params.clone(), (**ret).clone())), _ => None, } } /// `Int` → `i64`, `Float` → `double`, a single-ctor scalar record /// (any other named `Type::Con` reaching an `(export)` signature, by /// the Task-2 export gate) → `ptr`. The check-side gate rejects every /// other shape before codegen. fn llvm_scalar(t: &Type) -> &'static str { match t { Type::Con { name, .. } if name == "Float" => "double", Type::Con { name, .. } if name == "Int" => "i64", // M3: any other Type::Con reaching an (export) signature is, // by the Task-2 export gate, a single-ctor scalar record; // it crosses the C ABI as a bare `ptr` // (design/contracts/0006-frozen-value-layout.md — payload pointer). Type::Con { .. } => "ptr", _ => "i64", } } /// raw-buf.4: does `f`'s declared return type construct the TypeDef /// named `td_name`? Used by the drop-dispatch loop to recognise an /// intrinsic-storage `new` op (its return is `(own (con T …))`). /// Peels `Forall` → `Fn.ret`, then matches the result `Type::Con` /// name against `td_name` (the `own`/`borrow` mode lives in /// `ret_mode`, not in the type, so it needs no peeling). Accepts both /// the bare same-module name and a `.` qualified spelling. fn fn_returns_type(f: &FnDef, td_name: &str) -> bool { let mut ty = &f.ty; if let Type::Forall { body, .. } = ty { ty = body; } let Type::Fn { ret, .. } = ty else { return false; }; match ret.as_ref() { Type::Con { name, .. } => { name == td_name || name.rsplit('.').next() == Some(td_name) } _ => false, } } fn main_is_void(t: &Type) -> bool { match t { Type::Fn { params, ret, .. } => { params.is_empty() && matches!(ret.as_ref(), Type::Con { name, .. } if name == "Unit") } _ => false, } } /// One loop binder's storage on the recur stack: `(binder name, its /// alloca SSA, the LLVM type string, the AILang type)`. type LoopBinderSlot = (String, String, String, Type); /// A loop frame: `(loop header block label, the binder slots active in /// that loop)`. Pushed on entry to a `Term::Loop`, popped on exit, and /// read by `Term::Recur` to store back into the right allocas. type LoopFrame = (String, Vec); struct Emitter<'a> { module: &'a Module, /// Typed fn bodies for this module (the post-mono `MTerm` per /// non-intrinsic `Def::Fn`, keyed by name). Structural reads come /// from `module` (= the MirModule's `ast`); each fn's body is the /// matching `MirDef.body` looked up here by `emit_module`. mir_bodies: &'a [MirDef], /// Typed bodies of every non-literal const in the workspace, keyed /// by owner module then const name. The `Var` arm of `lower_term` /// inlines the matching body here when a reference (bare or /// qualified) resolves to a non-literal const — the cross-module /// case needs the *owner* module's body, so this is workspace-wide. module_mir_consts: &'a BTreeMap>, /// Name of the currently lowered module (for mangling). module_name: &'a str, header: String, pub(crate) body: String, /// String constants: content -> (global name (without `@`), llvm type length incl. \0) strings: BTreeMap, /// language `Str` literals interned as /// packed-struct globals (`<{ i64, [N+1 x i8] }>`) carrying an /// explicit `len` field and the bytes + trailing NUL. Parallel to /// `strings` (which still serves runtime-internal format strings /// like `%lld\n` / `true\n` in the raw `[N x i8]` shape). Same /// key shape; the two tables coexist. str_literals: BTreeMap, /// Local symbol table per function: (name, ssa, llvm_type, ail_type). /// The AILang type is recorded so that the codegen-side type tracker /// can derive substitutions at polymorphic call sites without /// re-running the typechecker (Iter 12b). pub(crate) locals: Vec<(String, String, String, Type)>, /// Monotonic counter for SSA values and labels. counter: u64, /// Monotonic counter for global string names (per module). str_counter: u64, /// Top-level functions per module of the workspace, for call resolution. module_user_fns: &'a BTreeMap>, /// Import map of the current module (alias/module name → actual module name). import_map: BTreeMap, /// workspace-global per-monomorph drop metadata (leg C). Tells the /// drop-fn emission loop which polymorphic ADTs to emit one drop fn /// per instantiation for, and tells the call-site manglers whether /// a `Type::Con` takes a per-monomorph `__` on its /// `drop_`/`partial_drop_` symbol. Shared by reference with every /// `Emitter`. drop_monos: &'a dropmono::DropAdtMeta, /// 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>, /// per-module const defs, used to resolve `Term::Var` /// references (bare or qualified) to a const's body. Literal /// consts emit a global and are loaded via `@ail__`; /// non-literal consts are inlined at every reference site (sound /// because `check_const` rejects effects, so the body is pure). module_consts: &'a BTreeMap>, /// Current basic block label. Set by `start_block` and is /// the single source of truth for `phi` operands. current_block: String, /// true while the current block already ends in a /// terminator (currently only `ret` after a `musttail call`). /// Callers in the term lowering walk consult this to skip /// fall-through `br` emission and to omit the value from a /// surrounding match-arm phi. Reset by [`Self::start_block`]. pub(crate) block_terminated: bool, /// SSA value (or `@global`) -> its FnSig, for first-class /// function values. Populated whenever we lower a `Term::Var` to a /// top-level fn pointer or when a fn-typed parameter is bound at /// function entry. Used by `Term::App` when the callee is not a /// statically-known top-level name. ssa_fn_sigs: BTreeMap, /// name of the currently-emitted def (for lambda thunk /// naming `_lam`). current_def: String, /// per-def counter for lambda thunks. Reset in emit_fn. lam_counter: u32, /// thunk fn IR text for lambdas encountered during /// lowering. Flushed at the end of emit_fn (LLVM IR allows fns in /// any order). deferred_thunks: Vec, /// per-fn escape-analysis result. Set of pointer-as-usize /// addresses of `Term::Ctor` and `Term::Lam` nodes that the /// analysis proved do not escape the fn frame they are allocated /// in. Such allocations lower to `alloca` instead of the runtime /// allocator (`@ailang_rc_alloc` or `@bump_malloc`). /// Populated by `analyze_fn_body` at the start of `emit_fn` and at /// the start of every lambda thunk emission inside `lower_lambda`. non_escape: NonEscapeSet, /// Bench iter: which allocator the heap-allocation paths target. /// Decided at the top-level entry point (`lower_workspace_inner`) /// and propagated to every site that emits a `call ptr @(...)`. alloc: AllocStrategy, /// per-binder consume_count for the current module, keyed by /// `(def_name, binder_name)`. Consulted by `Term::Let` lowering /// (and the own-param / match-arm drop sites) to decide whether to /// emit `call void @ailang_rc_dec(ptr %v)` at scope close. The table /// is module-scoped because the inference is whole-fn local; no /// cross-module entries appear. Built from `MirDef.consume` (mir.3a) /// — codegen no longer re-runs the uniqueness pass; `lower_to_mir` /// owns the single run. consume: std::collections::BTreeMap<(String, String), u32>, /// per-closure-pair drop-function symbol. Keyed by the /// closure-pair SSA value (e.g. `%v17`) the most-recent /// `lower_lambda` call returned. Consulted by the `Term::Let` /// lowering to emit `call void @(ptr %v17)` instead of the /// raw `@ailang_rc_dec` when the binder owns a closure pair. /// Empty under non-`Rc` allocators — a closure under /// `--alloc=bump` has no drop fn (bump leaks by design). closure_drops: BTreeMap, /// per-fn-body move tracking. Keyed by binder name, maps /// to the set of positional ctor-field indices that have been /// "moved out" via a pattern destructure. A field is moved when a /// `(case (Ctor h t) )` arm binds a non-wildcard, pointer- /// typed slot — the load-into-binder is treated as a transfer of /// ownership from the source slot to the binder's SSA. The source /// slot is NOT mutated; codegen merely remembers, statically, that /// the binder's new owner now holds the only live reference along /// this path. /// /// Consulted at two call sites that emit dec sequences against the /// source binder: /// 1. `Term::Let` scope close (`is_rc_heap_allocated` path) — /// when the entry is non-empty, codegen inlines a per-field /// dec sequence that skips slots in the moved set. When the /// entry is empty (the common case), the existing /// `drop__(ptr)` call is emitted unchanged. /// 2. `lower_reuse_as_rc`'s reuse arm — moved-out slots are /// skipped (they no longer hold a live reference); non-moved /// slots are dec'd via `field_drop_call` before the new field /// values overwrite them. /// /// Reset to empty at the top of every fn body (`emit_fn` and the /// thunk-emission section of `lower_lambda`). Entries for a /// particular binder are removed when that binder leaves scope /// (on `Term::Let` body close, on match-arm body close). moved_slots: BTreeMap>, /// per-fn-body parameter modes, keyed by parameter /// name. Set once at the top of `emit_fn` (and at lambda thunk /// entry) from the fn type's `param_modes`. Consulted by /// `lower_match`'s arm-close pattern-binder dec emission (Iter A) to /// decide whether the scrutinee was statically owned: if the /// scrutinee resolves to a fn-param whose mode is `Borrow`, the /// pattern-binder dec must NOT fire — the caller still holds a /// reference and dec'ing the pattern-binder would fragment the /// caller's structure. /// /// Symmetric with the Iter B gate at fn return (`emit_fn`'s Own- /// param dec): both sites check the param-mode signal before /// dec'ing, because `Borrow` does not carry the "caller handed off /// ownership" signal that makes the dec safe. current_param_modes: BTreeMap, /// Per-fn map of name → (alloca SSA name, AIL element type) for /// alloca-resident loop binders. Populated on entry to a /// `Term::Loop` (iter loop-recur.3): the `Term::Loop` arm inserts /// each binder here and `recur` stores into the slot. Entries are /// removed (or restored to their prior binding) on block exit. /// Consulted by the `Term::Var` arm of `lower_term` before /// `self.locals` — the single load path for loop binders. Loop /// binders are tracked typecheck-side in the ordinary `locals` /// plus `loop_stack` (positional); this codegen map is a /// representation detail, not a scope-precedence claim. binder_allocas: BTreeMap, /// loop-recur iter 3: stack of enclosing `Term::Loop` codegen /// frames. Each frame is `(header_block_label, binder_slots)` /// where `binder_slots` is `(binder_name, alloca_ssa, llvm_ty, /// ail_ty)` in declaration order. Pushed on `Term::Loop` body /// entry, popped on exit; `Term::Recur` reads `.last()` for its /// target header + the binder allocas to store into. The `ail_ty` /// component (bug #49) lets the recur store dec the superseded /// prior value of an RC-heap-non-Str binder before overwriting /// it. Saved/reset/restored at the lambda-lowering boundary /// alongside `binder_allocas` (a `recur` cannot cross a lambda /// boundary). loop_frames: Vec, /// Side buffer for `alloca` instructions emitted during body /// lowering but hoisted to the fn's entry block. Flushed once /// into `self.body` at `entry_block_end_marker` after /// `lower_term` completes. Entry-block placement is what makes /// the loop-binder allocas mem2reg-eligible regardless of how /// deeply nested the originating `Term::Loop` is. pending_entry_allocas: String, /// byte position in `self.body` immediately after /// the `entry:\n` label, captured during `start_block("entry")` /// at fn-body emission. Used to splice `pending_entry_allocas` /// into the entry block once body lowering completes. entry_block_end_marker: Option, } #[derive(Debug, Clone)] struct CtorRef { type_name: String, tag: u32, /// Precomputed LLVM field types. Valid only for monomorphic ADTs /// (`type_vars.is_empty()`). For parameterised ADTs the entries are /// meaningless (free `Type::Var` lowers via the `_ => ptr` fallback) /// and must be re-derived per use site after substituting through /// `ail_fields`. fields: Vec, /// AILang-level field types (parallel to `fields`). Carries /// `Type::Var` references for parameterised ADTs (Iter 13b); these /// are substituted at every ctor / match-arm use site. ail_fields: Vec, /// type parameters of the owning TypeDef, in declaration /// order. Empty for monomorphic ADTs (`type IntList = ...`); non- /// empty for parameterised ADTs (`type Box[a] = MkBox(a)` → /// `["a"]`). Used as the var-set for `unify_for_subst` when deriving /// substitutions at a use site, and to map type-args /// (`Type::Con.args[i]`) back to the right var when lowering match /// arms against a parameterised scrutinee. type_vars: Vec, } #[derive(Debug, Clone)] struct FnSig { params: Vec, // llvm types ret: String, // llvm type } impl<'a> Emitter<'a> { // The parameters are all the workspace-flat tables (plus this // module's typed fn bodies) the emitter needs at construction // time; bundling them into a context struct would just rename the // boilerplate without reducing it. Keep the explicit-arg shape. #[allow(clippy::too_many_arguments)] fn new( module: &'a Module, mir_bodies: &'a [MirDef], module_mir_consts: &'a BTreeMap>, module_name: &'a str, module_user_fns: &'a BTreeMap>, module_ctor_index: &'a BTreeMap>, module_consts: &'a BTreeMap>, import_map: BTreeMap, drop_monos: &'a dropmono::DropAdtMeta, alloc: AllocStrategy, ) -> Self { // mir.3a: read the per-binder consume_count off MIR (filled by // lower_to_mir from the single uniqueness pass) instead of // re-running the uniqueness pass here. let consume: std::collections::BTreeMap<(String, String), u32> = mir_bodies .iter() .flat_map(|d| { let dname = d.name.clone(); d.consume .iter() .map(move |(b, c)| ((dname.clone(), b.clone()), *c)) }) .collect(); Self { module, mir_bodies, module_mir_consts, module_name, header: String::new(), body: String::new(), strings: BTreeMap::new(), str_literals: BTreeMap::new(), locals: Vec::new(), counter: 0, str_counter: 0, module_user_fns, import_map, drop_monos, module_ctor_index, module_consts, current_block: String::new(), block_terminated: false, ssa_fn_sigs: BTreeMap::new(), current_def: String::new(), lam_counter: 0, deferred_thunks: Vec::new(), non_escape: NonEscapeSet::new(), alloc, consume, closure_drops: BTreeMap::new(), moved_slots: BTreeMap::new(), current_param_modes: BTreeMap::new(), binder_allocas: BTreeMap::new(), loop_frames: Vec::new(), pending_entry_allocas: String::new(), entry_block_end_marker: None, } } pub(crate) fn start_block(&mut self, label: &str) { self.body.push_str(label); self.body.push_str(":\n"); // capture the entry-block splice point for // `pending_entry_allocas`. The marker points to the byte // position immediately after the `entry:\n` label — the // splice (in `emit_fn`'s tail) inserts the hoisted allocas // there, before any subsequent body emission. if label == "entry" { self.entry_block_end_marker = Some(self.body.len()); } self.current_block = label.to_string(); self.block_terminated = false; } fn emit_module(&mut self) -> Result<()> { let defs: Vec<&Def> = self.module.defs.iter().collect(); for def in defs { match def { Def::Fn(f) => { // Polymorphic defs aren't emitted in their original // form — they are specialised on demand at call sites // (Iter 12b). Skip them here; the drain pass below // emits the specialised versions. if matches!(&f.ty, Type::Forall { .. }) { continue; } // The typed body is the matching `MirDef.body`. // Intrinsic-bodied fns have no MirDef (lower_module // skips them); they are handled in `emit_fn` via the // intercept registry, which reads the AST `f.body` // marker and never inspects the MTerm — so a missing // body is only an error for a real-bodied fn, caught // by the `None` arm inside `emit_fn`. let body = self .mir_bodies .iter() .find(|d| d.name == f.name) .map(|d| &d.body); self.emit_fn(f, body) .map_err(|e| CodegenError::Def(f.name.clone(), Box::new(e)))?; } Def::Const(c) => { self.emit_const(c) .map_err(|e| CodegenError::Def(c.name.clone(), Box::new(e)))?; } Def::Type(_) => { // No LLVM definition needed: the ADT exists only as a // logical type. Heap boxes are allocated ad hoc via // the runtime allocator (Iter 14f's escape-analysis target). } // class/instance defs do not emit IR yet. // 22b.3 monomorphisation will rewrite class-method // calls into calls against synthesised monomorphic // FnDefs; once that pass runs, class/instance bodies // never reach the emit path on their own — they only // appear inlined into the synthesised fns. Def::Class(_) | Def::Instance(_) => {} } } // iter 23.4: the monomorphisation queue is gone — the // typecheck-time mono pass synthesises every specialised // `Def::Fn` before codegen runs (see // `ailang_check::mono::monomorphise_workspace`). Codegen // sees only monomorphic defs; the drain loop and // `emit_specialised_fn` have been removed. // per-ADT drop functions. Emitted only under // `--alloc=rc`. One `void @drop__(ptr)` per // `Def::Type` in the current module — the call site for // recursive ADTs (`drop__List` calling itself on the tail) // requires every ADT to have a uniformly-named drop fn, so we // emit even for ADTs with no boxed children (those drop fns // just dec the outer box). Under `Gc`/`Bump` no drop fns // appear — the IR shape stays byte-identical to pre-18c.4. if matches!(self.alloc, AllocStrategy::Rc) { for def in &self.module.defs { if let Def::Type(td) = def { // raw-buf.4: a TypeDef whose construction is // compiler-supplied (its `new` op is an (intrinsic), // not a real term-ctor body) has a flat slab layout // [size:i64][elements], NOT a tagged-ADT layout. The // generic drop loads a tag at offset 0 and switches // on it — but offset 0 here is the size, so the // generic tag-switch drop is wrong. Emit a flat // drop instead: a single rc-dec on the slab pointer // (primitive elements carry no recursive drops). // This distinguishes RawBuf (intrinsic `new`) from // an ordinary param_in user ADT (real-body `new` → // generic ADT drop), where a param_in heuristic // would misclassify the user ADT (also param_in). // The intrinsic-`new` test is the load-bearing // signal, not param_in. A matching flat partial-drop is // emitted for symbol-resolution parity with the // generic path (no carve-out site can actually // target it — the sole field is a primitive). if self.module.defs.iter().any(|d| { matches!(d, Def::Fn(f) if f.name == "new" && matches!(f.body, Term::Intrinsic) && fn_returns_type(f, &td.name)) }) { self.emit_flat_intrinsic_drop_fn(td); self.emit_flat_intrinsic_partial_drop_fn(td); continue; } if td.drop_iterative { // opt-in iterative-drop body. The // recursive cascade overflows the C stack on // long chains (a million-cell list ≈ 8MB // stack); the iterative variant uses an // explicit heap-allocated worklist instead. self.emit_iterative_drop_fn_for_type(td); } else { self.emit_drop_fn_for_type(td); } // tag-conditional partial-drop // helper alongside the recursive/iterative drop fn. // Used at carve-out sites where a binder's runtime // tag is dynamic and `moved_slots` is a strict // subset of its ptr fields. Same shape regardless // of `(drop-iterative)` (the helper is single-shot // on the binder; field cascades go through their // own drop fns which themselves choose recursive // vs iterative). self.emit_partial_drop_fn_for_type(td); } } } Ok(()) } /// emit one specialised version of a polymorphic def. /// Substitutes rigid vars in both the type and the body, then /// calls `emit_fn` against a synthetic FnDef whose `name` already /// contains the descriptor — the existing mangling concatenates /// `ail__` and produces the desired symbol. fn emit_const(&mut self, c: &ConstDef) -> Result<()> { // non-literal const values (e.g. ctor expressions) are // not emitted as globals. They are inlined at every `Term::Var` // reference site — sound because `check_const` rejects effectful // bodies, so re-evaluating the body at each use is observably // equivalent to a single computation. Trade-off: a long // recursive const evaluated in many places duplicates work, // but the demo-scale workloads shipped in the stdlib // examples are small enough that this is a non-issue. A // future iter may layer a `@llvm.global_ctors`-style init // path on top to share the result across reference sites. let lty = llvm_type(&c.ty)?; let lit = match &c.value { Term::Lit { lit } => lit, _ => return Ok(()), }; let (val_ty, val) = match lit { Literal::Int { value } => ("i64".to_string(), value.to_string()), Literal::Bool { value } => ( "i1".to_string(), if *value { "true".into() } else { "false".into() }, ), Literal::Unit => ("i8".to_string(), "0".to_string()), Literal::Str { value } => { // emit a packed-struct global and return a // constexpr-GEP pointer landing on the `len`-field (now // the first field of the packed struct, since the // hs.1-era sentinel rc-header slot was removed). Every // IR-Str pointer in this codegen pipeline has the // shape len at offset 0, bytes at offset 8. let g = self.intern_str_literal("str", value); let total = c_byte_len(value); // bytes + NUL ( "ptr".to_string(), format!( "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)", ), ) } Literal::Float { bits } => ("double".to_string(), format!("0x{:016X}", bits)), }; if val_ty != lty { return Err(CodegenError::Internal(format!( "const type mismatch: {} vs {}", lty, val_ty ))); } self.header.push_str(&format!( "@ail_{module}_{name} = constant {ty} {val}\n", module = self.module_name, name = c.name, ty = lty, val = val, )); Ok(()) } /// Returns `true` when the codegen should emit the LLVM /// `alwaysinline` attribute on this fn's `define` line. The /// allowlist is the set of primitive-instance bodies emitted by /// `try_emit_primitive_instance_body` whose IR is so trivial /// (one `icmp` / `fcmp` / `call` plus a `ret`) that `-O0` build /// paths pay an unacceptable per-call overhead without inlining, /// while `-O2` would inline these unconditionally anyway. The /// list mirrors the intercept arm set in /// `try_emit_primitive_instance_body` for the equality / /// ordering / Float-comparison primitives. Checked against the /// unmangled fn name; these symbols only live in the prelude /// module so cross-module collisions are not a concern. fn intercept_emit_wants_alwaysinline(symbol: &str) -> bool { // Branch 1 — registry-driven (raw-buf.1). Every intercept entry // carries its own `wants_alwaysinline` bool; the predicate // consults the single table instead of a duplicate name-list. // This closes the silent-drift class where the predicate's // hardcoded list could diverge from the dispatch-arm set. if intercepts::lookup(symbol).is_some_and(|i| i.wants_alwaysinline) { return true; } // Branch 2 — polymorphic-body carve-out, NOT covered by the // registry. The free helpers `ne` / `lt` / `le` / `gt` / `ge` // mono into per-type bodies. At `_Int` (iter // operator-routing-eq-ord.1 Task 13) they are intercepted as // single direct `icmp` instructions in // `try_emit_primitive_instance_body` — same shape as // `eq__Int`, ie. one `icmp` + one `ret`. At other types // (`_Bool`, `_Str`, user ADTs) they keep their polymorphic // body — a one-arm-deep `match (compare x y) (case LT ... ) // (case _ ... )` — which still folds at `-O2` provided the // mono symbol carries `alwaysinline`. The registry only covers // the `_Int` specialisations; this stem-prefix match covers // every other T the mono pass produces (`lt__Bool`, // `ne__MyADT`, …), preserving the bench-perf parity that the // pre-milestone arithmetic-comparator emission established. if let Some(stem) = symbol.split("__").next() { if matches!(stem, "ne" | "lt" | "le" | "gt" | "ge") { return true; } } false } fn emit_fn(&mut self, f: &FnDef, body: Option<&MTerm>) -> Result<()> { // also lift `param_modes` out of the fn type. The // fn-return Own-param dec emission below consults it to decide // which params get a drop call before `ret`. `Borrow` entries // are skipped — only `Own` carries the static "caller handed // off ownership" signal. let (param_tys, ret_ty, param_modes) = match &f.ty { Type::Fn { params, ret, param_modes, .. } => (params.clone(), (**ret).clone(), param_modes.clone()), other => { return Err(CodegenError::NotFnType( ailang_core::pretty::type_to_string(other), )); } }; let llvm_param_tys: Vec = param_tys.iter().map(llvm_type).collect::>()?; let llvm_ret = llvm_type(&ret_ty)?; self.locals.clear(); self.counter = 0; // Per-fn body: the sidetable starts empty. Top-level fn references // get registered on demand by `lower_term(Term::Var)`. self.ssa_fn_sigs.clear(); // lambda thunks live in the same module body but get // collected during lowering and appended after the parent fn. self.current_def = f.name.clone(); self.lam_counter = 0; // move tracking is per-fn-body. self.moved_slots.clear(); // Param-mode lookup is per-fn-body. Built from the fn type's // `param_modes` (already destructured above) and // consulted by `lower_match`'s Iter A gate to skip arm-close // pattern-binder dec when the scrutinee is a non-Own param. self.current_param_modes.clear(); // Per-fn-body loop-binder bookkeeping. The map is // populated/restored by the `Term::Loop` arm of `lower_term`; // the side buffer collects alloca instructions for hoisting; // the marker is captured by `start_block("entry")` below. self.binder_allocas.clear(); self.pending_entry_allocas.clear(); self.entry_block_end_marker = None; for (i, pname) in f.params.iter().enumerate() { // Codegen-synthesised fn-defs (lambda thunks, local-rec // lifts) may carry an empty `param_modes`; fall back to // `Own` (the synthesis default) rather than index-panic. let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Own); self.current_param_modes.insert(pname.clone(), mode); } // run escape analysis over the fn body. The result // is queried at every `Term::Ctor` / `Term::Lam` lowering site // to decide between `alloca` (non-escaping) and the runtime // allocator (escaping). The analysis is purely additive — a // stale or empty result only loses optimisation opportunities, // never correctness. // Escape analysis over the typed body. Intrinsic-bodied fns // have no MTerm body (handled via the intercept registry); an // empty non-escape set is the correct conservative default — // they never reach `lower_term`. self.non_escape = match body { Some(b) => escape::analyze_fn_body(b), None => NonEscapeSet::new(), }; let mut sig = format!( "define {ret} @ail_{module}_{name}(", ret = llvm_ret, module = self.module_name, name = f.name ); for (i, ((pname, pty), pty_ail)) in f .params .iter() .zip(llvm_param_tys.iter()) .zip(param_tys.iter()) .enumerate() { if i > 0 { sig.push_str(", "); } // SSA argument name: %arg_ let pssa = format!("%arg_{}", pname); sig.push_str(&format!("{pty} {pssa}")); self.locals.push(( pname.clone(), pssa.clone(), pty.clone(), pty_ail.clone(), )); // if this param is a function value, record its sig // so that `f(args)` inside the body can emit an indirect call. if let Some(fs) = fn_sig_from_type(pty_ail) { self.ssa_fn_sigs.insert(pssa, fs); } } if Self::intercept_emit_wants_alwaysinline(&f.name) { // Append the `alwaysinline` attribute between the close-paren // of the parameter list and the `{` opening the body. Symbols // matched here are emitted by `try_emit_primitive_instance_body`; // forcing inlining keeps the single `icmp` / `fcmp` body folded // at every use site under `-O0` (matching the IR shape the // deleted `lower_eq` direct-emit path produced). sig.push_str(") alwaysinline {\n"); } else { sig.push_str(") {\n"); } self.body.push_str(&sig); self.start_block("entry"); // primitive-instance body intercept. Returns true if the body // was emitted in full (including the closing `}\n\n`). A // compiler-supplied body is an `(intrinsic)` marker // (`Term::Intrinsic`) whose name resolves through the intercept // registry — the intercept is matched by name here, before the // body is ever inspected, so the marker is never lowered. When // the intercept fires we skip the normal body-lowering block // below but still fall through to deferred-thunk flush + // closure-pair emission, so the intercepted fn participates in // the same post-body machinery as every other top-level fn // (iter 23.2.2 fixup: previously this short-circuited past // `emit_adapter_and_static_closure`, leaving the fn without a // closure-pair symbol — a footgun the moment any caller // referenced it by value). let body_was_intercepted = self.try_emit_primitive_instance_body(&f.name, &llvm_param_tys, &llvm_ret)?; // An `(intrinsic)` body is supplied entirely by the intercept // registry. `try_emit_primitive_instance_body` already consults // `intercepts::lookup(&f.name)`; if it fired, the body is fully // emitted. If it did NOT fire, the intrinsic has no registered // implementation — surface that as an internal error rather than // falling through to `lower_term`, which cannot lower a // `Term::Intrinsic` (it is signature-only, not an expression). if !body_was_intercepted && matches!(f.body, Term::Intrinsic) { return Err(CodegenError::Internal(format!( "intrinsic fn `{}` has no registered intercept; an `(intrinsic)` body must \ resolve through the intercept registry", f.name ))); } if !body_was_intercepted { // A non-intercepted, non-intrinsic fn must have a typed // MIR body (lower_module produced a MirDef for it). A // missing body here means the producer skipped a real fn — // surface it rather than silently emitting an empty body. let body = body.ok_or_else(|| { CodegenError::Internal(format!( "fn `{}` has no MIR body but is not intrinsic-bodied", f.name )) })?; let (val, val_ty) = self.lower_term(body)?; if !self.block_terminated { if val_ty != llvm_ret { return Err(CodegenError::Internal(format!( "fn `{}`: body type {val_ty} != return type {llvm_ret}", f.name ))); } // fn-return Own-param dec. Symmetric to // 18c.3/18c.4's `Term::Let`-scope-close drop and 18d.4's // arm-close pattern-binder dec, fired at the lexical // close of a fn body. For each parameter with // `ParamMode::Own`, emit a drop call iff: // - alloc strategy is `Rc`, // - the parameter's lowered type is `ptr`, // - uniqueness inference recorded `consume_count == 0` // for the param in this fn's body (no internal use // consumed it; the param's slot owns the only ref the // callee received from the caller's hand-off), // - the param's SSA is not the body's tail value // (returning the param transfers ownership back to // the caller's frame; caller dec's, not us), // - the current block is still open. // // `Borrow`-mode params do NOT get dec'd: the caller still // owns them, so there is no caller-handed-off-ownership // signal and a dec here would fragment the caller's // structure. // // Closes the 18c.3/18c.4 carve-out: "fn parameters still // don't get dec'd at fn return — the caller-handed-off- // ownership signal is the `(own T)` mode, but wiring it // through codegen is part of the wider mode-aware story." if matches!(self.alloc, AllocStrategy::Rc) { for (i, ((pname, plty), pty_ail)) in f .params .iter() .zip(llvm_param_tys.iter()) .zip(param_tys.iter()) .enumerate() { if plty != "ptr" { continue; } let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Own); if !matches!(mode, ParamMode::Own) { continue; } let consume_count = self .consume .get(&(self.current_def.clone(), pname.clone())) .copied() .unwrap_or(u32::MAX); if consume_count != 0 { continue; } let p_ssa = format!("%arg_{}", pname); if val == p_ssa { // The param IS the fn's return value — // ownership transfers back to the caller. continue; } let moves = self .moved_slots .get(pname) .cloned() .unwrap_or_default(); if moves.is_empty() { // Route through the per-type drop fn for the // param's static type. `field_drop_call` // resolves `Type::Con` to `drop__` // and falls back to `ailang_rc_dec` for // closure / Var fields — closure-typed Own // params therefore use the same shallow free // 18c.4 set up for closure-typed ADT fields, // matching the iter brief's "closure-typed // Own params follow whichever debt path // 18c.4 set up" carve-out. let drop_call = self.field_drop_call(pty_ail); self.body.push_str(&format!( " call void @{drop_call}(ptr {p_ssa})\n" )); } else { // dynamic-tag partial-drop // via the per-type helper. The param's runtime // tag is dynamic but its static type is known // (`pty_ail`), so we route through // `partial_drop__(p, mask)` which // dispatches on the runtime tag and dec's only // the unmoved fields. The fallback to shallow // `ailang_rc_dec` only fires for non-ADT param // types (Str, fn-typed, vars) — none of which // can populate `moved_slots` in practice, so // the fallback is dead under the typechecker. let sym = Self::partial_drop_symbol_for_type( self, pty_ail, ); let mask = Self::build_moved_mask(&moves); if let (Some(sym), Some(mask)) = (sym, mask) { self.body.push_str(&format!( " call void @{sym}(ptr {p_ssa}, i64 {mask})\n" )); } else { self.body.push_str(&format!( " call void @ailang_rc_dec(ptr {p_ssa})\n" )); } } } } self.body .push_str(&format!(" ret {val_ty} {val}\n}}\n\n")); } else { // a `tail-app`/`tail-do` at the body root already // emitted its own `ret` (after `musttail call`). Just close // the function body — no fall-through ret. self.body.push_str("}\n\n"); } } // close `if !body_was_intercepted` // splice any deferred entry-block allocas at the // captured marker (immediately after the `entry:\n` label). // If no mut-vars were emitted for this fn, // `pending_entry_allocas` is empty and this is a no-op. The // splice runs after body lowering finalises but BEFORE the // deferred-thunks flush — thunks are already self-contained // strings parked in `deferred_thunks`, with their own splice // already performed inside the lambda emission boundary in // `lambda.rs`. if !self.pending_entry_allocas.is_empty() { let marker = self.entry_block_end_marker.ok_or_else(|| { CodegenError::Internal(format!( "fn `{}`: pending_entry_allocas accumulated but no entry-block \ marker was captured", f.name )) })?; let allocas = std::mem::take(&mut self.pending_entry_allocas); self.body.insert_str(marker, &allocas); } // flush lambda thunks collected while lowering this fn's // body. They go after the closing `}` of the parent fn, before // the adapter, so the parent fn is contiguous. for t in self.deferred_thunks.drain(..) { self.body.push_str(&t); } // emit closure-pair scaffold (adapter + static closure) // for this fn. The adapter takes an extra `ptr %_env` (ignored, // null sentinel for top-level fns) and forwards to the real fn. // The static closure pair `{ adapter_ptr, null }` is the value // produced when this fn is referenced as a `Term::Var` value // (closure-pair pointer ABI). self.emit_adapter_and_static_closure(&f.name, &llvm_param_tys, &llvm_ret); Ok(()) } /// #63 leg 3: drop owned heap params that are LIVE on this branch /// (`branch_consume[p] == 0`) but CONSUMED on a sibling branch (per-fn /// aggregate `self.consume[p] >= 1`) — the leak class. The /// `aggregate >= 1` guard keeps this disjoint from the fn-return dec /// (which owns the `aggregate == 0` class), so no param is double- /// dropped — no fn-return disable / tail-position analysis is needed. /// And because the checker rejects use-after-consume, an /// `aggregate >= 1` param is provably dead past the branch construct, /// so dropping it at the branch close is safe regardless of tail /// position. `tail_val` is the branch's own tail SSA (a param returned /// by the branch transfers ownership to the join consumer — skip it); /// `scrutinee` is the match scrutinee binder to skip (None for `if`). fn emit_leakclass_branch_param_drops( &mut self, branch_consume: &std::collections::BTreeMap, tail_val: &str, scrutinee: Option<&str>, ) { if !matches!(self.alloc, AllocStrategy::Rc) { return; } let owned_params: Vec<(String, ParamMode)> = self .current_param_modes .iter() .map(|(n, m)| (n.clone(), *m)) .collect(); for (pname, mode) in &owned_params { if !matches!(mode, ParamMode::Own) { continue; } if scrutinee == Some(pname.as_str()) { continue; } let branch_cc = branch_consume.get(pname).copied().unwrap_or(u32::MAX); if branch_cc != 0 { continue; // consumed on this branch — ownership transferred } let agg = self .consume .get(&(self.current_def.clone(), pname.clone())) .copied() .unwrap_or(0); if agg == 0 { continue; // live on every path — the fn-return dec owns this } let p_ssa = format!("%arg_{}", pname); if p_ssa == tail_val { continue; // param is this branch's return value } let p_ail = self .locals .iter() .find(|(_, ssa, _, _)| ssa == &p_ssa) .map(|(_, _, lty, ail)| (lty.clone(), ail.clone())); let (p_lty, p_ail) = match p_ail { Some(t) => t, None => continue, }; if p_lty != "ptr" { continue; } // #63 leg 3 TYPE precondition: only drop params with a real // per-type heap-ADT drop fn. `field_drop_call` routes // `Type::Fn` (static OR heap closures), static-`Str`, and // `Type::Var` to the bare `ailang_rc_dec`, which reads a // non-existent rc-header on a `.rodata` static closure/Str and // underflows. This `agg >= 1` site is the FIRST drop site that // can reach a static closure param (a called closure has // `agg >= 1`; every existing site needs `agg == 0`), so it must // gate on a real drop fn. Sound + leak-correct: a static // closure / static `Str` allocates nothing to drop. let drop_call = self.field_drop_call(&p_ail); if drop_call == "ailang_rc_dec" { continue; } let moves = self.moved_slots.get(pname).cloned().unwrap_or_default(); if moves.is_empty() { self.body .push_str(&format!(" call void @{drop_call}(ptr {p_ssa})\n")); } else { let sym = self.partial_drop_symbol_for_type(&p_ail); let mask = Self::build_moved_mask(&moves); if let (Some(sym), Some(mask)) = (sym, mask) { self.body .push_str(&format!(" call void @{sym}(ptr {p_ssa}, i64 {mask})\n")); } else { self.body .push_str(&format!(" call void @ailang_rc_dec(ptr {p_ssa})\n")); } } } } /// closure-pair scaffold for a top-level fn. Always emitted /// (one wrapper per fn), so cross-module references just use the /// `__clos` symbol without coordination. fn emit_adapter_and_static_closure( &mut self, fn_name: &str, param_tys: &[String], ret_ty: &str, ) { let m = self.module_name; // Adapter: `(ptr %_env, params...) -> ret` calls the real fn, // returning whatever it returned. let mut adapter = format!( "define {ret} @ail_{m}_{fn_name}_adapter(ptr %_env", ret = ret_ty, ); for (i, pty) in param_tys.iter().enumerate() { adapter.push_str(&format!(", {pty} %a{i}")); } adapter.push_str(") {\nentry:\n"); let mut call_args = String::new(); for (i, pty) in param_tys.iter().enumerate() { if i > 0 { call_args.push_str(", "); } call_args.push_str(&format!("{pty} %a{i}")); } adapter.push_str(&format!( " %r = call {ret} @ail_{m}_{fn_name}({call_args})\n", ret = ret_ty, )); adapter.push_str(&format!(" ret {ret} %r\n}}\n\n", ret = ret_ty)); self.body.push_str(&adapter); // Static closure pair: `{ adapter_ptr, null }`. The address of // this global IS the fn-value that escapes to other code. self.header.push_str(&format!( "@ail_{m}_{fn_name}_clos = private unnamed_addr constant {{ ptr, ptr }} {{ ptr @ail_{m}_{fn_name}_adapter, ptr null }}\n" )); } /// Lowers a term to (SSA value string, LLVM type). pub(crate) fn lower_term(&mut self, t: &MTerm) -> Result<(String, String)> { match t { MTerm::Lit { lit, .. } => Ok(match lit { Literal::Int { value } => (value.to_string(), "i64".into()), Literal::Bool { value } => ( if *value { "true".into() } else { "false".into() }, "i1".into(), ), Literal::Str { value } => { // A `Literal::Str` carried in an `MTerm::Lit` would // be a producer bug — the lowering splits every // string literal into the dedicated `MTerm::Str` // arm below. Lower it the same way for robustness. let g = self.intern_str_literal("str", value); let total = c_byte_len(value); // bytes + NUL ( format!( "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)", ), "ptr".into(), ) } Literal::Unit => ("0".into(), "i8".into()), Literal::Float { bits } => (format!("0x{:016X}", bits), "double".into()), }), MTerm::Str { lit, rep } => { // language `Str` literals materialise as a constexpr-GEP // into the packed-struct global, landing on the `len` // field (the first field). IR-Str pointer carries len at // 0, bytes at +8. let g = self.intern_str_literal("str", lit); let total = c_byte_len(lit); // bytes + NUL let gep = format!( "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)", ); match rep { // Static: a constexpr-GEP into the rodata global, no // rc_header — the default for every non-loop-carried // literal. Never RC-dec'd (see drop.rs Str note). StrRep::Static => Ok((gep, "ptr".into())), // mir.4: a loop-carried Str literal (seed or recur // arg, flipped to Heap by lower_to_mir) must be an // owned heap slab so the recur superseded-value dec // is sound. Promote via `ailang_str_clone`, which // deep-copies into a fresh `ailang_rc_alloc` slab // (rc_header refcount 1 — runtime/str.c). The GEP is // a valid constexpr operand; str_clone reads `len` at // offset 0 (ABI-shared between static and heap Str). StrRep::Heap => { let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call ptr @ailang_str_clone(ptr {gep})\n" )); Ok((dst, "ptr".into())) } } } MTerm::Var { name, .. } => { // Floats iter 4.5: bare-value Float constants resolve // directly to LLVM hex-float `double` SSA values at // the use site — no global declaration, no // intern-global path. Parallel to how `__unreachable__` // is intercepted, but as a value rather than a // terminator (constants are SSA values; the // unreachable-instruction path doesn't apply). match name.as_str() { "nan" => return Ok(("0x7FF8000000000000".into(), "double".into())), "inf" => return Ok(("0x7FF0000000000000".into(), "double".into())), "neg_inf" => return Ok(("0xFFF0000000000000".into(), "double".into())), _ => {} } // `__unreachable__` is a polymorphic bottom // value (`forall a. a`). At codegen we emit LLVM // `unreachable` as the block terminator and return a // dummy SSA value. The surrounding `if`/`match`/`seq` // already inspects `block_terminated` and forwards the // sibling branch's type, so the type we report here is // not consumed by a phi node — `i8` is a sound // placeholder. Subsequent emissions in this block are // gated by `block_terminated`. if name == "__unreachable__" { self.body.push_str(" unreachable\n"); self.block_terminated = true; return Ok(("0".into(), "i8".into())); } // Loop binders take precedence over let-bound / param // resolutions. Shadowing across nested loops is // handled by the `Term::Loop` arm's push-and-restore // on the BTreeMap entry, so a single `get` suffices // here (the entry visible at this point is the // innermost binding). if let Some((alloca_name, ail_ty)) = self.binder_allocas.get(name).cloned() { let llvm_ty = llvm_type(&ail_ty)?; let load_ssa = self.fresh_ssa(); self.body.push_str(&format!( " {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n" )); return Ok((load_ssa, llvm_ty)); } if let Some((_, ssa, ty, _)) = self.locals.iter().rev().find(|(n, _, _, _)| n == name) { return Ok((ssa.clone(), ty.clone())); } // bare reference to a top-level fn yields a fn-pointer // value of type `ptr`. Cross-module via `prefix.def`, current // module via plain `def`. Sidetable carries the sig. if let Some((global, sig)) = self.resolve_top_level_fn(name) { self.ssa_fn_sigs.entry(global.clone()).or_insert(sig); return Ok((global, "ptr".into())); } // const lookup. Both bare (`xs`) and qualified // (`prefix.xs`) forms resolve through `module_consts`. // Literal-bodied consts get a load from the global; non- // literal bodies (e.g. ctor expressions) are inlined. if let Some((owner_module, cdef)) = self.resolve_const(name) { let lty = llvm_type(&cdef.ty)?; if matches!(&cdef.value, Term::Lit { .. }) { let v = self.fresh_ssa(); self.body.push_str(&format!( " {v} = load {lty}, ptr @ail_{owner_module}_{cname}, align 8\n", cname = cdef.name, )); return Ok((v, lty)); } else { // Inline the const body — the checker-typed // `MTerm` from the owner module's MIR. The // emitter's module context is fine because // cross-module ctors are already qualified in the // AST after typecheck. let body = self .module_mir_consts .get(&owner_module) .and_then(|m| m.get(&cdef.name)) .cloned() .ok_or_else(|| CodegenError::Internal(format!( "non-literal const `{owner_module}.{cname}` has no MIR body", cname = cdef.name, )))?; return self.lower_term(&body); } } Err(CodegenError::UnknownVar(name.clone())) } MTerm::Let { name, init, body, .. } => { let value = init; // decide whether this let-binder is // trackable for `dec` emission BEFORE lowering. The // value term must lower through `ailang_rc_alloc` — // that means `MTerm::Ctor` / `MTerm::Lam` whose escape // analysis says "heap" (not `alloca`) under // `--alloc=rc`. Other value shapes (calls, vars, // literals) lower to SSAs we don't statically own at // this scope. let trackable = self.is_rc_heap_allocated(value); let val_ail = value.ty(); let (val_ssa, val_ty) = self.lower_term(value)?; self.locals .push((name.clone(), val_ssa.clone(), val_ty.clone(), val_ail)); // let-alias-aware mode propagation. // If `value` is a `Term::Var` referencing a name in // `current_param_modes`, the let-binder inherits that // mode for the duration of the body. Without this, // `(let a t (match a ...))` where `t` is a // `Borrow`-mode param defeats the // `scrutinee_is_owned` gate in `lower_match` (the // gate looks up `a` in `current_param_modes`, misses, // and defaults to "owned" — Iter A then dec's // pattern-binders whose underlying memory belongs to // the caller). // // Restored on let-body-close (push/pop pattern). let inherited_mode: Option = match value.as_ref() { MTerm::Var { name: src, .. } => { self.current_param_modes.get(src).copied() } _ => None, }; let prior_mode = if let Some(m) = inherited_mode { let prior = self.current_param_modes.insert(name.clone(), m); Some(prior) } else { None }; let r = self.lower_term(body); if let Some(prior) = prior_mode { match prior { Some(m) => { self.current_param_modes.insert(name.clone(), m); } None => { self.current_param_modes.remove(name); } } } self.locals.pop(); // lift the binder's move set out of the // side table. The binder is leaving scope here; we // remove the entry whether or not we end up using it // for the dec emission below. `take` returns a // by-value `BTreeSet` (or empty) so we can // both consult and clear in one move. let moves_for_binder: BTreeSet = self.moved_slots.remove(name).unwrap_or_default(); // emit a drop call at scope close iff: // - we're tracking this binder (heap RC alloc above), // - the value type is `ptr` (not a primitive), // - uniqueness inference recorded `consume_count == 0` // for the binder (no callee / outer term has already // taken ownership; the binder owns the only ref), // - the body's tail value is NOT the binder itself // (a binder that flows out as the result transfers // ownership to the caller — caller dec's), and // - the current block is still open (a tail-call / // `unreachable` already exited; nothing to emit). // // the drop call is no longer a raw // `ailang_rc_dec`. For a `Term::Ctor` binder the call // routes through `@drop__` so any // boxed children of the cell cascade through their // own drop fns; for a `Term::Lam` binder the call // routes through the per-pair drop fn the lambda // emission recorded in `closure_drops`. Both shapes // call `ailang_rc_dec` on the outer box internally, // so the refcount story is unchanged. if trackable && val_ty == "ptr" && !self.block_terminated { let consume_count = self .consume .get(&(self.current_def.clone(), name.clone())) .copied() .unwrap_or(u32::MAX); // Defensive: skip if missing. let body_returns_binder = match &r { Ok((body_ssa, _)) => body_ssa == &val_ssa, Err(_) => true, // Don't emit on error path either. }; if consume_count == 0 && !body_returns_binder { // when the binder has moved-out // pattern slots, the uniform `drop__` // would re-dec values that have already been // transferred to other binders. Inline a // per-field dec sequence that skips moved // slots; non-moved pointer slots dec via // `field_drop_call` (null-guarded drop fns // matching the recursive cascade in // `emit_drop_fn_for_type`). For the empty- // moves common case (every fixture pre-18d.3 // and most fixtures post-18d.3) we emit the // identical `drop__(ptr)` call as // 18c.4, preserving IR shape. if !moves_for_binder.is_empty() { self.emit_inlined_partial_drop( value, &val_ssa, &moves_for_binder, )?; } else { let drop_sym = self.drop_symbol_for_binder(value, &val_ssa); self.body.push_str(&format!( " call void @{drop_sym}(ptr {val_ssa})\n" )); } } } r } MTerm::If { cond, then, else_, then_consume, else_consume, .. } => { let (cond_v, cond_ty) = self.lower_term(cond)?; if cond_ty != "i1" { return Err(CodegenError::Internal(format!( "if cond not i1: {cond_ty}" ))); } let id = self.fresh_id(); let then_lbl = format!("then.{id}"); let else_lbl = format!("else.{id}"); let join_lbl = format!("join.{id}"); self.body.push_str(&format!( " br i1 {cond_v}, label %{then_lbl}, label %{else_lbl}\n" )); self.start_block(&then_lbl); let (then_v, then_ty) = self.lower_term(then)?; // a tail-call in this branch already terminated // its block; skip its branch to join and exclude from phi. let then_terminated = self.block_terminated; let then_block_end = self.current_block.clone(); if !then_terminated { self.emit_leakclass_branch_param_drops(then_consume, &then_v, None); self.body.push_str(&format!(" br label %{join_lbl}\n")); } self.start_block(&else_lbl); let (else_v, else_ty) = self.lower_term(else_)?; let else_terminated = self.block_terminated; let else_block_end = self.current_block.clone(); if !then_terminated && !else_terminated && then_ty != else_ty { return Err(CodegenError::Internal(format!( "if branches type mismatch: {then_ty} vs {else_ty}" ))); } if !else_terminated { self.emit_leakclass_branch_param_drops(else_consume, &else_v, None); self.body.push_str(&format!(" br label %{join_lbl}\n")); } // if both branches terminated, the whole `if` is // terminated and no join is reachable. Mark and bail. if then_terminated && else_terminated { self.block_terminated = true; return Ok(("0".into(), then_ty)); } // If exactly one branch terminated, the join receives only // the other branch's value — no phi node is needed. if then_terminated { self.start_block(&join_lbl); return Ok((else_v, else_ty)); } if else_terminated { self.start_block(&join_lbl); return Ok((then_v, then_ty)); } self.start_block(&join_lbl); let phi = self.fresh_ssa(); self.body.push_str(&format!( " {phi} = phi {ty} [ {tv}, %{tlbl} ], [ {ev}, %{elbl} ]\n", ty = then_ty, tv = then_v, tlbl = then_block_end, ev = else_v, elbl = else_block_end, )); // if both branches yield the same fn-pointer sig, // forward it to the phi SSA so subsequent indirect calls // can resolve. if then_ty == "ptr" { if let (Some(ts), Some(es)) = (self.ssa_fn_sigs.get(&then_v), self.ssa_fn_sigs.get(&else_v)) { if ts.params == es.params && ts.ret == es.ret { let merged = ts.clone(); self.ssa_fn_sigs.insert(phi.clone(), merged); } } } Ok((phi, then_ty)) } 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) } }, 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 // consult the escape-analysis result for this exact // allocation site. let term_ptr = (t as *const MTerm) as usize; self.lower_ctor(type_name, ctor, args, term_ptr) } MTerm::Match { scrutinee, arms, .. } => self.lower_match(scrutinee, arms), MTerm::Lam { params, param_tys, ret_ty, effects: _, body, .. } => { // same as `Ctor` — pass the term pointer for // escape-analysis lookup. A non-escaping closure pair // (and its env) lower to `alloca`. let term_ptr = (t as *const MTerm) as usize; self.lower_lambda(params, param_tys, ret_ty, body, term_ptr) } MTerm::Seq { lhs, rhs, .. } => { // lower lhs for its effects, discard the SSA; // lower rhs and return its value as the whole expression. // lhs may not legally be a `tail` call (the // typechecker rejects that), so `block_terminated` is // false after it. rhs is in the same tail context as the // surrounding seq, so a `tail-app` there will set // `block_terminated`; the outer match-arm/fn-body // handler honours that. let _ = self.lower_term(lhs)?; self.lower_term(rhs) } MTerm::LetRec { .. } => { // `MTerm::LetRec` is eliminated by the // desugar pass before codegen runs, so reaching it // here is a bug. unreachable!("MTerm::LetRec eliminated by desugar") } MTerm::Clone { value, .. } => { // lower the inner value, then emit // `call void @ailang_rc_inc(ptr %v)` under `--alloc=rc`. // Inc is skipped for non-`ptr` values (primitives like // `i64` carry no refcount) and for `@`-prefixed SSAs // (top-level fn closure-pair globals live in the LLVM // data segment, not heap memory — `runtime/rc.c`'s // header layout doesn't apply to them). Codegen elision // here matches `runtime/rc.c`'s comment about static // pointers. let (val_ssa, val_ty) = self.lower_term(value)?; if matches!(self.alloc, AllocStrategy::Rc) && val_ty == "ptr" && !val_ssa.starts_with('@') && !self.block_terminated { self.body.push_str(&format!( " call void @ailang_rc_inc(ptr {val_ssa})\n" )); } Ok((val_ssa, val_ty)) } MTerm::ReuseAs { source, body, .. } => { // under --alloc=rc, lower as a runtime- // refcount-1 dispatch — if the source's box is // unique we overwrite it in place (skipping the // alloc-and-cascade-dec round-trip); otherwise we // allocate a fresh box and dec the source. Other // allocators keep the 18d.1 identity behaviour. if !matches!(self.alloc, AllocStrategy::Rc) { return self.lower_term(body); } // The body must be an MTerm::Ctor for the in-place // rewrite to make sense. 18d.1 typecheck rejects // any other shape; lams are accepted by typecheck // but not yet supported by reuse codegen — fall // back to identity for those (the body still // allocates via ailang_rc_alloc, just without the // reuse fast path). let (body_type_name, body_ctor, body_args) = match body.as_ref() { MTerm::Ctor { type_name, ctor, args, .. } => (type_name, ctor, args), _ => return self.lower_term(body), }; self.lower_reuse_as_rc(source, body_type_name, body_ctor, body_args) } MTerm::Loop { binders, body, .. } => { // loop-recur iter 3: loop binders are loop-carried // values lowered as entry-block allocas (the // `pending_entry_allocas` mechanism) registered in // `binder_allocas` so the existing `Term::Var` load // path resolves them with zero new Var code // (representation-sharing; Boss calls 1+2). The loop // header is a fresh block reached by an // unconditional `br` from the pre-header (current) // block; `recur` stores new values into the binder // allocas and back-edges here. `clang -O2` mem2reg // promotes the allocas to the phi nodes the spec's // implementation-shape describes. The loop's value // is the body's value on the non-`recur` exit, // materialised by the existing `if`/`match` join // once `recur` sets `block_terminated` (Boss call 3). let id = self.fresh_id(); let header = format!("loop.header.{id}"); let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new(); let mut frame_slots: Vec<(String, String, String, Type)> = Vec::new(); for b in binders { let alloca_name = format!("%loopv_{}_{}", b.name, self.fresh_id()); let llvm_ty = llvm_type(&b.ty)?; self.pending_entry_allocas .push_str(&format!(" {alloca_name} = alloca {llvm_ty}\n")); let (init_ssa, init_ty) = self.lower_term(&b.init)?; if init_ty != llvm_ty { return Err(CodegenError::Internal(format!( "Term::Loop binder `{}`: init LLVM type {init_ty} != declared {llvm_ty}", b.name ))); } self.body.push_str(&format!( " store {llvm_ty} {init_ssa}, ptr {alloca_name}\n" )); let prior = self .binder_allocas .insert(b.name.clone(), (alloca_name.clone(), b.ty.clone())); saved.push((b.name.clone(), prior)); frame_slots.push((b.name.clone(), alloca_name, llvm_ty, b.ty.clone())); } self.body.push_str(&format!(" br label %{header}\n")); self.loop_frames.push((header.clone(), frame_slots)); self.start_block(&header); let body_result = self.lower_term(body); self.loop_frames.pop(); // Restore prior bindings unconditionally (defensive // on the `?` path). for (name, prior) in saved.into_iter().rev() { match prior { Some(p) => { self.binder_allocas.insert(name, p); } None => { self.binder_allocas.remove(&name); } } } body_result } MTerm::Recur { args, .. } => { // loop-recur iter 3: re-enter the innermost // enclosing loop. Typecheck (iter 2) pinned arity == // binder count and per-arg type == binder type, so a // miss/mismatch here is an internal error. ALL args // are lowered to SSA values BEFORE any store, so a // recur arg that reads a binder sees its OLD value // (recur is a simultaneous positional rebind, e.g. // `(recur (+ acc i) (+ i 1))`). The back-edge `br` is // a block terminator: set `block_terminated` at // recur's OWN emit site (the parallel setter — Boss // call 4; tail-app's SET sites byte-unchanged) so the // enclosing `if`/`match` join excludes this block // exactly like a tail-app block. recur never falls // through; its value is never used — return the // canonical Unit/dead SSA (`("0","i8")`, the // both-if-branches-terminated precedent). let (header, slots) = self .loop_frames .last() .cloned() .ok_or_else(|| { CodegenError::Internal( "Term::Recur reached codegen with no enclosing loop frame \ — typecheck should have rejected this earlier" .into(), ) })?; if args.len() != slots.len() { return Err(CodegenError::Internal(format!( "Term::Recur arg count {} != enclosing loop binder count {} \ — typecheck should have rejected this earlier", args.len(), slots.len() ))); } let mut lowered: Vec<(String, String)> = Vec::with_capacity(args.len()); for a in args { lowered.push(self.lower_term(&a.term)?); } for ((arg_ssa, arg_ty), (_name, alloca_name, llvm_ty, ail_ty)) in lowered.into_iter().zip(slots.iter()) { if &arg_ty != llvm_ty { return Err(CodegenError::Internal(format!( "Term::Recur arg LLVM type {arg_ty} != binder type {llvm_ty} \ — typecheck should have rejected this earlier" ))); } // Bug #49: this `recur` REPLACES the binder's prior // heap value with `arg_ssa`. Without a dec here the // superseded value leaks every iteration (the // scope-close path in drop.rs only ever sees the // loop's FINAL result). Dec the prior value iff the // binder lowers to an RC-heap `ptr`. mir.4 removed // the former `!is_str` carve-out: lower_to_mir now // promotes every loop-carried `Str` literal (seed + // recur args) to `StrRep::Heap`, so a `Str` loop // binder alloca always holds an owned heap slab with // an `rc_header` — the dec is sound for `Str` exactly // as for a boxed ADT. (drop.rs's loop-RESULT // trackability gate keeps its own `Str` carve-out — // see spec 0060 mir.4 refinement.) Primitives // (Int/Bool/Float/Unit) lower to non-`ptr` and are // excluded by the `ptr` gate. let is_ptr = matches!(llvm_type(ail_ty).as_deref(), Ok("ptr")); if matches!(self.alloc, AllocStrategy::Rc) && is_ptr { // Load the prior value sitting in the alloca and // guard against the own->own same-pointer case: // a `recur` that threads the SAME buffer back // (e.g. RawBuf fill loops, `RawBuf.set` mutating // in place) rebinds to an identical pointer, and // dec'ing it would free a live buffer. Skip the // dec exactly when prior == new; otherwise the // superseded distinct allocation drops once. let prior = self.fresh_ssa(); self.body.push_str(&format!( " {prior} = load ptr, ptr {alloca_name}, align 8\n" )); let same = self.fresh_ssa(); self.body.push_str(&format!( " {same} = icmp eq ptr {prior}, {arg_ssa}\n" )); let id = self.fresh_id(); let do_dec = format!("recur.dec.{id}"); let after = format!("recur.after.{id}"); self.body.push_str(&format!( " br i1 {same}, label %{after}, label %{do_dec}\n" )); self.start_block(&do_dec); let drop_call = self.field_drop_call(ail_ty); self.body.push_str(&format!( " call void @{drop_call}(ptr {prior})\n" )); self.body.push_str(&format!(" br label %{after}\n")); self.start_block(&after); } self.body.push_str(&format!( " store {llvm_ty} {arg_ssa}, ptr {alloca_name}\n" )); } self.body.push_str(&format!(" br label %{header}\n")); self.block_terminated = true; Ok(("0".into(), "i8".into())) } // raw-buf.4: the New desugar (desugar.rs) rewrites // every `(new T …)` to `(app T.new …)` before check and // codegen, so no MTerm::New survives to lowering. The arm is // kept only for match exhaustiveness. MTerm::New { .. } => { unreachable!("MTerm::New is desugared to (app T.new …) before codegen — raw-buf.4") } MTerm::Intrinsic { .. } => Err(CodegenError::Internal( "MTerm::Intrinsic must be consumed by the intercept route in fn-body \ emission, not lowered as an expression; reaching lower_term means an \ intrinsic body escaped its definition slot".into(), )), } } /// Resolves a `Term::Ctor.type_name` (canonical form: bare /// = local TypeDef, qualified `.` = explicit /// cross-module) to the codegen-side `CtorRef`. Qualified names /// route through `import_map`; bare names hit the current /// module's `module_ctor_index` directly. No imports-walk /// fallback — the typechecker (post-canonical-type-form) and the workspace /// validator (post-canonical-type-form) have already pinned canonical form. pub(crate) fn lookup_ctor_by_type( &self, type_name: &str, ctor_name: &str, ) -> Result { 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 { // Bare type_name: try the current module's ctor table // first (canonical local case). On miss, fall back to a // workspace-wide scan for a module declaring a TypeDef of // that name (prep.1: type-scoped namespacing — bare cross- // module type-names in scope via an imported module's // TypeDef are accepted by the typechecker, so codegen // must resolve them symmetrically). if let Some(cref) = self .module_ctor_index .get(self.module_name) .and_then(|m| m.get(ctor_name)) .cloned() { if cref.type_name == type_name { return Ok(cref); } } // prep.1: workspace-wide fallback for cross-module bare. for (owner_mod, ctors) in self.module_ctor_index { if owner_mod == self.module_name { continue; } if let Some(cref) = ctors.get(ctor_name) { if cref.type_name == type_name { return Ok(cref.clone()); } } } Err(CodegenError::Internal(format!( "unknown ctor `{ctor_name}` for type `{type_name}` in module `{}` \ (workspace-wide scan also missed)", self.module_name ))) } } /// 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. pub(crate) fn collect_owner_local_types(&self, owner_module: &str) -> BTreeSet { self.module_ctor_index .get(owner_module) .map(|m| { m.values() .map(|c| c.type_name.clone()) .collect::>() }) .unwrap_or_default() } /// 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`). pub(crate) fn lookup_ctor_in_pattern(&self, ctor_name: &str) -> Result { 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}`" ))) } /// 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 // intercepts `(app == …)` as `unknown variable` long before // codegen. Equality dispatch lives in // `try_emit_primitive_instance_body` for the primitive Eq // instance bodies (Int/Bool/Str/Unit), called via the // class-method `eq` from prelude.Eq. // Arithmetic ops `+`/`-`/`*`/`/`/`%` are still polymorphic // over `{Int, Float}`. Resolve the arg type, then dispatch // via `builtin_binop_typed`. if is_arithmetic_op(name) { if args.len() != 2 { return Err(CodegenError::Internal(format!( "builtin `{name}` expected 2 args" ))); } let arg_ty = args[0].term.ty(); let (instr, operand_ll_ty, result_ll_ty) = builtin_binop_typed(name, &arg_ty) .ok_or_else(|| CodegenError::Internal(format!( "`{name}` not supported for type `{}`", ailang_core::pretty::type_to_string(&arg_ty) )))?; let (a, _) = self.lower_term(&args[0].term)?; let (b, _) = self.lower_term(&args[1].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = {instr} {operand_ll_ty} {a}, {b}\n" )); // Builtins are not function calls in LLVM (they're inline // arithmetic); `tail` annotation has nothing to act on. // The typechecker accepts the marker but it is a no-op // here. (Iter 14e survey: no fixture marks a builtin tail.) let _ = tail; return Ok((dst, result_ll_ty.into())); } if name == "not" { if args.len() != 1 { return Err(CodegenError::Internal("not arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body .push_str(&format!(" {dst} = xor i1 {a}, true\n")); return Ok((dst, "i1".into())); } // Floats iter 4.4: polymorphic neg + 3 monomorphic fn builtins. if name == "neg" { if args.len() != 1 { return Err(CodegenError::Internal("neg arity".into())); } let arg_ty = args[0].term.ty(); let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); match &arg_ty { Type::Con { name, .. } if name == "Int" => { self.body.push_str(&format!(" {dst} = sub i64 0, {a}\n")); return Ok((dst, "i64".into())); } Type::Con { name, .. } if name == "Float" => { // LLVM 8+ `fneg` correctly handles -0.0. self.body.push_str(&format!(" {dst} = fneg double {a}\n")); return Ok((dst, "double".into())); } other => return Err(CodegenError::Internal(format!( "`neg` not supported for type `{}`", ailang_core::pretty::type_to_string(other) ))), } } if name == "int_to_float" { if args.len() != 1 { return Err(CodegenError::Internal("int_to_float arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!(" {dst} = sitofp i64 {a} to double\n")); return Ok((dst, "double".into())); } if name == "float_to_int_truncate" { if args.len() != 1 { return Err(CodegenError::Internal("float_to_int_truncate arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call i64 @llvm.fptosi.sat.i64.f64(double {a})\n" )); return Ok((dst, "i64".into())); } if name == "is_nan" { if args.len() != 1 { return Err(CodegenError::Internal("is_nan arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); // `fcmp uno x, x` returns `i1 1` iff `x` is NaN — only // NaN compares unordered against itself. self.body.push_str(&format!(" {dst} = fcmp uno double {a}, {a}\n")); return Ok((dst, "i1".into())); } if name == "int_to_str" { // lowers to the runtime C glue // `ailang_int_to_str(i64) -> ptr` defined in // `runtime/str.c`. Returned pointer is a heap-Str (see // the `float_to_str` arm below for the dual-realisation // ABI note). if args.len() != 1 { return Err(CodegenError::Internal("int_to_str arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call ptr @ailang_int_to_str(i64 {a})\n" )); return Ok((dst, "ptr".to_string())); } if name == "float_to_str" { // lowers to the runtime C glue // `ailang_float_to_str(double) -> ptr` defined in // `runtime/str.c`. The returned pointer is a heap-Str // (rc_header at offset -8; consumer ABI shared with // static-Str). The IR-header declare is unconditional; // `runtime/rc.c` is unconditionally linked since iter // hs.4 so the `ailang_rc_alloc` callee in str.c always // resolves. if args.len() != 1 { return Err(CodegenError::Internal("float_to_str arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call ptr @ailang_float_to_str(double {a})\n" )); return Ok((dst, "ptr".to_string())); } if name == "bool_to_str" { // lowers to the runtime C glue // `ailang_bool_to_str(i1) -> ptr` defined in // `runtime/str.c`. Returned pointer is a heap-Str // (rc_header at offset -8; consumer ABI shared with // static-Str). Used by `show__Bool` in milestone 24. if args.len() != 1 { return Err(CodegenError::Internal("bool_to_str arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call ptr @ailang_bool_to_str(i1 {a})\n" )); return Ok((dst, "ptr".to_string())); } if name == "str_clone" { // lowers to the runtime C glue // `ailang_str_clone(ptr) -> ptr` defined in // `runtime/str.c`. Reads `len` from offset 0 of the // source Str payload and allocates a fresh heap-Str // slab; works uniformly on static-Str and heap-Str // inputs because the consumer ABI is identical. Used by // `show__Str` in milestone 24. if args.len() != 1 { return Err(CodegenError::Internal("str_clone arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call ptr @ailang_str_clone(ptr {a})\n" )); return Ok((dst, "ptr".to_string())); } if name == "str_concat" { // lowers to the runtime C glue // `ailang_str_concat(ptr, ptr) -> ptr` defined in // `runtime/str.c`. Reads `len` from offset 0 of each // source Str payload and allocates a fresh heap-Str // slab sized for the combined bytes; works uniformly // on static-Str and heap-Str inputs because the // consumer ABI is identical. Common shape in Show // bodies: `(app str_concat "label=" (app int_to_str x))`. if args.len() != 2 { return Err(CodegenError::Internal("str_concat arity".into())); } let (a, _) = self.lower_term(&args[0].term)?; let (b, _) = self.lower_term(&args[1].term)?; let dst = self.fresh_ssa(); self.body.push_str(&format!( " {dst} = call ptr @ailang_str_concat(ptr {a}, ptr {b})\n" )); return Ok((dst, "ptr".to_string())); } Err(CodegenError::Internal(format!( "lower_builtin: `{name}` is not a builtin (should have been \ classified as Static or Indirect in lower_to_mir)" ))) } fn emit_call( &mut self, target_module: &str, target_def: &str, sig: &FnSig, args: &[MArg], tail: bool, ) -> Result<(String, String)> { let mut compiled_args = Vec::new(); for (a, exp_ty) in args.iter().zip(sig.params.iter()) { let (v, vty) = self.lower_term(&a.term)?; if &vty != exp_ty { return Err(CodegenError::Internal(format!( "call `{target_module}.{target_def}` arg type mismatch: expected {exp_ty}, got {vty}" ))); } compiled_args.push((v, vty)); } let arglist = compiled_args .iter() .map(|(v, t)| format!("{t} {v}")) .collect::>() .join(", "); let dst = self.fresh_ssa(); // emit `musttail call ... ret` for `tail: true`. The // call SSA flows directly into the `ret`, satisfying LLVM's // "must immediately ret" rule. Same calling convention and // signature as the surrounding fn (the typechecker enforces // type compatibility). let call_kw = if tail { "musttail call" } else { "call" }; self.body.push_str(&format!( " {dst} = {call_kw} {ret} @ail_{module}_{name}({arglist})\n", ret = sig.ret, module = target_module, name = target_def, )); // owned-temp drop at the call site. An argument that is // itself an Own-returning heap allocation (an anonymous owned // temporary, e.g. the in-place result of `RawBuf.set`) and // that lands in a `borrow`-mode parameter slot is borrowed, // not consumed, by the callee. After the call returns it is // dead and no binder owns it, so neither scope-close drop // emitter (the `Term::Let` binder gate, the Own fn-param drop) // covers it. Emit its drop here, routed through the same // per-type drop symbol the let gate uses. Only in the non-tail // path: a tail call terminates the block (the call result IS // the fn result) and there is no post-call point to emit at. // // Scope of the rule (intentionally narrow, per the raw-buf.5 // steer — do NOT blanket-drop every owned arg): // - the arg is `is_rc_heap_allocated` (Own-ret call / fresh // escaping ctor / lambda); a plain `Term::Var` arg is an // alias whose owner is some other binder and is dropped // there, never here; // - the matching callee param mode is `Borrow` — `Own` slots // consume the arg (the callee dec's it); // - the dropped SSA is never the call result `dst` (an input // argument SSA is always distinct from the freshly-minted // result SSA), so this can never dec a value that flows out // as the surrounding fn's result. if !tail { // mir.3b: the per-arg slot mode rides on the MIR arg // (`MArg.mode`, filled by lower_to_mir from the callee's // `param_modes`), so the borrow-slot test reads it directly // — no re-lookup of the callee's signature from a codegen // sig table. `Borrow` slots borrow the arg, so an Own-ret // heap temp landing in one is dropped here; `Own` // slots consume the arg (the callee dec's it). for (arg, (arg_ssa, arg_ty)) in args.iter().zip(compiled_args.iter()) { let is_borrow_slot = matches!(arg.mode, Mode::Borrow); if is_borrow_slot && arg_ty == "ptr" && arg_ssa != &dst && self.is_rc_heap_allocated(&arg.term) { let drop_sym = self.drop_symbol_for_binder(&arg.term, arg_ssa); self.body.push_str(&format!( " call void @{drop_sym}(ptr {arg_ssa})\n" )); } } } if tail { self.body .push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret)); self.block_terminated = true; } Ok((dst, sig.ret.clone())) } /// indirect call through a closure-pair pointer. The /// callee SSA points at `{ ptr thunk, ptr env }`; we GEP+load both /// halves and call `thunk(env, args...)`. The user-visible `sig` /// describes only the user-level params/ret — the env_ptr is /// inserted by codegen, transparent to the source language. fn emit_indirect_call( &mut self, callee_ssa: &str, sig: &FnSig, args: &[MArg], tail: bool, ) -> Result<(String, String)> { if args.len() != sig.params.len() { return Err(CodegenError::Internal(format!( "indirect call arity mismatch: sig expects {}, got {}", sig.params.len(), args.len() ))); } let mut compiled = Vec::new(); for (a, exp_ty) in args.iter().zip(sig.params.iter()) { let (v, vty) = self.lower_term(&a.term)?; if &vty != exp_ty { return Err(CodegenError::Internal(format!( "indirect call arg type mismatch: expected {exp_ty}, got {vty}" ))); } compiled.push((v, vty)); } // Unpack the closure pair: thunk pointer at offset 0, env pointer // at offset 8. Use a typed GEP through `{ ptr, ptr }` so the // offsets are computed correctly across targets. let thunk_p = self.fresh_ssa(); let thunk = self.fresh_ssa(); let env_p = self.fresh_ssa(); let env = self.fresh_ssa(); self.body.push_str(&format!( " {thunk_p} = getelementptr inbounds {{ ptr, ptr }}, ptr {callee_ssa}, i64 0, i32 0\n" )); self.body .push_str(&format!(" {thunk} = load ptr, ptr {thunk_p}\n")); self.body.push_str(&format!( " {env_p} = getelementptr inbounds {{ ptr, ptr }}, ptr {callee_ssa}, i64 0, i32 1\n" )); self.body .push_str(&format!(" {env} = load ptr, ptr {env_p}\n")); // Build the actual call. The thunk's signature is `(ptr, params...)` // — env_ptr is the implicit first arg, transparent to the user. let mut arglist = format!("ptr {env}"); for (v, t) in &compiled { arglist.push_str(&format!(", {t} {v}")); } let mut param_tys = String::from("ptr"); for pt in &sig.params { param_tys.push_str(", "); param_tys.push_str(pt); } let dst = self.fresh_ssa(); // indirect tail calls. Same `musttail`/`ret` shape as // emit_call. The thunk's signature uniformly inserts an // `env_ptr` first arg, but a `musttail call` to a thunk whose // signature exactly matches the parent fn's prototype + // env_ptr is malformed (parent has no env_ptr in its prototype). // For the MVP no fixture marks an indirect tail call; we honour // the flag by emitting `musttail call` (LLVM verifier will // catch a real signature mismatch at IR-verification time). let call_kw = if tail { "musttail call" } else { "call" }; self.body.push_str(&format!( " {dst} = {call_kw} {ret} ({ptys}) {thunk}({arglist})\n", ret = sig.ret, ptys = param_tys, )); if tail { self.body .push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret)); self.block_terminated = true; } Ok((dst, sig.ret.clone())) } /// 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 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('.')?; // dotted form resolves through the current // module's import_map first (the standard cross-module // reference path). If the prefix isn't in import_map, // fall back to a direct module-name lookup against // `module_user_fns` — 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; this is // a valid post-mono construct because both ends were // independently typechecked under their original module // contexts before mono ran). // 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 => prefix.to_string(), }; let sig = self.module_user_fns.get(&target)?.get(suffix)?.clone(); return Some((format!("@ail_{target}_{suffix}_clos"), sig)); } if let Some(sig) = self .module_user_fns .get(self.module_name) .and_then(|m| m.get(name)) .cloned() { return Some(( format!("@ail_{module}_{name}_clos", module = self.module_name), sig, )); } // operator-routing-eq-ord.1: fall back to prelude (mirrors // the auto-import the typechecker performs for non-prelude // modules). Needed for monomorphic prelude fns (e.g. `float_eq`) // which have no mono mirror in the user's module. if self.module_name != "prelude" { if let Some(sig) = self .module_user_fns .get("prelude") .and_then(|m| m.get(name)) .cloned() { return Some((format!("@ail_prelude_{name}_clos"), sig)); } } None } /// resolve a `Term::Var` reference to a const def. Returns /// `(owning_module, ConstDef)` on hit. Both bare current-module /// references and qualified `prefix.name` cross-module references /// resolve through the same path; the prefix routes through the /// emitter's `import_map` to the actual module. fn resolve_const(&self, name: &str) -> Option<(String, ConstDef)> { if name.matches('.').count() == 1 { let (prefix, suffix) = name.split_once('.')?; let target = self.import_map.get(prefix)?; let cdef = self.module_consts.get(target)?.get(suffix)?.clone(); return Some((target.clone(), cdef)); } let cdef = self .module_consts .get(self.module_name)? .get(name)? .clone(); Some((self.module_name.to_string(), cdef)) } fn lower_effect_op(&mut self, op: &str, args: &[MArg], tail: bool) -> Result<(String, String)> { // `musttail` requires identical caller/callee // prototypes (same return type, same param types). The MVP's // runtime print helpers (`printf`, `fputs`) return `i32`, but // the AILang fn enclosing a `tail-do io/print_*` returns `Unit` // (`i8`). `musttail` would be rejected by the LLVM verifier. // We therefore use the `tail` keyword (LLVM IR optimisation // hint, NOT a guarantee) for `tail: true` do-ops. The optimiser // is free to TCO it; if it can't, the call falls back to a // normal call. The body of the AILang fn afterwards is empty // (the op was the last thing), so we close it with `ret i8 0`. let _ = tail; let call_kw = if tail { "tail call" } else { "call" }; match op { "io/print_str" => { if args.len() != 1 { return Err(CodegenError::Internal( "io/print_str arity".into(), )); } let (v, vty) = self.lower_term(&args[0].term)?; if vty != "ptr" { return Err(CodegenError::Internal( "io/print_str needs ptr".into(), )); } // `Str` values now flow as a pointer to the // `len`-field of the packed-struct slab; @fputs needs // the bytes pointer 8 bytes further on. Loading // `@stdout` is mandatory: it is `extern FILE *stdout` // in libc, an opaque pointer-to-pointer at the IR // level, and we need the inner pointer (the FILE*) as // the second arg to `fputs`. let bytes = self.fresh_ssa(); self.body.push_str(&format!( " {bytes} = getelementptr inbounds i8, ptr {v}, i64 8\n" )); let fp = self.fresh_ssa(); self.body.push_str(&format!( " {fp} = load ptr, ptr @stdout, align 8\n" )); self.body.push_str(&format!( " {call_kw} i32 @fputs(ptr {bytes}, ptr {fp})\n" )); if tail { self.body.push_str(" ret i8 0\n"); self.block_terminated = true; } Ok(("0".into(), "i8".into())) } other => Err(CodegenError::Internal(format!( "unknown effect op: {other}" ))), } } /// hand-rolled body for monomorphiser-synthesised /// primitive instance methods whose natural lambda-lowering would /// not produce the spec-mandated IR shape. Returns `Ok(true)` if /// the body was emitted (including the closing `}` and a final /// `\n\n`); `emit_fn` skips its normal body-lowering branch but /// MUST still run its post-body steps (deferred-thunk flush and /// `emit_adapter_and_static_closure` — the closure-pair every /// top-level fn gets so it is reachable as a `Term::Var` value). /// `Ok(false)` lets `emit_fn` continue with normal body lowering. /// /// Currently inhabited arms (post-operator-routing-eq-ord /// milestone): the `Eq` instance bodies `eq__Int`, `eq__Bool`, /// `eq__Str`, `eq__Unit`; the `Ord` instance bodies `compare__Int`, /// `compare__Bool`, `compare__Str`; the Int-direct short-circuits /// for the Ord-class free helpers `lt__Int`, `le__Int`, `gt__Int`, /// `ge__Int`, `ne__Int`; and the six Float-named comparison fns /// `float_eq`, `float_ne`, `float_lt`, `float_le`, `float_gt`, /// `float_ge`. Fourteen arms total; every arm pairs with an /// `alwaysinline` attribute on the generated `define` line (via /// `intercept_emit_wants_alwaysinline`) so the call folds to a /// single instruction at every use site under -O0 as well as /// -O2. The eq/compare/Float arms exist because there is no /// type-polymorphic builtin that would lower to the right /// per-type instruction (`lower_eq` was deleted in the /// operator-routing-eq-ord milestone — `==` is no longer a /// language identifier; the class-method dispatch through /// `prelude.Eq` / `prelude.Ord` is the only path). The `lt/le/gt/ /// ge/ne` Int-direct arms exist as an asymmetric optimization /// (Int-only) — without them the source-level body of `lt`/etc. /// allocates an `Ordering` ctor per call via the /// `compare__Int` → `match` path, which surfaces as a /// `bench_closure_chain` regression. See /// `design/contracts/0017-prelude-classes.md` for the full surface /// and the Int-only asymmetry rationale. fn try_emit_primitive_instance_body( &mut self, fn_name: &str, param_tys: &[String], ret_ty: &str, ) -> Result { match intercepts::lookup(fn_name) { Some(intercept) => { intercepts::check_sig(intercept, param_tys, ret_ty)?; (intercept.emit)(self)?; Ok(true) } None => Ok(false), } } /// Emit a direct ` i64` body for the `lt__Int` / /// `le__Int` / `gt__Int` / `ge__Int` / `ne__Int` intercept arms. /// All five share the same shape: pull the two operand SSAs from /// `self.locals` (populated by `emit_fn` before dispatch), emit /// `%r = %a, %b` and `ret i1 %r`, close the body. /// `icmp_op` is the full instruction-and-operand-type prefix /// (e.g. `"icmp slt i64"`); the operand SSAs are appended. pub(crate) fn emit_direct_int_icmp_intercept( &mut self, fn_name: &str, icmp_op: &str, param_tys: &[String], ret_ty: &str, ) -> Result { if param_tys != ["i64", "i64"] || ret_ty != "i1" { return Err(CodegenError::Internal(format!( "{fn_name} body intercept: unexpected signature \ ({param_tys:?}) -> {ret_ty} (want (i64, i64) -> i1)" ))); } let n = self.locals.len(); let a_ssa = self.locals[n - 2].1.clone(); let b_ssa = self.locals[n - 1].1.clone(); let r = self.fresh_ssa(); self.body.push_str(&format!( " {r} = {icmp_op} {a_ssa}, {b_ssa}\n" )); self.body.push_str(&format!(" ret i1 {r}\n")); self.body.push_str("}\n\n"); self.block_terminated = true; Ok(true) } /// emit one arm of the `compare__T` branch ladder. /// Starts a fresh basic block labelled `label`, constructs the /// matching `Ordering` ctor (LT / EQ / GT) via `lower_ctor`, and /// emits a `ret ptr `. Used three times per `compare__T` /// arm in `try_emit_primitive_instance_body`. The ctor is a /// zero-field allocation; `lower_ctor` handles alloc-strategy /// variance (Gc / Bump / Rc) without the intercept duplicating /// the per-strategy logic. pub(crate) fn emit_ordering_arm(&mut self, label: &str, ctor: &str) -> Result<()> { self.start_block(label); // term_ptr 0: synthetic call site, not present in the // escape-analysis result; falls back to the conservative // "escapes → heap allocate" default. Safe for the Ordering // return value (the caller owns it after `ret`). let (ssa, _llvm_ty) = self.lower_ctor( "Ordering", ctor, &[], 0, )?; self.body.push_str(&format!(" ret ptr {ssa}\n")); self.block_terminated = true; Ok(()) } /// emit the labelled three-way branch ladder shared /// across all three `compare__T` intercept arms. Given two /// instruction RHS strings (the LT-test and the EQ-test, e.g. /// `"icmp slt i64 %a, %b"`), assigns each to a fresh SSA, wires /// them into LT-block / EQ-block / GT-block via /// `emit_ordering_arm`, and closes the fn body. The EQ-test /// must be emitted INSIDE the `after_lt_label` block (not before /// the LT-branch), so the helper takes the RHS as a string and /// performs the SSA assignment itself at the correct point. The /// two test instructions vary per arm (`icmp slt i64` / /// `icmp ult i1` / `icmp slt i32` etc.); any per-arm prep /// (e.g. `compare__Str`'s `@ail_str_compare` call) must be /// emitted by the caller before invoking this helper. pub(crate) fn emit_compare_ladder( &mut self, lt_test_instr: &str, eq_test_instr: &str, ) -> Result<()> { let lt_test_ssa = self.fresh_ssa(); self.body.push_str(&format!( " {lt_test_ssa} = {lt_test_instr}\n" )); let id = self.fresh_id(); let lt_label = format!("cmp_lt_{id}"); let after_lt_label = format!("cmp_after_lt_{id}"); let eq_label = format!("cmp_eq_{id}"); let gt_label = format!("cmp_gt_{id}"); self.body.push_str(&format!( " br i1 {lt_test_ssa}, label %{lt_label}, label %{after_lt_label}\n" )); self.emit_ordering_arm(<_label, "LT")?; self.start_block(&after_lt_label); let eq_test_ssa = self.fresh_ssa(); self.body.push_str(&format!( " {eq_test_ssa} = {eq_test_instr}\n" )); self.body.push_str(&format!( " br i1 {eq_test_ssa}, label %{eq_label}, label %{gt_label}\n" )); self.emit_ordering_arm(&eq_label, "EQ")?; self.emit_ordering_arm(>_label, "GT")?; self.body.push_str("}\n\n"); self.block_terminated = true; Ok(()) } // 2026-05-21 operator-routing-eq-ord: `lower_eq` is deleted. // Equality is no longer a direct-emit codegen path; it flows // through the class method `eq` (prelude.Eq) whose primitive // instance bodies are emitted by // `try_emit_primitive_instance_body` (Eq Int/Bool/Str/Unit // arms, each lowering to a single `icmp` / call / // constant-`i1`-`1` plus the alwaysinline attribute on the // generated fn definition). The deleted fn handled Int / Bool / // Str / Unit / Float branches plus the ADT/Fn rejection arms; // none of those code paths are now reachable. pub(crate) fn fresh_ssa(&mut self) -> String { self.counter += 1; format!("%v{}", self.counter) } /// Return the SSA names of the last `count` locals, in their /// original (ascending-index) order. Intercept emit fns use this /// to pull their parameter SSAs off `self.locals` without the /// off-by-one-prone `locals[len - k]` idiom. For `count == 2` the /// result is `[locals[n-2].1, locals[n-1].1]`, i.e. first param /// then second — identical to the hand-rolled extraction it /// replaces. pub(crate) fn last_param_ssas(&self, count: usize) -> Vec { let n = self.locals.len(); self.locals[n - count..] .iter() .map(|l| l.1.clone()) .collect() } pub(crate) fn fresh_id(&mut self) -> u64 { self.counter += 1; self.counter } /// parallel to the legacy /// `intern_string` (retired in the per-type-print-op retirement alongside the per-type /// print effect-ops it served), but for /// language `Str` literals emitted as packed-struct globals /// (len + bytes + NUL). Shares the same monotonic `str_counter` /// so the produced global names remain alphabetically orderable /// alongside format-string globals. fn intern_str_literal(&mut self, hint: &str, content: &str) -> String { if let Some((name, _)) = self.str_literals.get(content) { return name.clone(); } let name = format!(".str_{}_{}_{}", self.module_name, hint, self.str_counter); self.str_counter += 1; let len = c_byte_len(content); self.str_literals .insert(content.to_string(), (name.clone(), len)); name } } #[cfg(test)] mod tests { use super::*; use ailang_core::SCHEMA; #[test] fn emits_arith_fn() { // Single module becomes a trivial workspace via `emit_ir`; the // mangling is `@ail__` even in the single-file case. let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ Def::Fn(FnDef { name: "add".into(), ty: Type::Fn { params: vec![Type::int(), Type::int()], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec!["a".into(), "b".into()], body: Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], tail: false, }, suppress: vec![], doc: None, export: None, }), // Entry module needs a `main`, otherwise // `lower_workspace` returns `MissingEntryMain`. Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("define i64 @ail_t_add(i64 %arg_a, i64 %arg_b)"), "ir was: {ir}" ); assert!(ir.contains("add i64 %arg_a, %arg_b")); assert!( ir.contains("call i8 @ail_t_main()"), "trampoline call missing: {ir}" ); } // 2026-05-21 operator-routing-eq-ord: the two // `eq_on_adt_rejected_at_codegen` / `eq_on_fn_rejected_at_codegen` // tests pinned the `lower_eq` direct-emit rejection path for ADT / // Fn args. With `lower_eq` deleted in Task 7 and `==` no longer a // builtin, the rejection path is gone by structure: `(app eq ADT // ADT)` is rejected at typecheck as `NoInstance Eq ` (the // user did not provide an instance), and `(app eq Fn Fn)` is // similarly rejected. Coverage of the ADT no-instance case lives // in `eq_ord_e2e.rs` (negative path on a missing user instance). #[test] fn missing_entry_main_is_error() { let m = Module { schema: SCHEMA.into(), name: "noentry".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "helper".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Int { value: 1 }, }, suppress: vec![], doc: None, export: None, })], }; let err = emit_ir(&m).unwrap_err(); match err { CodegenError::MissingEntryMain(name) => assert_eq!(name, "noentry"), other => panic!("expected MissingEntryMain, got {other:?}"), } } /// under `--alloc=rc`, codegen emits one /// `define void @drop__` per `Def::Type`. For a recursive /// ADT — a `IntList` with `Cons(Int, IntList)` — the `Cons` arm /// of the drop fn loads the tail field and calls the ADT's own /// drop fn on it (the recursion 18e replaces with an iterative /// worklist). We assert both shapes here: /// /// 1. `define void @drop__IntList(ptr %p)` is present. /// 2. The fn's body contains a self-recursive call /// `call void @drop__IntList(ptr %v...)` — proof that /// the `Cons` arm walked the tail field rather than just /// decrementing the outer cell. /// /// The `Nil` arm has no boxed children and is a `br` to the /// shared `join` block — implicit in (1). /// /// Negative complement: under `--alloc=bump` no drop fn is /// emitted (bump leaks by design); the IR shape stays /// byte-identical to the pre-18c.4 pipeline. #[test] fn rc_alloc_emits_recursive_drop_fn_for_recursive_adt() { let m = Module { schema: SCHEMA.into(), name: "rclist".into(), kernel: false, imports: vec![], defs: vec![ Def::Type(TypeDef { name: "IntList".into(), vars: vec![], ctors: vec![ Ctor { name: "Nil".into(), fields: vec![], }, Ctor { name: "Cons".into(), fields: vec![ Type::int(), Type::Con { name: "IntList".into(), args: vec![], }, ], }, ], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }), Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ws = Workspace { entry: m.name.clone(), modules: { let mut x = BTreeMap::new(); x.insert(m.name.clone(), m.clone()); x }, root_dir: std::path::PathBuf::from("."), registry: ailang_core::workspace::Registry::default(), }; let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates"); let ir_rc = lower_workspace_with_alloc(&mir, AllocStrategy::Rc).unwrap(); assert!( ir_rc.contains("define void @drop_rclist_IntList(ptr %p)"), "rc IR missing per-type drop fn header. IR was:\n{ir_rc}" ); // The Cons arm loads the tail field and recurses through // the same drop symbol — proof that the cascade is wired. assert!( ir_rc.contains("call void @drop_rclist_IntList(ptr %v"), "rc IR missing recursive drop call inside drop_rclist_IntList. IR was:\n{ir_rc}" ); // The drop fn finishes by dec'ing the outer box. assert!( ir_rc.contains("call void @ailang_rc_dec(ptr %p)"), "rc IR missing outer-box dec inside drop_rclist_IntList. IR was:\n{ir_rc}" ); // Negative complement: no drop fns under `--alloc=bump` // (only RC emits per-type drop fns; bump leaks). let ir_bump = lower_workspace_with_alloc(&mir, AllocStrategy::Bump).unwrap(); assert!( !ir_bump.contains("@drop_rclist_IntList"), "bump IR should not declare/define any per-type drop fn. IR was:\n{ir_bump}" ); } /// Floats iter 4.1 RED: a `Literal::Float { bits: 0x3ff8_0000_0000_0000 }` /// (= `1.5_f64`) lowers in a `Const` definition as an LLVM hex-float /// `double` SSA constant. The exact IR snippet pinned: `@ail_t_k = /// constant double 0x3FF8000000000000`. #[test] fn lowers_float_const_to_hex_double() { use ailang_core::ast::*; let m = Module { schema: ailang_core::SCHEMA.to_string(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ Def::Const(ConstDef { name: "k".into(), ty: Type::float(), value: Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } }, doc: None, }), Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("@ail_t_k = constant double 0x3FF8000000000000"), "ir missing the Float literal lowering: {ir}" ); } /// Floats iter 4.2 RED: `(+ 1.5 2.5)` lowers as `fadd double`, /// not `add i64`. The Int regression `(+ 1 2)` still lowers as /// `add i64`. Both lowerings live in one IR for one workspace /// build. #[test] fn lowers_float_arithmetic_dispatched() { use ailang_core::ast::*; fn fn_def(name: &str, body: Term, ret_ty: Type) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![], ret: Box::new(ret_ty), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body, suppress: vec![], doc: None, export: None, }) } let plus_int = Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Lit { lit: Literal::Int { value: 1 } }, Term::Lit { lit: Literal::Int { value: 2 } }, ], tail: false, }; let plus_float = Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } }, Term::Lit { lit: Literal::Float { bits: 0x4004_0000_0000_0000u64 } }, ], tail: false, }; let m = Module { schema: ailang_core::SCHEMA.to_string(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ fn_def("ai", plus_int, Type::int()), fn_def("af", plus_float, Type::float()), fn_def("main", Term::Lit { lit: Literal::Unit }, Type::unit()), ], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("add i64"), "Int arithmetic regressed (no `add i64` in IR): {ir}" ); assert!( ir.contains("fadd double"), "Float arithmetic missing (no `fadd double` in IR): {ir}" ); } // 2026-05-21 operator-routing-eq-ord: the // `lowers_float_comparison_dispatched` test pinned the direct-emit // `builtin_binop_typed` comparator arms for `==`/`!=`/`<` over // Int and Float. Those arms are deleted in Task 7 (comparison is // now class-method dispatch for Int/Bool/Str via prelude.Eq/Ord // and named-fn dispatch for Float via float_eq / float_lt / etc.); // the test's premise is gone. Replacement coverage of the new // IR shape lives in the intercept-arm machinery exercised by // `prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir` // (Eq Int → icmp eq + alwaysinline) and indirectly via the // workspace e2e tests for the Float-named-fn surface. /// Floats iter 4.4 RED: four new fn-builtins lower to the spec'd /// LLVM ops. `neg` polymorphic dispatches to `sub i64 0, %x` for /// Int and `fneg double %x` for Float (NOT `fsub 0.0, %x`, which /// is wrong for `-0.0`). `int_to_float` → `sitofp`. `is_nan` → /// `fcmp uno double %x, %x`. `float_to_int_truncate` → /// `@llvm.fptosi.sat.i64.f64` intrinsic call. #[test] fn lowers_float_fn_builtins() { use ailang_core::ast::*; fn fn_def(name: &str, body: Term, ret_ty: Type) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![], ret: Box::new(ret_ty), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body, suppress: vec![], doc: None, export: None, }) } fn app1(callee: &str, arg: Term) -> Term { Term::App { callee: Box::new(Term::Var { name: callee.into() }), args: vec![arg], tail: false, } } let neg_int = app1("neg", Term::Lit { lit: Literal::Int { value: 5 } }); let neg_float = app1("neg", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } }); let i2f = app1("int_to_float", Term::Lit { lit: Literal::Int { value: 5 } }); let f2i = app1("float_to_int_truncate", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } }); let isnan = app1("is_nan", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } }); let main_def = fn_def("main", Term::Lit { lit: Literal::Unit }, Type::unit()); let m = Module { schema: ailang_core::SCHEMA.to_string(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ fn_def("ni", neg_int, Type::int()), fn_def("nf", neg_float, Type::float()), fn_def("c1", i2f, Type::float()), fn_def("c2", f2i, Type::int()), fn_def("isn", isnan, Type::bool_()), main_def, ], }; let ir = emit_ir(&m).unwrap(); assert!(ir.contains("sub i64 0,"), "neg Int missing: {ir}"); assert!(ir.contains("fneg double"), "neg Float missing (must use fneg, not fsub-from-zero): {ir}"); assert!(ir.contains("sitofp i64"), "int_to_float missing: {ir}"); assert!(ir.contains("@llvm.fptosi.sat.i64.f64"), "float_to_int_truncate intrinsic missing: {ir}"); assert!(ir.contains("fcmp uno double"), "is_nan missing (must be fcmp uno x, x): {ir}"); } /// Floats iter 4.5 RED: `nan`/`inf`/`neg_inf` resolve as bare /// `Term::Var` references and lower to direct hex-float `double` /// SSA values at the use site (no global definition emitted — /// they are values, not unreachable-style terminators). #[test] fn lowers_float_constants() { use ailang_core::ast::*; fn fn_def(name: &str, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::float()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body, suppress: vec![], doc: None, export: None, }) } let main_def = Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.to_string(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ fn_def("k_nan", Term::Var { name: "nan".into() }), fn_def("k_inf", Term::Var { name: "inf".into() }), fn_def("k_neg_inf", Term::Var { name: "neg_inf".into() }), main_def, ], }; let ir = emit_ir(&m).unwrap(); assert!(ir.contains("0x7FF8000000000000"), "nan bit pattern missing: {ir}"); assert!(ir.contains("0x7FF0000000000000"), "+inf bit pattern missing: {ir}"); assert!(ir.contains("0xFFF0000000000000"), "-inf bit pattern missing: {ir}"); } /// codegen intercepts a fn named `eq__Str` (the /// monomorphiser's synthesised symbol for `Eq Str.eq`) and emits /// a two-instruction body that calls @ail_str_eq, regardless of /// what the lambda body in the source would lower to. Pinned with /// a synthetic FnDef so the test does not depend on prelude /// auto-injection. #[test] fn eq_str_mono_symbol_emits_ail_str_eq_call() { let m = Module { schema: SCHEMA.into(), name: "prelude".into(), kernel: false, imports: vec![], defs: vec![ Def::Fn(FnDef { name: "eq__Str".into(), ty: Type::Fn { params: vec![Type::str_(), Type::str_()], ret: Box::new(Type::bool_()), effects: vec![], param_modes: vec![ParamMode::Borrow, ParamMode::Borrow], ret_mode: ParamMode::Own, }, params: vec!["x".into(), "y".into()], // Body is a placeholder — the intercept must // ignore it. We use Lit::Bool false so even if the // intercept misfires the test still compiles. body: Term::Lit { lit: Literal::Bool { value: false } }, suppress: vec![], doc: None, export: None, }), Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("declare zeroext i1 @ail_str_eq(ptr, ptr)"), "header missing @ail_str_eq declaration; ir was:\n{ir}" ); assert!( ir.contains("call zeroext i1 @ail_str_eq("), "eq__Str body must call @ail_str_eq; ir was:\n{ir}" ); } /// every top-level fn — including primitive- /// instance-bodied ones like `eq__Str` — must flow through /// `emit_adapter_and_static_closure` so that referencing the fn /// as a `Term::Var` value (dictionary entry, by-value pass to /// higher-order code) finds the expected closure-pair symbol. /// The intercept path used to short-circuit past adapter emission, /// leaving `eq__Str` as the only top-level fn in the module /// without an `@ail___adapter` / `@ail___clos` pair. /// This test pins the invariant. #[test] fn eq_str_mono_symbol_emits_closure_adapter_pair() { let m = Module { schema: SCHEMA.into(), name: "prelude".into(), kernel: false, imports: vec![], defs: vec![ Def::Fn(FnDef { name: "eq__Str".into(), ty: Type::Fn { params: vec![Type::str_(), Type::str_()], ret: Box::new(Type::bool_()), effects: vec![], param_modes: vec![ParamMode::Borrow, ParamMode::Borrow], ret_mode: ParamMode::Own, }, params: vec!["x".into(), "y".into()], body: Term::Lit { lit: Literal::Bool { value: false } }, suppress: vec![], doc: None, export: None, }), Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("@ail_prelude_eq__Str_adapter"), "eq__Str must emit a closure adapter; ir was:\n{ir}" ); assert!( ir.contains("@ail_prelude_eq__Str_clos"), "eq__Str must emit a static closure pair; ir was:\n{ir}" ); } // `eq_int_call_produces_icmp_i64_mono_fn` + // `eq_bool_call_produces_icmp_i1_mono_fn` + // `eq_str_call_produces_ail_str_eq_call_mono_fn` relocated to // `crates/ailang-codegen/tests/eq_primitives_pin.rs` in iter // form-a.1 Task 5 (use `ailang_surface::load_workspace` on the // `eq_primitives_smoke.ail` Form-A fixture; the original `.ail.json` // fixture is deleted in T8). /// `runtime/str.c::ail_str_compare` backs the prelude's /// `compare__Str` mono symbol (see `try_emit_primitive_instance_body`). /// The declaration is unconditional in the IR header alongside /// `@ail_str_eq`; this test pins the header line so a regression /// that drops it would fail at link time only on programs that /// actually call `compare` on a Str. #[test] fn ir_header_declares_ail_str_compare() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("declare i32 @ail_str_compare(ptr, ptr)"), "header missing @ail_str_compare declaration; ir was:\n{ir}" ); } /// codegen intercepts a fn named `compare__Int` and /// emits a three-way branch ladder: `icmp slt i64 a, b` → /// LT-block; else `icmp eq i64 a, b` → EQ-block; else GT-block. /// Each block constructs the matching Ordering ctor via the /// existing `lower_ctor` path. Pinned with a synthetic FnDef so /// the test does not depend on prelude auto-injection. #[test] fn compare_int_mono_symbol_emits_branch_ladder() { let m = synth_compare_module("compare__Int", Type::int()); let ir = emit_ir(&m).unwrap(); assert!( ir.contains("icmp slt i64"), "compare__Int body must contain `icmp slt i64`; ir:\n{ir}" ); assert!( ir.contains("icmp eq i64"), "compare__Int body must contain `icmp eq i64`; ir:\n{ir}" ); } /// same shape for `compare__Bool`. The LT-test uses /// `icmp ult i1` (unsigned: false=0 ult true=1 gives the natural /// Bool ordering false < true); the EQ-test uses `icmp eq i1`. #[test] fn compare_bool_mono_symbol_emits_branch_ladder() { let m = synth_compare_module("compare__Bool", Type::bool_()); let ir = emit_ir(&m).unwrap(); assert!( ir.contains("icmp ult i1"), "compare__Bool body must contain `icmp ult i1`; ir:\n{ir}" ); assert!( ir.contains("icmp eq i1"), "compare__Bool body must contain `icmp eq i1`; ir:\n{ir}" ); } /// `compare__Str` calls `@ail_str_compare` to get the /// normalised {-1, 0, +1}, then branches: slt 0 → LT, eq 0 → EQ, /// else GT. #[test] fn compare_str_mono_symbol_emits_ail_str_compare_call() { let m = synth_compare_module("compare__Str", Type::str_()); let ir = emit_ir(&m).unwrap(); assert!( ir.contains("call i32 @ail_str_compare("), "compare__Str body must call @ail_str_compare; ir:\n{ir}" ); assert!( ir.contains("icmp slt i32"), "compare__Str body must compare result `slt i32` against 0; ir:\n{ir}" ); assert!( ir.contains("icmp eq i32"), "compare__Str body must compare result `eq i32` against 0; ir:\n{ir}" ); } /// Test helper: minimal two-module workspace where the "prelude" /// module declares `data Ordering = LT | EQ | GT` and an /// instance-fn shell (the intercept overrides the body), and the /// entry module's `main` is a Unit no-op so `emit_ir` returns a /// well-formed program. Used by the three `compare_*` tests above. fn synth_compare_module(fn_name: &str, param_ail_ty: Type) -> Module { let ordering = Def::Type(TypeDef { name: "Ordering".into(), vars: vec![], ctors: vec![ Ctor { name: "LT".into(), fields: vec![] }, Ctor { name: "EQ".into(), fields: vec![] }, Ctor { name: "GT".into(), fields: vec![] }, ], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }); // Ordering is `ptr` at the LLVM level (boxed ADT). let compare = Def::Fn(FnDef { name: fn_name.into(), ty: Type::Fn { params: vec![param_ail_ty.clone(), param_ail_ty.clone()], ret: Box::new(Type::Con { name: "Ordering".into(), args: vec![] }), effects: vec![], // value-typed params (Int/Bool) cannot be `(borrow V)` — // the cutover's `borrow-over-value` reject forbids it; a // value type is copied, so `own` is the only legal mode. param_modes: vec![ParamMode::Own, ParamMode::Own], ret_mode: ParamMode::Own, }, params: vec!["x".into(), "y".into()], // placeholder body; the `compare__` intercept overrides // it at codegen. It must typecheck against the declared // `Ordering` return — `elaborate_workspace` now runs the // checker before lowering — so it constructs an `Ordering` // ctor rather than the old Unit literal. body: Term::Ctor { type_name: "Ordering".into(), ctor: "LT".into(), args: vec![], }, suppress: vec![], doc: None, export: None, }); let main_def = Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }); Module { schema: SCHEMA.into(), name: "prelude".into(), kernel: false, imports: vec![], defs: vec![ordering, compare, main_def], } } /// language `Str` literals emit as packed-struct globals /// carrying an explicit `len` field followed by the bytes + trailing /// NUL. (The hs.1-era sentinel rc-header slot was removed per the /// amended spec; static-Str pointers are kept out of `ailang_rc_dec` /// at the codegen level via move-tracking and non-escape lowering, /// so no header slot is needed.) This test pins the layout shape /// against a tiny single-`Literal::Str` fixture. #[test] fn static_str_global_uses_packed_struct_with_len() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ Def::Const(ConstDef { name: "greeting".into(), ty: Type::str_(), value: Term::Lit { lit: Literal::Str { value: "hello".into() } }, doc: None, }), Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains(r#"<{ i64, [6 x i8] }> <{ i64 5, [6 x i8] c"hello\00" }>"#), "expected packed-struct global with len + bytes + NUL; ir was:\n{ir}" ); } /// a `Literal::Str` at a callsite materialises a constexpr /// `getelementptr` landing on the `len`-field of the packed-struct /// global (now the *first* field, since the hs.1-era sentinel /// rc-header slot was removed per the amended spec). This pins the /// IR-Str-pointer convention used uniformly by all consumers. #[test] fn static_str_callsite_pointer_is_payload_via_constexpr_gep() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains(r#"getelementptr inbounds (<{ i64, [3 x i8] }>, ptr @.str_t_str_0, i32 0, i32 0)"#), "expected constexpr-GEP-to-len-field at callsite; ir was:\n{ir}" ); } /// after the layout migration, the `io/print_str` /// path must `getelementptr i8` +8 onto the IR-Str pointer before /// passing it to `@fputs`, so `@fputs` receives the bytes pointer /// (skipping the `len` field) and produces correct output. Post /// Gitea #29 the call lowers to `@fputs(ptr bytes, ptr fp)` where /// `fp` is a `load ptr, ptr @stdout` — `@fputs` (unlike `@puts`) /// does NOT append a trailing newline, so `io/print_str` is /// byte-faithful. #[test] fn print_str_calls_fputs_with_bytes_pointer_and_stdout() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); let body_idx = ir.find("define i8 @ail_t_main").expect("main body"); let body = &ir[body_idx..]; let fputs_idx = body.find("@fputs(").expect("@fputs call present"); let before_fputs = &body[..fputs_idx]; assert!( before_fputs.contains("getelementptr inbounds i8, ptr ") && before_fputs.contains(", i64 8"), "expected `getelementptr inbounds i8, ptr , i64 8` before @fputs call; ir body was:\n{body}" ); assert!( before_fputs.contains("load ptr, ptr @stdout"), "expected `load ptr, ptr @stdout` before @fputs call; ir body was:\n{body}" ); // Module preamble must declare both: fputs and the @stdout extern. assert!( ir.contains("declare i32 @fputs(ptr, ptr)"), "expected `declare i32 @fputs(ptr, ptr)` in module preamble; ir was:\n{ir}" ); assert!( ir.contains("@stdout = external global ptr"), "expected `@stdout = external global ptr` in module preamble; ir was:\n{ir}" ); // After the swap there must be no `@puts` call left anywhere // in the emitted IR — the print path is the only consumer. assert!( !ir.contains("@puts("), "no @puts call should remain after the fputs swap; ir was:\n{ir}" ); } /// `eq__Str`'s body must `getelementptr i8` +8 on /// both operand pointers before calling `@ail_str_eq`, since the /// IR-Str pointer now lands on the `len`-field, not the bytes. #[test] fn eq_str_calls_ail_str_eq_with_bytes_pointer() { let m = Module { schema: SCHEMA.into(), name: "prelude".into(), kernel: false, imports: vec![], defs: vec![ Def::Fn(FnDef { name: "eq__Str".into(), ty: Type::Fn { params: vec![Type::str_(), Type::str_()], ret: Box::new(Type::bool_()), effects: vec![], param_modes: vec![ParamMode::Borrow, ParamMode::Borrow], ret_mode: ParamMode::Own, }, params: vec!["x".into(), "y".into()], body: Term::Lit { lit: Literal::Bool { value: false } }, suppress: vec![], doc: None, export: None, }), Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Lit { lit: Literal::Unit }, suppress: vec![], doc: None, export: None, }), ], }; let ir = emit_ir(&m).unwrap(); let body_idx = ir.find("define i1 @ail_prelude_eq__Str").expect("eq__Str body"); let body = &ir[body_idx..]; let eq_idx = body.find("@ail_str_eq(").expect("@ail_str_eq call present"); let before_eq = &body[..eq_idx]; // Two GEPs (one per operand) must precede the call. let gep_count = before_eq.matches("getelementptr inbounds i8, ptr ").count(); assert_eq!( gep_count, 2, "expected 2 +8 GEPs before @ail_str_eq (one per operand); ir body was:\n{body}" ); assert!( before_eq.matches(", i64 8").count() >= 2, "expected both GEPs to be `, i64 8`; ir body was:\n{body}" ); } /// `compare__Str`'s body must `getelementptr i8` +8 /// on both operand pointers before calling `@ail_str_compare`, /// symmetric to the `eq__Str` change. #[test] fn compare_str_calls_ail_str_compare_with_bytes_pointer() { let m = synth_compare_module("compare__Str", Type::str_()); let ir = emit_ir(&m).unwrap(); let body_idx = ir .find("define ptr @ail_prelude_compare__Str") .expect("compare__Str body"); let body = &ir[body_idx..]; let cmp_idx = body .find("@ail_str_compare(") .expect("@ail_str_compare call present"); let before_cmp = &body[..cmp_idx]; let gep_count = before_cmp.matches("getelementptr inbounds i8, ptr ").count(); assert_eq!( gep_count, 2, "expected 2 +8 GEPs before @ail_str_compare; ir body was:\n{body}" ); assert!( before_cmp.matches(", i64 8").count() >= 2, "expected both GEPs to be `, i64 8`; ir body was:\n{body}" ); } // 2026-05-21 operator-routing-eq-ord: the // `lower_eq_str_calls_strcmp_with_bytes_pointer` test pinned the // `lower_eq` direct-emit Str path that used an inline `@strcmp` // call. With `lower_eq` deleted in Task 7 and `==` no longer a // builtin, the only Str-equality path is class-method dispatch // via prelude.Eq.eq, which the intercept lowers via the existing // `eq__Str` arm (call to `@ail_str_eq`, not `@strcmp`). That // path's GEP-+8 / bytes-pointer correctness is pinned by // `eq_str_mono_symbol_emits_ail_str_eq_call` below. /// a `Term::App` calling `int_to_str` lowers to /// `call ptr @ailang_int_to_str(i64 %a)`. Pins the new builtin's /// lowering shape against the runtime C glue introduced in iter /// hs.3. #[test] fn int_to_str_lowers_to_ailang_int_to_str_call() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::App { callee: Box::new(Term::Var { name: "int_to_str".into() }), args: vec![Term::Lit { lit: Literal::Int { value: 42 } }], tail: false, }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("call ptr @ailang_int_to_str(i64 "), "expected lowering of int_to_str to call @ailang_int_to_str; ir was:\n{ir}" ); } /// `float_to_str` no longer raises CodegenError::Internal /// — it lowers to `call ptr @ailang_float_to_str(double %a)`, /// symmetric to the new `int_to_str` arm. #[test] fn float_to_str_no_longer_errors_internal() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::App { callee: Box::new(Term::Var { name: "float_to_str".into() }), args: vec![Term::Lit { lit: Literal::Float { bits: (3.5_f64).to_bits() } }], tail: false, }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("call ptr @ailang_float_to_str(double "), "expected lowering of float_to_str to call @ailang_float_to_str; ir was:\n{ir}" ); } /// a `Term::App` calling `bool_to_str` lowers to /// `call ptr @ailang_bool_to_str(i1 %a)`. Pins the new builtin's /// lowering shape against the runtime C glue introduced in this /// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`). #[test] fn bool_to_str_emits_call_to_ailang_bool_to_str() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::App { callee: Box::new(Term::Var { name: "bool_to_str".into() }), args: vec![Term::Lit { lit: Literal::Bool { value: true } }], tail: false, }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("call ptr @ailang_bool_to_str(i1 "), "expected lowering of bool_to_str to call @ailang_bool_to_str; ir was:\n{ir}" ); } /// a `Term::App` calling `str_clone` lowers to /// `call ptr @ailang_str_clone(ptr %a)`. Pins the new builtin's /// lowering shape against the runtime C glue introduced in this /// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`). #[test] fn str_clone_emits_call_to_ailang_str_clone() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::App { callee: Box::new(Term::Var { name: "str_clone".into() }), args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }], tail: false, }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("call ptr @ailang_str_clone(ptr "), "expected lowering of str_clone to call @ailang_str_clone; ir was:\n{ir}" ); } /// a `Term::App` calling `str_concat` lowers to /// `call ptr @ailang_str_concat(ptr %a, ptr %b)`. Pins the new /// builtin's extern declaration AND the lower_app arm together /// (mirror of the str_clone IR pin above). #[test] fn str_concat_emits_call_to_ailang_str_concat() { let m = Module { schema: SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![Def::Fn(FnDef { name: "main".into(), ty: Type::Fn { params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()], param_modes: vec![], ret_mode: ParamMode::Own, }, params: vec![], body: Term::Do { op: "io/print_str".into(), args: vec![Term::App { callee: Box::new(Term::Var { name: "str_concat".into() }), args: vec![ Term::Lit { lit: Literal::Str { value: "x".into() } }, Term::Lit { lit: Literal::Str { value: "y".into() } }, ], tail: false, }], tail: false, }, suppress: vec![], doc: None, export: None, })], }; let ir = emit_ir(&m).unwrap(); assert!( ir.contains("declare ptr @ailang_str_concat(ptr, ptr)"), "expected extern declaration; ir was:\n{ir}" ); assert!( ir.contains("call ptr @ailang_str_concat(ptr "), "expected lowering of str_concat to call @ailang_str_concat; ir was:\n{ir}" ); } }