Skip to main content

gdscript_hir/
flow.rs

1//! Per-body control-flow narrowing (Phase-6 Workstream 2).
2//!
3//! A pure forward dataflow over a lowered [`Body`] (no engine API, no `&dyn Db`, no types — types
4//! are layered by the checker in [`crate::infer`] consulting these facts). For each reachable
5//! statement it records the [`FlowFacts`] that hold *before* it; the checker installs those as the
6//! active narrowing environment, replacing the old lexical `narrowing` map. It also computes
7//! reachability — statements after a `return`/`break`/`continue` (or where every branch diverges)
8//! are dead, feeding `UNREACHABLE_CODE` (Workstream 1 owns the emission).
9//!
10//! **Soundness over precision (the 1.0 invariant).** When unsure we *widen* (drop a fact), never
11//! narrow wrongly: a join is an intersection, a reassignment / opaque call invalidates, a loop body
12//! is entered with its assignments widened (no back-edge fixpoint). A wrong narrowing would hide a
13//! real `UNSAFE_*` or assert an absent member — both worse than over-warning. The checker keeps the
14//! load-bearing `is_uninformative` + widen-only gate when it *consumes* a fact (M1).
15//!
16//! GDScript's control flow is fully structured (reducible: the only non-local edges are
17//! `break`/`continue`), so this recursive dataflow is equivalent to — and simpler + less
18//! error-prone than — an explicit basic-block graph. It produces exactly the contract the checker
19//! (M1) and the warning layer (M3 `UNREACHABLE_*`) consume: per-statement entry facts +
20//! reachability.
21
22use rustc_hash::FxHashMap;
23use smol_str::SmolStr;
24
25use gdscript_base::TextRange;
26
27use crate::body::{BinOp, Body, Expr, ExprId, Literal, Stmt, StmtId, UnOp};
28use crate::cst::AstPtr;
29
30/// A narrowable place: a local/param, or a (shallow) dotted access rooted at a local or `self`.
31/// Deliberately shallow — we narrow `x`, `x.y`, `self.y` but **not** arbitrary call results
32/// (`f().y`), array indices (`a[i].y`), or anything whose identity isn't stable under re-evaluation.
33/// Shallowness is what keeps narrowing sound under mutation/aliasing (the 1.0 cut).
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub enum Place {
36    /// A function local / parameter, by name (GDScript locals are function-scoped).
37    Local(SmolStr),
38    /// `self.member` (or a bare member resolving through `self`).
39    SelfMember(SmolStr),
40    /// A field access on another place (`x.y`, `self.y.z`).
41    Field(Box<Place>, SmolStr),
42}
43
44impl Place {
45    /// Derive the place an expression denotes, or `None` for a non-narrowable expression.
46    #[must_use]
47    pub fn of(body: &Body, id: ExprId) -> Option<Place> {
48        match body.expr(id) {
49            Expr::Name(n) => Some(Place::Local(n.clone())),
50            Expr::Paren(inner) => Place::of(body, *inner),
51            Expr::Field { receiver, name, .. } => match body.expr(*receiver) {
52                Expr::SelfExpr => Some(Place::SelfMember(name.clone())),
53                _ => Some(Place::Field(
54                    Box::new(Place::of(body, *receiver)?),
55                    name.clone(),
56                )),
57            },
58            // `self` itself isn't narrowed (only `self.m`, above); all else is non-narrowable.
59            _ => None,
60        }
61    }
62
63    /// Whether assigning to `assigned` may invalidate a narrowing of `self`. Conservative prefix
64    /// check: assigning `x` clears `x` and `x.*`; assigning `x.y` clears `x.y` and `x.y.*` (but not
65    /// `x`). I.e. `assigned` is an ancestor-or-equal of `self`.
66    #[must_use]
67    pub fn invalidated_by(&self, assigned: &Place) -> bool {
68        let mut cur = self;
69        loop {
70            if cur == assigned {
71                return true;
72            }
73            match cur {
74                Place::Field(base, _) => cur = base,
75                _ => return false,
76            }
77        }
78    }
79
80    /// The dotted access-path key for this place (`x`, `self.field`, `a.b.c`) — the format the
81    /// checker's `narrow_key` produces, so the two agree when the checker consults a fact.
82    #[must_use]
83    pub fn dotted_key(&self) -> String {
84        match self {
85            Place::Local(n) => n.to_string(),
86            Place::SelfMember(m) => format!("self.{m}"),
87            Place::Field(base, name) => format!("{}.{name}", base.dotted_key()),
88        }
89    }
90
91    /// Whether this place is rooted at `self` (a `self.member` or a field chain under it) — the
92    /// places an opaque call may have mutated.
93    #[must_use]
94    fn is_self_rooted(&self) -> bool {
95        match self {
96            Place::SelfMember(_) => true,
97            Place::Field(base, _) => base.is_self_rooted(),
98            Place::Local(_) => false,
99        }
100    }
101}
102
103/// A narrowing fact that holds at a program point (a place narrowed to a type or proven non-null).
104/// The type-test variants carry an [`AstPtr`] to the `TypeRef`, resolved lazily by the checker
105/// against the engine model — exactly like the old `apply_narrowing` resolved its `ptr`.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum NarrowedTy {
108    /// The place is statically `T` — from `is T`, an `x as T` assignment, or a `match T():` arm.
109    Is(AstPtr),
110    /// Proven non-null (from `!= null`, a truthy object guard, or a prior `is`). 1.0 records it but
111    /// the checker uses it only to suppress null access, not to assert a type.
112    NotNull,
113    /// Proven **not** `T` (the else-branch of `is T`). Best-effort: 1.0 records it but never uses it
114    /// to assert a member (no positive type).
115    Not(AstPtr),
116}
117
118/// The narrowing facts in force at a program point (a `Place → NarrowedTy` environment).
119#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct FlowFacts(FxHashMap<Place, NarrowedTy>);
121
122impl FlowFacts {
123    /// The narrowed type of `place`, if any.
124    #[must_use]
125    pub fn get(&self, place: &Place) -> Option<&NarrowedTy> {
126        self.0.get(place)
127    }
128
129    /// Whether there are no facts.
130    #[must_use]
131    pub fn is_empty(&self) -> bool {
132        self.0.is_empty()
133    }
134
135    /// Iterate the `(place, narrowed-type)` facts.
136    pub fn iter(&self) -> impl Iterator<Item = (&Place, &NarrowedTy)> {
137        self.0.iter()
138    }
139
140    /// Install a fact, preferring the stronger of an existing `Is` over a new `NotNull` (an `Is`
141    /// already implies non-null, so a later truthy guard must not weaken it).
142    fn insert(&mut self, place: Place, ty: NarrowedTy) {
143        if matches!(ty, NarrowedTy::NotNull)
144            && matches!(self.0.get(&place), Some(NarrowedTy::Is(_)))
145        {
146            return;
147        }
148        self.0.insert(place, ty);
149    }
150
151    /// Drop every fact invalidated by an assignment to `assigned` (it and its sub-places).
152    fn invalidate_assigned(&mut self, assigned: &Place) {
153        self.0.retain(|p, _| !p.invalidated_by(assigned));
154    }
155
156    /// Drop every `self`-rooted fact (an opaque call may have mutated `self`'s members).
157    fn invalidate_self_rooted(&mut self) {
158        self.0.retain(|p, _| !p.is_self_rooted());
159    }
160
161    /// The intersection of two fact sets — a place survives a control-flow merge only if narrowed
162    /// **identically** on both incoming edges (the soundness core: drop on any disagreement).
163    #[must_use]
164    fn join(&self, other: &FlowFacts) -> FlowFacts {
165        let mut out = FxHashMap::default();
166        for (p, t) in &self.0 {
167            if other.0.get(p) == Some(t) {
168                out.insert(p.clone(), t.clone());
169            }
170        }
171        FlowFacts(out)
172    }
173}
174
175/// The result of flowing a body: per-statement entry facts + the statements proven unreachable.
176#[derive(Debug, Clone, Default)]
177pub struct FlowAnalysis {
178    /// Facts holding *before* each reachable statement. A statement absent here is either
179    /// unreachable or carries no narrowing — the checker treats both as "no facts".
180    entry_facts: FxHashMap<StmtId, FlowFacts>,
181    /// The first statement of each maximal unreachable run (the `UNREACHABLE_CODE` anchors).
182    unreachable_anchors: Vec<StmtId>,
183}
184
185impl FlowAnalysis {
186    /// The facts in force before `stmt` (empty if none / unreachable).
187    #[must_use]
188    pub fn facts_before(&self, stmt: StmtId) -> Option<&FlowFacts> {
189        self.entry_facts.get(&stmt)
190    }
191
192    /// The byte ranges of the unreachable-code anchors (Workstream 1 emits `UNREACHABLE_CODE` here).
193    #[must_use]
194    pub fn unreachable_ranges(&self, body: &Body) -> Vec<TextRange> {
195        self.unreachable_anchors
196            .iter()
197            .map(|&sid| body.source_map.stmt_range(sid))
198            .collect()
199    }
200}
201
202/// Run the forward dataflow over a lowered body, producing per-statement entry facts + reachability.
203#[must_use]
204pub fn analyze(body: &Body) -> FlowAnalysis {
205    let mut a = Analyzer {
206        body,
207        entry_facts: FxHashMap::default(),
208        unreachable_anchors: Vec::new(),
209    };
210    a.block(FlowFacts::default(), &body.block);
211    // Each lambda body is a fresh scope — analyze it independently (its statements share the body's
212    // arena, so their entry facts merge into the same map). A single pass over the expression arena
213    // catches every lambda, including nested ones.
214    for expr in &body.exprs {
215        if let Expr::Lambda { body: lbody, .. } = expr {
216            a.block(FlowFacts::default(), lbody);
217        }
218    }
219    FlowAnalysis {
220        entry_facts: a.entry_facts,
221        unreachable_anchors: a.unreachable_anchors,
222    }
223}
224
225struct Analyzer<'a> {
226    body: &'a Body,
227    entry_facts: FxHashMap<StmtId, FlowFacts>,
228    unreachable_anchors: Vec<StmtId>,
229}
230
231impl Analyzer<'_> {
232    /// Flow a statement block. Returns the facts that fall through to the next statement, or `None`
233    /// if the block diverges (every path `return`s/`break`s/`continue`s). Records the first
234    /// unreachable statement as an anchor.
235    fn block(&mut self, facts: FlowFacts, block: &[StmtId]) -> Option<FlowFacts> {
236        let mut cur = Some(facts);
237        for &sid in block {
238            let Some(f) = cur else {
239                // The first statement past a divergence anchors `UNREACHABLE_CODE`.
240                self.unreachable_anchors.push(sid);
241                return None;
242            };
243            cur = self.stmt(f, sid);
244        }
245        cur
246    }
247
248    /// Flow one statement: record its entry facts, then return the facts that fall through (or
249    /// `None` if it diverges).
250    fn stmt(&mut self, facts: FlowFacts, sid: StmtId) -> Option<FlowFacts> {
251        self.entry_facts.insert(sid, facts.clone());
252        match self.body.stmt(sid) {
253            Stmt::Return(_) | Stmt::Break | Stmt::Continue => None,
254            Stmt::Pass | Stmt::Assert(_) => Some(facts),
255            Stmt::Expr(e) => Some(self.after_expr_stmt(facts, *e)),
256            Stmt::Var(v) => {
257                let mut f = facts;
258                // A (re-)declaration shadows: drop any prior narrowing of this name.
259                f.invalidate_assigned(&Place::Local(v.name.clone()));
260                Some(f)
261            }
262            Stmt::If {
263                cond,
264                then_branch,
265                elifs,
266                else_branch,
267            } => self.flow_if(&facts, *cond, then_branch, elifs, else_branch.as_deref()),
268            Stmt::While { body, .. } => Some(self.flow_loop(facts, body, None)),
269            Stmt::For(f) => Some(self.flow_loop(facts, &f.body, Some(&f.var))),
270            Stmt::Match { arms, .. } => {
271                // Conservative: each arm is flowed from the original facts (no scrutinee narrowing
272                // in the 1.0 cut — pattern types aren't lowered yet); the match falls through with
273                // every arm's assignments widened away (we can't yet prove exhaustiveness).
274                let mut after = facts.clone();
275                for arm in arms {
276                    let _ = self.block(facts.clone(), &arm.body);
277                    self.scan_invalidations(&mut after, &arm.body);
278                }
279                Some(after)
280            }
281        }
282    }
283
284    /// Facts after an expression statement: a reassignment invalidates the assigned place; any call
285    /// invalidates `self`-rooted narrowing (an opaque call may mutate `self`'s members).
286    fn after_expr_stmt(&self, mut facts: FlowFacts, e: ExprId) -> FlowFacts {
287        if let Expr::Bin {
288            op: BinOp::Assign,
289            lhs,
290            ..
291        } = self.body.expr(e)
292            && let Some(p) = Place::of(self.body, *lhs)
293        {
294            facts.invalidate_assigned(&p);
295        }
296        if self.expr_contains_call(e) {
297            facts.invalidate_self_rooted();
298        }
299        facts
300    }
301
302    /// `if … elif … else …`: each branch is flowed under its guard's narrowing; the result is the
303    /// join of the branches that fall through. The early-return idiom falls out — if the `then`
304    /// branch diverges, the merge is just the `else`/no-else facts (with the guard negated).
305    fn flow_if(
306        &mut self,
307        facts: &FlowFacts,
308        cond: ExprId,
309        then_branch: &[StmtId],
310        elifs: &[(ExprId, crate::body::Block)],
311        else_branch: Option<&[StmtId]>,
312    ) -> Option<FlowFacts> {
313        let mut exits: Vec<Option<FlowFacts>> = Vec::new();
314        let then_in = self.apply(facts, cond, true);
315        exits.push(self.block(then_in, then_branch));
316
317        // `elif` chain: each guard is evaluated under "all previous guards false".
318        let mut chain = self.apply(facts, cond, false);
319        for (econd, eblock) in elifs {
320            let etrue = self.apply(&chain, *econd, true);
321            exits.push(self.block(etrue, eblock));
322            chain = self.apply(&chain, *econd, false);
323        }
324        // The final `else` (or the implicit fall-through when there is none).
325        exits.push(match else_branch {
326            Some(eb) => self.block(chain, eb),
327            None => Some(chain),
328        });
329
330        join_exits(exits)
331    }
332
333    /// A `while`/`for` loop, entered with its body's assignments widened (no back-edge fixpoint —
334    /// the 1.0 cut). Always falls through (the body may run zero times); after the loop the body's
335    /// assignments are widened away.
336    fn flow_loop(
337        &mut self,
338        facts: FlowFacts,
339        body: &[StmtId],
340        loop_var: Option<&SmolStr>,
341    ) -> FlowFacts {
342        let mut widened = facts;
343        if let Some(v) = loop_var {
344            widened.invalidate_assigned(&Place::Local(v.clone()));
345        }
346        self.scan_invalidations(&mut widened, body);
347        // Flow the body once with the widened facts (records the body's entry facts); its exit is
348        // discarded — a loop's after-state is the widened pre-loop facts, not the body's.
349        let _ = self.block(widened.clone(), body);
350        widened
351    }
352
353    /// Apply a condition's narrowing to a fact set for the truthy/falsy edge.
354    fn apply(&self, facts: &FlowFacts, cond: ExprId, truthy: bool) -> FlowFacts {
355        let mut out = facts.clone();
356        for (p, t) in self.derive_facts(cond, truthy) {
357            out.insert(p, t);
358        }
359        // An opaque call in the condition may run *after* a narrowing test (e.g. the rhs of an
360        // `and`, or `if mutate() and self.x is T:`) and mutate `self`'s members, so no `self`-rooted
361        // narrowing from this edge is trustworthy — drop it (mirrors `after_expr_stmt`). Local
362        // narrowing is unaffected (a callee cannot reassign a caller's local). Soundness > precision.
363        if self.expr_contains_call(cond) {
364            out.invalidate_self_rooted();
365        }
366        out
367    }
368
369    /// The facts a condition establishes on its truthy (or falsy) edge.
370    fn derive_facts(&self, cond: ExprId, truthy: bool) -> Vec<(Place, NarrowedTy)> {
371        match self.body.expr(cond) {
372            Expr::Paren(inner) => self.derive_facts(*inner, truthy),
373            Expr::Unary {
374                op: UnOp::Not,
375                operand,
376            } => self.derive_facts(*operand, !truthy),
377            Expr::Is {
378                operand,
379                ty: Some(ptr),
380                negated,
381            } => {
382                let positive = truthy != *negated;
383                Place::of(self.body, *operand)
384                    .map(|p| {
385                        let t = if positive {
386                            NarrowedTy::Is(*ptr)
387                        } else {
388                            NarrowedTy::Not(*ptr)
389                        };
390                        vec![(p, t)]
391                    })
392                    .unwrap_or_default()
393            }
394            Expr::Bin {
395                op: BinOp::Eq,
396                lhs,
397                rhs,
398            } => self.null_cmp_facts(*lhs, *rhs, true, truthy),
399            Expr::Bin {
400                op: BinOp::Ne,
401                lhs,
402                rhs,
403            } => self.null_cmp_facts(*lhs, *rhs, false, truthy),
404            // `a and b` truthy ⇒ both true; `a or b` falsy ⇒ both false. The other directions
405            // cannot be attributed to one operand (either could be the deciding one) — widen.
406            Expr::Bin {
407                op: BinOp::And,
408                lhs,
409                rhs,
410            } if truthy => {
411                let mut v = self.derive_facts(*lhs, true);
412                v.extend(self.derive_facts(*rhs, true));
413                v
414            }
415            Expr::Bin {
416                op: BinOp::Or,
417                lhs,
418                rhs,
419            } if !truthy => {
420                let mut v = self.derive_facts(*lhs, false);
421                v.extend(self.derive_facts(*rhs, false));
422                v
423            }
424            // A bare truthy guard `if x:` / `if x.y:` proves the place non-null.
425            _ if truthy => Place::of(self.body, cond)
426                .map(|p| vec![(p, NarrowedTy::NotNull)])
427                .unwrap_or_default(),
428            _ => Vec::new(),
429        }
430    }
431
432    /// Facts from a `==`/`!=` comparison against `null`: the non-null operand becomes `NotNull` on
433    /// the edge where it is proven non-null (`x != null` true, or `x == null` false).
434    fn null_cmp_facts(
435        &self,
436        lhs: ExprId,
437        rhs: ExprId,
438        is_eq: bool,
439        truthy: bool,
440    ) -> Vec<(Place, NarrowedTy)> {
441        let other = if self.is_null(lhs) {
442            rhs
443        } else if self.is_null(rhs) {
444            lhs
445        } else {
446            return Vec::new();
447        };
448        let proves_not_null = if is_eq { !truthy } else { truthy };
449        if proves_not_null {
450            Place::of(self.body, other)
451                .map(|p| vec![(p, NarrowedTy::NotNull)])
452                .unwrap_or_default()
453        } else {
454            Vec::new()
455        }
456    }
457
458    fn is_null(&self, id: ExprId) -> bool {
459        matches!(self.body.expr(id), Expr::Literal(Literal::Null))
460    }
461
462    /// Drop, from `facts`, every place a block's statements may assign / invalidate (for widening a
463    /// loop entry/exit and a `match` fall-through). Recurses into nested blocks.
464    fn scan_invalidations(&self, facts: &mut FlowFacts, block: &[StmtId]) {
465        for &sid in block {
466            match self.body.stmt(sid) {
467                Stmt::Expr(e) => {
468                    if let Expr::Bin {
469                        op: BinOp::Assign,
470                        lhs,
471                        ..
472                    } = self.body.expr(*e)
473                        && let Some(p) = Place::of(self.body, *lhs)
474                    {
475                        facts.invalidate_assigned(&p);
476                    }
477                    if self.expr_contains_call(*e) {
478                        facts.invalidate_self_rooted();
479                    }
480                }
481                Stmt::Var(v) => facts.invalidate_assigned(&Place::Local(v.name.clone())),
482                Stmt::If {
483                    cond,
484                    then_branch,
485                    elifs,
486                    else_branch,
487                } => {
488                    // A call in a guard (run every iteration when this `if` is inside the loop) may
489                    // mutate `self` — account for it alongside the branch bodies.
490                    if self.expr_contains_call(*cond) {
491                        facts.invalidate_self_rooted();
492                    }
493                    self.scan_invalidations(facts, then_branch);
494                    for (econd, b) in elifs {
495                        if self.expr_contains_call(*econd) {
496                            facts.invalidate_self_rooted();
497                        }
498                        self.scan_invalidations(facts, b);
499                    }
500                    if let Some(eb) = else_branch {
501                        self.scan_invalidations(facts, eb);
502                    }
503                }
504                Stmt::While { cond, body } => {
505                    if self.expr_contains_call(*cond) {
506                        facts.invalidate_self_rooted();
507                    }
508                    self.scan_invalidations(facts, body);
509                }
510                Stmt::For(f) => {
511                    facts.invalidate_assigned(&Place::Local(f.var.clone()));
512                    if self.expr_contains_call(f.iter) {
513                        facts.invalidate_self_rooted();
514                    }
515                    self.scan_invalidations(facts, &f.body);
516                }
517                Stmt::Match { scrutinee, arms } => {
518                    if self.expr_contains_call(*scrutinee) {
519                        facts.invalidate_self_rooted();
520                    }
521                    for arm in arms {
522                        self.scan_invalidations(facts, &arm.body);
523                    }
524                }
525                Stmt::Assert(Some(c)) => {
526                    if self.expr_contains_call(*c) {
527                        facts.invalidate_self_rooted();
528                    }
529                }
530                Stmt::Return(_)
531                | Stmt::Break
532                | Stmt::Continue
533                | Stmt::Pass
534                | Stmt::Assert(None) => {}
535            }
536        }
537    }
538
539    /// Whether an expression subtree contains a call (so an opaque mutation of `self` is possible).
540    fn expr_contains_call(&self, id: ExprId) -> bool {
541        match self.body.expr(id) {
542            Expr::Call { .. } => true,
543            Expr::Bin { lhs, rhs, .. } | Expr::In { lhs, rhs, .. } => {
544                self.expr_contains_call(*lhs) || self.expr_contains_call(*rhs)
545            }
546            Expr::Unary { operand, .. }
547            | Expr::Await(operand)
548            | Expr::Paren(operand)
549            | Expr::Cast { operand, .. }
550            | Expr::Is { operand, .. } => self.expr_contains_call(*operand),
551            Expr::Ternary {
552                cond,
553                then_branch,
554                else_branch,
555            } => {
556                self.expr_contains_call(*cond)
557                    || self.expr_contains_call(*then_branch)
558                    || self.expr_contains_call(*else_branch)
559            }
560            Expr::Field { receiver, .. } => self.expr_contains_call(*receiver),
561            Expr::Index { base, index } => {
562                self.expr_contains_call(*base) || self.expr_contains_call(*index)
563            }
564            Expr::Array(items) => items.iter().any(|&e| self.expr_contains_call(e)),
565            Expr::Dict(entries) => entries.iter().any(|(k, v)| {
566                self.expr_contains_call(*k) || v.is_some_and(|e| self.expr_contains_call(e))
567            }),
568            _ => false,
569        }
570    }
571}
572
573/// The narrowing facts a condition establishes on its truthy (or falsy) edge — exposed so the
574/// checker can apply `and`/`or` short-circuit narrowing *within* a condition expression (the RHS of
575/// `a and b` is typed under `a`'s then-facts, `a or b`'s under `a`'s else-facts).
576#[must_use]
577pub fn condition_facts(body: &Body, cond: ExprId, truthy: bool) -> Vec<(Place, NarrowedTy)> {
578    Analyzer {
579        body,
580        entry_facts: FxHashMap::default(),
581        unreachable_anchors: Vec::new(),
582    }
583    .derive_facts(cond, truthy)
584}
585
586/// Merge the exits of several control-flow paths: the join (intersection) of the ones that fall
587/// through, or `None` if every path diverges.
588fn join_exits(exits: Vec<Option<FlowFacts>>) -> Option<FlowFacts> {
589    let mut iter = exits.into_iter().flatten();
590    let first = iter.next()?;
591    Some(iter.fold(first, |acc, f| acc.join(&f)))
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::body::{self, Body};
598    use gdscript_syntax::{SyntaxKind, ast, parse};
599
600    fn func_body(src: &str) -> Body {
601        let root = parse(src).syntax_node();
602        let func = ast::descendants(&root)
603            .into_iter()
604            .find(|n| n.kind() == SyntaxKind::FuncDecl)
605            .expect("a FuncDecl");
606        body::body_of_func(&func)
607    }
608
609    /// The (single) `Place::Local` narrowed in the facts before the statement at top-level index `i`.
610    fn fact_at(body: &Body, a: &FlowAnalysis, i: usize) -> Option<(Place, NarrowedTy)> {
611        let sid = body.block[i];
612        let facts = a.facts_before(sid)?;
613        facts.0.iter().next().map(|(p, t)| (p.clone(), t.clone()))
614    }
615
616    #[test]
617    fn is_guard_narrows_then_branch() {
618        let body = func_body("func f(x):\n\tif x is Node:\n\t\tx.free()\n");
619        let a = analyze(&body);
620        // The `x.free()` stmt lives inside the then-branch; its entry facts narrow x to `Is`.
621        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
622            panic!("if")
623        };
624        let inner = a.facts_before(then_branch[0]).expect("then facts");
625        assert_eq!(
626            inner.get(&Place::Local("x".into())),
627            Some(&NarrowedTy::Is(match body.stmt(body.block[0]) {
628                Stmt::If { cond, .. } => match body.expr(*cond) {
629                    Expr::Is { ty: Some(p), .. } => *p,
630                    _ => panic!("is"),
631                },
632                _ => unreachable!(),
633            })),
634        );
635    }
636
637    #[test]
638    fn early_return_narrows_after_the_guard() {
639        // `if x == null: return` ⇒ after the if, x is NotNull (the then-branch diverged).
640        let body = func_body("func f(x):\n\tif x == null:\n\t\treturn\n\tx.free()\n");
641        let a = analyze(&body);
642        // block[0] = the if, block[1] = `x.free()`.
643        let after = a.facts_before(body.block[1]).expect("after-if facts");
644        assert_eq!(
645            after.get(&Place::Local("x".into())),
646            Some(&NarrowedTy::NotNull)
647        );
648    }
649
650    #[test]
651    fn code_after_return_is_unreachable() {
652        let body = func_body("func f():\n\treturn\n\tvar dead := 1\n");
653        let a = analyze(&body);
654        assert_eq!(a.unreachable_ranges(&body).len(), 1);
655        // The anchor is the `var dead` statement (block index 1).
656        assert_eq!(a.unreachable_anchors, vec![body.block[1]]);
657    }
658
659    #[test]
660    fn reassignment_invalidates_narrowing() {
661        // After `if x is Node:` narrows x, an assignment `x = other` inside drops the fact.
662        let body = func_body("func f(x, other):\n\tif x is Node:\n\t\tx = other\n\t\tx.free()\n");
663        let a = analyze(&body);
664        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
665            panic!("if")
666        };
667        // then_branch[0] = `x = other` (narrowed on entry), then_branch[1] = `x.free()` (widened).
668        let at_free = a.facts_before(then_branch[1]).expect("facts");
669        assert_eq!(at_free.get(&Place::Local("x".into())), None);
670    }
671
672    #[test]
673    fn opaque_call_invalidates_self_members() {
674        // `if self.node is Node2D:` narrows self.node; a bare call may mutate it → invalidated.
675        let body =
676            func_body("func f():\n\tif self.node is Node2D:\n\t\tmutate()\n\t\tself.node.foo()\n");
677        let a = analyze(&body);
678        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
679            panic!("if")
680        };
681        let at_use = a.facts_before(then_branch[1]).expect("facts");
682        assert_eq!(at_use.get(&Place::SelfMember("node".into())), None);
683    }
684
685    #[test]
686    fn opaque_call_in_guard_invalidates_self_member_narrowing() {
687        // A call in the guard itself (`mutate()` in the `and`) may reassign self.node *after* the
688        // `is` test, so self.node must NOT be narrowed in the then-branch — the soundness invariant.
689        let body =
690            func_body("func f():\n\tif self.node is Node2D and mutate():\n\t\tself.node.foo()\n");
691        let a = analyze(&body);
692        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
693            panic!("if")
694        };
695        let inner = a.facts_before(then_branch[0]).expect("then facts");
696        assert_eq!(inner.get(&Place::SelfMember("node".into())), None);
697    }
698
699    #[test]
700    fn merge_drops_disagreeing_facts() {
701        // x narrowed in then but not else ⇒ dropped after the if (intersection).
702        let body =
703            func_body("func f(x):\n\tif x is Node:\n\t\tpass\n\telse:\n\t\tpass\n\tx.free()\n");
704        let a = analyze(&body);
705        let after = fact_at(&body, &a, 1);
706        assert!(
707            after.is_none(),
708            "narrowing must not survive a non-exhaustive merge"
709        );
710    }
711
712    #[test]
713    fn and_short_circuit_narrows_rhs_and_after() {
714        // `if x is Node and x.is_inside_tree():` — the whole-cond-true edge narrows x.
715        let body = func_body("func f(x):\n\tif x is Node and true:\n\t\tx.free()\n");
716        let a = analyze(&body);
717        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
718            panic!("if")
719        };
720        let inner = a.facts_before(then_branch[0]).expect("then facts");
721        assert!(matches!(
722            inner.get(&Place::Local("x".into())),
723            Some(NarrowedTy::Is(_))
724        ));
725    }
726
727    #[test]
728    fn loop_body_is_entered_widened() {
729        // A narrowing from before the loop does not survive into a body that reassigns the place.
730        let body = func_body(
731            "func f(x, other):\n\tif x is Node:\n\t\twhile true:\n\t\t\tx = other\n\t\t\tx.free()\n",
732        );
733        let a = analyze(&body);
734        // Just assert it runs without panic and produces facts for the outer then-branch.
735        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
736            panic!("if")
737        };
738        assert!(a.facts_before(then_branch[0]).is_some());
739    }
740}