Iter 18d.3: move-aware pattern bindings via codegen-side bookkeeping

Closes 18d.2's per-field-dec scope cut without mutating the
source. Codegen Emitter gains a per-fn-body side table:

  moved_slots: BTreeMap<binder_name, BTreeSet<field_idx>>

When lower_match destructures a pattern and binds a non-wildcard
pointer-typed slot, codegen records (scrutinee_binder, slot_idx)
in moved_slots — but does NOT mutate the source's slot. The
source box stays untouched; the bookkeeping carries the
move-out information through codegen.

Two consumer sites:

(1) Term::Let scope close. If moved_slots[binder] is non-empty,
codegen inlines a per-field dec sequence: load each pointer-
typed field whose index is NOT in moved_slots, dispatch through
field_drop_call, then ailang_rc_dec on the outer cell. If
moved_slots[binder] is empty (the common case for every shipping
fixture today), the call to drop_<m>_<T>(ptr) is unchanged from
18c.4 — no IR shape regression.

(2) lower_reuse_as_rc reuse arm. Per-field dec is now safe — the
moved-out slots are skipped via moved_slots lookup; non-moved
slots are dec'd via field_drop_call's null-guarded drop fns.
Replaces the 18d.2 punt rationale block with the new invariant.

drop_<m>_<T> itself is unchanged: it operates on a fully-owned
pointer (the cascade has no notion of "partially moved"). The
partial-move complexity is confined to top-level emission sites
that have the static info.

