diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 09f1261..b4a1843 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -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__` — 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__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__IntList()` — it is non-moved. +/// 3. YES `@ailang_rc_dec(

)` 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__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}" + ); +} diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index 6a036ca..4333c46 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -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, + /// 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) )` 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__(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>, } #[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__(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, + ) -> 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__` (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` (or empty) so we can + // both consult and clear in one move. + let moves_for_binder: BTreeSet = + 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__` + // 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__(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 = 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 = 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 = 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 = 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 diff --git a/examples/pat_extract_partial_drop.ail.json b/examples/pat_extract_partial_drop.ail.json new file mode 100644 index 0000000..665908e --- /dev/null +++ b/examples/pat_extract_partial_drop.ail.json @@ -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"} diff --git a/examples/pat_extract_partial_drop.ailx b/examples/pat_extract_partial_drop.ailx new file mode 100644 index 0000000..431dd67 --- /dev/null +++ b/examples/pat_extract_partial_drop.ailx @@ -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

)` +; (the uniform drop fn is replaced by inline partial drop) +; - YES a `getelementptr ... ptr

, i64 16` + `load ptr` + +; `call void @drop_pat_extract_partial_drop_IntList(ptr )` +; (slot 1 was NOT moved → emit per-field dec) +; - YES `call void @ailang_rc_dec(ptr

)` (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))))))))