Files
AILang/crates/ailang-codegen/src/match_lower.rs
T
Brummel ac4d545570 source: scrub iter-code / Decision-N residue from inline comments
Follow-up to bcd4181: the remaining ~530 inline `//` and `///`
comments still carrying opaque shorthand are now reformulated to
their content phrases. The only surviving `iter-<code>` reference
in source is the literal filename
`docs/journals/2026-05-13-iter-mq.3.md` (a real journal file).

Sweep covered:
  - `// Iter X.Y: <text>` prefixes (Iter 13a / 14a / 14e / 15g-aux /
    16b.x / 16d / 16e / 18b / 18c.x / 18d.x / 18e / 18g.x / 19a /
    19a.1 / 19b / 20a / 20f / 22-floats.x / 22b.x / 22c / 23.x /
    24.1 / cli-diag-human / hs.x / str-concat / etc.) — fully
    removed; the descriptive text that followed each prefix stays.
  - `// (Decision N)` and `per Decision N` and `Decision N axis 3` —
    replaced with the content phrase plus the relevant contract
    file (`design/contracts/tail-calls.md` for Decision 8,
    `design/contracts/memory-model.md` for Decision 10,
    `design/contracts/typeclasses.md` and `design/models/typeclasses.md`
    for Decision 11, `design/contracts/authoring-surface.md` for
    Decision 6, "the transitional dual-allocator" for Decision 9,
    "Effect prose" for Decision 3).
  - `// mq.X / mq.X (Task N) / mq.X journal / mq.X invariant` ->
    "the canonical-class-form rule / Class-class repurpose /
    method-dispatch-refactor journal / canonical-class-form
    invariant".
  - `// ct.X / ct.X (canonical-type-names) / ct.1.5a + ctt.2 /
    ct.2 Task N` -> "the canonical-form rule for type references /
    the canonical-form normalisation step / canonical-type-lookup
    refactor".
  - `// eob.X` -> "heap-Str-ABI" / "the Str carve-out".
  - `// rpe.X` -> "the per-type-print-op retirement".
  - `// post-mq.X / Pre-ct.X / pre-mq.X` -> "post-canonical-class-form" /
    "Pre-canonical-type-form" etc.
  - `/// Iter X regression: / /// Iter X.Y: / /// Iter A arm-close /
    /// Iter ct.4 (...): / /// Iter rpe.1 ...` -> descriptive
    phrases.

The journal filename in `crates/ailang-core/src/workspace.rs:573`
stays verbatim because it points at an actual file under
`docs/journals/`.

Tests: full `cargo test --workspace` green (80/80 test-result blocks
clean, no FAILED line). design_index_pin 5/5 + docs_honesty_pin 5/5
gating tests pass.
2026-05-20 09:58:04 +02:00

942 lines
43 KiB
Rust

//! 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<Type> = 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<String> = if cref.type_vars.is_empty() {
cref.fields.clone()
} else {
let arg_ail_tys: Vec<Type> = args
.iter()
.map(|a| self.synth_arg_type(a))
.collect::<Result<_>>()?;
let var_set: BTreeSet<&str> =
cref.type_vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = 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::<Result<_>>()?
};
// 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 = <source's SSA>
/// %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_<field-T>(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_<m>_<source-type>(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<String> = 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<Type> = 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<String> = if cref.type_vars.is_empty() {
cref.fields.clone()
} else {
let arg_ail_tys: Vec<Type> = body_args
.iter()
.map(|a| self.synth_arg_type(a))
.collect::<Result<_>>()?;
let var_set: BTreeSet<&str> =
cref.type_vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = 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::<Result<_>>()?
};
// (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 `<source>` 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<usize> = 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<String> = 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<Option<String>>)> = Vec::new();
let mut open_arm: Option<&Arm> = None;
let mut open_var: Option<String> = 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<Option<String>> = 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<String> = 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<String> = 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<String, Type> = 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<String> = 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_<m>_<T>`) 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_<owner>_<T>`
// 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_<owner>_<T>(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::<Vec<_>>()
.join(", ");
self.body.push_str(&format!(
" {phi} = phi {rt} {phi_args}\n"
));
Ok((phi, rt))
}
}