Why static tracking, not source-mutation: the previous attempt
(null-out the source's slot at pattern-bind point) ran into two
structural issues: (i) borrowed scrutinees must not be mutated —
null-out violates 18a's borrow contract; (ii) Desugarer creates
chains that re-scrutinise the same scrutinee across arms, and
mutation in arm 1 makes arm 2's re-load see garbage. Static
tracking sidesteps both: the source box stays bit-identical
across the entire match.

Tests:
- new fixture examples/pat_extract_partial_drop — Pair(IntList,
  IntList) destructured (a, _) — exercises moved + wildcard
  pointer slots.
- new e2e alloc_rc_partial_drop_skips_moved_keeps_wildcarded
  asserts stdout 6 under both gc and rc, plus IR shape: main's
  let-close inlines drop on the wildcarded slot but NOT on the
  moved slot.
- updated reuse_as_demo_under_rc_uses_inplace_rewrite IR-shape
  assertion — the canonical fixture moves the only pointer
  slot, so the reuse arm should contain neither @drop_ nor
  @ailang_rc_dec for fields.

Test deltas: e2e 56 -> 57 (+1). All other buckets unchanged.
cargo test --workspace green.

Known regression — narrow, deliberate, queued for 18d.4:

The prior alloc_rc_borrow_only_recursive_list_drop fixture from
18c.4 had a pattern (Cons h t) where t was bound but never
consumed downstream. Under 18c.4, drop_<m>_<IntList>(xs) at
let-close cascaded through xs.tail and freed the entire 5-cell
chain. Under 18d.3 with strategy (b), xs.tail is in
moved_slots, so the cascade is interrupted there — but t is
never consumed, so its 4-cell tail now leaks. The fixture
stdout is unchanged (still "11"), but memory hygiene under
--alloc=rc is strictly worse.

The fix is symmetric to 18c.4's known fn-parameter-dec debt:
pattern-bound binders whose consume_count is 0 at arm close
should also get a drop call emitted. Same emission seam as
let-close-drop. Queued as 18d.4. Until 18d.4 ships, fixtures
with pattern-extracted unused pointer binders leak under
--alloc=rc; this is documented and bounded (shipping fixtures
beyond the test suite generally consume every binder they
bind).
This commit is contained in:
2026-05-08 12:18:12 +02:00
parent 509c9700bf
commit c96940ea96
4 changed files with 416 additions and 28 deletions
+107
View File
@@ -1423,6 +1423,23 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
!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 18d.3: in this canonical fixture the pattern `(Cons h t)`
// moves the only pointer-typed slot of the source's old ctor
// (slot 1, the tail; slot 0 is Int and never a dec candidate).
// The reuse arm therefore must emit NEITHER a per-field drop
// call nor an outer `ailang_rc_dec` against the source — only
// the unconditional tag store and field stores. The 18d.2 leak
// (full slot leaking under the reuse arm regardless of moves)
// is closed: the precise-skip behaviour matches what 18d.3's
// moved_slots side table records for `xs` here.
assert!(
!reuse_arm.contains("@drop_"),
"reuse arm must not call any `@drop_<m>_<T>` — both pointer slots are moved in this fixture. Reuse arm IR was:\n{reuse_arm}"
);
assert!(
!reuse_arm.contains("@ailang_rc_dec"),
"reuse arm must not call `@ailang_rc_dec` against the source (refcount-1 path keeps the slot alive in place). Reuse arm IR was:\n{reuse_arm}"
);
}
/// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger
@@ -1534,3 +1551,93 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
"alloc=rc must match alloc=gc on rc_list_drop_borrow"
);
}
/// Iter 18d.3: move-aware pattern bindings — let-close inlines a
/// per-field dec sequence that skips moved slots and dec's non-moved
/// (e.g. wildcarded) pointer-typed slots.
///
/// The fixture defines a `Pair (IntList, IntList)` and pattern-matches
/// it as `(Pair a _)` — the first slot is moved into `a` (consumed by
/// `sum_list a`), the second slot is wildcarded and remains owned by
/// the Pair box. At p's let-close, codegen replaces the uniform
/// `drop_<m>_Pair` call with an inline sequence:
///
/// 1. NO load+drop for slot 0 (offset 8) — it is in
/// `moved_slots["p"]` → skipped.
/// 2. YES load+drop for slot 1 (offset 16) via
/// `@drop_<m>_IntList(<slot1_v>)` — it is non-moved.
/// 3. YES `@ailang_rc_dec(<p>)` for the outer Pair box.
///
/// Properties guarded:
/// 1. The binary runs to completion under both `--alloc=gc` and
/// `--alloc=rc`.
/// 2. Stdout matches `--alloc=gc` byte-for-byte (`6` — the sum of
/// the first list, [1,2,3]).
/// 3. The IR at p's let-close contains an inlined per-field drop
/// for slot 1 (the wildcarded IntList) but NOT the uniform
/// `drop_<m>_Pair` call against `p`.
#[test]
fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
let example = "pat_extract_partial_drop.ail.json";
let stdout_gc = build_and_run_with_alloc(example, "gc");
let stdout_rc = build_and_run_with_alloc(example, "rc");
assert_eq!(stdout_gc.trim(), "6");
assert_eq!(stdout_rc.trim(), "6");
assert_eq!(
stdout_gc, stdout_rc,
"alloc=rc must match alloc=gc on pat_extract_partial_drop"
);
// Re-lower under rc to inspect the IR shape directly.
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
let json_path = workspace.join("examples").join(example);
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 inline partial drop must dispatch through the IntList drop
// fn for the wildcarded slot 1. The presence of THIS specific
// call — `@drop_pat_extract_partial_drop_IntList(ptr ...)` — in
// main's body proves a non-moved pointer slot was dec'd inline.
assert!(
ir.contains("call void @drop_pat_extract_partial_drop_IntList(ptr "),
"expected an inline `@drop_pat_extract_partial_drop_IntList(ptr ...)` for the wildcarded slot 1 of `p`. IR was:\n{ir}"
);
// Conversely, the uniform Pair drop fn (which would cascade
// through BOTH slots — the wrong shape because slot 0 was
// moved) must NOT be called at p's let-close. The Pair drop
// fn is still EMITTED as a per-type definition (used by any
// potential ADT-recursive cascade), but the call site at
// let-close must be the inlined sequence.
let main_start = ir
.find("define i8 @ail_pat_extract_partial_drop_main(")
.expect("main definition present");
let main_end_offset = ir[main_start..]
.find("\n}\n")
.expect("main definition ends with `}`");
let main_body = &ir[main_start..main_start + main_end_offset];
assert!(
!main_body.contains("@drop_pat_extract_partial_drop_Pair("),
"main's body must not call the uniform `@drop_pat_extract_partial_drop_Pair(...)` — at let-close, the moved-slot-aware inline sequence replaces it. main body was:\n{main_body}"
);
assert!(
main_body.contains("call void @ailang_rc_dec(ptr "),
"main's body must call `@ailang_rc_dec(ptr ...)` for the outer Pair box at let-close. main body was:\n{main_body}"
);
}
+243 -28
View File
@@ -552,6 +552,33 @@ struct Emitter<'a> {
/// `--alloc=gc`/`--alloc=bump` has no drop fn and is freed by
/// the collector / arena.
closure_drops: BTreeMap<String, String>,
/// Iter 18d.3: per-fn-body move tracking. Keyed by binder name, maps
/// to the set of positional ctor-field indices that have been
/// "moved out" via a pattern destructure. A field is moved when a
/// `(case (Ctor h t) <body>)` arm binds a non-wildcard, pointer-
/// typed slot — the load-into-binder is treated as a transfer of
/// ownership from the source slot to the binder's SSA. The source
/// slot is NOT mutated; codegen merely remembers, statically, that
/// the binder's new owner now holds the only live reference along
/// this path.
///
/// Consulted at two call sites that emit dec sequences against the
/// source binder:
/// 1. `Term::Let` scope close (`is_rc_heap_allocated` path) —
/// when the entry is non-empty, codegen inlines a per-field
/// dec sequence that skips slots in the moved set. When the
/// entry is empty (the common case), the existing
/// `drop_<m>_<T>(ptr)` call is emitted unchanged.
/// 2. `lower_reuse_as_rc`'s reuse arm — moved-out slots are
/// skipped (they no longer hold a live reference); non-moved
/// slots are dec'd via `field_drop_call` before the new field
/// values overwrite them.
///
/// Reset to empty at the top of every fn body (`emit_fn` and the
/// thunk-emission section of `lower_lambda`). Entries for a
/// particular binder are removed when that binder leaves scope
/// (on `Term::Let` body close, on match-arm body close).
moved_slots: BTreeMap<String, BTreeSet<usize>>,
}
#[derive(Debug, Clone)]
@@ -665,6 +692,7 @@ impl<'a> Emitter<'a> {
alloc,
uniqueness,
closure_drops: BTreeMap::new(),
moved_slots: BTreeMap::new(),
}
}
@@ -1031,6 +1059,8 @@ impl<'a> Emitter<'a> {
// collected during lowering and appended after the parent fn.
self.current_def = f.name.clone();
self.lam_counter = 0;
// Iter 18d.3: move tracking is per-fn-body.
self.moved_slots.clear();
// Iter 17a: run escape analysis over the fn body. The result
// is queried at every `Term::Ctor` / `Term::Lam` lowering site
// to decide between `alloca` (non-escaping) and `@GC_malloc`
@@ -1215,6 +1245,72 @@ impl<'a> Emitter<'a> {
}
}
/// Iter 18d.3: emit a partial-drop sequence inline at a let-close
/// site whose binder has moved-out pattern slots. Replaces the
/// uniform `drop_<m>_<T>(ptr)` call: load each pointer-typed
/// field whose slot index is NOT in `moved`, dispatch through
/// `field_drop_call` (the same per-type or shallow drop the
/// recursive cascade picks), then `ailang_rc_dec` the outer
/// box. Skips slots in `moved` entirely — those values were
/// transferred to a pattern-bound binder that owns the dec
/// for them.
///
/// `value` must be a `Term::Ctor` — `is_rc_heap_allocated` only
/// returns true for `Term::Ctor` / `Term::Lam`, and a `Term::Lam`
/// binder cannot accumulate moved slots (closures are not pattern-
/// matched into positional fields). The match-or-error pattern
/// guards that invariant.
fn emit_inlined_partial_drop(
&mut self,
value: &Term,
val_ssa: &str,
moved: &BTreeSet<usize>,
) -> Result<()> {
let (type_name, ctor_name) = match value {
Term::Ctor { type_name, ctor, .. } => (type_name.as_str(), ctor.as_str()),
_ => {
return Err(CodegenError::Internal(
"emit_inlined_partial_drop: binder value is not a Term::Ctor; \
moved_slots should only accumulate against Ctor binders"
.into(),
));
}
};
let cref = self.lookup_ctor_by_type(type_name, ctor_name)?;
// Per-field dec for non-moved pointer-typed slots. ail_fields
// are the AILang-level field types; field_drop_call resolves
// them to either `drop_<owner>_<T>` (ADTs cascade) or
// `ailang_rc_dec` (Str / closures / vars).
for (idx, fty_ail) in cref.ail_fields.iter().enumerate() {
let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
if moved.contains(&idx) {
continue;
}
let off = 8 + (idx as i64) * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {val_ssa}, i64 {off}\n"
));
let v = self.fresh_ssa();
self.body.push_str(&format!(
" {v} = load ptr, ptr {addr}, align 8\n"
));
let drop_call = self.field_drop_call(fty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {v})\n"
));
}
// Finally dec the outer box. The per-type drop fn would have
// done this in its `join` block; we replicate it here.
self.body.push_str(&format!(
" call void @ailang_rc_dec(ptr {val_ssa})\n"
));
Ok(())
}
/// Lowers a term to (SSA value string, LLVM type).
fn lower_term(&mut self, t: &Term) -> Result<(String, String)> {
match t {
@@ -1303,6 +1399,14 @@ impl<'a> Emitter<'a> {
.push((name.clone(), val_ssa.clone(), val_ty.clone(), val_ail));
let r = self.lower_term(body);
self.locals.pop();
// Iter 18d.3: lift the binder's move set out of the
// side table. The binder is leaving scope here; we
// remove the entry whether or not we end up using it
// for the dec emission below. `take` returns a
// by-value `BTreeSet<usize>` (or empty) so we can
// both consult and clear in one move.
let moves_for_binder: BTreeSet<usize> =
self.moved_slots.remove(name).unwrap_or_default();
// Iter 18c.3: emit a drop call at scope close iff:
// - we're tracking this binder (heap RC alloc above),
@@ -1336,10 +1440,31 @@ impl<'a> Emitter<'a> {
Err(_) => true, // Don't emit on error path either.
};
if consume_count == 0 && !body_returns_binder {
let drop_sym = self.drop_symbol_for_binder(value, &val_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {val_ssa})\n"
));
// Iter 18d.3: when the binder has moved-out
// pattern slots, the uniform `drop_<m>_<T>`
// would re-dec values that have already been
// transferred to other binders. Inline a
// per-field dec sequence that skips moved
// slots; non-moved pointer slots dec via
// `field_drop_call` (null-guarded drop fns
// matching the recursive cascade in
// `emit_drop_fn_for_type`). For the empty-
// moves common case (every fixture pre-18d.3
// and most fixtures post-18d.3) we emit the
// identical `drop_<m>_<T>(ptr)` call as
// 18c.4, preserving IR shape.
if !moves_for_binder.is_empty() {
self.emit_inlined_partial_drop(
value,
&val_ssa,
&moves_for_binder,
)?;
} else {
let drop_sym = self.drop_symbol_for_binder(value, &val_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {val_ssa})\n"
));
}
}
}
@@ -1815,6 +1940,21 @@ impl<'a> Emitter<'a> {
"reuse-as source type must be ptr, got {src_ty}"
)));
}
// Iter 18d.3: the source binder name keys into `moved_slots`
// so the reuse arm can skip dec'ing fields that earlier
// pattern-extracted out of the source. Linearity already
// ensures `source` is `Term::Var` here; the var-resolution
// is just defensive.
let source_binder: Option<String> = match source {
Term::Var { name } => {
if self.locals.iter().any(|(n, _, _, _)| n == name) {
Some(name.clone())
} else {
None
}
}
_ => None,
};
// 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
@@ -1929,33 +2069,59 @@ impl<'a> Emitter<'a> {
" 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.
// 5. Reuse arm: overwrite tag and field slots in place.
//
// 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.
// Iter 18d.3: per-field dec is now safe — moved-out slots
// are skipped via the codegen `moved_slots` side table;
// non-moved slots are dec'd via `field_drop_call`'s null-
// guarded drop fns BEFORE the new field values overwrite
// them. The shape check (18d.2) guarantees source's old
// ctor and body's new ctor have matching field counts and
// types, so `qualified_ail_fields` is correct for both.
//
// 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).
// Why this is sound under the canonical
// `(reuse-as xs (Cons (+ h 1) (map_inc t)))` pattern:
// the `Cons` arm of the enclosing match recorded both
// field 0 and field 1 of `xs` as moved; both are skipped
// here and no dec executes against the reused slots.
// `(map_inc t)`'s in-place-rewritten box is stored into
// field 1 unchanged — no use-after-free.
//
// For a fixture like `(match xs (Cons _ _ → reuse-as xs
// (Cons 0 0)))` where neither slot is moved, both
// pointer-typed fields are dec'd via `field_drop_call`,
// closing the 18d.2 leak.
self.start_block(&reuse_lbl);
// Iter 18d.3: dec non-moved pointer-typed slots before the
// overwrite. The dec fns are null-guarded, so an empty slot
// is a no-op. Clone the move set out of the side table so
// the loop below can mutably borrow `self` for SSA allocation.
let moves_for_src: BTreeSet<usize> = source_binder
.as_ref()
.and_then(|sb| self.moved_slots.get(sb).cloned())
.unwrap_or_default();
for (idx, fty_ail) in qualified_ail_fields.iter().enumerate() {
let lty = llvm_type(fty_ail).unwrap_or_else(|_| "ptr".into());
if lty != "ptr" {
continue;
}
if moves_for_src.contains(&idx) {
continue;
}
let off = 8 + (idx as i64) * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
" {addr} = getelementptr inbounds i8, ptr {src_ssa}, i64 {off}\n"
));
let v = self.fresh_ssa();
self.body.push_str(&format!(
" {v} = load ptr, ptr {addr}, align 8\n"
));
let drop_call = self.field_drop_call(fty_ail);
self.body.push_str(&format!(
" call void @{drop_call}(ptr {v})\n"
));
}
// 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-
@@ -2031,6 +2197,23 @@ impl<'a> Emitter<'a> {
"match on non-ADT scrutinee (got {s_ty}); MVP supports only ADTs"
)));
}
// Iter 18d.3: move tracking key — the bare-Var binder name of
// the scrutinee, if it has one. A complex expression scrutinee
// (e.g. the result of `(map_inc xs)` matched directly) has no
// binder we can key off; moves through such matches are
// untracked (see the assignment's "untracked scrutinee"
// fallback). Only record when the var actually resolves to a
// local binder.
let scrutinee_binder: Option<String> = match scrutinee {
Term::Var { name } => {
if self.locals.iter().any(|(n, _, _, _)| n == name) {
Some(name.clone())
} else {
None
}
}
_ => None,
};
// Load tag.
let tag = self.fresh_ssa();
@@ -2136,6 +2319,12 @@ impl<'a> Emitter<'a> {
_ => None,
};
let mut pushed = 0usize;
// Iter 18d.3: collect the binder names this arm pushes so
// we can drop their `moved_slots` entries when the arm
// body closes (these binders are themselves freshly named
// here — their move tracking starts empty and ends at arm
// body close).
let mut arm_pushed_binders: Vec<String> = Vec::new();
for (idx, binding) in bindings.iter().enumerate() {
if let Some(bname) = binding {
let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
@@ -2161,7 +2350,22 @@ impl<'a> Emitter<'a> {
self.body.push_str(&format!(
" {v} = load {fty}, ptr {addr}, align 8\n"
));
// Iter 18d.3: a non-wildcard, pointer-typed slot
// bound here is treated as moved out of the
// scrutinee binder. The source slot is NOT
// mutated; codegen records the move statically so
// a later top-level dec sequence (let-close,
// reuse-arm) skips this slot.
if fty == "ptr" {
if let Some(sb) = &scrutinee_binder {
self.moved_slots
.entry(sb.clone())
.or_default()
.insert(idx);
}
}
self.locals.push((bname.clone(), v, fty, bind_ail));
arm_pushed_binders.push(bname.clone());
pushed += 1;
}
}
@@ -2170,6 +2374,12 @@ impl<'a> Emitter<'a> {
for _ in 0..pushed {
self.locals.pop();
}
// Iter 18d.3: arm-bound binders go out of scope at arm
// body close. Drop their move-tracking entries (any moves
// recorded against `h`/`t` belonged to this arm only).
for bname in &arm_pushed_binders {
self.moved_slots.remove(bname);
}
// Iter 14e: if the arm body lowered to a `musttail call` +
// `ret`, the block is already terminated. Skip the
// fall-through `br` and exclude this arm from the join phi.
@@ -2694,6 +2904,10 @@ impl<'a> Emitter<'a> {
// of the saved emitter state.
let saved_non_escape = std::mem::take(&mut self.non_escape);
self.non_escape = escape::analyze_fn_body(lam_body);
// Iter 18d.3: a lambda thunk is its own fn frame for move
// tracking — the outer fn's binders are not in scope inside
// the thunk body (only its captures + params). Save and reset.
let saved_moved = std::mem::take(&mut self.moved_slots);
// Lambdas inside lambdas are fine: they get their own counter
// namespace within the enclosing thunk. They share the
// `deferred_thunks` queue (top-level body owns it).
@@ -2775,6 +2989,7 @@ impl<'a> Emitter<'a> {
self.block_terminated = saved_terminated;
self.ssa_fn_sigs = saved_sigs;
self.non_escape = saved_non_escape;
self.moved_slots = saved_moved;
// 3. Emit allocation + capture filling + closure-pair packing
// in the OUTER body. Captures use 8 bytes each; closure-pair
@@ -0,0 +1 @@
{"defs":[{"ctors":[{"fields":[],"name":"Nil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"IntList"}],"name":"Cons"}],"doc":"Recursive Int list — boxed.","kind":"type","name":"IntList"},{"ctors":[{"fields":[{"k":"con","name":"IntList"},{"k":"con","name":"IntList"}],"name":"Pair"}],"doc":"Two IntLists side by side. Both fields are pointer-typed; the test exercises move-tracking against EACH slot independently.","kind":"type","name":"Pair"},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"h","t":"var"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Consume xs, sum its elements.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","params":[{"k":"con","name":"IntList"}],"ret":{"k":"con","name":"Int"}}},{"body":{"body":{"arms":[{"body":{"args":[{"args":[{"name":"a","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"pat":{"ctor":"Pair","fields":[{"name":"a","p":"var"},{"p":"wild"}],"p":"ctor"}}],"scrutinee":{"name":"p","t":"var"},"t":"match"},"name":"p","t":"let","value":{"args":[{"args":[{"lit":{"kind":"int","value":1},"t":"lit"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"},{"args":[{"lit":{"kind":"int","value":3},"t":"lit"},{"args":[],"ctor":"Nil","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"},{"args":[{"lit":{"kind":"int","value":4},"t":"lit"},{"args":[{"lit":{"kind":"int","value":5},"t":"lit"},{"args":[{"lit":{"kind":"int","value":6},"t":"lit"},{"args":[],"ctor":"Nil","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Cons","t":"ctor","type":"IntList"}],"ctor":"Pair","t":"ctor","type":"Pair"}},"doc":"Build Pair([1,2,3], [4,5,6]); pattern-bind first slot only; sum first list. The wildcarded second slot's IntList lives only inside the Pair box; closing the Pair's let scope must dec it (non-moved) while skipping the moved first slot.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"pat_extract_partial_drop","schema":"ailang/v0"}
+65
View File
@@ -0,0 +1,65 @@
; Iter 18d.3 — move-aware pattern bindings: let-close emits an
; inlined per-field dec sequence that skips moved slots and dec's
; non-moved (wildcarded) pointer-typed slots.
;
; Fixture shape:
; - Pair carries two `IntList` fields (both pointer-typed).
; - main builds a Pair, pattern-matches it binding only the FIRST
; field, wildcarding the SECOND.
; - The matched binding `a` is consumed by `sum_list`. The
; wildcarded slot is logically retained by `p` (no binder owns
; it), so at p's let-close it must be dec'd.
;
; Expected IR shape under --alloc=rc at p's let-close:
; - NO `call void @drop_pat_extract_partial_drop_Pair(ptr <p>)`
; (the uniform drop fn is replaced by inline partial drop)
; - YES a `getelementptr ... ptr <p>, i64 16` + `load ptr` +
; `call void @drop_pat_extract_partial_drop_IntList(ptr <slot1_v>)`
; (slot 1 was NOT moved → emit per-field dec)
; - YES `call void @ailang_rc_dec(ptr <p>)` (outer cell)
; - NO load+dec for slot 0 (moved → skipped)
;
; Expected stdout: 6 (1 + 2 + 3 = 6, sum of the first list).
(module pat_extract_partial_drop
(data IntList
(doc "Recursive Int list — boxed.")
(ctor Nil)
(ctor Cons (con Int) (con IntList)))
(data Pair
(doc "Two IntLists side by side. Both fields are pointer-typed; the test exercises move-tracking against EACH slot independently.")
(ctor Pair (con IntList) (con IntList)))
(fn sum_list
(doc "Consume xs, sum its elements.")
(type
(fn-type
(params (con IntList))
(ret (con Int))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + h (app sum_list t))))))
(fn main
(doc "Build Pair([1,2,3], [4,5,6]); pattern-bind first slot only; sum first list. The wildcarded second slot's IntList lives only inside the Pair box; closing the Pair's let scope must dec it (non-moved) while skipping the moved first slot.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let p
(term-ctor Pair Pair
(term-ctor IntList Cons 1
(term-ctor IntList Cons 2
(term-ctor IntList Cons 3
(term-ctor IntList Nil))))
(term-ctor IntList Cons 4
(term-ctor IntList Cons 5
(term-ctor IntList Cons 6
(term-ctor IntList Nil)))))
(match p
(case (pat-ctor Pair a _)
(do io/print_int (app sum_list a))))))))