From 86406aca72cbe71cbef95ab58c094225a580120f Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 15:51:53 +0200 Subject: [PATCH] codegen tidy: extract match/ctor/reuse-as lowering into match_lower.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the codegen split. Pulls the three big lowering methods that share the ADT runtime layout into a dedicated module. No behaviour change. Methods moved (all pub(crate)): - lower_ctor (135 lines): allocation + tag store + field stores. Iter 17a alloca-vs-heap routing through escape analysis lives here. - lower_reuse_as_rc (261 lines): Iter 18d.2 in-place rewrite optimisation under --alloc=rc. Reuse arm + fresh arm, Lean-4-shaped runtime refcount-1 dispatch. - lower_match (497 lines): switch + per-arm pattern destructure + per-arm phi + 18d.4 Iter A pattern-binder drops + 18g.1 outer-cell shallow dec at tail-call sites. lookup_ctor_in_pattern stays in lib.rs (sits next to its lookup-family siblings). Visibility: four parent-module helpers that match_lower.rs calls back into were upgraded to pub(crate): start_block, lower_term, collect_owner_local_types, fresh_id. lookup_ctor_in_pattern likewise pub(crate). lib.rs drops from 4139 → 3237 lines. match_lower.rs is 935 lines (the lower_match body alone is ~500 of those — the single-largest method in the codebase, but moving it gives the biggest navigability win). cargo test --workspace clean. --- crates/ailang-codegen/src/lib.rs | 914 +--------------------- crates/ailang-codegen/src/match_lower.rs | 935 +++++++++++++++++++++++ 2 files changed, 941 insertions(+), 908 deletions(-) create mode 100644 crates/ailang-codegen/src/match_lower.rs diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 2daa300..540ccbe 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -42,6 +42,7 @@ use ailang_check::uniqueness::{infer_module, UniquenessTable}; mod drop; mod escape; +mod match_lower; mod subst; mod synth; @@ -732,7 +733,7 @@ impl<'a> Emitter<'a> { } } - fn start_block(&mut self, label: &str) { + pub(crate) fn start_block(&mut self, label: &str) { self.body.push_str(label); self.body.push_str(":\n"); self.current_block = label.to_string(); @@ -1162,7 +1163,7 @@ impl<'a> Emitter<'a> { } /// Lowers a term to (SSA value string, LLVM type). - fn lower_term(&mut self, t: &Term) -> Result<(String, String)> { + pub(crate) fn lower_term(&mut self, t: &Term) -> Result<(String, String)> { match t { Term::Lit { lit } => Ok(match lit { Literal::Int { value } => (value.to_string(), "i64".into()), @@ -1612,7 +1613,7 @@ impl<'a> Emitter<'a> { /// Used to mirror the typechecker's `qualify_local_types` rewrite /// when reading a polymorphic fn's signature pulled across the /// import boundary. - fn collect_owner_local_types(&self, owner_module: &str) -> BTreeSet { + pub(crate) fn collect_owner_local_types(&self, owner_module: &str) -> BTreeSet { self.module_ctor_index .get(owner_module) .map(|m| { @@ -1630,7 +1631,7 @@ impl<'a> Emitter<'a> { /// 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`). - fn lookup_ctor_in_pattern(&self, ctor_name: &str) -> Result { + pub(crate) fn lookup_ctor_in_pattern(&self, ctor_name: &str) -> Result { if let Some(cref) = self .module_ctor_index .get(self.module_name) @@ -1664,909 +1665,6 @@ 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. - /// - /// Iter 17a: `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. - 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" - ))); - } - // Iter 13b: 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). - // Iter 15b: 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)); - } - let size_bytes = 8 + (compiled.len() * 8) as i64; - let p = self.fresh_ssa(); - // Iter 17a: 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())) - } - - /// Iter 18d.2: 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. - 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}" - ))); - } - // Iter 18d.3: 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. - // - // Iter 18d.3: 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); - // Iter 18d.3: 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())) - } - - 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" - ))); - } - // Iter 18d.3: 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 } => { - // Iter 15a: 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]); - // Iter 13b: 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. - // Iter 15b: 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; - // Iter 18d.3: 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). - // - // Iter 18d.4: 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" - )); - // Iter 18d.3: 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; - } - } - // Iter 18d.4 fix + 18g.1: scrutinee ownership signal, - // hoisted here so both the pre-tail-call shallow-dec - // (Iter 18g.1, 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, - }; - // Iter 18g.1: 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)?; - // Iter 18d.4: 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. - // - // Iter 18d.4 fix (regression - // `alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children`): - // Iter A is symmetric to Iter B (Own-param dec at fn - // return) and must share Iter B's 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 { - // Iter 18d.4 debt: pattern-binder with - // statically-recorded moved slots requires - // tag-conditional partial-drop emission. The - // 18d.3 `emit_inlined_partial_drop` helper - // assumes a static ctor (it's keyed against a - // `Term::Ctor` node), but a pattern-bound - // binder's runtime tag is dynamic. Until a - // dynamic-tag-aware partial-drop lands, fall - // back to a shallow `ailang_rc_dec` of the - // outer cell. The non-moved fields of the - // active ctor (if any) leak under this path — - // the canonical 18d.4 fixtures avoid this - // case by construction (the regression `t` - // is never re-scrutinised inside the arm, - // so its `moved_slots` entry is empty). - self.body.push_str(&format!( - " call void @ailang_rc_dec(ptr {b_ssa})\n" - )); - } - } - } - // pop bindings - for _ in 0..pushed { - self.locals.pop(); - } - // Iter 18d.3: 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); - } - // Iter 14e: 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 - // Iter 14e: 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)) - } - fn lower_app(&mut self, name: &str, args: &[Term], tail: bool) -> Result<(String, String)> { // Iter 16e: `==` is polymorphic (`forall a. (a, a) -> Bool`). // Dispatch on the resolved AIL arg type — the LLVM `ptr` shape @@ -3581,7 +2679,7 @@ impl<'a> Emitter<'a> { self.counter += 1; format!("%v{}", self.counter) } - fn fresh_id(&mut self) -> u64 { + pub(crate) fn fresh_id(&mut self) -> u64 { self.counter += 1; self.counter } diff --git a/crates/ailang-codegen/src/match_lower.rs b/crates/ailang-codegen/src/match_lower.rs new file mode 100644 index 0000000..252df2a --- /dev/null +++ b/crates/ailang-codegen/src/match_lower.rs @@ -0,0 +1,935 @@ +//! 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. + /// + /// Iter 17a: `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" + ))); + } + // Iter 13b: 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). + // Iter 15b: 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)); + } + let size_bytes = 8 + (compiled.len() * 8) as i64; + let p = self.fresh_ssa(); + // Iter 17a: 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())) + } + + /// Iter 18d.2: 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}" + ))); + } + // Iter 18d.3: 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. + // + // Iter 18d.3: 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); + // Iter 18d.3: 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" + ))); + } + // Iter 18d.3: 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 } => { + // Iter 15a: 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]); + // Iter 13b: 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. + // Iter 15b: 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; + // Iter 18d.3: 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). + // + // Iter 18d.4: 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" + )); + // Iter 18d.3: 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; + } + } + // Iter 18d.4 fix + 18g.1: scrutinee ownership signal, + // hoisted here so both the pre-tail-call shallow-dec + // (Iter 18g.1, 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, + }; + // Iter 18g.1: 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)?; + // Iter 18d.4: 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. + // + // Iter 18d.4 fix (regression + // `alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children`): + // Iter A is symmetric to Iter B (Own-param dec at fn + // return) and must share Iter B's 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 { + // Iter 18d.4 debt: pattern-binder with + // statically-recorded moved slots requires + // tag-conditional partial-drop emission. The + // 18d.3 `emit_inlined_partial_drop` helper + // assumes a static ctor (it's keyed against a + // `Term::Ctor` node), but a pattern-bound + // binder's runtime tag is dynamic. Until a + // dynamic-tag-aware partial-drop lands, fall + // back to a shallow `ailang_rc_dec` of the + // outer cell. The non-moved fields of the + // active ctor (if any) leak under this path — + // the canonical 18d.4 fixtures avoid this + // case by construction (the regression `t` + // is never re-scrutinised inside the arm, + // so its `moved_slots` entry is empty). + self.body.push_str(&format!( + " call void @ailang_rc_dec(ptr {b_ssa})\n" + )); + } + } + } + // pop bindings + for _ in 0..pushed { + self.locals.pop(); + } + // Iter 18d.3: 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); + } + // Iter 14e: 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 + // Iter 14e: 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)) + } + +}