Iter 18c.4: per-type drop fn + recursive dec cascade

Closes 18c.3's main scope cut. Codegen now emits a per-ADT and
per-closure drop function under --alloc=rc; the Term::Let
scope-close emission routes through these drops instead of
ailang_rc_dec directly, so a recursive ADT (List, Tree) under
--alloc=rc actually frees its tail cells when the outer binder
is dec'd.

Drop-symbol scheme:

- For every Def::Type T in module m: emit one
  define void @drop_<m>_<T>(ptr %p) under --alloc=rc.
  Body: null-guard, load tag, switch on tag, per-ctor arm dec's
  every pointer-typed field via field_drop_call, then dec's the
  outer cell and rets.

- For every escaping Term::Lam: emit drop_<m>_<lam>_env(ptr) for
  the captured-free-vars block + drop_<m>_<lam>_pair(ptr) for the
  {env, fn-ptr} closure pair. A new closure_drops side table on
  Emitter keys closure-pair SSAs to their pair-drop symbol.

Decision (preempted in dispatch): always emit @drop_<m>_<T> for
every ADT, even ones with no boxed children. The body for
no-boxed-children ADTs is null-guard + dec + ret. Call sites are
uniform; future codegen changes that thread cleanup through the
drop seam (atomic dec under threading, etc.) have one canonical
entry per type.

field_drop_call dispatches on field type:
- Type::Con { name, .. } → drop_<owner>_<name> (recursion).
- Type::Str / Type::Fn / Type::Var → fall back to ailang_rc_dec.
  These three fall-backs are 18d/18e/monomorphisation debt; they
  are flagged in-source for grep.

Term::Let scope-close emission is the same as 18c.3 modulo the
target: Term::Ctor binders → drop_<owner>_<type>; Term::Lam
binders → the closure_drops-recorded pair-drop symbol. All
other emission gates from 18c.3 (consume_count == 0,
body-tail-not-binder, block-not-terminated, non-escape) are
unchanged.

Recursion is stack-recursive — drop_IntList's Cons arm calls
itself on the tail. For 18c.4's 5-element fixtures the depth is
safe; long lists are out of scope until 18e replaces the
recursive call with a worklist allocator. The recursive site is
commented for grep.

Tests:
- examples/rc_list_drop.ail.json — 5-element IntList summed via
  sum_list (xs has consume_count == 1; codegen skips the let-
  close drop call but the IR shape is locked in by the unit test
  below).
- examples/rc_list_drop_borrow.ail.json — same 5-element IntList,
  match-only consumption (consume_count == 0). Codegen emits
  drop_<m>_IntList(xs) at scope close; the cascade runs over all
  5 cells at runtime.
- crates/ail/tests/e2e.rs — alloc_rc_recursive_list_sum +
  alloc_rc_borrow_only_recursive_list_drop. Both assert
  --alloc=rc stdout matches --alloc=gc and the binaries exit
  cleanly.
- crates/ailang-codegen/src/lib.rs — unit test asserting the IR
  shape: define void @drop_rclist_IntList header, recursive
  self-call inside, outer ailang_rc_dec at the tail; negative-
  complement under --alloc=gc.

