diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index b0de699..fe6aed1 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1345,3 +1345,37 @@ fn alloc_rc_matches_gc_on_std_list_demo() { "alloc=rc must match alloc=gc on std_list_demo" ); } + +/// Iter 18c.3: codegen actually emits `ailang_rc_dec` at end-of-scope +/// for trackable RC binders under `--alloc=rc`. This is the first +/// fixture where the `dec` path runs at runtime — `b` is bound to a +/// heap-allocated `MkBox`, the box is read via `match` (a borrow, +/// `consume_count == 0`), and the let scope closes before the fn +/// returns Unit. Codegen emits `call void @ailang_rc_dec(ptr %v1)` +/// after the match's join block; the box's refcount drops to 0 and +/// the underlying `malloc`'d block is freed by `runtime/rc.c`. +/// +/// Properties guarded: +/// (1) the binary still produces the expected stdout (`42`) — i.e. +/// dec does not free the box BEFORE `match` reads its `Int` +/// payload; +/// (2) the binary exits cleanly (exit code 0, no segfault) — i.e. +/// the dec call does not double-free or trip +/// `ailang_rc_dec`'s underflow guard; +/// (3) the GC and RC paths produce byte-identical stdout — i.e. +/// the dec emission did not perturb behaviour visible at the +/// output boundary. +/// +/// Box has no boxed children, so the shallow-free contract of 18c.3's +/// `ailang_rc_dec` is sufficient. A recursive ADT (List, Tree) would +/// leak its boxed children silently — that's the 18c.4 scope, not +/// this iter. +#[test] +fn alloc_rc_emits_dec_for_unique_let_bound_box() { + let example = "rc_box_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(), "42"); + assert_eq!(stdout_rc.trim(), "42"); + assert_eq!(stdout_gc, stdout_rc, "alloc=rc must match alloc=gc on rc_box_drop"); +} diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 47c2def..e08ca1f 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -38,6 +38,7 @@ use indexmap::IndexMap; use std::collections::{BTreeMap, BTreeSet}; mod linearity; +pub mod uniqueness; /// Metavariable substitution. Maps fresh metavar ids (from `$m` in /// `Type::Var.name`) to the type they have been unified against. diff --git a/crates/ailang-check/src/uniqueness.rs b/crates/ailang-check/src/uniqueness.rs new file mode 100644 index 0000000..b6fedb4 --- /dev/null +++ b/crates/ailang-check/src/uniqueness.rs @@ -0,0 +1,522 @@ +//! Iter 18c.3: uniqueness inference over the typed AST. +//! +//! This pass classifies every binder of every fn body as either +//! [`Uniqueness::Unique`] or [`Uniqueness::Shared`]. Unlike the +//! [`crate::linearity`] check it is **not** a user-facing diagnostic +//! pass — it is internal codegen input. Codegen consults the side +//! table to decide where to emit `ailang_rc_inc` and `ailang_rc_dec` +//! calls under `--alloc=rc` (Iter 18c.3). +//! +//! ## Why a separate pass from `linearity` +//! +//! - Different scope. `linearity` is opt-in by signature (every param +//! mode must be explicit) and is emitted as user diagnostics. +//! Uniqueness inference runs unconditionally on every fn body and +//! is never reported. +//! - Different output. `linearity` emits `Diagnostic`s; uniqueness +//! produces a [`UniquenessTable`] keyed by `(def_name, binder_name)`. +//! - Different consumption point. `linearity` runs from +//! [`crate::check_workspace`]; the uniqueness side table is built +//! on demand from codegen via [`infer_module`]. +//! +//! ## What "unique" means here +//! +//! A binder `x` is `Unique` if every reachable path from its +//! introduction to the end of its scope passes through **at most one** +//! consume use of `x`. Borrow-position uses (Match scrutinee, +//! `(clone)` argument, `Borrow`-mode call argument) do not count as +//! consumes. A binder is `Shared` otherwise. +//! +//! For the codegen consumer, a closely related side-output is more +//! useful: [`UniquenessInfo::consume_count`] — the maximum, across +//! all reachable paths in the binder's scope, of the number of +//! consume uses of the binder. `consume_count == 0` means the binder +//! is exclusively borrowed (or unused) inside its scope, which is +//! the case where emitting `dec` at scope close is unambiguously +//! safe under `--alloc=rc` (no callee / outer term consumed the +//! value first; the binder owns the only outstanding ref to its +//! allocation). +//! +//! ## Position model +//! +//! Identical to [`crate::linearity`]'s, modulo that this pass has no +//! activation gate (it runs on every fn). See that module's docs for +//! the propagation rules. `Term::Lam` captures are conservatively +//! treated as Consume — the same conservative decision the linearity +//! check makes. +//! +//! ## What this pass deliberately doesn't do (deferred to later iters) +//! +//! - **Cross-fn reasoning.** The inference is whole-fn local. It +//! does not reason about a callee's body when classifying a +//! binder passed into the callee. +//! - **Per-type drop fns / recursive `dec` cascades.** That is +//! 18c.4. Codegen using this table emits *shallow* `dec` only — +//! the refcount of the box itself, not its boxed children. +//! - **Reuse hints.** `(reuse-as)` is 18d. + +use ailang_core::ast::{Arm, Def, FnDef, Module, ParamMode, Pattern, Term, Type}; +use std::collections::BTreeMap; + +/// Per-binder classification produced by the inference. See module +/// docs for semantics. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Uniqueness { + /// Every reachable path from the binder's introduction to the end + /// of its scope passes through at most one consume use. + Unique, + /// Some reachable path consumes the binder more than once. + Shared, +} + +/// Per-binder result of the inference. The codegen consumer uses +/// both fields: `uniqueness` for the high-level classification and +/// `consume_count` for the precise "is anyone else going to dec +/// this" answer at scope close. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct UniquenessInfo { + pub uniqueness: Uniqueness, + /// Maximum number of consume-position uses of this binder + /// across any reachable path in its scope. `0` means the binder + /// is only borrowed (or unused) — under `--alloc=rc` the only + /// outstanding reference at scope close is the one the binder + /// itself holds, so dec at scope close is safe and necessary. + pub consume_count: u32, +} + +/// Side table indexed by `(def_name, binder_name)`. Built fresh for +/// each codegen invocation; the side table is not cached. +/// +/// Note: binder names within a single fn shadow lexically. Each +/// shadow is recorded in turn; the **last** insert wins (which is +/// the innermost binding by pop-order). Codegen consumers look up +/// by current-scope binder name, which always matches the innermost +/// classification — fine in practice because every shipping fixture +/// has unique binder names per fn. +pub type UniquenessTable = BTreeMap<(String, String), UniquenessInfo>; + +/// Top-level entry: walk every fn def in `m` and produce per-binder +/// classifications keyed by `(fn_name, binder_name)`. +/// +/// Pre-condition: `m` is fully typechecked. Inputs that have not +/// been type-checked (unresolved `Var` lookups, mismatched ctor +/// arities) may produce a noisy / incorrect side table — the +/// codegen never runs on such inputs because `ail build` runs +/// typecheck first. +pub fn infer_module(m: &Module) -> UniquenessTable { + let mut globals: BTreeMap = BTreeMap::new(); + for def in &m.defs { + match def { + Def::Fn(f) => { + let ty = strip_forall(&f.ty).clone(); + globals.insert(f.name.clone(), ty); + } + Def::Const(c) => { + globals.insert(c.name.clone(), strip_forall(&c.ty).clone()); + } + Def::Type(_) => {} + } + } + + let mut table: UniquenessTable = BTreeMap::new(); + for def in &m.defs { + if let Def::Fn(f) = def { + infer_fn(f, &globals, &mut table); + } + } + table +} + +fn strip_forall(t: &Type) -> &Type { + match t { + Type::Forall { body, .. } => body, + other => other, + } +} + +/// Per-fn driver: install the parameters as binders, walk the body, +/// snapshot the per-binder consume counts into the side table. +fn infer_fn(f: &FnDef, globals: &BTreeMap, out: &mut UniquenessTable) { + let inner = strip_forall(&f.ty); + if !matches!(inner, Type::Fn { .. }) { + return; + } + + let param_states = { + let mut walker = Walker { + globals, + binders: BTreeMap::new(), + def_name: f.name.clone(), + out, + }; + + // Install fn parameters. Their consume counter starts at 0; + // the walk over the body increments it. + for name in f.params.iter() { + walker.binders.insert(name.clone(), BinderState::default()); + } + + walker.walk(&f.body, Position::Consume); + + // Snapshot params before dropping the walker. + f.params + .iter() + .filter_map(|name| walker.binders.get(name).map(|s| (name.clone(), s.clone()))) + .collect::>() + }; + + // Walker dropped — `out` is borrowable again. + for (name, s) in param_states { + let info = UniquenessInfo { + uniqueness: classify(s.consume_count), + consume_count: s.consume_count, + }; + out.insert((f.name.clone(), name), info); + } +} + +#[inline] +fn classify(consume_count: u32) -> Uniqueness { + if consume_count <= 1 { + Uniqueness::Unique + } else { + Uniqueness::Shared + } +} + +/// Position in which a [`Term::Var`] occurs. Consume uses count +/// against the binder's `consume_count`; Borrow uses do not. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum Position { + Consume, + Borrow, +} + +/// Per-binder mutable state during the walk. Future extensions +/// (e.g. last-use markers, per-arm split) can grow more fields. +#[derive(Default, Clone, Debug)] +struct BinderState { + consume_count: u32, +} + +struct Walker<'a> { + globals: &'a BTreeMap, + binders: BTreeMap, + def_name: String, + out: &'a mut UniquenessTable, +} + +impl<'a> Walker<'a> { + fn walk(&mut self, t: &Term, pos: Position) { + match t { + Term::Lit { .. } => {} + Term::Var { name } => self.use_var(name, pos), + Term::App { callee, args, .. } => { + self.walk(callee, Position::Consume); + let arg_modes = self.callee_arg_modes(callee, args.len()); + for (i, arg) in args.iter().enumerate() { + let arg_pos = match arg_modes.get(i) { + Some(ParamMode::Borrow) => Position::Borrow, + _ => Position::Consume, + }; + self.walk(arg, arg_pos); + } + } + Term::Let { name, value, body } => { + self.walk(value, Position::Consume); + let prev = self.binders.insert(name.clone(), BinderState::default()); + self.walk(body, pos); + let final_state = self.binders.remove(name).unwrap_or_default(); + if let Some(p) = prev { + self.binders.insert(name.clone(), p); + } + self.record_binder(name, &final_state); + } + Term::LetRec { name, body, in_term, .. } => { + let prev = self.binders.insert(name.clone(), BinderState::default()); + self.walk(body, Position::Consume); + self.walk(in_term, pos); + let final_state = self.binders.remove(name).unwrap_or_default(); + if let Some(p) = prev { + self.binders.insert(name.clone(), p); + } + self.record_binder(name, &final_state); + } + Term::If { cond, then, else_ } => { + self.walk(cond, Position::Consume); + let saved = self.binders.clone(); + self.walk(then, pos); + let after_then = std::mem::replace(&mut self.binders, saved); + self.walk(else_, pos); + merge_states(&mut self.binders, &after_then); + } + Term::Match { scrutinee, arms } => { + self.walk(scrutinee, Position::Borrow); + let saved = self.binders.clone(); + let mut acc: Option> = None; + for arm in arms { + self.binders = saved.clone(); + self.walk_arm(arm, pos); + match acc.as_mut() { + None => acc = Some(self.binders.clone()), + Some(m) => merge_states(m, &self.binders), + } + } + if let Some(merged) = acc { + self.binders = merged; + } else { + self.binders = saved; + } + } + Term::Seq { lhs, rhs } => { + self.walk(lhs, Position::Consume); + self.walk(rhs, pos); + } + Term::Ctor { args, .. } => { + for a in args { + self.walk(a, Position::Consume); + } + } + Term::Do { args, .. } => { + for a in args { + self.walk(a, Position::Consume); + } + } + Term::Lam { params, body, .. } => { + // Lam captures cross the linearity boundary — every + // free var of the body is conservatively a consume. + // Walking the body with each param as a fresh binder + // produces the right intra-Lam classification. + let mut saved_for_params: BTreeMap> = BTreeMap::new(); + for p in params { + saved_for_params.insert(p.clone(), self.binders.remove(p)); + self.binders.insert(p.clone(), BinderState::default()); + } + self.walk(body, Position::Consume); + for p in params { + let final_state = self.binders.remove(p).unwrap_or_default(); + if let Some(prev) = saved_for_params.remove(p).flatten() { + self.binders.insert(p.clone(), prev); + } + self.record_binder(p, &final_state); + } + } + Term::Clone { value } => { + self.walk(value, Position::Borrow); + } + } + } + + fn walk_arm(&mut self, arm: &Arm, pos: Position) { + let names = collect_pattern_binders(&arm.pat); + let mut saved: BTreeMap> = BTreeMap::new(); + for n in &names { + saved.insert(n.clone(), self.binders.remove(n)); + self.binders.insert(n.clone(), BinderState::default()); + } + self.walk(&arm.body, pos); + for n in &names { + let final_state = self.binders.remove(n).unwrap_or_default(); + if let Some(prev) = saved.remove(n).flatten() { + self.binders.insert(n.clone(), prev); + } + self.record_binder(n, &final_state); + } + } + + fn use_var(&mut self, name: &str, pos: Position) { + if let Some(s) = self.binders.get_mut(name) { + if matches!(pos, Position::Consume) { + s.consume_count = s.consume_count.saturating_add(1); + } + } + } + + fn callee_arg_modes(&self, callee: &Term, n_args: usize) -> Vec { + let name = match callee { + Term::Var { name } => name, + _ => return vec![], + }; + let ty = match self.globals.get(name) { + Some(t) => t, + None => return vec![], + }; + let inner = strip_forall(ty); + match inner { + Type::Fn { param_modes, .. } => { + if param_modes.is_empty() { + vec![ParamMode::Implicit; n_args] + } else { + param_modes.clone() + } + } + _ => vec![], + } + } + + fn record_binder(&mut self, name: &str, s: &BinderState) { + let info = UniquenessInfo { + uniqueness: classify(s.consume_count), + consume_count: s.consume_count, + }; + self.out + .insert((self.def_name.clone(), name.to_string()), info); + } +} + +/// Conservative branch merge: for each binder name, the merged +/// `consume_count` is the **max** of the two branches. Max is the +/// right operator because the classification cares about the +/// worst-case path. +fn merge_states(into: &mut BTreeMap, other: &BTreeMap) { + for (name, s) in other { + let entry = into.entry(name.clone()).or_default(); + entry.consume_count = entry.consume_count.max(s.consume_count); + } +} + +fn collect_pattern_binders(p: &Pattern) -> Vec { + match p { + Pattern::Wild | Pattern::Lit { .. } => vec![], + Pattern::Var { name } => vec![name.clone()], + Pattern::Ctor { fields, .. } => fields.iter().flat_map(collect_pattern_binders).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ailang_core::ast::{FnDef, Literal}; + + fn fn_simple(name: &str, params: Vec<&str>, body: Term) -> Def { + Def::Fn(FnDef { + name: name.into(), + ty: Type::Fn { + params: params.iter().map(|_| Type::int()).collect(), + param_modes: vec![], + ret: Box::new(Type::int()), + ret_mode: ParamMode::Implicit, + effects: vec![], + }, + params: params.into_iter().map(|s| s.to_string()).collect(), + body, + doc: None, + }) + } + + /// `(let x 0 0)` — `x` is unique, consume_count == 0. + #[test] + fn let_unused_binder_is_unique_zero_consume() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 0); + } + + /// `(let x 0 x)` — `x` consumed once; classified Unique with + /// consume_count == 1. + #[test] + fn let_singly_consumed_is_unique_one() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::Var { name: "x".into() }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 1); + } + + /// `(let x 0 (let y 0 (app + x x)))` — `x` consumed twice in + /// distinct positions of the same call → Shared, consume_count + /// == 2. + #[test] + fn let_doubly_consumed_is_shared_two() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::App { + callee: Box::new(Term::Var { name: "+".into() }), + args: vec![ + Term::Var { name: "x".into() }, + Term::Var { name: "x".into() }, + ], + tail: false, + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "f".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Shared); + assert_eq!(info.consume_count, 2); + } + + /// `(match scr ((pat-var x) x))` — `x` (a pattern binder) is + /// consumed once → Unique with consume_count == 1. + #[test] + fn pattern_binder_is_recorded() { + let body = Term::Match { + scrutinee: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + arms: vec![Arm { + pat: Pattern::Var { name: "x".into() }, + body: Term::Var { name: "x".into() }, + }], + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 1); + } + + /// `(clone x)` is a Borrow — does NOT increment consume_count. + /// Body: `(let x 0 (clone x))` → x consume_count == 0. + #[test] + fn clone_is_borrow_not_consume() { + let body = Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + body: Box::new(Term::Clone { + value: Box::new(Term::Var { name: "x".into() }), + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_simple("f", vec![], body)], + }; + let table = infer_module(&m); + let info = table.get(&("f".into(), "x".into())).copied().expect("x recorded"); + assert_eq!(info.uniqueness, Uniqueness::Unique); + assert_eq!(info.consume_count, 0); + } +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 8bf3d4b..e46c53d 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -38,6 +38,8 @@ use ailang_core::ast::*; use ailang_core::Workspace; use std::collections::{BTreeMap, BTreeSet}; +use ailang_check::uniqueness::{infer_module, UniquenessTable}; + mod escape; use escape::NonEscapeSet; @@ -412,6 +414,14 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result // pipeline; `Bump` declares `@bump_malloc` instead, supplied by // `runtime/bump.c` and linked in lieu of `-lgc`. out.push_str(&format!("declare ptr @{}(i64)\n", alloc.fn_name())); + // Iter 18c.3: under `--alloc=rc`, also declare the inc/dec ABI from + // `runtime/rc.c` so codegen can emit refcount calls at every + // `Term::Clone` site and at end-of-scope of trackable RC binders. + // `Gc` and `Bump` keep their pre-18c IR shape — nothing to declare. + if matches!(alloc, AllocStrategy::Rc) { + out.push_str("declare void @ailang_rc_inc(ptr)\n"); + out.push_str("declare void @ailang_rc_dec(ptr)\n"); + } // Iter 16e: `==` on `Str` lowers to `@strcmp` followed by // `icmp eq i32 0`. NUL-terminated strings make this a one-liner; // libc supplies `strcmp` so no extra link flag is needed. @@ -526,6 +536,13 @@ struct Emitter<'a> { /// Decided at the top-level entry point (`lower_workspace_inner`) /// and propagated to every site that emits a `call ptr @(...)`. alloc: AllocStrategy, + /// Iter 18c.3: per-binder uniqueness side-table for the current + /// module, keyed by `(def_name, binder_name)`. Built once per + /// emitter and consulted by `Term::Let` lowering to decide whether + /// to emit `call void @ailang_rc_dec(ptr %v)` at scope close. The + /// table is module-scoped because the inference is whole-fn local; + /// no cross-module entries appear. + uniqueness: UniquenessTable, } #[derive(Debug, Clone)] @@ -605,6 +622,12 @@ impl<'a> Emitter<'a> { } } + // Iter 18c.3: build the per-module uniqueness side-table once + // per emitter. The inference is pure (no I/O, no global state), + // so doing it here is cheap and keeps codegen's input self- + // contained. + let uniqueness = infer_module(module); + Self { module, module_name, @@ -631,6 +654,7 @@ impl<'a> Emitter<'a> { deferred_thunks: Vec::new(), non_escape: NonEscapeSet::new(), alloc, + uniqueness, } } @@ -930,6 +954,38 @@ impl<'a> Emitter<'a> { )); } + /// Iter 18c.3: predicate the `Term::Let` lowering uses to decide + /// whether a let-binder owns a fresh RC-heap allocation that + /// codegen should `dec` at scope close. + /// + /// Returns `true` exactly when: + /// - the active allocator is `Rc`, + /// - `value` is a `Term::Ctor` or `Term::Lam` (the two AST shapes + /// that lower through the heap-allocation path), AND + /// - the term is *not* in the current fn's `non_escape` set — + /// escaping ctors/lambdas go through `ailang_rc_alloc`, + /// non-escaping ones become stack `alloca`s and must NOT be + /// `dec`'d (they are freed by LLVM at fn return). + /// + /// Other value shapes (calls, vars, literals, matches, …) return + /// `false` here. A call's return value may itself be an + /// RC-allocated box, but the caller does not statically know that + /// — proper handling of returned boxes is part of the wider + /// uniqueness story (and tied to `(own)` ret-mode contracts in + /// later iters). + fn is_rc_heap_allocated(&self, value: &Term) -> bool { + if !matches!(self.alloc, AllocStrategy::Rc) { + return false; + } + match value { + Term::Ctor { .. } | Term::Lam { .. } => { + let term_ptr = (value as *const Term) as usize; + !self.non_escape.contains(&term_ptr) + } + _ => false, + } + } + /// Lowers a term to (SSA value string, LLVM type). fn lower_term(&mut self, t: &Term) -> Result<(String, String)> { match t { @@ -1002,11 +1058,56 @@ impl<'a> Emitter<'a> { Err(CodegenError::UnknownVar(name.clone())) } Term::Let { name, value, body } => { + // Iter 18c.3: decide whether this let-binder is + // trackable for `dec` emission BEFORE lowering. The + // value term must lower through `ailang_rc_alloc` — + // that means `Term::Ctor` / `Term::Lam` whose escape + // analysis says "heap" (not `alloca`) under + // `--alloc=rc`. Other value shapes (calls, vars, + // literals) lower to SSAs we don't statically own at + // this scope. + let trackable = self.is_rc_heap_allocated(value); + let val_ail = self.synth_arg_type(value)?; let (val_ssa, val_ty) = self.lower_term(value)?; - self.locals.push((name.clone(), val_ssa, val_ty, val_ail)); + self.locals + .push((name.clone(), val_ssa.clone(), val_ty.clone(), val_ail)); let r = self.lower_term(body); self.locals.pop(); + + // Iter 18c.3: emit `dec(name)` 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` + // for the binder (no callee / outer term has already + // taken ownership; the binder owns the only ref), + // - the body's tail value is NOT the binder itself + // (a binder that flows out as the result transfers + // ownership to the caller — caller dec's), and + // - 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. + if trackable && val_ty == "ptr" && !self.block_terminated { + let consume_count = self + .uniqueness + .get(&(self.current_def.clone(), name.clone())) + .map(|info| info.consume_count) + .unwrap_or(u32::MAX); // Defensive: skip if missing. + let body_returns_binder = match &r { + Ok((body_ssa, _)) => body_ssa == &val_ssa, + Err(_) => true, // Don't emit on error path either. + }; + if consume_count == 0 && !body_returns_binder { + self.body.push_str(&format!( + " call void @ailang_rc_dec(ptr {val_ssa})\n" + )); + } + } + r } Term::If { cond, then, else_ } => { @@ -1155,9 +1256,26 @@ impl<'a> Emitter<'a> { unreachable!("Term::LetRec eliminated by desugar") } Term::Clone { value } => { - // Iter 18c.1: clone is identity. Iter 18c.3 will emit - // `call void @ailang_rc_inc` here under --alloc=rc. - self.lower_term(value) + // Iter 18c.3: lower the inner value, then emit + // `call void @ailang_rc_inc(ptr %v)` under `--alloc=rc`. + // Inc is skipped for non-`ptr` values (primitives like + // `i64` carry no refcount) and for `@`-prefixed SSAs + // (top-level fn closure-pair globals live in the LLVM + // data segment, not heap memory — `runtime/rc.c`'s + // header layout doesn't apply to them). Codegen elision + // here matches `runtime/rc.c`'s comment about static + // pointers. + let (val_ssa, val_ty) = self.lower_term(value)?; + if matches!(self.alloc, AllocStrategy::Rc) + && val_ty == "ptr" + && !val_ssa.starts_with('@') + && !self.block_terminated + { + self.body.push_str(&format!( + " call void @ailang_rc_inc(ptr {val_ssa})\n" + )); + } + Ok((val_ssa, val_ty)) } } } diff --git a/examples/rc_box_drop.ail.json b/examples/rc_box_drop.ail.json new file mode 100644 index 0000000..b84d22e --- /dev/null +++ b/examples/rc_box_drop.ail.json @@ -0,0 +1,59 @@ +{ + "schema": "ailang/v0", + "name": "rc_box_drop", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "Box", + "vars": [], + "doc": "Single-cell ADT around an Int. Iter 18c.3 RC fixture: shallow free is sufficient because Box has no boxed children.", + "ctors": [ + { + "name": "MkBox", + "fields": [{ "k": "con", "name": "Int" }] + } + ] + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Bind a heap-allocated MkBox to `b`, read its Int via match, print it. Under --alloc=rc, codegen emits `ailang_rc_dec(b)` at the let scope close: `b` is unique, has zero consume uses (only the match scrutinee, a borrow), and the body's tail value is the printed Unit, not `b` itself. The Box has no boxed children so shallow `free()` is correct.", + "body": { + "t": "let", + "name": "b", + "value": { + "t": "ctor", + "type": "Box", + "ctor": "MkBox", + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + }, + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "b" }, + "arms": [ + { + "pat": { + "p": "ctor", + "ctor": "MkBox", + "fields": [{ "p": "var", "name": "x" }] + }, + "body": { + "t": "do", + "op": "io/print_int", + "args": [{ "t": "var", "name": "x" }] + } + } + ] + } + } + } + ] +}