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:
+78
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user