Iter 18d.2: codegen reuse-as in-place rewrite under --alloc=rc

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.
This commit is contained in:
2026-05-08 11:21:02 +02:00
parent 0bf1a2fa1c
commit 73545ab086
5 changed files with 1235 additions and 22 deletions
+78 -11
View File
@@ -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 <var>
/// <ctor>)` 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.<id>:` 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.<id>:` then code, then
// `br label %rejoin.<id>`. The fresh arm immediately follows
// as `fresh.<id>:` — 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
+10
View File
@@ -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<id>` in
@@ -667,6 +668,15 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
// 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 <var>
// <body-ctor>)` site against the path-ctor of `<var>` 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
+745
View File
@@ -0,0 +1,745 @@
//! Iter 18d.2: shape-compatibility check for `(reuse-as <var> <body>)`.
//!
//! ## Why this is its own pass
//!
//! Reuse-as semantics says: "free `<var>`'s heap slot, write `<body>`'s
//! freshly-allocated payload into it." That is sound only when
//! `<var>`'s ctor on this control-flow path has the same field count
//! and the same per-field LLVM types as `<body>`'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: `<var>`'s
//! AILang type is `(con T)` (the binder type), but the *runtime ctor*
//! of the box `<var>` points at depends on the path — `<var>` 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 `<body>`'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 `<var>` 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<Diagnostic> {
// 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<String, TypeDef> = 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<String, TypeDef>, diags: &mut Vec<Diagnostic>) {
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<Fn ...>` (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 `<var>` 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<String, TypeDef>,
diags: &'a mut Vec<Diagnostic>,
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<String, PathCtor>,
}
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<String, Option<PathCtor>> = 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<PathCtor>)> = 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<String> {
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<Vec<Type>> {
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<String> {
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<String> {
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<ParamMode>, 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")
);
}
}
+123
View File
@@ -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:
+279 -11
View File
@@ -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 = <source's SSA>
/// %hdr_ptr = getelementptr inbounds i8, ptr %src, i64 -8
/// %refcnt = load i64, ptr %hdr_ptr
/// %is_one = icmp eq i64 %refcnt, 1
/// br i1 %is_one, label %reuse, label %fresh
///
/// reuse:
/// ; for each pointer-typed slot j: load old, drop_<field-T>(old)
/// ; store new tag at offset 0
/// ; store new field values at offsets 8, 16, ...
/// br label %join
///
/// fresh:
/// %newp = call ptr @ailang_rc_alloc(i64 SIZE)
/// ; store new tag + new fields (mirrors lower_ctor)
/// ; dec the now-superseded source
/// call void @drop_<m>_<source-type>(ptr %src)
/// br label %join
///
/// join:
/// %result = phi ptr [ %src, %reuse_end ], [ %newp, %fresh_end ]
/// ```
///
/// The shape-mismatch invariant (source's ctor has the same field
/// count and per-field LLVM types as `body`'s ctor) is enforced
/// upstream by `ailang_check::reuse_shape::check_module`. Codegen
/// trusts the check; if a mismatched shape ever reaches us, the
/// `reuse` arm's per-slot field iteration is bounded by
/// `expected_llvm_tys.len()` and the source-cell layout
/// guaranteed by `lower_ctor`/`@ailang_rc_alloc` — we won't read
/// past the end of the source, but we will silently corrupt
/// fields. The shape check ensures that path is unreachable.
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<Type> = if body_type_name.matches('.').count() == 1 {
let (prefix, _) = body_type_name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
let owner_local_types = self.collect_owner_local_types(target);
cref.ail_fields
.iter()
.map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
.collect()
} else {
cref.ail_fields.clone()
}
} else {
cref.ail_fields.clone()
};
let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
cref.fields.clone()
} else {
let arg_ail_tys: Vec<Type> = body_args
.iter()
.map(|a| self.synth_arg_type(a))
.collect::<Result<_>>()?;
let var_set: BTreeSet<&str> =
cref.type_vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) {
unify_for_subst(exp, actual, &var_set, &mut subst)?;
}
qualified_ail_fields
.iter()
.map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
.collect::<Result<_>>()?
};
// (18d.2 currently does not dec old fields in the reuse
// arm — see the rationale at the reuse arm — so the post-
// substitution per-field AILang types are not needed here.
// 18d.3 / 18e will revisit when the move-aware pattern
// story lands and the reuse arm can dec moved-out slots
// safely.)
// 3. Evaluate the body's args. They live in SSAs that both
// branches consume, so they are computed before the branch
// on refcount.
let mut compiled = Vec::new();
for (a, exp) in body_args.iter().zip(expected_llvm_tys.iter()) {
let (v, vty) = self.lower_term(a)?;
if &vty != exp {
return Err(CodegenError::Internal(format!(
"reuse-as body ctor `{body_ctor_name}` field type {vty} != expected {exp}"
)));
}
compiled.push((v, vty));
}
// 4. Refcount-1 dispatch. Header is at offset -8 (see
// runtime/rc.c::header_of). A static / data-segment ptr
// (e.g. a top-level fn's static closure pair) would
// dereference garbage here, but reuse-as on such a
// pointer is meaningless — the linearity check would
// have rejected `<source>` as not naming a heap binder.
let id = self.fresh_id();
let reuse_lbl = format!("reuse.{id}");
let fresh_lbl = format!("fresh.{id}");
let join_lbl = format!("rejoin.{id}");
let hdr_ptr = self.fresh_ssa();
self.body.push_str(&format!(
" {hdr_ptr} = getelementptr inbounds i8, ptr {src_ssa}, i64 -8\n"
));
let refcnt = self.fresh_ssa();
self.body.push_str(&format!(
" {refcnt} = load i64, ptr {hdr_ptr}, align 8\n"
));
let is_one = self.fresh_ssa();
self.body.push_str(&format!(
" {is_one} = icmp eq i64 {refcnt}, 1\n"
));
self.body.push_str(&format!(
" br i1 {is_one}, label %{reuse_lbl}, label %{fresh_lbl}\n"
));
// 5. Reuse arm: overwrite tag and field slots in place. 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,