//! Lowering of `Term::Ctor`, `Term::ReuseAs`, and `Term::Match`. //! //! These three lowering paths form a tightly coupled cluster around //! the AILang ADT runtime layout (`{ i64 tag; ptr fields[] }`). The //! ctor and match lowerings are mirror images: the ctor body emits //! `alloc + store-tag + store-fields`, the match arm body emits //! `load-tag + switch + load-fields`. `Term::ReuseAs` (Iter 18d.1) //! is the in-place rewrite optimisation: a ctor whose source comes //! from a recently-destructured binder of the same shape reuses //! the source's box rather than allocating a fresh one. //! //! All three are `pub(crate)` so `lower_term` (in lib.rs) can //! dispatch to them; the ctor-lookup helper they share //! (`lookup_ctor_in_pattern`) stays in lib.rs since it sits next //! to the lookup-table family. //! //! Cross-references back into the parent module: //! - `synth_arg_type`, `lookup_ctor_by_type`, //! `lookup_ctor_in_pattern`, `collect_owner_local_types`, //! `field_drop_call`: lookup helpers, all `pub(crate)`. //! - `lower_term`, `start_block`, `fresh_ssa`, `fresh_id`: //! lowering primitives, all `pub(crate)`. use ailang_core::ast::*; use std::collections::{BTreeMap, BTreeSet}; use super::synth::llvm_type; use super::subst::{apply_subst_to_type, qualify_local_types_codegen, unify_for_subst}; use super::{AllocStrategy, CodegenError, CtorRef, Emitter, Result}; impl<'a> Emitter<'a> { /// Heap box layout: 8 bytes tag (i64) followed by 8 bytes per field. /// i1 and i8 fields also occupy a full 8-byte slot — the typed /// load/store instructions write/read only the required size. /// /// `term_ptr` is the pointer-as-usize of the lowered /// `Term::Ctor` node. If escape analysis flagged this site as /// non-escaping (i.e., the value cannot live past the current fn /// frame), allocation lowers to LLVM `alloca` instead of /// `@GC_malloc`. The rest of the lowering (tag store, field /// stores, ptr return) is identical. pub(crate) fn lower_ctor( &mut self, type_name: &str, ctor_name: &str, args: &[Term], term_ptr: usize, ) -> Result<(String, String)> { let cref = self.lookup_ctor_by_type(type_name, ctor_name)?; if args.len() != cref.ail_fields.len() { return Err(CodegenError::Internal(format!( "ctor `{type_name}/{ctor_name}` arity" ))); } // for parameterised ADTs the precomputed `cref.fields` // is meaningless because field types reference rigid type vars. // Derive a per-use-site substitution from the arg types and // re-lower each field type. Monomorphic ADTs hit the fast path // (no var-set, substitution is empty, ail_fields lower exactly // like cref.fields). // for cross-module ctors, qualify any local type-cons // in `cref.ail_fields` (symmetric to the term-ctor synth fix). let qualified_ail_fields: Vec = if type_name.matches('.').count() == 1 { let (prefix, _) = type_name.split_once('.').expect("checked"); if let Some(target) = self.import_map.get(prefix) { let owner_local_types = self.collect_owner_local_types(target); cref.ail_fields .iter() .map(|f| qualify_local_types_codegen(f, target, &owner_local_types)) .collect() } else { cref.ail_fields.clone() } } else { cref.ail_fields.clone() }; let expected_llvm_tys: Vec = if cref.type_vars.is_empty() { cref.fields.clone() } else { let arg_ail_tys: Vec = args .iter() .map(|a| self.synth_arg_type(a)) .collect::>()?; let var_set: BTreeSet<&str> = cref.type_vars.iter().map(|s| s.as_str()).collect(); let mut subst: BTreeMap = BTreeMap::new(); for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) { unify_for_subst(exp, actual, &var_set, &mut subst)?; } qualified_ail_fields .iter() .map(|f| llvm_type(&apply_subst_to_type(f, &subst))) .collect::>()? }; // Evaluate arguments up front so allocation and store stay close // together. let mut compiled = Vec::new(); for (a, exp) in args.iter().zip(expected_llvm_tys.iter()) { let (v, vty) = self.lower_term(a)?; if &vty != exp { return Err(CodegenError::Internal(format!( "ctor `{ctor_name}` field type {vty} != expected {exp}" ))); } compiled.push((v, vty)); } // FROZEN ABI (embedding boundary) — // design/contracts/frozen-value-layout.md. size = 8 + n*8, tag@0, fields@8+i*8. let size_bytes = 8 + (compiled.len() * 8) as i64; let p = self.fresh_ssa(); // pick allocator based on escape analysis. `alloca` // for non-escaping (stack-allocated, freed on fn return); // `@GC_malloc` for everything else. if self.non_escape.contains(&term_ptr) { self.body.push_str(&format!( " {p} = alloca i8, i64 {size_bytes}, align 8\n" )); } else { self.body.push_str(&format!( " {p} = call ptr @{}(i64 {size_bytes})\n", self.alloc.fn_name() )); } // Write tag. self.body.push_str(&format!( " store i64 {tag}, ptr {p}, align 8\n", tag = cref.tag )); // Write fields. for (i, (v, ty)) in compiled.iter().enumerate() { let off = 8 + i as i64 * 8; let addr = self.fresh_ssa(); self.body.push_str(&format!( " {addr} = getelementptr inbounds i8, ptr {p}, i64 {off}\n" )); self.body .push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n")); } Ok((p, "ptr".into())) } /// lower `Term::ReuseAs { source, body = Term::Ctor }` /// under `--alloc=rc` as a runtime refcount-1 dispatch. /// /// IR shape (Lean 4 / Roc lineage): /// ```text /// %src = /// %hdr_ptr = getelementptr inbounds i8, ptr %src, i64 -8 /// %refcnt = load i64, ptr %hdr_ptr /// %is_one = icmp eq i64 %refcnt, 1 /// br i1 %is_one, label %reuse, label %fresh /// /// reuse: /// ; for each pointer-typed slot j: load old, drop_(old) /// ; store new tag at offset 0 /// ; store new field values at offsets 8, 16, ... /// br label %join /// /// fresh: /// %newp = call ptr @ailang_rc_alloc(i64 SIZE) /// ; store new tag + new fields (mirrors lower_ctor) /// ; dec the now-superseded source /// call void @drop__(ptr %src) /// br label %join /// /// join: /// %result = phi ptr [ %src, %reuse_end ], [ %newp, %fresh_end ] /// ``` /// /// The shape-mismatch invariant (source's ctor has the same field /// count and per-field LLVM types as `body`'s ctor) is enforced /// upstream by `ailang_check::reuse_shape::check_module`. Codegen /// trusts the check; if a mismatched shape ever reaches us, the /// `reuse` arm's per-slot field iteration is bounded by /// `expected_llvm_tys.len()` and the source-cell layout /// guaranteed by `lower_ctor`/`@ailang_rc_alloc` — we won't read /// past the end of the source, but we will silently corrupt /// fields. The shape check ensures that path is unreachable. pub(crate) fn lower_reuse_as_rc( &mut self, source: &Term, body_type_name: &str, body_ctor_name: &str, body_args: &[Term], ) -> Result<(String, String)> { // 1. Lower the source. Linearity (18d.1) guarantees this is a // bare Var of an in-scope binder; lower_term resolves it // to the binder's SSA. let (src_ssa, src_ty) = self.lower_term(source)?; if src_ty != "ptr" { return Err(CodegenError::Internal(format!( "reuse-as source type must be ptr, got {src_ty}" ))); } // the source binder name keys into `moved_slots` // so the reuse arm can skip dec'ing fields that earlier // pattern-extracted out of the source. Linearity already // ensures `source` is `Term::Var` here; the var-resolution // is just defensive. let source_binder: Option = match source { Term::Var { name } => { if self.locals.iter().any(|(n, _, _, _)| n == name) { Some(name.clone()) } else { None } } _ => None, }; // The source's drop symbol — needed on the fresh arm to // release this caller's share of the source (refcount > 1, // by the branch we are inside). We deliberately use the // shallow `ailang_rc_dec` here, NOT the per-type cascading // drop: // // The fresh arm only fires when the source's refcount is // > 1, i.e. another holder also has a reference. In that // case the source's children are still owned by the other // holder; cascading dec would dec children we don't have // exclusive ownership over, and on aliasing with the new // box's fields could leave a use-after-free in the new box. // A shallow dec on the source's refcount preserves the // 18c.4 baseline: source.refcount goes down by one; the // box only frees when the LAST holder dec's it; children // are dec'd by that final dec's cascade, not by ours. // // Identical to the unused-source-after-reuse-misfire // pattern in Lean 4's reset/reuse codegen: the // refcount-decrement is the contract; the drop fn cascade // is owned by the last release, not by intermediate ones. let _ = source; // synth_arg_type not needed for shallow dec let src_drop_call = "ailang_rc_dec".to_string(); // 2. Resolve the body ctor and its expected per-field LLVM // types. Mirrors the head of lower_ctor — same type-var // substitution path so parameterised ADT bodies work the // same way reuse-as lowers as plain ctor lowers. let cref = self.lookup_ctor_by_type(body_type_name, body_ctor_name)?; if body_args.len() != cref.ail_fields.len() { return Err(CodegenError::Internal(format!( "reuse-as body ctor `{body_type_name}/{body_ctor_name}` arity" ))); } let qualified_ail_fields: Vec = if body_type_name.matches('.').count() == 1 { let (prefix, _) = body_type_name.split_once('.').expect("checked"); if let Some(target) = self.import_map.get(prefix) { let owner_local_types = self.collect_owner_local_types(target); cref.ail_fields .iter() .map(|f| qualify_local_types_codegen(f, target, &owner_local_types)) .collect() } else { cref.ail_fields.clone() } } else { cref.ail_fields.clone() }; let expected_llvm_tys: Vec = if cref.type_vars.is_empty() { cref.fields.clone() } else { let arg_ail_tys: Vec = body_args .iter() .map(|a| self.synth_arg_type(a)) .collect::>()?; let var_set: BTreeSet<&str> = cref.type_vars.iter().map(|s| s.as_str()).collect(); let mut subst: BTreeMap = BTreeMap::new(); for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) { unify_for_subst(exp, actual, &var_set, &mut subst)?; } qualified_ail_fields .iter() .map(|f| llvm_type(&apply_subst_to_type(f, &subst))) .collect::>()? }; // (18d.2 currently does not dec old fields in the reuse // arm — see the rationale at the reuse arm — so the post- // substitution per-field AILang types are not needed here. // 18d.3 / 18e will revisit when the move-aware pattern // story lands and the reuse arm can dec moved-out slots // safely.) // 3. Evaluate the body's args. They live in SSAs that both // branches consume, so they are computed before the branch // on refcount. let mut compiled = Vec::new(); for (a, exp) in body_args.iter().zip(expected_llvm_tys.iter()) { let (v, vty) = self.lower_term(a)?; if &vty != exp { return Err(CodegenError::Internal(format!( "reuse-as body ctor `{body_ctor_name}` field type {vty} != expected {exp}" ))); } compiled.push((v, vty)); } // 4. Refcount-1 dispatch. Header is at offset -8 (see // runtime/rc.c::header_of). A static / data-segment ptr // (e.g. a top-level fn's static closure pair) would // dereference garbage here, but reuse-as on such a // pointer is meaningless — the linearity check would // have rejected `` as not naming a heap binder. let id = self.fresh_id(); let reuse_lbl = format!("reuse.{id}"); let fresh_lbl = format!("fresh.{id}"); let join_lbl = format!("rejoin.{id}"); let hdr_ptr = self.fresh_ssa(); self.body.push_str(&format!( " {hdr_ptr} = getelementptr inbounds i8, ptr {src_ssa}, i64 -8\n" )); let refcnt = self.fresh_ssa(); self.body.push_str(&format!( " {refcnt} = load i64, ptr {hdr_ptr}, align 8\n" )); let is_one = self.fresh_ssa(); self.body.push_str(&format!( " {is_one} = icmp eq i64 {refcnt}, 1\n" )); self.body.push_str(&format!( " br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n" )); // 5. Reuse arm: overwrite tag and field slots in place. // // per-field dec is now safe — moved-out slots // are skipped via the codegen `moved_slots` side table; // non-moved slots are dec'd via `field_drop_call`'s null- // guarded drop fns BEFORE the new field values overwrite // them. The shape check (18d.2) guarantees source's old // ctor and body's new ctor have matching field counts and // types, so `qualified_ail_fields` is correct for both. // // Why this is sound under the canonical // `(reuse-as xs (Cons (+ h 1) (map_inc t)))` pattern: // the `Cons` arm of the enclosing match recorded both // field 0 and field 1 of `xs` as moved; both are skipped // here and no dec executes against the reused slots. // `(map_inc t)`'s in-place-rewritten box is stored into // field 1 unchanged — no use-after-free. // // For a fixture like `(match xs (Cons _ _ → reuse-as xs // (Cons 0 0)))` where neither slot is moved, both // pointer-typed fields are dec'd via `field_drop_call`, // closing the 18d.2 leak. self.start_block(&reuse_lbl); // dec non-moved pointer-typed slots before the // overwrite. The dec fns are null-guarded, so an empty slot // is a no-op. Clone the move set out of the side table so // the loop below can mutably borrow `self` for SSA allocation. let moves_for_src: BTreeSet = source_binder .as_ref() .and_then(|sb| self.moved_slots.get(sb).cloned()) .unwrap_or_default(); for (idx, fty_ail) in qualified_ail_fields.iter().enumerate() { let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into()); if lty != "ptr" { continue; } if moves_for_src.contains(&idx) { continue; } let off = 8 + (idx as i64) * 8; let addr = self.fresh_ssa(); self.body.push_str(&format!( " {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n" )); let v = self.fresh_ssa(); self.body.push_str(&format!( " {v} = load ptr, ptr {addr}, align 8\n" )); let drop_call = self.field_drop_call(fty_ail); self.body.push_str(&format!( " call void @{drop_call}(ptr {v})\n" )); } // Store new tag at offset 0. Even when the new tag equals the // old tag (most reuse-as fixtures: Cons → Cons), we emit the // store unconditionally — codegen doesn't have to special- // case "same tag", the store is cheap, and the IR is simpler. self.body.push_str(&format!( " store i64 {tag}, ptr {src_ssa}, align 8\n", tag = cref.tag )); // Store new field values into the slots. for (i, (v, ty)) in compiled.iter().enumerate() { let off = 8 + i as i64 * 8; let addr = self.fresh_ssa(); self.body.push_str(&format!( " {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n" )); self.body .push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n")); } let reuse_end = self.current_block.clone(); self.body.push_str(&format!(" br label %{join_lbl}\n")); // 6. Fresh arm: standard alloc + tag + field stores; then // dec the source (whose refcount was > 1, so this is the // callee's release of its share — may or may not free). self.start_block(&fresh_lbl); let size_bytes = 8 + (compiled.len() * 8) as i64; let newp = self.fresh_ssa(); self.body.push_str(&format!( " {newp} = call ptr @{}(i64 {size_bytes})\n", self.alloc.fn_name() )); self.body.push_str(&format!( " store i64 {tag}, ptr {newp}, align 8\n", tag = cref.tag )); for (i, (v, ty)) in compiled.iter().enumerate() { let off = 8 + i as i64 * 8; let addr = self.fresh_ssa(); self.body.push_str(&format!( " {addr} = getelementptr inbounds i8, ptr {newp}, i64 {off}\n" )); self.body .push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n")); } // Dec the source — the user's reuse-hint failed because the // box was shared. The source's drop fn cascades through any // boxed children of the OLD ctor (the slot we couldn't reuse), // matching the cascade lower_ctor would do for any other // owned binder going out of scope. self.body .push_str(&format!(" call void @{src_drop_call}(ptr {src_ssa})\n")); let fresh_end = self.current_block.clone(); self.body.push_str(&format!(" br label %{join_lbl}\n")); // 7. Join: phi over the two arms' result pointers. self.start_block(&join_lbl); let phi = self.fresh_ssa(); self.body.push_str(&format!( " {phi} = phi ptr [ {src_ssa}, %{reuse_end} ], [ {newp}, %{fresh_end} ]\n" )); Ok((phi, "ptr".into())) } pub(crate) fn lower_match( &mut self, scrutinee: &Term, arms: &[Arm], ) -> Result<(String, String)> { let s_ail = self.synth_arg_type(scrutinee)?; let (s_val, s_ty) = self.lower_term(scrutinee)?; if s_ty != "ptr" { return Err(CodegenError::Internal(format!( "match on non-ADT scrutinee (got {s_ty}); MVP supports only ADTs" ))); } // move tracking key — the bare-Var binder name of // the scrutinee, if it has one. A complex expression scrutinee // (e.g. the result of `(map_inc xs)` matched directly) has no // binder we can key off; moves through such matches are // untracked (see the assignment's "untracked scrutinee" // fallback). Only record when the var actually resolves to a // local binder. let scrutinee_binder: Option = match scrutinee { Term::Var { name } => { if self.locals.iter().any(|(n, _, _, _)| n == name) { Some(name.clone()) } else { None } } _ => None, }; // Load tag. let tag = self.fresh_ssa(); self.body .push_str(&format!(" {tag} = load i64, ptr {s_val}, align 8\n")); // Separate arms. let mut ctor_arms: Vec<(CtorRef, &Arm, Vec>)> = Vec::new(); let mut open_arm: Option<&Arm> = None; let mut open_var: Option = None; for arm in arms { match &arm.pat { Pattern::Wild => { open_arm = Some(arm); } Pattern::Var { name } => { open_arm = Some(arm); open_var = Some(name.clone()); } Pattern::Ctor { ctor, fields } => { // lookup falls back to imported modules when // a bare ctor name doesn't resolve locally. let cref = self.lookup_ctor_in_pattern(ctor)?; let bindings: Vec> = fields .iter() .map(|p| match p { Pattern::Var { name } => Some(name.clone()), Pattern::Wild => None, _ => None, // MVP: nested ctor/lit patterns not supported }) .collect(); ctor_arms.push((cref, arm, bindings)); } Pattern::Lit { .. } => { return Err(CodegenError::Internal( "MVP: lit patterns in match not supported".into(), )); } } } let id = self.fresh_id(); let join_lbl = format!("mjoin.{id}"); let default_lbl = format!("mdefault.{id}"); // switch let mut sw = format!( " switch i64 {tag}, label %{default_lbl} [\n", tag = tag ); let mut arm_labels: Vec = Vec::new(); for (i, (cref, _, _)) in ctor_arms.iter().enumerate() { let lbl = format!("marm.{id}.{i}"); sw.push_str(&format!(" i64 {}, label %{}\n", cref.tag, lbl)); arm_labels.push(lbl); } sw.push_str(" ]\n"); self.body.push_str(&sw); let mut phi_inputs: Vec<(String, String)> = Vec::new(); // (value, block) let mut result_ty: Option = None; for (i, (cref, arm, bindings)) in ctor_arms.iter().enumerate() { self.start_block(&arm_labels[i]); // derive substitution for parameterised ADTs from // the scrutinee's concrete type-args. Map `cref.type_vars[i]` // → `s_ail.args[i]`. For monomorphic ADTs the mapping is // empty and substitution is a no-op. The substituted AILang // types are used both for the LLVM `load` instruction and as // the AILang-type slot of the local — without the latter, a // downstream `unbox(b)` would see `b: a` (rigid var) instead // of `b: Int`. let arm_subst: BTreeMap = if cref.type_vars.is_empty() { BTreeMap::new() } else { match &s_ail { Type::Con { args, .. } if args.len() == cref.type_vars.len() => cref .type_vars .iter() .cloned() .zip(args.iter().cloned()) .collect(), _ => { return Err(CodegenError::Internal(format!( "match: scrutinee type `{}` not aligned with ctor `{}`'s {} type vars", ailang_core::pretty::type_to_string(&s_ail), cref.type_name, cref.type_vars.len(), ))); } } }; // Load fields and bind as locals. // when the scrutinee's ADT lives in another module, // `cref.ail_fields[idx]` carries the field type written in // the owner's local namespace. Qualify it before substituting // — symmetric to the term-ctor and pat-ctor fixes in // ailang-check. let owning_module: Option = match &s_ail { Type::Con { name, .. } if name.matches('.').count() == 1 => name .split_once('.') .map(|(p, _)| p.to_string()), _ => None, }; let mut pushed = 0usize; // collect the binder names this arm pushes so // we can drop their `moved_slots` entries when the arm // body closes (these binders are themselves freshly named // here — their move tracking starts empty and ends at arm // body close). // // the metadata is widened to (name, SSA, // llvm-type, AILang-type) so that the arm-close drop // emission can route the call through the binder's // per-type drop symbol without re-walking `self.locals`. // The same tuple shape `self.locals` carries. let mut arm_pushed_binders: Vec<(String, String, String, Type)> = Vec::new(); for (idx, binding) in bindings.iter().enumerate() { if let Some(bname) = binding { let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit()); let qualified_ail = match &owning_module { Some(m) => { let owner_local_types = self.collect_owner_local_types(m); qualify_local_types_codegen(&raw_ail, m, &owner_local_types) } None => raw_ail, }; let bind_ail = if arm_subst.is_empty() { qualified_ail } else { apply_subst_to_type(&qualified_ail, &arm_subst) }; let fty = llvm_type(&bind_ail)?; let off = 8 + idx as i64 * 8; let addr = self.fresh_ssa(); self.body.push_str(&format!( " {addr} = getelementptr inbounds i8, ptr {s_val}, i64 {off}\n" )); let v = self.fresh_ssa(); self.body.push_str(&format!( " {v} = load {fty}, ptr {addr}, align 8\n" )); // a non-wildcard, pointer-typed slot // bound here is treated as moved out of the // scrutinee binder. The source slot is NOT // mutated; codegen records the move statically so // a later top-level dec sequence (let-close, // reuse-arm) skips this slot. if fty == "ptr" { if let Some(sb) = &scrutinee_binder { self.moved_slots .entry(sb.clone()) .or_default() .insert(idx); } } self.locals.push(( bname.clone(), v.clone(), fty.clone(), bind_ail.clone(), )); arm_pushed_binders.push((bname.clone(), v, fty, bind_ail)); pushed += 1; } } // Scrutinee ownership signal, hoisted here so both the // pre-tail-call shallow-dec (below) and the arm-close // pattern-binder // dec (Iter A, further below) consult the same gate. // // If the scrutinee resolves to a fn-param whose mode is // `Borrow` or `Implicit`, the caller still holds a // reference to the heap value the pattern-binders were // loaded from. Dec'ing those binders (Iter A) — or the // scrutinee's outer cell (18g.1) — fragments the // caller's structure. Only `Own` carries the static // "caller handed off ownership" signal that makes the // children-dec safe. // // Non-fn-param scrutinees (let-binders, temp expressions) // are treated as owned: the let-binder owns its RC ref, // and a temp scrutinee was just produced by the local // frame. let scrutinee_is_owned: bool = match &scrutinee_binder { Some(sb) => match self.current_param_modes.get(sb) { Some(mode) => matches!(mode, ParamMode::Own), None => true, }, None => true, }; // pre-tail-call shallow-dec for moved-from // scrutinee outer cell. Iter A (arm-close pattern-binder // dec, below) cannot fire when `arm.body` is a tail call: // `lower_term` emits `musttail call ... ret` and sets // `block_terminated = true`, so the post-arm-body Iter A // block is skipped. Iter B (Own-param dec at fn return) is // also skipped for the same reason — the fn body's tail // path is the tail call, not a fall-through `ret`. // // Concretely, in `(case (LCons h t) (tail-app sum_acc t (...)))`, // the LCons outer cell (`xs`) has its only ptr field `t` // moved into the tail call's args list (via // `arm_pushed_binders` -> `moved_slots[xs]`). After the // tail call, the outer cell is a husk: its tag and Int // fields are stale, its ptr field points to memory now // owned downstream. Nothing dec's the husk → one outer // cell leak per consumed list element. 18f.2's tail- // latency bench surfaced this: explicit-mode RC RSS peaks // at the same level as implicit-mode RC despite full mode // annotations. // // The fix is a shallow `ailang_rc_dec(scrutinee_ssa)` // emitted *before* `lower_term(arm.body)`, which lands // ahead of the `musttail call`. Shallow is safe iff every // ptr-typed slot in this ctor was bound by a non-wildcard // pattern (so its content is moved into a binder that // owns it downstream); a Wild-bound ptr slot would still // hold a live reference that the shallow free would // strand. The gate enforces both: arm.body must be a // tail call, scrutinee must be statically owned (the // 18d.4-Iter-A param-mode gate), and every ptr field of // the active ctor must be in `moved_slots[scrutinee]`. // // The dec lowers to `ailang_rc_dec` directly (not // `field_drop_call` / `drop__`) because the per- // type drop fn would re-walk the ptr fields, dec'ing // values that are now owned by downstream frames — // that's exactly the bug 18d.3's `moved_slots` was set // up to prevent. let arm_body_is_tail_call = matches!( &arm.body, Term::App { tail: true, .. } | Term::Do { tail: true, .. } ); if matches!(self.alloc, AllocStrategy::Rc) && arm_body_is_tail_call && scrutinee_is_owned { if let Some(sb) = &scrutinee_binder { let moved = self.moved_slots.get(sb).cloned().unwrap_or_default(); let mut all_ptr_moved = true; let mut any_ptr_field = false; for idx in 0..bindings.len() { let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit()); let qualified_ail = match &owning_module { Some(m) => { let owner_local_types = self.collect_owner_local_types(m); qualify_local_types_codegen(&raw_ail, m, &owner_local_types) } None => raw_ail, }; let bind_ail = if arm_subst.is_empty() { qualified_ail } else { apply_subst_to_type(&qualified_ail, &arm_subst) }; let fty = llvm_type(&bind_ail).unwrap_or_else(|_| "ptr".into()); if fty == "ptr" { any_ptr_field = true; if !moved.contains(&idx) { all_ptr_moved = false; break; } } } if any_ptr_field && all_ptr_moved { self.body.push_str(&format!( " call void @ailang_rc_dec(ptr {s_val})\n" )); } } } let (val, vty) = self.lower_term(&arm.body)?; // arm-close pattern-binder dec. Symmetric to // 18c.3/18c.4's `Term::Let`-scope-close drop emission, but // fired at the lexical close of a match arm. For each // pattern-bound binder this arm pushed, emit a drop call // iff: // - alloc strategy is `Rc`, // - the binder's lowered type is `ptr` (heap-allocated), // - uniqueness inference recorded `consume_count == 0` // for the binder in this fn's body (no downstream use // consumed it; the binder's slot owns the only ref), // - the arm's tail SSA value is not the binder itself // (returning the binder transfers ownership to the // phi-join consumer; that consumer dec's, not us), // - the current block is still open (a tail-call / // `unreachable` already exited; nothing to emit). // // Closes the 18d.3-shipped regression on // `alloc_rc_borrow_only_recursive_list_drop`: the `t` // pattern-binder of `(Cons h t)` is unused, has // `consume_count == 0`, and previously leaked because // 18d.3's `moved_slots` interrupts the `xs`-cascade // through the tail slot. Now the arm itself dec's `t`, // freeing the 4-cell tail. // // Regression // `alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children`: // arm-close pattern-binder dec is symmetric to Own-param // dec at fn return and must share the same mode gate. If // the scrutinee resolves to a fn-param whose mode is // `Borrow` or `Implicit`, the caller still holds a // reference to the heap value the pattern-binders were // loaded from. Dec'ing those binders fragments the // caller's structure (refcount underflow on subsequent // accesses; segfault under deeper recursion). // // Only `Own` carries the static "caller handed off // ownership" signal that makes the children-dec safe. // Non-fn-param scrutinees (let-binders, temp // expressions) are treated as owned — the let-binder // owns its RC ref, and a temp scrutinee was just // produced by the local frame. // // The `scrutinee_is_owned` binding is hoisted above so // the 18g.1 pre-tail-call shallow-dec sees the same // gate; do not re-declare here. if matches!(self.alloc, AllocStrategy::Rc) && !self.block_terminated && scrutinee_is_owned { for (bname, b_ssa, b_lty, b_ail) in &arm_pushed_binders { if b_lty != "ptr" { continue; } let consume_count = self .uniqueness .get(&(self.current_def.clone(), bname.clone())) .map(|info| info.consume_count) .unwrap_or(u32::MAX); if consume_count != 0 { continue; } if &val == b_ssa { // The binder IS the arm's tail value — // ownership transfers to the join consumer. continue; } let moves = self .moved_slots .get(bname) .cloned() .unwrap_or_default(); if moves.is_empty() { // Common case (canonical regression `t`): // route through the per-type drop fn for the // binder's static type. `field_drop_call` // resolves `Type::Con` to `drop__` // and falls back to `ailang_rc_dec` for // closure / Var fields (same fallback the // recursive cascade uses). let drop_call = self.field_drop_call(b_ail); self.body.push_str(&format!( " call void @{drop_call}(ptr {b_ssa})\n" )); } else { // pattern-binder with // statically-recorded moved slots routes // through the tag-conditional helper // `partial_drop__(b, mask)`. The // binder's runtime tag is dynamic but its // static type (`b_ail`) selects the per-type // helper, which dispatches on the runtime tag // and dec's only the unmoved fields. Closes // the 18d.4 carve-out: previously the shallow // `ailang_rc_dec` leaked the unmoved fields of // whichever ctor `b` happened to be at runtime. let sym = self.partial_drop_symbol_for_type(b_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 {b_ssa}, i64 {mask})\n" )); } else { self.body.push_str(&format!( " call void @ailang_rc_dec(ptr {b_ssa})\n" )); } } } } // pop bindings for _ in 0..pushed { self.locals.pop(); } // arm-bound binders go out of scope at arm // body close. Drop their move-tracking entries (any moves // recorded against `h`/`t` belonged to this arm only). for (bname, _, _, _) in &arm_pushed_binders { self.moved_slots.remove(bname); } // if the arm body lowered to a `musttail call` + // `ret`, the block is already terminated. Skip the // fall-through `br` and exclude this arm from the join phi. if !self.block_terminated { phi_inputs.push((val, self.current_block.clone())); self.body .push_str(&format!(" br label %{join_lbl}\n")); if let Some(rt) = &result_ty { if rt != &vty { return Err(CodegenError::Internal(format!( "match arm result type {vty} != {rt}" ))); } } else { result_ty = Some(vty); } } } // default block self.start_block(&default_lbl); if let Some(arm) = open_arm { // set up var binding if needed let pushed = if let Some(name) = open_var.take() { self.locals .push((name, s_val.clone(), "ptr".into(), s_ail.clone())); 1 } else { 0 }; let (val, vty) = self.lower_term(&arm.body)?; for _ in 0..pushed { self.locals.pop(); } if !self.block_terminated { phi_inputs.push((val, self.current_block.clone())); self.body .push_str(&format!(" br label %{join_lbl}\n")); if let Some(rt) = &result_ty { if rt != &vty { return Err(CodegenError::Internal(format!( "match default arm result type {vty} != {rt}" ))); } } else { result_ty = Some(vty); } } } else { // Typechecker guarantees exhaustiveness, so unreachable. self.body.push_str(" unreachable\n"); } // join // if every arm tail-called and terminated its own // block, no predecessor branches into the join. Mark the whole // match as block-terminated and emit no join body — the // surrounding context (top-level fn body, seq rhs, etc.) checks // `block_terminated` before any fall-through emission. if phi_inputs.is_empty() { self.block_terminated = true; // Return a dummy SSA + type that won't be consumed. Use the // result_ty if any arm produced one, else fall back to i8. let rt = result_ty.unwrap_or_else(|| "i8".into()); return Ok(("0".into(), rt)); } self.start_block(&join_lbl); let phi = self.fresh_ssa(); let rt = result_ty.unwrap_or_else(|| "i64".into()); let phi_args = phi_inputs .iter() .map(|(v, b)| format!("[ {v}, %{b} ]")) .collect::>() .join(", "); self.body.push_str(&format!( " {phi} = phi {rt} {phi_args}\n" )); Ok((phi, rt)) } }