Tests: E2E 53 -> 55, codegen unit 11 -> 12. cargo test
--workspace green.
This commit is contained in:
2026-05-08 10:37:25 +02:00
parent b4cddd830e
commit 298dcaf0f7
4 changed files with 753 additions and 6 deletions
+61
View File
@@ -1379,3 +1379,64 @@ fn alloc_rc_emits_dec_for_unique_let_bound_box() {
assert_eq!(stdout_rc.trim(), "42");
assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop");
}
/// Iter 18c.4: per-type drop fns + recursive `dec` cascade. Builds a
/// 5-element `IntList` and reduces it via `sum_list`. Under
/// `--alloc=rc` the codegen emits `define void @drop_<m>_IntList`
/// whose `Cons` arm recursively calls itself on the `tail` field —
/// the first iter where a recursive ADT under RC frees its tail
/// cells when their drop fn is invoked. Stdout must match `--alloc=gc`
/// (`15`) and the binary must exit cleanly.
///
/// Note: in this fixture the binder `xs` has `consume_count == 1`
/// (passed to `sum_list`), so codegen does NOT emit a drop call at
/// `main`'s let-close — the cells stay leaked at process exit (the
/// caller / fn-param dec story is 18d). The fixture's purpose is to
/// (a) lock in the recursive-ADT IR-shape (covered in the codegen
/// IR-shape test below) and (b) confirm the program still runs
/// correctly. The companion `rc_list_drop_borrow` fixture exercises
/// the dec path itself.
#[test]
fn alloc_rc_recursive_list_sum() {
let example = "rc_list_drop.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc.trim(), "15");
assert_eq!(stdout_rc.trim(), "15");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_list_drop"
);
}
/// Iter 18c.4: actually exercise the recursive drop cascade at
/// runtime. The let-binder `xs` of a 5-element `IntList` has
/// `consume_count == 0` (only the match scrutinee, a borrow), so
/// codegen emits `call void @drop_<m>_IntList(ptr xs)` at scope
/// close. The drop fn's `Cons` arm recurses on the tail — for a
/// 5-cell list that is 5 nested calls, exactly the recursive shape
/// 18e will replace with an iterative worklist. The depth here is
/// safely below any reasonable stack limit; longer lists are out
/// of scope until 18e.
///
/// Properties guarded:
/// 1. The binary runs to completion (no segfault from a
/// mishandled recursive cascade or refcount underflow).
/// 2. Stdout matches `--alloc=gc` byte-for-byte (`11` — the head
/// of the 5-element list).
/// 3. The match arm's pattern bindings (`h`, `t`) do NOT trip
/// the dec machinery — `t` was loaded as a borrow inside the
/// arm and must not be dec'd; we rely on `lower_match`'s
/// pattern binding flow not adding a competing dec call.
#[test]
fn alloc_rc_borrow_only_recursive_list_drop() {
let example = "rc_list_drop_borrow.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc.trim(), "11");
assert_eq!(stdout_rc.trim(), "11");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on rc_list_drop_borrow"
);
}
+440 -6
View File
@@ -543,6 +543,15 @@ struct Emitter<'a> {
/// table is module-scoped because the inference is whole-fn local;
/// no cross-module entries appear.
uniqueness: UniquenessTable,
/// Iter 18c.4: per-closure-pair drop-function symbol. Keyed by the
/// closure-pair SSA value (e.g. `%v17`) the most-recent
/// `lower_lambda` call returned. Consulted by the `Term::Let`
/// lowering to emit `call void @<drop>(ptr %v17)` instead of the
/// raw `@ailang_rc_dec` when the binder owns a closure pair.
/// Empty under non-`Rc` allocators — a closure under
/// `--alloc=gc`/`--alloc=bump` has no drop fn and is freed by
/// the collector / arena.
closure_drops: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
@@ -655,6 +664,7 @@ impl<'a> Emitter<'a> {
non_escape: NonEscapeSet::new(),
alloc,
uniqueness,
closure_drops: BTreeMap::new(),
}
}
@@ -703,9 +713,194 @@ impl<'a> Emitter<'a> {
)
})?;
}
// Iter 18c.4: per-ADT drop functions. Emitted only under
// `--alloc=rc`. One `void @drop_<module>_<TypeName>(ptr)` per
// `Def::Type` in the current module — the call site for
// recursive ADTs (`drop_<m>_List` calling itself on the tail)
// requires every ADT to have a uniformly-named drop fn, so we
// emit even for ADTs with no boxed children (those drop fns
// just dec the outer box). Under `Gc`/`Bump` no drop fns
// appear — the IR shape stays byte-identical to pre-18c.4.
if matches!(self.alloc, AllocStrategy::Rc) {
for def in &self.module.defs {
if let Def::Type(td) = def {
self.emit_drop_fn_for_type(td);
}
}
}
Ok(())
}
/// Iter 18c.4: emit a `void @drop_<module>_<TypeName>(ptr %p)`
/// function that decrements the refcount of every pointer-typed
/// field of every ctor, then frees the outer box.
///
/// Shape:
/// ```text
/// define void @drop_<m>_<T>(ptr %p) {
/// %tag = load i64, ptr %p
/// switch i64 %tag, label %dflt [
/// i64 0, label %arm0
/// ...
/// ]
/// arm_i:
/// for each pointer-typed field f_j:
/// %addr = gep ptr %p, i64 (8 + 8*j)
/// %v = load ptr, ptr %addr
/// call void @drop_<owner>_<FieldT>(ptr %v) ; or @ailang_rc_dec
/// br label %join
/// dflt:
/// unreachable
/// join:
/// call void @ailang_rc_dec(ptr %p)
/// ret void
/// }
/// ```
///
/// For ADTs with no boxed children every arm is empty and falls
/// straight through to `join`, which is just the final dec — see
/// the assignment's "always emit drop_X for every ADT" decision
/// (`rc_box_drop`'s `MkBox(Int)` is the canonical example).
///
/// Recursion: when a ctor field's type is the same ADT (or any
/// ADT in the workspace), the emitted call to
/// `@drop_<owner>_<T>(field)` is recursive at the IR level and
/// will overflow the stack on long lists. Iter 18e replaces the
/// recursive call with an iterative worklist free; for 18c.4 the
/// recursion is intentional and shallow — the assignment lifts
/// the bound to "5-element list" fixtures only.
fn emit_drop_fn_for_type(&mut self, td: &TypeDef) {
let m = self.module_name;
let tname = &td.name;
let mut out = String::new();
out.push_str(&format!("define void @drop_{m}_{tname}(ptr %p) {{\n"));
out.push_str("entry:\n");
// Null guard: a null payload is a no-op (matches
// `runtime/rc.c::ailang_rc_dec`'s null guard).
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
out.push_str(" %tag = load i64, ptr %p, align 8\n");
let n_ctors = td.ctors.len();
// Switch over the tag. Each ctor gets one arm.
out.push_str(" switch i64 %tag, label %dflt [\n");
for (i, _) in td.ctors.iter().enumerate() {
out.push_str(&format!(" i64 {i}, label %arm_{i}\n"));
}
out.push_str(" ]\n");
// Per-ctor arm: iterate fields, dec the boxed ones.
let mut local = 0u64;
for (i, ctor) in td.ctors.iter().enumerate() {
out.push_str(&format!("arm_{i}:\n"));
for (j, fty) in ctor.fields.iter().enumerate() {
// Decide what to call for this field. If the field
// lowers to `ptr` (boxed), we issue a `dec` call.
// For known ADT field types we route through that
// ADT's own drop fn so the recursion cascades; for
// anything else that lowers to `ptr` (Str, fn-typed,
// unresolved Var), fall back to plain `ailang_rc_dec`.
let lty = llvm_type(fty).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %p, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(fty);
// Iter 18e replaces this recursive call with an
// iterative worklist push. For 18c.4 it stays
// recursive — fine for the shipping fixtures.
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
}
out.push_str(" br label %join\n");
}
// Default arm: unreachable when the typechecker has accepted
// the input — every legal box has one of the ctor tags.
out.push_str("dflt:\n");
if n_ctors == 0 {
// No ctors at all: a Type with zero ctors cannot be
// instantiated; the drop fn is dead. Still emit a
// br-to-join for IR validity.
out.push_str(" br label %join\n");
} else {
out.push_str(" unreachable\n");
}
// Join: free the outer box.
out.push_str("join:\n");
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n");
out.push_str("}\n\n");
self.body.push_str(&out);
}
/// Iter 18c.4: pick the drop-fn symbol to call for a single
/// pointer-typed field. Routes ADT fields to their own
/// `drop_<owner>_<T>` symbol so the recursion cascades through
/// recursive types (List, Tree). Falls back to `ailang_rc_dec`
/// for non-ADT pointer types (Str, fn-typed, unresolved Var) —
/// those are not user-defined ADTs and have no per-type drop fn.
fn field_drop_call(&self, fty: &Type) -> String {
match fty {
Type::Con { name, .. } => {
// Built-in pointer-typed cons: Str. No drop fn —
// shallow `ailang_rc_dec` is the right answer (Str
// payloads are NUL-terminated bytes in static
// memory; nothing to recurse into).
if matches!(name.as_str(), "Str") {
return "ailang_rc_dec".to_string();
}
// Qualified `module.T` → drop fn lives in `module`.
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return format!("drop_{target}_{suffix}");
}
// Fallback: treat the prefix itself as the owner
// module (typechecker would have rejected an
// unimported prefix earlier).
return format!("drop_{prefix}_{suffix}");
}
// Bare name: declared in the current module.
format!("drop_{m}_{name}", m = self.module_name)
}
// Type::Fn (closure-typed field) → no per-type drop fn
// exists for closures (each closure has its own per-pair
// drop fn keyed by the lam-id, not by type). Iter 18c.4
// shallow-frees the closure-pair; the env and any
// captured ADT fields leak. A future iter that types
// closure-typed fields with a runtime descriptor pointer
// would close this. For 18c.4's recursive-ADT story this
// is acceptable — the shipping fixtures don't store
// closures inside ADT fields.
Type::Fn { .. } => "ailang_rc_dec".to_string(),
// Type::Var (parameterised ADT field whose type-arg was
// not pinned at declaration): we don't know the field's
// concrete shape from the static decl alone, and the
// generated drop fn is a single symbol per ADT (no
// monomorphisation). Shallow free is the conservative
// choice — boxed children of polymorphic-typed fields
// leak. Iter 18d/18e will revisit this when reuse hints
// and the worklist allocator land.
Type::Var { .. } | Type::Forall { .. } => "ailang_rc_dec".to_string(),
}
}
/// Iter 12b: emit one specialised version of a polymorphic def.
/// Substitutes rigid vars in both the type and the body, then
/// calls `emit_fn` against a synthetic FnDef whose `name` already
@@ -986,6 +1181,40 @@ impl<'a> Emitter<'a> {
}
}
/// Iter 18c.4: pick the drop-fn symbol to call at the close of a
/// trackable `Term::Let` scope. For a `Term::Ctor` binder the
/// symbol is `drop_<owner>_<TypeName>` — derived from the ctor's
/// `type_name` (which already encodes the owning module via the
/// `module.T` form when cross-module). For a `Term::Lam` binder
/// the symbol comes from `closure_drops`, populated by
/// [`lower_lambda`] when it emitted the per-pair drop fn.
///
/// Falls back to `ailang_rc_dec` for any other shape — should be
/// unreachable since `is_rc_heap_allocated` only returns `true`
/// for `Term::Ctor` / `Term::Lam`, but a defensive fallback
/// keeps the IR well-formed even if the predicate ever widens.
fn drop_symbol_for_binder(&self, value: &Term, val_ssa: &str) -> String {
match value {
Term::Ctor { type_name, .. } => {
if type_name.matches('.').count() == 1 {
let (prefix, suffix) =
type_name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
return format!("drop_{target}_{suffix}");
}
return format!("drop_{prefix}_{suffix}");
}
format!("drop_{m}_{type_name}", m = self.module_name)
}
Term::Lam { .. } => self
.closure_drops
.get(val_ssa)
.cloned()
.unwrap_or_else(|| "ailang_rc_dec".to_string()),
_ => "ailang_rc_dec".to_string(),
}
}
/// Lowers a term to (SSA value string, LLVM type).
fn lower_term(&mut self, t: &Term) -> Result<(String, String)> {
match t {
@@ -1075,7 +1304,7 @@ impl<'a> Emitter<'a> {
let r = self.lower_term(body);
self.locals.pop();
// Iter 18c.3: emit `dec(name)` at scope close iff:
// Iter 18c.3: emit a drop call at scope close iff:
// - we're tracking this binder (heap RC alloc above),
// - the value type is `ptr` (not a primitive),
// - uniqueness inference recorded `consume_count == 0`
@@ -1087,10 +1316,15 @@ impl<'a> Emitter<'a> {
// - the current block is still open (a tail-call /
// `unreachable` already exited; nothing to emit).
//
// Without per-type drop fns (Iter 18c.4), `dec` is
// shallow — boxed children of the freed cell leak. The
// assignment limits 18c.3 fixtures to single-cell ADTs
// / closures whose lifecycle does not need recursion.
// Iter 18c.4: the drop call is no longer a raw
// `ailang_rc_dec`. For a `Term::Ctor` binder the call
// routes through `@drop_<owner>_<TypeName>` so any
// boxed children of the cell cascade through their
// own drop fns; for a `Term::Lam` binder the call
// routes through the per-pair drop fn the lambda
// emission recorded in `closure_drops`. Both shapes
// call `ailang_rc_dec` on the outer box internally,
// so the refcount story is unchanged.
if trackable && val_ty == "ptr" && !self.block_terminated {
let consume_count = self
.uniqueness
@@ -1102,8 +1336,9 @@ impl<'a> Emitter<'a> {
Err(_) => true, // Don't emit on error path either.
};
if consume_count == 0 && !body_returns_binder {
let drop_sym = self.drop_symbol_for_binder(value, &val_ssa);
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
" call void @{drop_sym}(ptr {val_ssa})\n"
));
}
}
@@ -2329,9 +2564,112 @@ impl<'a> Emitter<'a> {
};
self.ssa_fn_sigs.insert(clos.clone(), fs);
// Iter 18c.4: emit per-pair drop fns for heap-allocated
// closures under `--alloc=rc`. `lam_local` closures live on
// the stack and need no drop fn — LLVM `alloca` reclaims the
// pair on fn return. For heap closures we emit two symbols:
//
// - `drop_<thunk>_env(env)` — dec each pointer-typed
// capture, then dec the env block itself. ADT captures
// cascade through the per-type drop fn; closure-typed
// captures fall back to plain `ailang_rc_dec` because we
// don't keep a per-pair drop pointer alongside the env
// cell (yet). Iter 18d/18e revisit this.
//
// - `drop_<thunk>_pair(p)` — load the env from offset 8,
// call `drop_<thunk>_env(env)`, then dec the pair box.
//
// The pair drop is the symbol `Term::Let` lowering calls
// when the binder is a closure; we record it in
// `closure_drops` keyed by the closure-pair SSA.
if matches!(self.alloc, AllocStrategy::Rc) && !lam_local {
let pair_drop = format!("drop_{m}_{thunk_name}_pair", m = self.module_name);
let env_drop = format!("drop_{m}_{thunk_name}_env", m = self.module_name);
let env_text = self.build_env_drop_fn(&env_drop, &cap_meta);
self.deferred_thunks.push(env_text);
let pair_text = self.build_pair_drop_fn(&pair_drop, &env_drop, !cap_meta.is_empty());
self.deferred_thunks.push(pair_text);
self.closure_drops.insert(clos.clone(), pair_drop);
}
Ok((clos, "ptr".into()))
}
/// Iter 18c.4: build the IR text for a closure env's drop fn.
/// Layout: 8 bytes per capture, in declaration order.
/// For each pointer-typed capture, emit a load + drop call;
/// finally `ailang_rc_dec` the env block.
fn build_env_drop_fn(
&self,
sym: &str,
cap_meta: &[(String, String, String, Type, Option<FnSig>)],
) -> String {
let mut out = String::new();
out.push_str(&format!("define void @{sym}(ptr %env) {{\nentry:\n"));
out.push_str(" %is_null = icmp eq ptr %env, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
let mut local = 0u64;
for (i, (_cname, _outer_ssa, lty, ail_ty, _sig)) in cap_meta.iter().enumerate() {
if lty != "ptr" {
continue;
}
let off = (i as i64) * 8;
let addr_id = local;
local += 1;
let val_id = local;
local += 1;
out.push_str(&format!(
" %a{addr_id} = getelementptr inbounds i8, ptr %env, i64 {off}\n"
));
out.push_str(&format!(
" %v{val_id} = load ptr, ptr %a{addr_id}, align 8\n"
));
let drop_call = self.field_drop_call(ail_ty);
out.push_str(&format!(
" call void @{drop_call}(ptr %v{val_id})\n"
));
}
out.push_str(" call void @ailang_rc_dec(ptr %env)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n}\n\n");
out
}
/// Iter 18c.4: build the IR text for a closure pair's drop fn.
/// Layout: { ptr thunk, ptr env } — env at offset 8.
/// Loads env, calls the env drop, then decs the pair box.
fn build_pair_drop_fn(
&self,
sym: &str,
env_drop: &str,
has_env: bool,
) -> String {
let mut out = String::new();
out.push_str(&format!("define void @{sym}(ptr %p) {{\nentry:\n"));
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
if has_env {
out.push_str(
" %ea = getelementptr inbounds {{ ptr, ptr }}, ptr %p, i64 0, i32 1\n",
);
out.push_str(" %env = load ptr, ptr %ea, align 8\n");
out.push_str(&format!(" call void @{env_drop}(ptr %env)\n"));
} else {
// No env — the env-drop is still emitted (uniform shape)
// but is a no-op on null. Skip the load and call dec
// directly on the pair.
let _ = env_drop;
}
out.push_str(" call void @ailang_rc_dec(ptr %p)\n");
out.push_str(" br label %ret\n");
out.push_str("ret:\n");
out.push_str(" ret void\n}\n\n");
out
}
/// Iter 8b: walk a Term collecting free-variable names that should
/// be captured by an enclosing lambda. Builtins and top-level fns
/// are excluded — they're globally accessible. Qualified names
@@ -3651,4 +3989,100 @@ mod tests {
other => panic!("expected MissingEntryMain, got {other:?}"),
}
}
/// Iter 18c.4: under `--alloc=rc`, codegen emits one
/// `define void @drop_<m>_<T>` per `Def::Type`. For a recursive
/// ADT — a `IntList` with `Cons(Int, IntList)` — the `Cons` arm
/// of the drop fn loads the tail field and calls the ADT's own
/// drop fn on it (the recursion 18e replaces with an iterative
/// worklist). We assert both shapes here:
///
/// 1. `define void @drop_<m>_IntList(ptr %p)` is present.
/// 2. The fn's body contains a self-recursive call
/// `call void @drop_<m>_IntList(ptr %v...)` — proof that
/// the `Cons` arm walked the tail field rather than just
/// decrementing the outer cell.
///
/// The `Nil` arm has no boxed children and is a `br` to the
/// shared `join` block — implicit in (1).
///
/// Negative complement: under `--alloc=gc` no drop fn is
/// emitted; the IR shape stays byte-identical to the pre-18c.4
/// pipeline.
#[test]
fn rc_alloc_emits_recursive_drop_fn_for_recursive_adt() {
let m = Module {
schema: SCHEMA.into(),
name: "rclist".into(),
imports: vec![],
defs: vec![
Def::Type(TypeDef {
name: "IntList".into(),
vars: vec![],
ctors: vec![
Ctor {
name: "Nil".into(),
fields: vec![],
},
Ctor {
name: "Cons".into(),
fields: vec![
Type::int(),
Type::Con {
name: "IntList".into(),
args: vec![],
},
],
},
],
doc: None,
}),
Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
doc: None,
}),
],
};
let ws = Workspace {
entry: m.name.clone(),
modules: {
let mut x = BTreeMap::new();
x.insert(m.name.clone(), m.clone());
x
},
root_dir: std::path::PathBuf::from("."),
};
let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap();
assert!(
ir_rc.contains("define void @drop_rclist_IntList(ptr %p)"),
"rc IR missing per-type drop fn header. IR was:\n{ir_rc}"
);
// The Cons arm loads the tail field and recurses through
// the same drop symbol — proof that the cascade is wired.
assert!(
ir_rc.contains("call void @drop_rclist_IntList(ptr %v"),
"rc IR missing recursive drop call inside drop_rclist_IntList. IR was:\n{ir_rc}"
);
// The drop fn finishes by dec'ing the outer box.
assert!(
ir_rc.contains("call void @ailang_rc_dec(ptr %p)"),
"rc IR missing outer-box dec inside drop_rclist_IntList. IR was:\n{ir_rc}"
);
// Negative complement: no drop fns under `--alloc=gc`.
let ir_gc = lower_workspace_with_alloc(&ws, AllocStrategy::Gc).unwrap();
assert!(
!ir_gc.contains("@drop_rclist_IntList"),
"gc IR should not declare/define any per-type drop fn. IR was:\n{ir_gc}"
);
}
}
+139
View File
@@ -0,0 +1,139 @@
{
"schema": "ailang/v0",
"name": "rc_list_drop",
"imports": [],
"defs": [
{
"kind": "type",
"name": "IntList",
"doc": "Recursive Int list. Iter 18c.4 RC fixture: codegen emits `drop_rc_list_drop_IntList` whose `Cons` arm recursively calls itself on the tail field.",
"ctors": [
{ "name": "Nil", "fields": [] },
{
"name": "Cons",
"fields": [
{ "k": "con", "name": "Int" },
{ "k": "con", "name": "IntList" }
]
}
]
},
{
"kind": "fn",
"name": "sum_list",
"type": {
"k": "fn",
"params": [{ "k": "con", "name": "IntList" }],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["xs"],
"doc": "Recursive fold over IntList.",
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "xs" },
"arms": [
{
"pat": { "p": "ctor", "ctor": "Nil", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
},
{
"pat": {
"p": "ctor",
"ctor": "Cons",
"fields": [
{ "p": "var", "name": "h" },
{ "p": "var", "name": "t" }
]
},
"body": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "var", "name": "h" },
{
"t": "app",
"fn": { "t": "var", "name": "sum_list" },
"args": [{ "t": "var", "name": "t" }]
}
]
}
}
]
}
},
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"doc": "Build a 5-element IntList [1,2,3,4,5] and print its sum (15). Under --alloc=rc the recursive drop_rc_list_drop_IntList fn cascades through the tail at process exit when sum_list returns ownership-implicit (the consume_count of `xs` is 1 so no dec at the outer let; the test's correctness invariant is the byte-identical stdout — leak quantification is 18f's bench).",
"body": {
"t": "let",
"name": "xs",
"value": {
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 1 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 2 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 3 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 4 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 5 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Nil",
"args": []
}
]
}
]
}
]
}
]
}
]
},
"body": {
"t": "do",
"op": "io/print_int",
"args": [
{
"t": "app",
"fn": { "t": "var", "name": "sum_list" },
"args": [{ "t": "var", "name": "xs" }]
}
]
}
}
}
]
}
+113
View File
@@ -0,0 +1,113 @@
{
"schema": "ailang/v0",
"name": "rc_list_drop_borrow",
"imports": [],
"defs": [
{
"kind": "type",
"name": "IntList",
"doc": "Recursive Int list. Iter 18c.4 RC fixture: codegen emits a recursive drop fn; this fixture keeps the binder consume_count at 0 so the drop call actually fires at scope close, exercising the recursive cascade at runtime over a 5-element list.",
"ctors": [
{ "name": "Nil", "fields": [] },
{
"name": "Cons",
"fields": [
{ "k": "con", "name": "Int" },
{ "k": "con", "name": "IntList" }
]
}
]
},
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"doc": "Build a 5-element IntList and print only its head via match. xs has consume_count == 0 (only the match scrutinee, a borrow), so under --alloc=rc the let scope close emits `drop_rc_list_drop_borrow_IntList(xs)` which recursively dec-cascades over the whole 5-cell chain.",
"body": {
"t": "let",
"name": "xs",
"value": {
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 11 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 22 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 33 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 44 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Cons",
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 55 } },
{
"t": "ctor",
"type": "IntList",
"ctor": "Nil",
"args": []
}
]
}
]
}
]
}
]
}
]
},
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "xs" },
"arms": [
{
"pat": { "p": "ctor", "ctor": "Nil", "fields": [] },
"body": {
"t": "do",
"op": "io/print_int",
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 0 } }]
}
},
{
"pat": {
"p": "ctor",
"ctor": "Cons",
"fields": [
{ "p": "var", "name": "h" },
{ "p": "var", "name": "t" }
]
},
"body": {
"t": "do",
"op": "io/print_int",
"args": [{ "t": "var", "name": "h" }]
}
}
]
}
}
}
]
}