From 73545ab0866f13c49b1aedfd4cf903aef6d61773 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 11:21:02 +0200 Subject: [PATCH] Iter 18d.2: codegen reuse-as in-place rewrite under --alloc=rc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lowers Term::ReuseAs { source, body=Term::Ctor } under --alloc=rc to a runtime refcount-1 dispatch. At the reuse-as site: %hdr_ptr = getelementptr 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 The reuse arm overwrites the source's box in place: stores the new tag at offset 0 and the new field values at offsets 8, 16, ... — no malloc, no outer drop. The fresh arm allocates a brand- new box via ailang_rc_alloc, stores tag and fields, then shallow- dec's the source. Both arms phi-join to the same `ptr` result. Other allocators (gc, bump) keep 18d.1's identity behaviour unchanged. Static shape compatibility is enforced by a new pre-codegen pass crates/ailang-check/src/reuse_shape.rs. It tracks the path-resolved ctor of every let-bound and pattern-matched binder, then checks each Term::ReuseAs site against the body's ctor. Mismatches emit reuse-as-shape-mismatch with stable ctx.reason sub-codes (field-count-mismatch, field-type-mismatch, indeterminate-source-ctor, cross-module-body-ctor, ctor-not-in- module) and a drop-the-wrapper SuggestedRewrite. Build fails on mismatch — the structured diagnostic tells the author whether to fix the shapes or remove the hint. Field-cleanup design — deliberate scope cut: The reuse arm does NOT dec the old pointer-typed field values before overwriting them. Doing so on the canonical fixture `(reuse-as xs (Cons (+h 1) (map_inc t)))` causes use-after-free: the recursive map_inc(t) returns t's in-place-rewritten box, so the "new tail" being stored is the same pointer as the "old tail". A dec-old-then-store-new schedule would dec the box (to zero, free), then store the freed pointer. The root cause is upstream: 18c.4 doesn't yet null-out pattern- moved field slots, so codegen can't statically distinguish "old field still owned" from "already moved out". The chosen trade-off: skip the field dec entirely; pattern-wildcarded fields (rare in shipping fixtures) leak; pattern-moved fields are the body's responsibility (consistent with the 18c.4 leak baseline — Own params still leak today). 18d.2 ships zero regression vs 18c.4, plus the perf win on the alloc + outer cascade. Closing the leak is the move-aware-pattern story queued for 18d.3 / 18e. Tests: - e2e reuse_as_demo_under_rc_uses_inplace_rewrite — runs examples/reuse_as_demo.ail.json under --alloc=rc, asserts stdout 9 and locks in the IR shape (icmp eq i64 ..., 1; the reuse arm has no @ailang_rc_alloc call). - workspace reuse_as_shape_mismatch_is_reported_on_cons_to_nil — happy negative for the new diagnostic. - 3 unit tests in reuse_shape::tests covering same-ctor-clean, cons-to-nil-reject, and indeterminate-source-ctor-reject. Test deltas: e2e 56, ailang-check unit 46 -> 49 (+3), workspace 9 -> 10 (+1). cargo test --workspace green. Hand-verified --alloc=rc binary on reuse_as_demo: stdout 9, exit 0. --- crates/ail/tests/e2e.rs | 89 ++- crates/ailang-check/src/lib.rs | 10 + crates/ailang-check/src/reuse_shape.rs | 745 +++++++++++++++++++++++++ crates/ailang-check/tests/workspace.rs | 123 ++++ crates/ailang-codegen/src/lib.rs | 290 +++++++++- 5 files changed, 1235 insertions(+), 22 deletions(-) create mode 100644 crates/ailang-check/src/reuse_shape.rs diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index f9fe1cd..09f1261 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1331,20 +1331,31 @@ fn clone_demo_is_identity_in_18c1() { assert_eq!(stdout.trim(), "42"); } -/// Iter 18d.1: `Term::ReuseAs` is a schema-floor addition. In 18d.1 the -/// wrapper is identity at codegen — the body is lowered, the source is -/// dropped on the floor — so a program containing `(reuse-as -/// )` produces the same stdout under `--alloc=gc` it would have -/// produced without the wrapper. Iter 18d.2 will replace the codegen -/// passthrough with an in-place rewrite under `--alloc=rc`. +/// Iter 18d.2: under `--alloc=rc`, `Term::ReuseAs { source, body = +/// Term::Ctor }` lowers to a runtime refcount-1 dispatch — when the +/// source's box is unique we overwrite it in place (skipping the +/// alloc + cascade-dec round-trip); otherwise we fall back to a +/// fresh allocation and dec the source. Other allocators keep the +/// 18d.1 identity behaviour. /// /// Properties guarded: /// (1) The fixture's canonical JSON contains `"t":"reuse-as"` — the /// schema for the new variant did not regress. -/// (2) `--alloc=gc` produces `9` (1+1 + 2+1 + 3+1) — the typechecker, -/// parser/printer round-trip, and codegen identity all agree. +/// (2) `--alloc=gc` produces `9` (1+1 + 2+1 + 3+1) — the legacy +/// identity codegen still produces correct output. +/// (3) `--alloc=rc` produces the SAME `9` — the in-place rewrite is +/// observably equivalent to the gc path on this fixture. +/// (4) `--alloc=rc` exits cleanly (status 0; no segfault, no +/// refcount underflow). `build_and_run_with_alloc` panics on +/// non-zero exit, so this is implicit but worth naming. +/// (5) The emitted IR for `--alloc=rc` contains both: +/// - `icmp eq i64 %... 1` (the refcount-1 branch test) +/// - a path that does NOT call `@ailang_rc_alloc` for the inner +/// Cons ctor on the reuse arm — verified by the +/// `reuse.:` block label and the absence of +/// `@ailang_rc_alloc` calls inside it. #[test] -fn reuse_as_demo_is_identity_in_18d1() { +fn reuse_as_demo_under_rc_uses_inplace_rewrite() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let json_path = workspace.join("examples").join("reuse_as_demo.ail.json"); @@ -1354,8 +1365,64 @@ fn reuse_as_demo_is_identity_in_18d1() { "expected `\"t\":\"reuse-as\"` in canonical JSON; the schema for `(reuse-as ...)` regressed" ); - let stdout = build_and_run_with_alloc("reuse_as_demo.ail.json", "gc"); - assert_eq!(stdout.trim(), "9"); + // (2) gc baseline. + let stdout_gc = build_and_run_with_alloc("reuse_as_demo.ail.json", "gc"); + assert_eq!(stdout_gc.trim(), "9"); + // (3) rc matches gc, (4) clean exit (build_and_run_with_alloc + // panics on non-zero status). + let stdout_rc = build_and_run_with_alloc("reuse_as_demo.ail.json", "rc"); + assert_eq!(stdout_rc.trim(), "9"); + + // (5) Inspect the rc IR text. We re-lower via the codegen API + // directly so the assertion runs without depending on the + // filesystem layout of `ail build`'s tmpdir. + let ws = ailang_core::load_workspace(&json_path).expect("load workspace"); + let mut lifted_modules = std::collections::BTreeMap::new(); + for (mname, m) in &ws.modules { + let desugared = ailang_core::desugar::desugar_module(m); + let lifted = ailang_check::lift_letrecs(&desugared) + .unwrap_or_else(|e| panic!("lift_letrecs in module `{mname}`: {e:?}")); + lifted_modules.insert(mname.clone(), lifted); + } + let lifted_ws = ailang_core::Workspace { + entry: ws.entry.clone(), + modules: lifted_modules, + root_dir: ws.root_dir.clone(), + }; + let ir = ailang_codegen::lower_workspace_with_alloc( + &lifted_ws, + ailang_codegen::AllocStrategy::Rc, + ) + .expect("lower workspace under rc"); + // The refcount-1 branch test: lower_reuse_as_rc emits exactly + // one such instruction per reuse-as site. Search lenient on the + // SSA register name (we don't care which `%v...` was assigned). + assert!( + ir.contains("icmp eq i64") && ir.contains(", 1\n"), + "rc IR missing `icmp eq i64 %... 1` (the refcount-1 branch test). IR was:\n{ir}" + ); + assert!( + ir.contains("br i1") && ir.contains("label %reuse"), + "rc IR missing `br i1 ... label %reuse...` (the refcount-1 branch). IR was:\n{ir}" + ); + // The reuse arm must NOT call `@ailang_rc_alloc` — that's the + // whole point of the in-place rewrite. We check the substring + // that begins with the reuse label and ends at the next label + // (the label `rejoin.` per `lower_reuse_as_rc`'s scheme). + let reuse_idx = ir.find("reuse.").expect("reuse label present"); + // Walk forward to the next block label after the reuse: arm. + // `lower_reuse_as_rc` emits `reuse.:` then code, then + // `br label %rejoin.`. The fresh arm immediately follows + // as `fresh.:` — that's the boundary. + let after_reuse = &ir[reuse_idx..]; + let reuse_arm_end = after_reuse + .find("\nfresh.") + .expect("fresh label follows the reuse arm in lower_reuse_as_rc layout"); + let reuse_arm = &after_reuse[..reuse_arm_end]; + assert!( + !reuse_arm.contains("@ailang_rc_alloc"), + "reuse arm must not call @ailang_rc_alloc (the slot is reused in place). Reuse arm IR was:\n{reuse_arm}" + ); } /// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index f17967e..bd493ff 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; +mod reuse_shape; pub mod uniqueness; /// Metavariable substitution. Maps fresh metavar ids (from `$m` in @@ -667,6 +668,15 @@ pub fn check_workspace(ws: &Workspace) -> Vec { // designed to inspect. if !had_typecheck_errors { diagnostics.extend(linearity::check_module(m)); + // Iter 18d.2: reuse-as shape compatibility check. Runs on + // the same activation gate as linearity (all-explicit-mode + // fns only). The check resolves each `(reuse-as + // )` site against the path-ctor of `` and + // rejects mismatches with `reuse-as-shape-mismatch`. Runs + // after linearity so its output appears after linearity's + // diagnostics for the same def — stable ordering for the + // JSON consumer. + diagnostics.extend(reuse_shape::check_module(m)); } } diagnostics diff --git a/crates/ailang-check/src/reuse_shape.rs b/crates/ailang-check/src/reuse_shape.rs new file mode 100644 index 0000000..aaddb8c --- /dev/null +++ b/crates/ailang-check/src/reuse_shape.rs @@ -0,0 +1,745 @@ +//! Iter 18d.2: shape-compatibility check for `(reuse-as )`. +//! +//! ## Why this is its own pass +//! +//! Reuse-as semantics says: "free ``'s heap slot, write ``'s +//! freshly-allocated payload into it." That is sound only when +//! ``'s ctor on this control-flow path has the same field count +//! and the same per-field LLVM types as ``'s ctor — otherwise +//! the in-place rewrite would either underrun the box (smaller body +//! into bigger source slot, leaving uninitialised tail bytes) or write +//! a field of one LLVM type into a slot built for another (e.g. +//! storing a `ptr` over a previous `i64` slot, both 8-byte but with +//! different drop semantics for the OLD value at the same offset). +//! +//! The typechecker doesn't know enough to do this check: ``'s +//! AILang type is `(con T)` (the binder type), but the *runtime ctor* +//! of the box `` points at depends on the path — `` may +//! flow into the `Cons` arm of a match where the pattern bound it as +//! the scrutinee, in which case its ctor on this arm is `Cons`. The +//! shape check resolves that ctor by walking the same way codegen +//! does, then compares against ``'s ctor (which is always +//! syntactically a `Term::Ctor` once 18d.1's typecheck is satisfied). +//! +//! Mismatches surface as a structured `reuse-as-shape-mismatch` +//! diagnostic with a suggested rewrite that drops the wrapper. The +//! build then fails before codegen runs; the LLM author either fixes +//! the shapes or removes the reuse hint. +//! +//! ## Activation gate +//! +//! Same as `linearity::check_module`: only fns whose `Type::Fn.param_modes` +//! is non-empty AND contains no [`ParamMode::Implicit`]. Reuse-as is +//! part of the explicit-mode discipline; firing the check on legacy +//! all-`Implicit` fns would risk false positives on workloads that +//! never opted into the explicit-mode lane. +//! +//! ## Path-ctor resolution +//! +//! A binder gets a known ctor on the current path if either: +//! +//! 1. It was bound by `Term::Let { value: Term::Ctor { type_name, ctor, .. }, .. }` +//! — the ctor is syntactically the rhs. +//! 2. It is the scrutinee `Term::Var { name }` of an enclosing +//! `Term::Match` and we are inside an arm whose `Pattern::Ctor { ctor, .. }` +//! matched it — the ctor is the arm's pattern. +//! +//! Both forms are tracked on a stack-of-bindings (`PathCtor`s) so that +//! lexical scope is honoured: nested matches and lets shadow correctly, +//! and on arm exit / let exit the binding pops. +//! +//! If the source binder is not in the path-ctor map at the reuse-as +//! site (e.g. complex control flow, or `` is a fn parameter that +//! was never matched), we **conservatively reject** with the same +//! diagnostic — the LLM either spells things to make the path +//! obvious or removes the wrapper. +//! +//! ## Cross-module note +//! +//! Both ctors must be in the same module's ctor index for the field- +//! count and field-type comparison to be meaningful (the field types +//! are written in the owning module's local namespace; cross-module +//! comparison would need the same `qualify_local_types` plumbing +//! `check_in_workspace` uses). For 18d.2 we only inspect ctors with +//! bare type names (no `module.T` prefix) and conservatively reject +//! anything else as shape-indeterminate. Cross-module reuse-as is not +//! exercised by any 18d.2 fixture; lifting the restriction is a +//! mechanical follow-up if it ever lands. + +use crate::diagnostic::Diagnostic; +use ailang_core::ast::{Arm, Def, FnDef, Module, ParamMode, Pattern, Term, Type, TypeDef}; +use ailang_surface::term_to_form_a; +use std::collections::HashMap; + +/// Iter 18d.2: top-level entry. Walks every fn in `m` and emits +/// `reuse-as-shape-mismatch` diagnostics for the all-explicit-mode +/// fns. Other fns are skipped. +/// +/// Output ordering: defs in declaration order; within a def, source- +/// order discovery (depth-first left-to-right walk). +pub(crate) fn check_module(m: &Module) -> Vec { + // Collect the module's own type defs once. Cross-module references + // are out of scope per the module-level note; same-module ctors + // are the only ones we inspect. + let mut types: HashMap = HashMap::new(); + for def in &m.defs { + if let Def::Type(td) = def { + types.insert(td.name.clone(), td.clone()); + } + } + let mut diags = Vec::new(); + for def in &m.defs { + if let Def::Fn(f) = def { + check_fn(f, &types, &mut diags); + } + } + diags +} + +/// Per-fn check. Skips fns whose signature has any `Implicit` param +/// (legacy back-compat lane) or whose param list is empty (no binders +/// to track). +fn check_fn(f: &FnDef, types: &HashMap, diags: &mut Vec) { + let param_modes: &[ParamMode] = match strip_forall(&f.ty) { + Type::Fn { param_modes, .. } => param_modes.as_slice(), + _ => return, + }; + if param_modes.is_empty() + || param_modes + .iter() + .any(|m| matches!(m, ParamMode::Implicit)) + { + return; + } + + let mut checker = Checker { + types, + diags, + def_name: &f.name, + path_ctors: HashMap::new(), + }; + checker.walk(&f.body); +} + +/// Returns the inner type of a top-level `Forall` (or `t` +/// unchanged if not a `Forall`). +fn strip_forall(t: &Type) -> &Type { + match t { + Type::Forall { body, .. } => body, + other => other, + } +} + +/// One entry in the path-ctor stack: the bare ctor name `` carries +/// on the current control-flow path. +#[derive(Debug, Clone)] +struct PathCtor { + /// The bare ctor name (e.g. `"Cons"`). + ctor: String, + /// The bare type name (e.g. `"List"`). Used to look the ctor up in + /// the module's `types` table. Cross-module (`module.T`) prefixes + /// disable the comparison — see module-level note. + type_name: String, +} + +struct Checker<'a> { + types: &'a HashMap, + diags: &'a mut Vec, + def_name: &'a str, + /// Path-resolved ctor for each in-scope binder. Modified in place + /// with save/restore at lexical-scope boundaries (let, match arms). + /// A binder absent from this map is path-ctor-indeterminate at the + /// current point (e.g. an as-yet-unmatched fn parameter). + path_ctors: HashMap, +} + +impl<'a> Checker<'a> { + /// Walk `t`, emitting diagnostics for every reuse-as whose + /// resolved source-ctor is incompatible with the body-ctor. + fn walk(&mut self, t: &Term) { + match t { + Term::Lit { .. } | Term::Var { .. } => {} + Term::App { callee, args, .. } => { + self.walk(callee); + for a in args { + self.walk(a); + } + } + Term::Let { name, value, body } => { + self.walk(value); + // Push binder→ctor if the rhs is a literal ctor. + let prev = self.path_ctors.remove(name); + if let Term::Ctor { type_name, ctor, .. } = value.as_ref() { + if let Some(bare_type) = bare_name(type_name) { + self.path_ctors.insert( + name.clone(), + PathCtor { + ctor: ctor.clone(), + type_name: bare_type, + }, + ); + } + } + self.walk(body); + self.path_ctors.remove(name); + if let Some(p) = prev { + self.path_ctors.insert(name.clone(), p); + } + } + Term::LetRec { name, body, in_term, .. } => { + // LetRec binds a fn, not a value — never a ctor. + let prev = self.path_ctors.remove(name); + self.walk(body); + self.walk(in_term); + self.path_ctors.remove(name); + if let Some(p) = prev { + self.path_ctors.insert(name.clone(), p); + } + } + Term::If { cond, then, else_ } => { + self.walk(cond); + // Branches share the pre-If ctor map; neither branch + // commits a new ctor binding upward (an If-typed value + // could in principle be a ctor, but we don't refine + // through If — too narrow a path). + let saved = self.path_ctors.clone(); + self.walk(then); + self.path_ctors = saved.clone(); + self.walk(else_); + self.path_ctors = saved; + } + Term::Match { scrutinee, arms } => { + self.walk(scrutinee); + let saved = self.path_ctors.clone(); + // The scrutinee's binder name (if it's a bare Var) is + // refined per-arm by the arm's pattern. + let scrut_var = match scrutinee.as_ref() { + Term::Var { name } => Some(name.clone()), + _ => None, + }; + for arm in arms { + self.path_ctors = saved.clone(); + self.walk_arm(arm, scrut_var.as_deref()); + } + self.path_ctors = saved; + } + Term::Seq { lhs, rhs } => { + self.walk(lhs); + self.walk(rhs); + } + Term::Ctor { args, .. } => { + for a in args { + self.walk(a); + } + } + Term::Do { args, .. } => { + for a in args { + self.walk(a); + } + } + Term::Lam { body, .. } => { + // A lam is a closure boundary. We don't refine + // captured binders through it (their path-ctor is + // already what we have); the lam's own params are + // fresh binders with no known ctor. + self.walk(body); + } + Term::Clone { value } => self.walk(value), + Term::ReuseAs { source, body } => { + // Walk both sides first so any nested reuse-as gets + // reported in source order. Then perform the shape + // check at this site. + self.walk(source); + self.walk(body); + self.check_reuse_as(source, body); + } + } + } + + /// Walk one match arm. If the scrutinee was a bare var and the + /// pattern is a `Pattern::Ctor`, refine that var's path-ctor to + /// the pattern's ctor for the arm's body. The pattern's bound + /// names (h, t) are introduced as path-ctor-indeterminate (they + /// could be any ctor). Wildcard / Var / Lit patterns leave the + /// scrutinee's path-ctor untouched. + fn walk_arm(&mut self, arm: &Arm, scrut_var: Option<&str>) { + let pattern_binders = collect_pattern_binders(&arm.pat); + let mut saved: HashMap> = HashMap::new(); + for n in &pattern_binders { + saved.insert(n.clone(), self.path_ctors.remove(n)); + } + // Refine the scrutinee's path-ctor for this arm. + let mut refined_scrut: Option<(String, Option)> = None; + if let (Some(sv), Pattern::Ctor { ctor, .. }) = (scrut_var, &arm.pat) { + // Resolve the type-name of the matched ctor by looking up + // the ctor in the module's type list. Same-module only — + // cross-module patterns disable the refinement (consistent + // with the module-level note). + if let Some(type_name) = self.find_type_for_ctor(ctor) { + let prev = self.path_ctors.insert( + sv.to_string(), + PathCtor { + ctor: ctor.clone(), + type_name, + }, + ); + refined_scrut = Some((sv.to_string(), prev)); + } + } + self.walk(&arm.body); + // Restore pattern-bound names. + for n in &pattern_binders { + self.path_ctors.remove(n); + if let Some(p) = saved.remove(n).flatten() { + self.path_ctors.insert(n.clone(), p); + } + } + // Restore the scrutinee's pre-arm path-ctor. + if let Some((sv, prev)) = refined_scrut { + self.path_ctors.remove(&sv); + if let Some(p) = prev { + self.path_ctors.insert(sv, p); + } + } + } + + /// The shape check at one reuse-as site. Pre-conditions handled + /// upstream: + /// - 18d.1's typecheck guarantees `body` is `Term::Ctor` (or + /// `Term::Lam`, but lams have no ctor and are not in scope here). + /// - 18d.1's linearity guarantees `source` is a bare `Term::Var` + /// referring to an in-scope binder. + /// + /// What this method enforces: the source's path-resolved ctor and + /// the body's ctor declare the same field count AND the same + /// per-field LLVM-equivalent types. Mismatch → emit + /// `reuse-as-shape-mismatch`. Source's path-ctor unresolved → + /// also emit (conservative reject — the codegen seam can't lower + /// what the static check can't verify). + fn check_reuse_as(&mut self, source: &Term, body: &Term) { + let src_var = match source { + Term::Var { name } => name, + // Linearity already flagged this; skip the shape check to + // avoid a duplicate diagnostic. + _ => return, + }; + let body_ctor = match body { + Term::Ctor { type_name, ctor, .. } => (type_name.clone(), ctor.clone()), + // Typecheck already flagged this; skip. + _ => return, + }; + let src_ctor = match self.path_ctors.get(src_var) { + Some(p) => p.clone(), + None => { + self.diags.push(make_shape_mismatch( + self.def_name, + src_var, + /*src=*/ None, + &body_ctor, + "indeterminate-source-ctor", + body, + )); + return; + } + }; + // Same-module ctor names only — cross-module is conservative + // reject. + let body_type = match bare_name(&body_ctor.0) { + Some(t) => t, + None => { + self.diags.push(make_shape_mismatch( + self.def_name, + src_var, + Some(&src_ctor), + &body_ctor, + "cross-module-body-ctor", + body, + )); + return; + } + }; + // Look up both ctors' declared field types. + let src_fields = self.lookup_ctor_fields(&src_ctor.type_name, &src_ctor.ctor); + let body_fields = self.lookup_ctor_fields(&body_type, &body_ctor.1); + let (sf, bf) = match (src_fields, body_fields) { + (Some(sf), Some(bf)) => (sf, bf), + _ => { + // One or both ctors not resolvable in the local module. + self.diags.push(make_shape_mismatch( + self.def_name, + src_var, + Some(&src_ctor), + &body_ctor, + "ctor-not-in-module", + body, + )); + return; + } + }; + if sf.len() != bf.len() { + self.diags.push(make_shape_mismatch( + self.def_name, + src_var, + Some(&src_ctor), + &body_ctor, + "field-count-mismatch", + body, + )); + return; + } + for (s, b) in sf.iter().zip(bf.iter()) { + if !llvm_shape_equiv(s, b) { + self.diags.push(make_shape_mismatch( + self.def_name, + src_var, + Some(&src_ctor), + &body_ctor, + "field-type-mismatch", + body, + )); + return; + } + } + } + + /// Find the bare type name owning the given ctor name in the + /// current module. Returns the bare type name, or `None` if no + /// type in the module declares that ctor. + fn find_type_for_ctor(&self, ctor: &str) -> Option { + for (tname, td) in self.types { + if td.ctors.iter().any(|c| c.name == ctor) { + return Some(tname.clone()); + } + } + None + } + + /// Look up a ctor's declared field types by (type-name, ctor-name) + /// in the current module. Returns `None` if the type isn't in + /// `self.types` or the ctor isn't in the type. + fn lookup_ctor_fields(&self, type_name: &str, ctor: &str) -> Option> { + let td = self.types.get(type_name)?; + td.ctors + .iter() + .find(|c| c.name == ctor) + .map(|c| c.fields.clone()) + } +} + +/// Returns the bare type name if `name` has no `module.` prefix, +/// otherwise `None`. The shape check intentionally doesn't try to +/// reach across module boundaries — see the module-level note. +fn bare_name(name: &str) -> Option { + if name.contains('.') { + None + } else { + Some(name.to_string()) + } +} + +/// LLVM-equivalence of two AILang [`Type`]s, mirroring the +/// `llvm_type` shape codegen uses: +/// - `Int` → `i64` +/// - `Bool` → `i1` +/// - `Unit` → `i8` +/// - `Str` / any other `Con` (ADT) / `Fn` / `Var` → `ptr` +/// +/// The shape check uses this to compare two ctors' fields slot-by- +/// slot. Two slots are "shape-equivalent" iff their LLVM lowerings +/// are equal. +fn llvm_shape_equiv(a: &Type, b: &Type) -> bool { + llvm_kind(a) == llvm_kind(b) +} + +fn llvm_kind(t: &Type) -> &'static str { + match t { + Type::Con { name, .. } => match name.as_str() { + "Int" => "i64", + "Bool" => "i1", + "Unit" => "i8", + // Str + every user-declared ADT lower to `ptr`. + _ => "ptr", + }, + Type::Fn { .. } | Type::Var { .. } => "ptr", + // Forall is a top-level wrapper; the shape inspector should + // never see one in a ctor field. Treat conservatively as + // `ptr` if it ever appears. + Type::Forall { .. } => "ptr", + } +} + +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(), + } +} + +/// Build a `reuse-as-shape-mismatch` diagnostic. The suggested +/// rewrite drops the wrapper and keeps the body alone — the LLM can +/// then either accept the normal allocator path or reshape the body. +/// +/// `reason` is a stable kebab-case sub-code carried in `ctx.reason` +/// so consumers can branch on the specific failure mode without +/// parsing prose. +fn make_shape_mismatch( + def: &str, + binder: &str, + src: Option<&PathCtor>, + body_ctor: &(String, String), + reason: &str, + body: &Term, +) -> Diagnostic { + let replacement = term_to_form_a(body); + let mut ctx = serde_json::json!({ + "binder": binder, + "reason": reason, + "body_type": body_ctor.0, + "body_ctor": body_ctor.1, + }); + if let Some(s) = src { + if let Some(obj) = ctx.as_object_mut() { + obj.insert( + "source_type".into(), + serde_json::Value::String(s.type_name.clone()), + ); + obj.insert( + "source_ctor".into(), + serde_json::Value::String(s.ctor.clone()), + ); + } + } + let msg = match (src, reason) { + (_, "indeterminate-source-ctor") => format!( + "reuse-as on `{binder}`: cannot statically determine the source's ctor on this control-flow path; reuse-as requires the source's ctor to be visible from a let or a match arm" + ), + (_, "cross-module-body-ctor") => format!( + "reuse-as on `{binder}`: cross-module body ctors are not yet supported by the shape check" + ), + (_, "ctor-not-in-module") => format!( + "reuse-as on `{binder}`: source or body ctor is not declared in the current module" + ), + (Some(s), "field-count-mismatch") => format!( + "reuse-as on `{binder}`: source ctor `{}` has {} field(s) but body ctor `{}` has {} — reuse requires the same shape", + s.ctor, + "?", + body_ctor.1, + "?", + ), + (Some(s), "field-type-mismatch") => format!( + "reuse-as on `{binder}`: source ctor `{}` and body ctor `{}` have incompatible per-field LLVM types — reuse requires identical per-slot shapes", + s.ctor, body_ctor.1 + ), + _ => format!("reuse-as on `{binder}`: shape mismatch ({reason})"), + }; + Diagnostic::error("reuse-as-shape-mismatch", msg) + .with_def(def) + .with_ctx(ctx) + .with_suggested_rewrite( + "drop the reuse-as wrapper; the body alone allocates a fresh box via the normal allocator", + replacement, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ailang_core::ast::{Ctor, FnDef, Literal}; + + fn list_type_def() -> TypeDef { + TypeDef { + name: "List".into(), + vars: vec![], + ctors: vec![ + Ctor { name: "Nil".into(), fields: vec![] }, + Ctor { + name: "Cons".into(), + fields: vec![ + Type::int(), + Type::Con { name: "List".into(), args: vec![] }, + ], + }, + ], + doc: None, + } + } + + fn fn_with_modes(name: &str, modes: Vec, body: Term) -> Def { + Def::Fn(FnDef { + name: name.into(), + ty: Type::Fn { + params: modes + .iter() + .map(|_| Type::Con { name: "List".into(), args: vec![] }) + .collect(), + param_modes: modes.clone(), + ret: Box::new(Type::Con { name: "List".into(), args: vec![] }), + ret_mode: ParamMode::Implicit, + effects: vec![], + }, + params: (0..modes.len()).map(|i| format!("p{i}")).collect(), + body, + doc: None, + }) + } + + /// Happy-path: `(reuse-as xs (Cons ...))` inside the Cons arm of + /// a match on xs — both ctors are `Cons`, identical shape. Clean. + #[test] + fn reuse_as_same_ctor_in_match_arm_is_clean() { + // body: (match xs (Nil → Nil) (Cons h t → (reuse-as xs (Cons h t)))) + let body = Term::Match { + scrutinee: Box::new(Term::Var { name: "p0".into() }), + arms: vec![ + Arm { + pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, + body: Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }, + }, + Arm { + pat: Pattern::Ctor { + ctor: "Cons".into(), + fields: vec![ + Pattern::Var { name: "h".into() }, + Pattern::Var { name: "t".into() }, + ], + }, + body: Term::ReuseAs { + source: Box::new(Term::Var { name: "p0".into() }), + body: Box::new(Term::Ctor { + type_name: "List".into(), + ctor: "Cons".into(), + args: vec![ + Term::Var { name: "h".into() }, + Term::Var { name: "t".into() }, + ], + }), + }, + }, + ], + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![ + Def::Type(list_type_def()), + fn_with_modes("f", vec![ParamMode::Own], body), + ], + }; + let diags = check_module(&m); + assert!( + diags.is_empty(), + "happy-path same-ctor reuse-as must be clean; got {diags:?}" + ); + } + + /// Mismatch: source-ctor is `Cons` (matched in arm) but body-ctor + /// is `Nil` — different field counts (2 vs 0). Must fire + /// `reuse-as-shape-mismatch` with reason `field-count-mismatch`. + #[test] + fn reuse_as_cons_to_nil_is_shape_mismatch() { + let body = Term::Match { + scrutinee: Box::new(Term::Var { name: "p0".into() }), + arms: vec![ + Arm { + pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, + body: Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }, + }, + Arm { + pat: Pattern::Ctor { + ctor: "Cons".into(), + fields: vec![ + Pattern::Var { name: "h".into() }, + Pattern::Var { name: "t".into() }, + ], + }, + // BAD: reuse Cons-shaped slot to write Nil. + body: Term::ReuseAs { + source: Box::new(Term::Var { name: "p0".into() }), + body: Box::new(Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }), + }, + }, + ], + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![ + Def::Type(list_type_def()), + fn_with_modes("f", vec![ParamMode::Own], body), + ], + }; + let diags = check_module(&m); + assert_eq!( + diags.len(), + 1, + "expected one shape-mismatch diagnostic; got {diags:?}" + ); + let d = &diags[0]; + assert_eq!(d.code, "reuse-as-shape-mismatch"); + assert_eq!(d.def.as_deref(), Some("f")); + assert_eq!( + d.ctx.get("reason").and_then(|v| v.as_str()), + Some("field-count-mismatch") + ); + assert!(!d.suggested_rewrites.is_empty()); + let rep = &d.suggested_rewrites[0].replacement; + ailang_surface::parse_term(rep) + .unwrap_or_else(|e| panic!("suggested rewrite must parse: {rep} ({e})")); + } + + /// Source-ctor unresolved: `(reuse-as p0 (Cons ...))` outside any + /// match — `p0` is a fn parameter but never matched, so we have + /// no path-ctor for it. Conservative reject. + #[test] + fn reuse_as_indeterminate_source_ctor_is_rejected() { + let body = Term::ReuseAs { + source: Box::new(Term::Var { name: "p0".into() }), + body: Box::new(Term::Ctor { + type_name: "List".into(), + ctor: "Cons".into(), + args: vec![ + Term::Lit { lit: Literal::Int { value: 0 } }, + Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }, + ], + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![ + Def::Type(list_type_def()), + fn_with_modes("f", vec![ParamMode::Own], body), + ], + }; + let diags = check_module(&m); + assert_eq!(diags.len(), 1, "got {diags:?}"); + assert_eq!(diags[0].code, "reuse-as-shape-mismatch"); + assert_eq!( + diags[0].ctx.get("reason").and_then(|v| v.as_str()), + Some("indeterminate-source-ctor") + ); + } +} diff --git a/crates/ailang-check/tests/workspace.rs b/crates/ailang-check/tests/workspace.rs index 62e2d4f..a39f1f0 100644 --- a/crates/ailang-check/tests/workspace.rs +++ b/crates/ailang-check/tests/workspace.rs @@ -526,6 +526,129 @@ fn reuse_as_happy_path_in_map_inc_is_linearity_clean() { ); } +/// Iter 18d.2: a deliberately mismatched reuse-as — `(reuse-as xs +/// (Nil))` inside the Cons arm of a match on `xs` — must surface as +/// a `reuse-as-shape-mismatch` diagnostic. The source's path-ctor +/// (Cons, 2 fields) and the body ctor (Nil, 0 fields) have different +/// shapes; the in-place rewrite is unsafe; the build must fail. +/// +/// The diagnostic must: +/// - carry code `reuse-as-shape-mismatch` +/// - identify the offending def (`f`) +/// - record `ctx.reason` as a stable kebab-case sub-code (here: +/// `field-count-mismatch`) so JSON consumers can branch on the +/// specific failure mode without prose-parsing +/// - ship a non-empty `suggested_rewrites` whose replacement parses +/// back as form-A AILang (the rewrite drops the wrapper and keeps +/// the body alone) +#[test] +fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() { + use ailang_core::ast::*; + use std::collections::BTreeMap; + + let list_adt = Def::Type(TypeDef { + name: "List".into(), + vars: vec![], + ctors: vec![ + Ctor { name: "Nil".into(), fields: vec![] }, + Ctor { + name: "Cons".into(), + fields: vec![ + Type::Con { name: "Int".into(), args: vec![] }, + Type::Con { name: "List".into(), args: vec![] }, + ], + }, + ], + doc: None, + }); + + let f_ty = Type::Fn { + params: vec![Type::Con { name: "List".into(), args: vec![] }], + param_modes: vec![ParamMode::Own], + ret: Box::new(Type::Con { name: "List".into(), args: vec![] }), + ret_mode: ParamMode::Own, + effects: vec![], + }; + + // Body: + // (match xs + // (Nil → Nil) + // (Cons h t → (reuse-as xs Nil))) ; BAD: 2-field Cons → 0-field Nil. + let cons_arm_body = Term::ReuseAs { + source: Box::new(Term::Var { name: "xs".into() }), + body: Box::new(Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }), + }; + let body = Term::Match { + scrutinee: Box::new(Term::Var { name: "xs".into() }), + arms: vec![ + Arm { + pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, + body: Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }, + }, + Arm { + pat: Pattern::Ctor { + ctor: "Cons".into(), + fields: vec![ + Pattern::Var { name: "h".into() }, + Pattern::Var { name: "t".into() }, + ], + }, + body: cons_arm_body, + }, + ], + }; + + let f = Def::Fn(FnDef { + name: "f".into(), + ty: f_ty, + params: vec!["xs".into()], + body, + doc: None, + }); + + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "reuse_as_mismatch".into(), + imports: vec![], + defs: vec![list_adt, f], + }; + let mut modules = BTreeMap::new(); + modules.insert(m.name.clone(), m.clone()); + let ws = ailang_core::Workspace { + entry: m.name.clone(), + modules, + root_dir: std::path::PathBuf::from("."), + }; + let diags = check_workspace(&ws); + let shape_diags: Vec<&ailang_check::Diagnostic> = diags + .iter() + .filter(|d| d.code == "reuse-as-shape-mismatch") + .collect(); + assert_eq!( + shape_diags.len(), + 1, + "expected exactly one reuse-as-shape-mismatch diagnostic; got: {diags:#?}" + ); + let d = shape_diags[0]; + assert!(matches!(d.severity, Severity::Error)); + assert_eq!(d.def.as_deref(), Some("f")); + assert_eq!( + d.ctx.get("reason").and_then(|v| v.as_str()), + Some("field-count-mismatch"), + "ctx.reason must be the stable sub-code; ctx was: {}", + d.ctx + ); + assert_suggested_rewrites_well_formed(d); +} + /// Iter 18c.2: positive control. The ON-DISK `borrow_own_demo` fixture /// (the only currently-shipping all-explicit-mode program) must remain /// linearity-clean. If this regresses, the check has become incorrect: diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index ea181c4..6a036ca 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -1512,17 +1512,28 @@ impl<'a> Emitter<'a> { } Ok((val_ssa, val_ty)) } - Term::ReuseAs { source: _, body } => { - // Iter 18d.1: identity. Iter 18d.2 will lower this as - // in-place rewrite under --alloc=rc. For now we simply - // lower `body` and return its `(ssa, ty)`. The `source` - // is dropped on the floor — in shipping fixtures it is - // always a `Term::Var` (no IR side effects), so the - // identity lowering is observably equivalent to lowering - // `body` alone. The linearity check (18c.2) and - // typecheck (18d.1) reject ill-formed shapes before we - // get here. - self.lower_term(body) + Term::ReuseAs { source, body } => { + // Iter 18d.2: under --alloc=rc, lower as a runtime- + // refcount-1 dispatch — if the source's box is + // unique we overwrite it in place (skipping the + // alloc-and-cascade-dec round-trip); otherwise we + // allocate a fresh box and dec the source. Other + // allocators keep the 18d.1 identity behaviour. + if !matches!(self.alloc, AllocStrategy::Rc) { + return self.lower_term(body); + } + // The body must be a Term::Ctor for the in-place + // rewrite to make sense. 18d.1 typecheck rejects + // any other shape; lams are accepted by typecheck + // but not yet supported by reuse codegen — fall + // back to identity for those (the body still + // allocates via ailang_rc_alloc, just without the + // reuse fast path). + let (body_type_name, body_ctor, body_args) = match body.as_ref() { + Term::Ctor { type_name, ctor, args } => (type_name, ctor, args), + _ => return self.lower_term(body), + }; + self.lower_reuse_as_rc(source, body_type_name, body_ctor, body_args) } } } @@ -1751,6 +1762,263 @@ impl<'a> Emitter<'a> { Ok((p, "ptr".into())) } + /// Iter 18d.2: lower `Term::ReuseAs { source, body = Term::Ctor }` + /// under `--alloc=rc` as a runtime refcount-1 dispatch. + /// + /// IR shape (Lean 4 / Roc lineage): + /// ```text + /// %src = + /// %hdr_ptr = getelementptr inbounds i8, ptr %src, i64 -8 + /// %refcnt = load i64, ptr %hdr_ptr + /// %is_one = icmp eq i64 %refcnt, 1 + /// br i1 %is_one, label %reuse, label %fresh + /// + /// reuse: + /// ; for each pointer-typed slot j: load old, drop_(old) + /// ; store new tag at offset 0 + /// ; store new field values at offsets 8, 16, ... + /// br label %join + /// + /// fresh: + /// %newp = call ptr @ailang_rc_alloc(i64 SIZE) + /// ; store new tag + new fields (mirrors lower_ctor) + /// ; dec the now-superseded source + /// call void @drop__(ptr %src) + /// br label %join + /// + /// join: + /// %result = phi ptr [ %src, %reuse_end ], [ %newp, %fresh_end ] + /// ``` + /// + /// The shape-mismatch invariant (source's ctor has the same field + /// count and per-field LLVM types as `body`'s ctor) is enforced + /// upstream by `ailang_check::reuse_shape::check_module`. Codegen + /// trusts the check; if a mismatched shape ever reaches us, the + /// `reuse` arm's per-slot field iteration is bounded by + /// `expected_llvm_tys.len()` and the source-cell layout + /// guaranteed by `lower_ctor`/`@ailang_rc_alloc` — we won't read + /// past the end of the source, but we will silently corrupt + /// fields. The shape check ensures that path is unreachable. + fn lower_reuse_as_rc( + &mut self, + source: &Term, + body_type_name: &str, + body_ctor_name: &str, + body_args: &[Term], + ) -> Result<(String, String)> { + // 1. Lower the source. Linearity (18d.1) guarantees this is a + // bare Var of an in-scope binder; lower_term resolves it + // to the binder's SSA. + let (src_ssa, src_ty) = self.lower_term(source)?; + if src_ty != "ptr" { + return Err(CodegenError::Internal(format!( + "reuse-as source type must be ptr, got {src_ty}" + ))); + } + // The source's drop symbol — needed on the fresh arm to + // release this caller's share of the source (refcount > 1, + // by the branch we are inside). We deliberately use the + // shallow `ailang_rc_dec` here, NOT the per-type cascading + // drop: + // + // The fresh arm only fires when the source's refcount is + // > 1, i.e. another holder also has a reference. In that + // case the source's children are still owned by the other + // holder; cascading dec would dec children we don't have + // exclusive ownership over, and on aliasing with the new + // box's fields could leave a use-after-free in the new box. + // A shallow dec on the source's refcount preserves the + // 18c.4 baseline: source.refcount goes down by one; the + // box only frees when the LAST holder dec's it; children + // are dec'd by that final dec's cascade, not by ours. + // + // Identical to the unused-source-after-reuse-misfire + // pattern in Lean 4's reset/reuse codegen: the + // refcount-decrement is the contract; the drop fn cascade + // is owned by the last release, not by intermediate ones. + let _ = source; // synth_arg_type not needed for shallow dec + let src_drop_call = "ailang_rc_dec".to_string(); + + // 2. Resolve the body ctor and its expected per-field LLVM + // types. Mirrors the head of lower_ctor — same type-var + // substitution path so parameterised ADT bodies work the + // same way reuse-as lowers as plain ctor lowers. + let cref = self.lookup_ctor_by_type(body_type_name, body_ctor_name)?; + if body_args.len() != cref.ail_fields.len() { + return Err(CodegenError::Internal(format!( + "reuse-as body ctor `{body_type_name}/{body_ctor_name}` arity" + ))); + } + let qualified_ail_fields: Vec = if body_type_name.matches('.').count() == 1 { + let (prefix, _) = body_type_name.split_once('.').expect("checked"); + if let Some(target) = self.import_map.get(prefix) { + let owner_local_types = self.collect_owner_local_types(target); + cref.ail_fields + .iter() + .map(|f| qualify_local_types_codegen(f, target, &owner_local_types)) + .collect() + } else { + cref.ail_fields.clone() + } + } else { + cref.ail_fields.clone() + }; + let expected_llvm_tys: Vec = if cref.type_vars.is_empty() { + cref.fields.clone() + } else { + let arg_ail_tys: Vec = body_args + .iter() + .map(|a| self.synth_arg_type(a)) + .collect::>()?; + let var_set: BTreeSet<&str> = + cref.type_vars.iter().map(|s| s.as_str()).collect(); + let mut subst: BTreeMap = BTreeMap::new(); + for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) { + unify_for_subst(exp, actual, &var_set, &mut subst)?; + } + qualified_ail_fields + .iter() + .map(|f| llvm_type(&apply_subst_to_type(f, &subst))) + .collect::>()? + }; + // (18d.2 currently does not dec old fields in the reuse + // arm — see the rationale at the reuse arm — so the post- + // substitution per-field AILang types are not needed here. + // 18d.3 / 18e will revisit when the move-aware pattern + // story lands and the reuse arm can dec moved-out slots + // safely.) + + // 3. Evaluate the body's args. They live in SSAs that both + // branches consume, so they are computed before the branch + // on refcount. + let mut compiled = Vec::new(); + for (a, exp) in body_args.iter().zip(expected_llvm_tys.iter()) { + let (v, vty) = self.lower_term(a)?; + if &vty != exp { + return Err(CodegenError::Internal(format!( + "reuse-as body ctor `{body_ctor_name}` field type {vty} != expected {exp}" + ))); + } + compiled.push((v, vty)); + } + + // 4. Refcount-1 dispatch. Header is at offset -8 (see + // runtime/rc.c::header_of). A static / data-segment ptr + // (e.g. a top-level fn's static closure pair) would + // dereference garbage here, but reuse-as on such a + // pointer is meaningless — the linearity check would + // have rejected `` as not naming a heap binder. + let id = self.fresh_id(); + let reuse_lbl = format!("reuse.{id}"); + let fresh_lbl = format!("fresh.{id}"); + let join_lbl = format!("rejoin.{id}"); + + let hdr_ptr = self.fresh_ssa(); + self.body.push_str(&format!( + " {hdr_ptr} = getelementptr inbounds i8, ptr {src_ssa}, i64 -8\n" + )); + let refcnt = self.fresh_ssa(); + self.body.push_str(&format!( + " {refcnt} = load i64, ptr {hdr_ptr}, align 8\n" + )); + let is_one = self.fresh_ssa(); + self.body.push_str(&format!( + " {is_one} = icmp eq i64 {refcnt}, 1\n" + )); + self.body.push_str(&format!( + " br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n" + )); + + // 5. Reuse arm: overwrite tag and field slots in place. The + // old pointer-typed field values are NOT dec'd here. + // + // Rationale: 18c.4's RC story does not yet emit a dec on + // pattern-bound fields at scope close (see 18c.4's "fn + // parameters / pattern-move debt" — patterns LOAD field + // pointers without inc'ing, treating the load as a + // logical move-out into the binder). Under that model, + // by the time control reaches the reuse-as site, every + // pointer-typed old field that the body actually + // consumes has already been "moved out" of the slot; + // dec'ing it again here would either double-free (when + // the body's evaluation freed it) or, worse, free a box + // the new field value aliases (the canonical case: + // `(reuse-as xs (Cons (+ h 1) (map_inc t)))` returns + // `t`'s in-place-rewritten box as the new tail). A + // self-dec that cascades through B.field[1]=C while C + // is also the new tail produces a use-after-free. + // + // The cost: an old pointer-typed field that the body did + // NOT pattern-move (e.g. `match xs (Cons _ _ → ...)` + // with both fields wildcarded) leaks here. That leak is + // the same shape 18c.4 already accepts for non-trackable + // pattern fields, so 18d.2 doesn't regress the baseline. + // Closing the leak requires the move-aware pattern story + // (queued for 18d.3 / 18e alongside fn-parameter dec). + self.start_block(&reuse_lbl); + // Store new tag at offset 0. Even when the new tag equals the + // old tag (most reuse-as fixtures: Cons → Cons), we emit the + // store unconditionally — codegen doesn't have to special- + // case "same tag", the store is cheap, and the IR is simpler. + self.body.push_str(&format!( + " store i64 {tag}, ptr {src_ssa}, align 8\n", + tag = cref.tag + )); + // Store new field values into the slots. + for (i, (v, ty)) in compiled.iter().enumerate() { + let off = 8 + i as i64 * 8; + let addr = self.fresh_ssa(); + self.body.push_str(&format!( + " {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n" + )); + self.body + .push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n")); + } + let reuse_end = self.current_block.clone(); + self.body.push_str(&format!(" br label %{join_lbl}\n")); + + // 6. Fresh arm: standard alloc + tag + field stores; then + // dec the source (whose refcount was > 1, so this is the + // callee's release of its share — may or may not free). + self.start_block(&fresh_lbl); + let size_bytes = 8 + (compiled.len() * 8) as i64; + let newp = self.fresh_ssa(); + self.body.push_str(&format!( + " {newp} = call ptr @{}(i64 {size_bytes})\n", + self.alloc.fn_name() + )); + self.body.push_str(&format!( + " store i64 {tag}, ptr {newp}, align 8\n", + tag = cref.tag + )); + for (i, (v, ty)) in compiled.iter().enumerate() { + let off = 8 + i as i64 * 8; + let addr = self.fresh_ssa(); + self.body.push_str(&format!( + " {addr} = getelementptr inbounds i8, ptr {newp}, i64 {off}\n" + )); + self.body + .push_str(&format!(" store {ty} {v}, ptr {addr}, align 8\n")); + } + // Dec the source — the user's reuse-hint failed because the + // box was shared. The source's drop fn cascades through any + // boxed children of the OLD ctor (the slot we couldn't reuse), + // matching the cascade lower_ctor would do for any other + // owned binder going out of scope. + self.body + .push_str(&format!(" call void @{src_drop_call}(ptr {src_ssa})\n")); + let fresh_end = self.current_block.clone(); + self.body.push_str(&format!(" br label %{join_lbl}\n")); + + // 7. Join: phi over the two arms' result pointers. + self.start_block(&join_lbl); + let phi = self.fresh_ssa(); + self.body.push_str(&format!( + " {phi} = phi ptr [ {src_ssa}, %{reuse_end} ], [ {newp}, %{fresh_end} ]\n" + )); + Ok((phi, "ptr".into())) + } + fn lower_match( &mut self, scrutinee: &Term,