caefcf996a
Three sibling sites previously fell back to shallow `ailang_rc_dec` when a binder had non-empty `moved_slots` and a dynamic runtime ctor tag: Iter B Own-param dec at fn-return (lib.rs), Iter A arm-close pattern-binder dec (match_lower.rs), and emit_inlined_partial_drop's non-Ctor branch (drop.rs). Shallow freed the outer cell but did not walk the unmoved ptr fields — silent leak. Adds `emit_partial_drop_fn_for_type`: a per-ADT helper structurally parallel to `emit_drop_fn_for_type` but taking an i64 moved-slots bitmask. Each ptr-field's dec is gated on the corresponding mask bit; the outer box is dec'd at join. Emitted alongside the per-type drop fn for every ADT under --alloc=rc. Three call sites rewritten to resolve the partial_drop symbol from the binder's static type and build the mask from `moved_slots`. Three RED-then-GREEN fixtures (rc_own_param_/rc_match_arm_/ rc_app_let_partial_drop_leak) pin the carve-outs in regression coverage; live cell counts go from 3/1/3 (pre-fix) to 0/0/0. e2e count: 66 → 69. The pre-existing `alloc_rc_own_param_dec_at_fn_return` IR-shape assertion is widened to accept `partial_drop_<m>_<T>(%arg_xs, i64 mask)` as a third valid drop shape (the canonical fu2 case). Closes the JOURNAL queue's only remaining iter; the "wait for organic fixture" stance from the 18g tidy is retracted — known leaks are bugs, CLAUDE.md's TDD rule covers them autonomously.
940 lines
43 KiB
Rust
940 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.
|
|
///
|
|
/// 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<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));
|
|
}
|
|
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 = <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}"
|
|
)));
|
|
}
|
|
// 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<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.
|
|
//
|
|
// 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<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"
|
|
)));
|
|
}
|
|
// 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<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 } => {
|
|
// 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<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]);
|
|
// 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<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.
|
|
// 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<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;
|
|
// 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_<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)?;
|
|
// 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_<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 {
|
|
// Iter 18g.tidy.fu2: 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();
|
|
}
|
|
// 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::<Vec<_>>()
|
|
.join(", ");
|
|
self.body.push_str(&format!(
|
|
" {phi} = phi {rt} {phi_args}\n"
|
|
));
|
|
Ok((phi, rt))
|
|
}
|
|
|
|
}
|