aver/ir/analyze.rs
1//! IR-level analysis pass — derives policy-independent (body classification,
2//! locals count) and policy-parametrized (alloc info) facts about each
3//! `FnDef`. Runs as the last stage of the canonical pipeline so consumers
4//! (codegen, dump, future inliner) can read derived metadata from one
5//! place instead of recomputing it.
6//!
7//! Why a unified analysis stage instead of ad-hoc calls scattered through
8//! VM compilation and WASM emission: every backend that wanted "is this
9//! fn no-alloc" or "what's its body shape" had its own call into
10//! `compute_alloc_info` / `classify_thin_fn_def`. Same input, same
11//! computation, repeated. Centralising means: one walk per program, one
12//! place to extend with new analyses, and `aver compile --emit-ir` /
13//! `--explain-passes` see the same numbers the codegen does.
14//!
15//! Backend-specific bits stay backend-specific: `AllocPolicy` is provided
16//! by the caller (`VmAllocPolicy`, `WasmAllocPolicy`, or the dump's
17//! conservative `DumpAllocPolicy`). Policy-independent facts (`body_shape`,
18//! `thin_kind`, `local_count`) are computed unconditionally.
19//!
20//! ## Scope: per-module by design (Aver module DAG invariant)
21//!
22//! `analyze` runs on one module at a time — the entry items, or a single
23//! dep module loaded from disk. It does NOT cross module boundaries to
24//! discover, say, a mutual-TCO SCC that spans two modules. This is correct
25//! by Aver's module-graph invariant, not a limitation:
26//!
27//! - `module M depends [A, B, ...]` — depends is a static list of names,
28//! no dynamic resolution.
29//! - Module loading (`load_module_recursive`) enforces a DAG via the
30//! `loaded: HashSet` cycle guard — a re-entered module name returns
31//! early, so the dep graph is acyclic by construction.
32//! - In a DAG, all calls flow from dependents to dependencies: module A
33//! calls into module B if A depends on B (transitively); B never calls
34//! back into A. Mutually-recursive call chains require a cycle in the
35//! call graph; a cycle requires either (i) a self-call within one
36//! module, or (ii) a cycle in the module DAG — and the latter is
37//! forbidden.
38//!
39//! Consequence: every SCC of mutually-recursive functions lives entirely
40//! within a single module. Per-module analysis covers every real case;
41//! "cross-module mutual TCO" is mathematically impossible in Aver.
42//! Backends that want a global `mutual_tco_members` set just union the
43//! per-module sets they get from each module's `AnalysisResult`.
44//!
45//! Same logic for `recursive_fns`, `recursive_call_count`, and the
46//! tail-call SCC computation that drives trampoline emission. None of
47//! these need to see cross-module data.
48
49use std::collections::{HashMap, HashSet};
50
51use crate::ast::{FnBody, FnDef, TopLevel};
52use crate::call_graph::{find_recursive_fns, tailcall_scc_components};
53use crate::ir::{
54 AllocPolicy, BodyExprPlan, BodyPlan, CallLowerCtx, ThinKind, classify_thin_fn_def,
55};
56
57/// Backend-neutral allocation policy useful for diagnostic dumps. Mirrors
58/// the VM/WASM "pure non-alloc builtins" whitelist exactly so the
59/// `[no_alloc]` annotation reads the same on both runtime backends.
60/// Constructors with payloads are treated as allocating; nullary
61/// constructors (`Option.None`, `Result.Ok` with primitive seed) are not.
62///
63/// Codegen pipelines should use the backend-specific policy
64/// (`VmAllocPolicy`, `WasmAllocPolicy`); this type is for `--emit-ir`,
65/// `aver bench`, and other tools that want a sensible answer without
66/// committing to a target.
67pub struct NeutralAllocPolicy;
68
69impl AllocPolicy for NeutralAllocPolicy {
70 fn builtin_allocates(&self, name: &str) -> bool {
71 !matches!(
72 name,
73 "Int.abs"
74 | "Int.min"
75 | "Int.max"
76 | "Float.fromInt"
77 | "Float.abs"
78 | "Float.floor"
79 | "Float.ceil"
80 | "Float.round"
81 | "Float.min"
82 | "Float.max"
83 | "Float.sin"
84 | "Float.cos"
85 | "Float.sqrt"
86 | "Float.pow"
87 | "Float.atan2"
88 | "Float.pi"
89 | "Char.toCode"
90 | "String.len"
91 | "String.byteLength"
92 | "String.startsWith"
93 | "String.endsWith"
94 | "String.contains"
95 | "List.len"
96 | "List.contains"
97 | "Vector.len"
98 | "Map.size"
99 | "Map.contains"
100 | "Set.size"
101 | "Set.contains"
102 | "Bool.and"
103 | "Bool.or"
104 | "Bool.not"
105 )
106 }
107 fn constructor_allocates(&self, _name: &str, has_payload: bool) -> bool {
108 has_payload
109 }
110}
111
112#[derive(Debug, Clone, Default)]
113pub struct AnalysisResult {
114 pub fn_analyses: HashMap<String, FnAnalysis>,
115 /// Names of fns that participate in a multi-member tail-call SCC
116 /// (mutual recursion). Backends emit a trampoline + thin wrappers
117 /// for each member; singleton self-recursive fns go through plain
118 /// TCO and aren't in this set.
119 pub mutual_tco_members: HashSet<String>,
120 /// Names of fns that call themselves directly or transitively.
121 /// Used by the type checker's flow analysis and the proof
122 /// exporters.
123 pub recursive_fns: HashSet<String>,
124}
125
126#[derive(Debug, Clone)]
127pub struct FnAnalysis {
128 /// `Some(true)` = proven to allocate under the supplied `AllocPolicy`;
129 /// `Some(false)` = proven not to; `None` = no policy was configured.
130 pub allocates: Option<bool>,
131 pub thin_kind: Option<ThinKind>,
132 pub body_shape: BodyShape,
133 pub local_count: Option<u16>,
134 /// `true` if this fn participates in a mutual-TCO SCC (not a singleton
135 /// self-recursive fn — those are plain TCO and don't need trampoline
136 /// codegen). Backends use this to gate trampoline emission.
137 pub mutual_tco_member: bool,
138 /// `true` if the fn calls itself directly or transitively. Used by
139 /// the type checker's flow analysis and the proof exporters.
140 pub recursive: bool,
141 /// Number of recursive call sites in the fn body. `0` for non-recursive
142 /// fns. Surfaced in the IR dump's `recursive×N` annotation.
143 pub recursive_call_count: usize,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum BodyShape {
148 /// `BodyPlan::SingleExpr` whose head is a leaf op (literal, ident,
149 /// resolved local, etc.) — the fastest possible body shape.
150 LeafExpr,
151 /// `BodyPlan::SingleExpr` of any other kind (call, match, ctor, …).
152 SingleExpr,
153 /// `BodyPlan::Block { stmts: N, .. }` — a multi-stmt block (let
154 /// bindings + tail expr).
155 Block(usize),
156 /// Body didn't match any of the classifier's recognised shapes;
157 /// usually means the body is a `match` or some other top-level
158 /// expression the thin-body classifier doesn't model.
159 Unclassified(usize),
160}
161
162/// Run the analysis on all `TopLevel::FnDef` items in `items`. The
163/// `alloc_policy` is optional — when `None`, `FnAnalysis::allocates`
164/// stays `None` for every fn (every other field is still computed).
165pub fn analyze(
166 items: &[TopLevel],
167 alloc_policy: Option<&dyn AllocPolicy>,
168 ctx: &impl CallLowerCtx,
169) -> AnalysisResult {
170 let fn_defs: Vec<&FnDef> = items
171 .iter()
172 .filter_map(|i| match i {
173 TopLevel::FnDef(fd) => Some(fd),
174 _ => None,
175 })
176 .collect();
177
178 let alloc_info = alloc_policy.map(|policy| {
179 // The trait object can't be passed straight to the generic
180 // `compute_alloc_info<P: AllocPolicy>`, so wrap it in a
181 // monomorphic adapter that re-dispatches through `dyn`.
182 struct PolicyRef<'a>(&'a dyn AllocPolicy);
183 impl AllocPolicy for PolicyRef<'_> {
184 fn builtin_allocates(&self, name: &str) -> bool {
185 self.0.builtin_allocates(name)
186 }
187 fn constructor_allocates(&self, name: &str, has_payload: bool) -> bool {
188 self.0.constructor_allocates(name, has_payload)
189 }
190 }
191 crate::ir::compute_alloc_info(&fn_defs, &PolicyRef(policy))
192 });
193
194 // Mutual-TCO membership — split out from `find_tco_groups` because
195 // backends want to know "is this fn in a multi-member SCC" specifically
196 // (singletons go through plain TCO and don't need trampoline codegen).
197 // Identical computation to what every codegen backend (`codegen::mod`,
198 // `codegen::rust::mod`) was running on its own; centralising means
199 // they read from `FnAnalysis` instead.
200 let entry_fns_for_scc: Vec<&FnDef> = fn_defs
201 .iter()
202 .filter(|fd| fd.name != "main")
203 .copied()
204 .collect();
205 let mut mutual_tco_set: HashSet<String> = HashSet::new();
206 for group in tailcall_scc_components(&entry_fns_for_scc) {
207 if group.len() < 2 {
208 continue; // singleton self-recursive fn — handled by plain TCO
209 }
210 for fd in group {
211 mutual_tco_set.insert(fd.name.clone());
212 }
213 }
214
215 let recursive_set = find_recursive_fns(items);
216 let recursive_call_counts = crate::call_graph::recursive_callsite_counts(items);
217
218 let mut fn_analyses: HashMap<String, FnAnalysis> = HashMap::with_capacity(fn_defs.len());
219 for fd in &fn_defs {
220 let plan = classify_thin_fn_def(fd, ctx);
221 let body_shape = match &plan {
222 Some(p) => match &p.body {
223 BodyPlan::SingleExpr(BodyExprPlan::Leaf(_)) => BodyShape::LeafExpr,
224 BodyPlan::SingleExpr(_) => BodyShape::SingleExpr,
225 BodyPlan::Block { stmts, .. } => BodyShape::Block(stmts.len()),
226 },
227 None => {
228 let FnBody::Block(stmts) = fd.body.as_ref();
229 BodyShape::Unclassified(stmts.len())
230 }
231 };
232 let analysis = FnAnalysis {
233 allocates: alloc_info.as_ref().and_then(|m| m.get(&fd.name).copied()),
234 thin_kind: plan.as_ref().map(|p| p.kind),
235 body_shape,
236 local_count: fd.resolution.as_ref().map(|r| r.local_count),
237 mutual_tco_member: mutual_tco_set.contains(&fd.name),
238 recursive: recursive_set.contains(&fd.name),
239 recursive_call_count: recursive_call_counts.get(&fd.name).copied().unwrap_or(0),
240 };
241 fn_analyses.insert(fd.name.clone(), analysis);
242 }
243
244 AnalysisResult {
245 fn_analyses,
246 mutual_tco_members: mutual_tco_set,
247 recursive_fns: recursive_set,
248 }
249}