Skip to main content

aver/ir/
alias.rs

1//! Alias-slot annotation pass.
2//!
3//! Identifies, per fn, every local slot whose value might share an
4//! arena entry with another live binding. Backends with a `mem::take`-
5//! style fast path on `Vector.set` / `Map.set` (the VM's
6//! `CALL_BUILTIN_OWNED` mask + the fused `VECTOR_SET_OR_KEEP` opcode)
7//! must NOT take the fast path on a flagged slot, because the entry
8//! they'd `mem::take` from is reachable via another binding and the
9//! mutation would be observed there too. Wasm-gc may use the same
10//! flag to skip clone-on-write when a slot is provably non-aliased.
11//!
12//! ## When is a collection slot owned-eligible (NOT flagged)?
13//!
14//! Sound-by-construction whitelist. A `Vector<T>` / `Map<K, V>` local stays
15//! unflagged (eligible for the owned in-place fast path) ONLY when both hold:
16//!
17//! - **Fresh source (destination half, `rhs_is_fresh_collection`).** Its
18//!   binding RHS is a *provably fresh* collection — a `MapLiteral`, an
19//!   allocating builtin (`Vector.new` of a non-compound element, `Vector.set`,
20//!   `Vector.fromList`, `Map.set`, `Map.remove`), or a self-keep rebuild
21//!   `withDefault(set(L,..), L)`. Any other collection RHS — field / element
22//!   extraction (`rec.held`, a tuple item), a `Vector.get` / `Map.get` result,
23//!   a rename `b = a`, a user-fn result — may alias an existing arena entry,
24//!   so the destination is flagged.
25//! - **No escape (source half, `flag_escaping_collection_locals`).** Its
26//!   handle is not RETAINED by any other binding — not stored into an
27//!   aggregate (record / tuple / list / map value), not passed as a builtin
28//!   value-arg, not passed to a user fn that might return it, not renamed. The
29//!   receiver (arg 0) of a `Vector` / `Map` builtin and the self-keep fallback
30//!   are consuming moves, not escapes.
31//!
32//! Vector / Map PARAMS are always flagged here (a caller may hold the same
33//! entry); the MIR `own_param` pass later clears that bit interprocedurally
34//! when every call site passes a uniquely-owned argument.
35//!
36//! ## Conservative
37//!
38//! False positives only cost the owned fast path for the flagged slot — the
39//! slow path (clone backing, fresh arena entry) is always sound. False
40//! negatives are unsound: a shared binding on the fast path silently mutates
41//! the user's data (issue #410). So the default is FLAGGED, and a slot clears
42//! only on positive proof of fresh-AND-non-escaping.
43//!
44//! Runs after `last_use`. Stamps `FnResolution.aliased_slots` in place.
45
46use std::sync::Arc;
47
48use crate::ast::{Expr, FnBody, FnDef, Spanned, Stmt, StrPart, TopLevel, Type};
49
50pub fn annotate_program_alias_slots(items: &mut [TopLevel]) {
51    for item in items {
52        if let TopLevel::FnDef(fd) = item {
53            annotate_fn(fd);
54        }
55    }
56}
57
58fn annotate_fn(fd: &mut FnDef) {
59    let Some(res) = fd.resolution.clone() else {
60        return;
61    };
62    let local_count = res.local_count as usize;
63    let mut aliased = vec![false; local_count];
64
65    // (1) Vector / Map params get flagged unconditionally.
66    for (i, (_, ty)) in fd.params.iter().enumerate() {
67        if param_type_is_alias_prone(ty)
68            && let Some(slot) = aliased.get_mut(i)
69        {
70            *slot = true;
71        }
72    }
73
74    // Body bindings, two forward passes (transitive aliases propagate: a
75    // later `b = a` sees `a` flagged in an earlier pass). For each binding of
76    // a Vector / Map local, ownership is decided SOUND-BY-CONSTRUCTION by two
77    // complementary halves.
78    let body = fd.body.clone();
79    let FnBody::Block(stmts) = body.as_ref();
80    for _ in 0..2 {
81        for stmt in stmts {
82            if let Stmt::Binding(name, _, expr) = stmt {
83                let Some(&slot) = res.local_slots.get(name) else {
84                    continue;
85                };
86                // Source half: flag every bare collection local whose handle
87                // ESCAPES into this binding's value — a rename (`b = a`), a
88                // match arm tail, an aggregate member (record / tuple / list /
89                // map value), a builtin value-arg, or an arg to a user fn that
90                // may return it. Without it, `a = {..}; b = a; Map.set(a, ..)`
91                // would own-mutate `a` in place and silently rewrite `b`. The
92                // receiver (arg 0) of a Vector/Map builtin and the self-keep
93                // `withDefault(set(L,..), L)` rebind are consuming moves, NOT
94                // escapes, so they stay eligible for the owned fast path.
95                flag_escaping_collection_locals(&expr.node, &res.local_slot_types, &mut aliased);
96                // Destination half: a collection-typed binding is owned-
97                // eligible ONLY if its RHS is a PROVABLY-FRESH collection
98                // (literal / allocating builtin / self-keep rebuild). Any other
99                // collection RHS — field/element extraction, a `get`, a rename,
100                // a user-fn result — yields a handle that may alias an existing
101                // arena entry, so flag the destination. This whitelist replaces
102                // the former enumerate-the-alias-sources blacklist, which was
103                // unsound by omission (it missed aggregate-field extraction —
104                // `x = rec.held; Map.set(x, ..)` clobbered `rec`'s field).
105                if slot_is_collection(slot, &res.local_slot_types)
106                    && !rhs_is_fresh_collection(expr)
107                    && let Some(s) = aliased.get_mut(slot as usize)
108                {
109                    *s = true;
110                }
111            }
112        }
113    }
114
115    // Re-stamp the resolution. `Arc` swap keeps the rest of the
116    // resolution shape unchanged.
117    let new_res = crate::ast::FnResolution {
118        local_count: res.local_count,
119        local_slots: res.local_slots.clone(),
120        local_slot_types: res.local_slot_types.clone(),
121        aliased_slots: Arc::new(aliased),
122    };
123    fd.resolution = Some(new_res);
124}
125
126fn param_type_is_alias_prone(ty: &str) -> bool {
127    let trimmed = ty.trim();
128    trimmed.starts_with("Vector<") || trimmed.starts_with("Map<")
129}
130
131/// A binding RHS that yields a PROVABLY-FRESH `Vector` / `Map` — a `MapLiteral`,
132/// an allocating builtin (`Vector.new` of a non-compound element, `Vector.set`,
133/// `Vector.fromList`, `Map.set`, `Map.remove`), a self-keep rebuild
134/// `withDefault(set(L,..), L)`, or a `withDefault` whose branches are each
135/// fresh — produces a uniquely-owned arena entry, so the destination local is
136/// safe for the owned in-place fast path. Every other collection-typed RHS may
137/// alias an existing entry (field/element extraction, `get`, a rename, a
138/// user-fn result), so the destination must be flagged. Conservative:
139/// anything unrecognized is NOT fresh (flagging only costs the fast path).
140fn rhs_is_fresh_collection(expr: &Spanned<Expr>) -> bool {
141    match &expr.node {
142        Expr::MapLiteral(_) => true,
143        // Fresh only if EVERY arm is fresh — one aliasing arm taints the value.
144        Expr::Match { arms, .. } => arms.iter().all(|a| rhs_is_fresh_collection(&a.body)),
145        Expr::FnCall(callee, args) => {
146            if is_option_with_default(&callee.node) && args.len() == 2 {
147                return self_keep_slot(&args[0].node, &args[1].node).is_some()
148                    || (rhs_is_fresh_collection(&args[0]) && rhs_is_fresh_collection(&args[1]));
149            }
150            is_fresh_collection_builtin(&callee.node, args)
151        }
152        _ => false,
153    }
154}
155
156/// Vector / Map builtins that ALLOCATE a fresh outer collection. `Vector.new`
157/// is fresh only when its element is non-compound — a compound element is
158/// shared by every cell (the old rule 3 aliasing). `get` is excluded: it
159/// returns an element that aliases the source.
160fn is_fresh_collection_builtin(callee: &Expr, args: &[Spanned<Expr>]) -> bool {
161    let Expr::Attr(parent, member) = callee else {
162        return false;
163    };
164    let Expr::Ident(ns) = &parent.node else {
165        return false;
166    };
167    match (ns.as_str(), member.as_str()) {
168        ("Vector", "set") | ("Vector", "fromList") | ("Map", "set") | ("Map", "remove") => true,
169        ("Vector", "new") => args
170            .get(1)
171            .and_then(|a| a.ty())
172            .is_some_and(|t| !type_is_compound(&t.display())),
173        _ => false,
174    }
175}
176
177fn slot_is_collection(slot: u16, slot_types: &[Type]) -> bool {
178    slot_types
179        .get(slot as usize)
180        .is_some_and(|t| matches!(t, Type::Vector(_) | Type::Map(_, _)))
181}
182
183/// `Vector` / `Map` builtin call (`Vector.set`, `Map.get`, …) whose arg 0 is
184/// the collection receiver — consumed / read in place, never retained into the
185/// result (a `get` result aliases an *element*, handled by rule 2 on the
186/// destination, not by the receiver here).
187fn is_vector_map_builtin(callee: &Expr) -> bool {
188    matches!(callee, Expr::Attr(parent, _)
189        if matches!(&parent.node, Expr::Ident(p) if p == "Vector" || p == "Map"))
190}
191
192/// `Option.withDefault(Vector.set(L, ..) | Map.set(L, ..), L)` — the self-keep
193/// rebuild fusion. The result IS `L` (mutated, or unchanged on the fallback),
194/// so this is a consuming rebind of `L`, not an escape. Returns the kept slot.
195fn self_keep_slot(op1: &Expr, op2: &Expr) -> Option<u16> {
196    let Expr::Resolved { slot: kept, .. } = op2 else {
197        return None;
198    };
199    let Expr::FnCall(callee, set_args) = op1 else {
200        return None;
201    };
202    let Expr::Attr(parent, member) = &callee.node else {
203        return None;
204    };
205    let Expr::Ident(ns) = &parent.node else {
206        return None;
207    };
208    if !((ns == "Vector" || ns == "Map") && member == "set") {
209        return None;
210    }
211    match set_args.first().map(|a| &a.node) {
212        Some(Expr::Resolved { slot, .. }) if slot == kept => Some(*kept),
213        _ => None,
214    }
215}
216
217fn is_option_with_default(callee: &Expr) -> bool {
218    matches!(callee, Expr::Attr(parent, member)
219        if member == "withDefault"
220            && matches!(&parent.node, Expr::Ident(p) if p == "Option"))
221}
222
223/// Flag (as aliased) every bare collection local whose handle is RETAINED by
224/// `expr` (the value of a binding) — see the call site for the rationale and
225/// the non-escape exceptions (builtin receiver arg 0, self-keep rebuild).
226fn flag_escaping_collection_locals(expr: &Expr, slot_types: &[Type], aliased: &mut [bool]) {
227    match expr {
228        // A bare collection local in an escaping position: its handle flows
229        // into the binding's value, so it can no longer be owned-mutated.
230        Expr::Resolved { slot, .. } => {
231            if slot_is_collection(*slot, slot_types)
232                && let Some(s) = aliased.get_mut(*slot as usize)
233            {
234                *s = true;
235            }
236        }
237        Expr::FnCall(callee, args) => {
238            // Self-keep rebuild `withDefault(set(L,..), L)`: L is consumed, not
239            // aliased. Recurse only into the set's value-args (which may carry
240            // a *different* escaping collection), skipping the receiver and the
241            // kept fallback.
242            if is_option_with_default(&callee.node)
243                && args.len() == 2
244                && self_keep_slot(&args[0].node, &args[1].node).is_some()
245            {
246                if let Expr::FnCall(_, set_args) = &args[0].node {
247                    for a in set_args.iter().skip(1) {
248                        flag_escaping_collection_locals(&a.node, slot_types, aliased);
249                    }
250                }
251                return;
252            }
253            let recv_skip = is_vector_map_builtin(&callee.node);
254            for (i, a) in args.iter().enumerate() {
255                // Receiver (arg 0 of a Vector/Map builtin): a bare local here is
256                // consumed in place, not retained — skip it. A compound arg 0
257                // still recurses (its own nested receivers self-handle).
258                if recv_skip && i == 0 && matches!(&a.node, Expr::Resolved { .. }) {
259                    continue;
260                }
261                flag_escaping_collection_locals(&a.node, slot_types, aliased);
262            }
263            flag_escaping_collection_locals(&callee.node, slot_types, aliased);
264        }
265        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::ErrorProp(inner) => {
266            flag_escaping_collection_locals(&inner.node, slot_types, aliased);
267        }
268        Expr::BinOp(_, lhs, rhs) => {
269            flag_escaping_collection_locals(&lhs.node, slot_types, aliased);
270            flag_escaping_collection_locals(&rhs.node, slot_types, aliased);
271        }
272        // The scrutinee is read/consumed; each arm tail becomes the value.
273        Expr::Match { subject: _, arms } => {
274            for a in arms {
275                flag_escaping_collection_locals(&a.body.node, slot_types, aliased);
276            }
277        }
278        Expr::Constructor(_, payload) => {
279            if let Some(p) = payload {
280                flag_escaping_collection_locals(&p.node, slot_types, aliased);
281            }
282        }
283        Expr::Tuple(items) | Expr::List(items) | Expr::IndependentProduct(items, _) => {
284            for i in items {
285                flag_escaping_collection_locals(&i.node, slot_types, aliased);
286            }
287        }
288        Expr::MapLiteral(pairs) => {
289            for (k, v) in pairs {
290                flag_escaping_collection_locals(&k.node, slot_types, aliased);
291                flag_escaping_collection_locals(&v.node, slot_types, aliased);
292            }
293        }
294        Expr::RecordCreate { fields, .. } => {
295            for (_, e) in fields {
296                flag_escaping_collection_locals(&e.node, slot_types, aliased);
297            }
298        }
299        Expr::RecordUpdate { base, updates, .. } => {
300            flag_escaping_collection_locals(&base.node, slot_types, aliased);
301            for (_, e) in updates {
302                flag_escaping_collection_locals(&e.node, slot_types, aliased);
303            }
304        }
305        Expr::InterpolatedStr(parts) => {
306            for p in parts {
307                if let StrPart::Parsed(e) = p {
308                    flag_escaping_collection_locals(&e.node, slot_types, aliased);
309                }
310            }
311        }
312        Expr::Literal(_) | Expr::Ident(_) | Expr::TailCall(_) => {}
313    }
314}
315
316fn type_is_compound(ty: &str) -> bool {
317    let trimmed = ty.trim();
318    trimmed.starts_with("Vector<")
319        || trimmed.starts_with("Map<")
320        || trimmed.starts_with("List<")
321        || trimmed.starts_with("Tuple<")
322        || trimmed.starts_with("Result<")
323        || trimmed.starts_with("Option<")
324        || (trimmed
325            .chars()
326            .next()
327            .is_some_and(|c| c.is_ascii_uppercase())
328            && !matches!(trimmed, "Int" | "Float" | "Bool" | "String" | "Unit"))
329}