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}