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, FxHashSet};
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/// Why a statement is unreachable — drives the `UNREACHABLE_CODE` message: Godot's own analyzer
176/// only flags (and phrases) the after-`return` case, so that cause reproduces Godot's exact text
177/// while the other divergences (after `break`/`continue`, mixed-exit `if`s) keep the analyzer's
178/// more precise wording (Godot is silent there — verified on 4.7, probes q04/q05/r42).
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum UnreachableCause {
181    /// Every diverging path of the preceding statement ends in a `return`.
182    AfterReturn,
183    /// A `break`/`continue` or a divergence with non-`return` exits.
184    Other,
185}
186
187/// The result of flowing a body: per-statement entry facts + the statements proven unreachable.
188#[derive(Debug, Clone, Default)]
189pub struct FlowAnalysis {
190    /// Facts holding *before* each reachable statement. A statement absent here is either
191    /// unreachable or carries no narrowing — the checker treats both as "no facts".
192    entry_facts: FxHashMap<StmtId, FlowFacts>,
193    /// The first statement of each maximal unreachable run (the `UNREACHABLE_CODE` anchors),
194    /// with the cause of the divergence that precedes it.
195    unreachable_anchors: Vec<(StmtId, UnreachableCause)>,
196    /// The byte ranges of `match` arms that follow an unconditional catch-all (the
197    /// `UNREACHABLE_PATTERN` anchors). Stored as ranges (an arm is not a `StmtId`).
198    unreachable_pattern_anchors: Vec<TextRange>,
199}
200
201impl FlowAnalysis {
202    /// The facts in force before `stmt` (empty if none / unreachable).
203    #[must_use]
204    pub fn facts_before(&self, stmt: StmtId) -> Option<&FlowFacts> {
205        self.entry_facts.get(&stmt)
206    }
207
208    /// The byte ranges of the unreachable-code anchors (Workstream 1 emits `UNREACHABLE_CODE`
209    /// here), each with the divergence cause that selects the message wording.
210    #[must_use]
211    pub fn unreachable_ranges(&self, body: &Body) -> Vec<(TextRange, UnreachableCause)> {
212        self.unreachable_anchors
213            .iter()
214            .map(|&(sid, cause)| (body.source_map.stmt_range(sid), cause))
215            .collect()
216    }
217
218    /// The byte ranges of `match` arms after an unconditional catch-all (`UNREACHABLE_PATTERN`).
219    #[must_use]
220    pub fn unreachable_pattern_ranges(&self) -> &[TextRange] {
221        &self.unreachable_pattern_anchors
222    }
223}
224
225/// Run the forward dataflow over a lowered body, producing per-statement entry facts + reachability.
226#[must_use]
227pub fn analyze(body: &Body) -> FlowAnalysis {
228    let mut a = Analyzer {
229        body,
230        entry_facts: FxHashMap::default(),
231        unreachable_anchors: Vec::new(),
232        unreachable_pattern_anchors: Vec::new(),
233    };
234    a.block(FlowFacts::default(), &body.block);
235    // Each lambda body is a fresh scope — analyze it independently (its statements share the body's
236    // arena, so their entry facts merge into the same map). A single pass over the expression arena
237    // catches every lambda, including nested ones.
238    for expr in &body.exprs {
239        if let Expr::Lambda { body: lbody, .. } = expr {
240            a.block(FlowFacts::default(), lbody);
241        }
242    }
243    FlowAnalysis {
244        entry_facts: a.entry_facts,
245        unreachable_anchors: a.unreachable_anchors,
246        unreachable_pattern_anchors: a.unreachable_pattern_anchors,
247    }
248}
249
250struct Analyzer<'a> {
251    body: &'a Body,
252    entry_facts: FxHashMap<StmtId, FlowFacts>,
253    unreachable_anchors: Vec<(StmtId, UnreachableCause)>,
254    unreachable_pattern_anchors: Vec<TextRange>,
255}
256
257impl Analyzer<'_> {
258    /// Flow a statement block. Returns the facts that fall through to the next statement, or `None`
259    /// if the block diverges (every path `return`s/`break`s/`continue`s). Records the first
260    /// unreachable statement as an anchor, with the cause read off the diverging statement.
261    fn block(&mut self, facts: FlowFacts, block: &[StmtId]) -> Option<FlowFacts> {
262        let mut cur = Some(facts);
263        let mut prev: Option<StmtId> = None;
264        for &sid in block {
265            let Some(f) = cur else {
266                // The first statement past a divergence anchors `UNREACHABLE_CODE`.
267                let cause = if prev.is_some_and(|p| self.ends_in_return(p)) {
268                    UnreachableCause::AfterReturn
269                } else {
270                    UnreachableCause::Other
271                };
272                self.unreachable_anchors.push((sid, cause));
273                return None;
274            };
275            cur = self.stmt(f, sid);
276            prev = Some(sid);
277        }
278        cur
279    }
280
281    /// Whether a (diverging) statement's exits are `return`-caused: a plain `return`, or an
282    /// `if`/`elif`/`else` whose every branch contains a `return`-caused divergence. `break`/
283    /// `continue` and mixed exits are not — Godot phrases only the after-`return` case, so this
284    /// distinction selects the message. A branch is scanned with `any` (not just its last
285    /// statement) because a `return` mid-branch leaves trailing statements that are themselves
286    /// unreachable (they get their own anchor) while the branch's divergence is still the return.
287    fn ends_in_return(&self, sid: StmtId) -> bool {
288        match self.body.stmt(sid) {
289            Stmt::Return(_) => true,
290            Stmt::If {
291                then_branch,
292                elifs,
293                else_branch,
294                ..
295            } => {
296                let block_returns = |b: &[StmtId]| b.iter().any(|&s| self.ends_in_return(s));
297                else_branch.as_deref().is_some_and(block_returns)
298                    && block_returns(then_branch)
299                    && elifs.iter().all(|(_, b)| block_returns(b))
300            }
301            _ => false,
302        }
303    }
304
305    /// Flow one statement: record its entry facts, then return the facts that fall through (or
306    /// `None` if it diverges).
307    fn stmt(&mut self, facts: FlowFacts, sid: StmtId) -> Option<FlowFacts> {
308        self.entry_facts.insert(sid, facts.clone());
309        match self.body.stmt(sid) {
310            Stmt::Return(_) | Stmt::Break | Stmt::Continue => None,
311            Stmt::Pass | Stmt::Assert(_) => Some(facts),
312            Stmt::Expr(e) => Some(self.after_expr_stmt(facts, *e)),
313            Stmt::Var(v) => {
314                let mut f = facts;
315                // A (re-)declaration shadows: drop any prior narrowing of this name.
316                f.invalidate_assigned(&Place::Local(v.name.clone()));
317                Some(f)
318            }
319            Stmt::If {
320                cond,
321                then_branch,
322                elifs,
323                else_branch,
324            } => self.flow_if(&facts, *cond, then_branch, elifs, else_branch.as_deref()),
325            Stmt::While { body, .. } => Some(self.flow_loop(facts, body, None)),
326            Stmt::For(f) => Some(self.flow_loop(facts, &f.body, Some(&f.var))),
327            Stmt::Match { arms, .. } => {
328                // Conservative: each arm is flowed from the original facts (no scrutinee narrowing
329                // in the 1.0 cut — pattern types aren't lowered yet); the match falls through with
330                // every arm's assignments widened away (we can't yet prove exhaustiveness).
331                let mut after = facts.clone();
332                // Every arm after an unconditional catch-all (`_`/`var x`, no guard) is unreachable.
333                let mut saw_catch_all = false;
334                for arm in arms {
335                    if saw_catch_all {
336                        self.unreachable_pattern_anchors.push(arm.range);
337                    }
338                    let _ = self.block(facts.clone(), &arm.body);
339                    self.scan_invalidations(&mut after, &arm.body);
340                    saw_catch_all |= arm.is_catch_all;
341                }
342                Some(after)
343            }
344        }
345    }
346
347    /// Facts after an expression statement: a reassignment invalidates the assigned place; any call
348    /// invalidates `self`-rooted narrowing (an opaque call may mutate `self`'s members).
349    fn after_expr_stmt(&self, mut facts: FlowFacts, e: ExprId) -> FlowFacts {
350        if let Expr::Bin {
351            op: BinOp::Assign,
352            lhs,
353            ..
354        } = self.body.expr(e)
355            && let Some(p) = Place::of(self.body, *lhs)
356        {
357            facts.invalidate_assigned(&p);
358        }
359        if self.expr_contains_call(e) {
360            facts.invalidate_self_rooted();
361        }
362        facts
363    }
364
365    /// `if … elif … else …`: each branch is flowed under its guard's narrowing; the result is the
366    /// join of the branches that fall through. The early-return idiom falls out — if the `then`
367    /// branch diverges, the merge is just the `else`/no-else facts (with the guard negated).
368    fn flow_if(
369        &mut self,
370        facts: &FlowFacts,
371        cond: ExprId,
372        then_branch: &[StmtId],
373        elifs: &[(ExprId, crate::body::Block)],
374        else_branch: Option<&[StmtId]>,
375    ) -> Option<FlowFacts> {
376        let mut exits: Vec<Option<FlowFacts>> = Vec::new();
377        let then_in = self.apply(facts, cond, true);
378        exits.push(self.block(then_in, then_branch));
379
380        // `elif` chain: each guard is evaluated under "all previous guards false".
381        let mut chain = self.apply(facts, cond, false);
382        for (econd, eblock) in elifs {
383            let etrue = self.apply(&chain, *econd, true);
384            exits.push(self.block(etrue, eblock));
385            chain = self.apply(&chain, *econd, false);
386        }
387        // The final `else` (or the implicit fall-through when there is none).
388        exits.push(match else_branch {
389            Some(eb) => self.block(chain, eb),
390            None => Some(chain),
391        });
392
393        join_exits(exits)
394    }
395
396    /// A `while`/`for` loop, entered with its body's assignments widened (no back-edge fixpoint —
397    /// the 1.0 cut). Always falls through (the body may run zero times); after the loop the body's
398    /// assignments are widened away.
399    fn flow_loop(
400        &mut self,
401        facts: FlowFacts,
402        body: &[StmtId],
403        loop_var: Option<&SmolStr>,
404    ) -> FlowFacts {
405        let mut widened = facts;
406        if let Some(v) = loop_var {
407            widened.invalidate_assigned(&Place::Local(v.clone()));
408        }
409        self.scan_invalidations(&mut widened, body);
410        // Flow the body once with the widened facts (records the body's entry facts); its exit is
411        // discarded — a loop's after-state is the widened pre-loop facts, not the body's.
412        let _ = self.block(widened.clone(), body);
413        widened
414    }
415
416    /// Apply a condition's narrowing to a fact set for the truthy/falsy edge.
417    fn apply(&self, facts: &FlowFacts, cond: ExprId, truthy: bool) -> FlowFacts {
418        let mut out = facts.clone();
419        for (p, t) in self.derive_facts(cond, truthy) {
420            out.insert(p, t);
421        }
422        // An opaque call in the condition may run *after* a narrowing test (e.g. the rhs of an
423        // `and`, or `if mutate() and self.x is T:`) and mutate `self`'s members, so no `self`-rooted
424        // narrowing from this edge is trustworthy — drop it (mirrors `after_expr_stmt`). Local
425        // narrowing is unaffected (a callee cannot reassign a caller's local). Soundness > precision.
426        if self.expr_contains_call(cond) {
427            out.invalidate_self_rooted();
428        }
429        out
430    }
431
432    /// The facts a condition establishes on its truthy (or falsy) edge.
433    fn derive_facts(&self, cond: ExprId, truthy: bool) -> Vec<(Place, NarrowedTy)> {
434        match self.body.expr(cond) {
435            Expr::Paren(inner) => self.derive_facts(*inner, truthy),
436            Expr::Unary {
437                op: UnOp::Not,
438                operand,
439            } => self.derive_facts(*operand, !truthy),
440            Expr::Is {
441                operand,
442                ty: Some(ptr),
443                negated,
444            } => {
445                let positive = truthy != *negated;
446                Place::of(self.body, *operand)
447                    .map(|p| {
448                        let t = if positive {
449                            NarrowedTy::Is(*ptr)
450                        } else {
451                            NarrowedTy::Not(*ptr)
452                        };
453                        vec![(p, t)]
454                    })
455                    .unwrap_or_default()
456            }
457            Expr::Bin {
458                op: BinOp::Eq,
459                lhs,
460                rhs,
461            } => self.null_cmp_facts(*lhs, *rhs, true, truthy),
462            Expr::Bin {
463                op: BinOp::Ne,
464                lhs,
465                rhs,
466            } => self.null_cmp_facts(*lhs, *rhs, false, truthy),
467            // `a and b` truthy ⇒ both true; `a or b` falsy ⇒ both false. The other directions
468            // cannot be attributed to one operand (either could be the deciding one) — widen.
469            Expr::Bin {
470                op: BinOp::And,
471                lhs,
472                rhs,
473            } if truthy => {
474                let mut v = self.derive_facts(*lhs, true);
475                v.extend(self.derive_facts(*rhs, true));
476                v
477            }
478            Expr::Bin {
479                op: BinOp::Or,
480                lhs,
481                rhs,
482            } if !truthy => {
483                let mut v = self.derive_facts(*lhs, false);
484                v.extend(self.derive_facts(*rhs, false));
485                v
486            }
487            // A bare truthy guard `if x:` / `if x.y:` proves the place non-null.
488            _ if truthy => Place::of(self.body, cond)
489                .map(|p| vec![(p, NarrowedTy::NotNull)])
490                .unwrap_or_default(),
491            _ => Vec::new(),
492        }
493    }
494
495    /// Facts from a `==`/`!=` comparison against `null`: the non-null operand becomes `NotNull` on
496    /// the edge where it is proven non-null (`x != null` true, or `x == null` false).
497    fn null_cmp_facts(
498        &self,
499        lhs: ExprId,
500        rhs: ExprId,
501        is_eq: bool,
502        truthy: bool,
503    ) -> Vec<(Place, NarrowedTy)> {
504        let other = if self.is_null(lhs) {
505            rhs
506        } else if self.is_null(rhs) {
507            lhs
508        } else {
509            return Vec::new();
510        };
511        let proves_not_null = if is_eq { !truthy } else { truthy };
512        if proves_not_null {
513            Place::of(self.body, other)
514                .map(|p| vec![(p, NarrowedTy::NotNull)])
515                .unwrap_or_default()
516        } else {
517            Vec::new()
518        }
519    }
520
521    fn is_null(&self, id: ExprId) -> bool {
522        matches!(self.body.expr(id), Expr::Literal(Literal::Null))
523    }
524
525    /// Drop, from `facts`, every place a block's statements may assign / invalidate (for widening a
526    /// loop entry/exit and a `match` fall-through). Recurses into nested blocks.
527    fn scan_invalidations(&self, facts: &mut FlowFacts, block: &[StmtId]) {
528        for &sid in block {
529            match self.body.stmt(sid) {
530                Stmt::Expr(e) => {
531                    if let Expr::Bin {
532                        op: BinOp::Assign,
533                        lhs,
534                        ..
535                    } = self.body.expr(*e)
536                        && let Some(p) = Place::of(self.body, *lhs)
537                    {
538                        facts.invalidate_assigned(&p);
539                    }
540                    if self.expr_contains_call(*e) {
541                        facts.invalidate_self_rooted();
542                    }
543                }
544                Stmt::Var(v) => facts.invalidate_assigned(&Place::Local(v.name.clone())),
545                Stmt::If {
546                    cond,
547                    then_branch,
548                    elifs,
549                    else_branch,
550                } => {
551                    // A call in a guard (run every iteration when this `if` is inside the loop) may
552                    // mutate `self` — account for it alongside the branch bodies.
553                    if self.expr_contains_call(*cond) {
554                        facts.invalidate_self_rooted();
555                    }
556                    self.scan_invalidations(facts, then_branch);
557                    for (econd, b) in elifs {
558                        if self.expr_contains_call(*econd) {
559                            facts.invalidate_self_rooted();
560                        }
561                        self.scan_invalidations(facts, b);
562                    }
563                    if let Some(eb) = else_branch {
564                        self.scan_invalidations(facts, eb);
565                    }
566                }
567                Stmt::While { cond, body } => {
568                    if self.expr_contains_call(*cond) {
569                        facts.invalidate_self_rooted();
570                    }
571                    self.scan_invalidations(facts, body);
572                }
573                Stmt::For(f) => {
574                    facts.invalidate_assigned(&Place::Local(f.var.clone()));
575                    if self.expr_contains_call(f.iter) {
576                        facts.invalidate_self_rooted();
577                    }
578                    self.scan_invalidations(facts, &f.body);
579                }
580                Stmt::Match { scrutinee, arms } => {
581                    if self.expr_contains_call(*scrutinee) {
582                        facts.invalidate_self_rooted();
583                    }
584                    for arm in arms {
585                        self.scan_invalidations(facts, &arm.body);
586                    }
587                }
588                Stmt::Assert(Some(c)) => {
589                    if self.expr_contains_call(*c) {
590                        facts.invalidate_self_rooted();
591                    }
592                }
593                Stmt::Return(_)
594                | Stmt::Break
595                | Stmt::Continue
596                | Stmt::Pass
597                | Stmt::Assert(None) => {}
598            }
599        }
600    }
601
602    /// Whether an expression subtree contains a call (so an opaque mutation of `self` is possible).
603    fn expr_contains_call(&self, id: ExprId) -> bool {
604        match self.body.expr(id) {
605            Expr::Call { .. } => true,
606            Expr::Bin { lhs, rhs, .. } | Expr::In { lhs, rhs, .. } => {
607                self.expr_contains_call(*lhs) || self.expr_contains_call(*rhs)
608            }
609            Expr::Unary { operand, .. }
610            | Expr::Await(operand)
611            | Expr::Paren(operand)
612            | Expr::Cast { operand, .. }
613            | Expr::Is { operand, .. } => self.expr_contains_call(*operand),
614            Expr::Ternary {
615                cond,
616                then_branch,
617                else_branch,
618            } => {
619                self.expr_contains_call(*cond)
620                    || self.expr_contains_call(*then_branch)
621                    || self.expr_contains_call(*else_branch)
622            }
623            Expr::Field { receiver, .. } => self.expr_contains_call(*receiver),
624            Expr::Index { base, index } => {
625                self.expr_contains_call(*base) || self.expr_contains_call(*index)
626            }
627            Expr::Array(items) => items.iter().any(|&e| self.expr_contains_call(e)),
628            Expr::Dict(entries) => entries.iter().any(|(k, v)| {
629                self.expr_contains_call(*k) || v.is_some_and(|e| self.expr_contains_call(e))
630            }),
631            _ => false,
632        }
633    }
634}
635
636/// The narrowing facts a condition establishes on its truthy (or falsy) edge — exposed so the
637/// checker can apply `and`/`or` short-circuit narrowing *within* a condition expression (the RHS of
638/// `a and b` is typed under `a`'s then-facts, `a or b`'s under `a`'s else-facts).
639#[must_use]
640pub fn condition_facts(body: &Body, cond: ExprId, truthy: bool) -> Vec<(Place, NarrowedTy)> {
641    Analyzer {
642        body,
643        entry_facts: FxHashMap::default(),
644        unreachable_anchors: Vec::new(),
645        unreachable_pattern_anchors: Vec::new(),
646    }
647    .derive_facts(cond, truthy)
648}
649
650/// Merge the exits of several control-flow paths: the join (intersection) of the ones that fall
651/// through, or `None` if every path diverges.
652fn join_exits(exits: Vec<Option<FlowFacts>>) -> Option<FlowFacts> {
653    let mut iter = exits.into_iter().flatten();
654    let first = iter.next()?;
655    Some(iter.fold(first, |acc, f| acc.join(&f)))
656}
657
658// ---- Definite-assignment (Workstream 2, for `UNASSIGNED_VARIABLE`) ------------------------------
659
660/// The locals **definitely** assigned a value at each statement's entry — the intersection over all
661/// incoming control-flow paths. A typed-no-initializer local read while absent here is a
662/// read-before-assign. The sound dual of narrowing: grow-only + intersect-at-merge, and *simpler* —
663/// a callee cannot assign a caller's function-scoped local, so there is no opaque-call / aliasing /
664/// self-rooted handling (do **not** copy those arms from the narrowing analyzer).
665#[derive(Debug, Clone, Default)]
666pub struct AssignedAnalysis {
667    entry: FxHashMap<StmtId, FxHashSet<SmolStr>>,
668}
669
670impl AssignedAnalysis {
671    /// The locals definitely assigned before `stmt`, or `None` if `stmt` was not analyzed (a
672    /// statement inside a lambda body — those are left unchecked, so the caller skips them).
673    #[must_use]
674    pub fn assigned_before(&self, stmt: StmtId) -> Option<&FxHashSet<SmolStr>> {
675        self.entry.get(&stmt)
676    }
677}
678
679/// Run definite-assignment over `body`'s top-level statements (recursing into `if`/`for`/`while`/
680/// `match` blocks but **not** lambda bodies — those get a fresh scope and are left unchecked),
681/// seeded with the function's `params` (always assigned).
682#[must_use]
683pub fn analyze_assigned(body: &Body, params: &[SmolStr]) -> AssignedAnalysis {
684    let mut a = AssignAnalyzer {
685        body,
686        entry: FxHashMap::default(),
687    };
688    let seed: FxHashSet<SmolStr> = params.iter().cloned().collect();
689    a.block(seed, &body.block);
690    AssignedAnalysis { entry: a.entry }
691}
692
693struct AssignAnalyzer<'a> {
694    body: &'a Body,
695    entry: FxHashMap<StmtId, FxHashSet<SmolStr>>,
696}
697
698impl AssignAnalyzer<'_> {
699    /// Thread the assigned-set through a block; `None` if every path diverges.
700    fn block(
701        &mut self,
702        assigned: FxHashSet<SmolStr>,
703        block: &[StmtId],
704    ) -> Option<FxHashSet<SmolStr>> {
705        let mut cur = Some(assigned);
706        for &sid in block {
707            let a = cur?;
708            cur = self.stmt(a, sid);
709        }
710        cur
711    }
712
713    fn stmt(&mut self, assigned: FxHashSet<SmolStr>, sid: StmtId) -> Option<FxHashSet<SmolStr>> {
714        self.entry.insert(sid, assigned.clone());
715        match self.body.stmt(sid) {
716            Stmt::Return(_) | Stmt::Break | Stmt::Continue => None,
717            Stmt::Pass | Stmt::Assert(_) => Some(assigned),
718            Stmt::Expr(e) => {
719                let mut a = assigned;
720                self.record_assign(&mut a, *e);
721                Some(a)
722            }
723            // `var x = e` / `var x := e` assigns; a bare `var x` / `var x: T` does not (and a
724            // re-declaration resets the slot to unassigned).
725            Stmt::Var(v) => {
726                let mut a = assigned;
727                if v.init.is_some() {
728                    a.insert(v.name.clone());
729                } else {
730                    a.remove(&v.name);
731                }
732                Some(a)
733            }
734            Stmt::If {
735                then_branch,
736                elifs,
737                else_branch,
738                ..
739            } => {
740                let mut exits = vec![self.block(assigned.clone(), then_branch)];
741                for (_, eblock) in elifs {
742                    exits.push(self.block(assigned.clone(), eblock));
743                }
744                exits.push(match else_branch {
745                    Some(eb) => self.block(assigned.clone(), eb),
746                    None => Some(assigned.clone()),
747                });
748                intersect_exits(exits)
749            }
750            // A loop body may run zero times — its assignments are NOT guaranteed after the loop.
751            Stmt::While { body, .. } => {
752                let _ = self.block(assigned.clone(), body);
753                Some(assigned)
754            }
755            Stmt::For(f) => {
756                // The loop variable is bound each iteration (assigned inside the body).
757                let mut body_in = assigned.clone();
758                body_in.insert(f.var.clone());
759                let _ = self.block(body_in, &f.body);
760                Some(assigned)
761            }
762            // No exhaustiveness proof — after the match only the pre-match assignments hold; each
763            // arm body is entered with the arm's `var` captures bound.
764            Stmt::Match { arms, .. } => {
765                for arm in arms {
766                    let mut arm_in = assigned.clone();
767                    for b in &arm.binds {
768                        arm_in.insert(b.name.clone());
769                    }
770                    let _ = self.block(arm_in, &arm.body);
771                }
772                Some(assigned)
773            }
774        }
775    }
776
777    /// Record an assignment to a bare local (`x = e` / `x += e` — all lowered to `BinOp::Assign`).
778    fn record_assign(&self, assigned: &mut FxHashSet<SmolStr>, e: ExprId) {
779        if let Expr::Bin {
780            op: BinOp::Assign,
781            lhs,
782            ..
783        } = self.body.expr(e)
784            && let Expr::Name(n) = self.body.expr(*lhs)
785        {
786            assigned.insert(n.clone());
787        }
788    }
789}
790
791/// The intersection of the fall-through exits (a local is assigned after a merge only if assigned on
792/// every path that falls through), or `None` if every path diverges.
793fn intersect_exits(exits: Vec<Option<FxHashSet<SmolStr>>>) -> Option<FxHashSet<SmolStr>> {
794    let mut iter = exits.into_iter().flatten();
795    let first = iter.next()?;
796    Some(iter.fold(first, |acc, s| acc.intersection(&s).cloned().collect()))
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::body::{self, Body};
803    use gdscript_syntax::{SyntaxKind, ast, parse};
804
805    fn func_body(src: &str) -> Body {
806        let root = parse(src).syntax_node();
807        let func = ast::descendants(&root)
808            .into_iter()
809            .find(|n| n.kind() == SyntaxKind::FuncDecl)
810            .expect("a FuncDecl");
811        body::body_of_func(&func)
812    }
813
814    /// The (single) `Place::Local` narrowed in the facts before the statement at top-level index `i`.
815    fn fact_at(body: &Body, a: &FlowAnalysis, i: usize) -> Option<(Place, NarrowedTy)> {
816        let sid = body.block[i];
817        let facts = a.facts_before(sid)?;
818        facts.0.iter().next().map(|(p, t)| (p.clone(), t.clone()))
819    }
820
821    #[test]
822    fn is_guard_narrows_then_branch() {
823        let body = func_body("func f(x):\n\tif x is Node:\n\t\tx.free()\n");
824        let a = analyze(&body);
825        // The `x.free()` stmt lives inside the then-branch; its entry facts narrow x to `Is`.
826        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
827            panic!("if")
828        };
829        let inner = a.facts_before(then_branch[0]).expect("then facts");
830        assert_eq!(
831            inner.get(&Place::Local("x".into())),
832            Some(&NarrowedTy::Is(match body.stmt(body.block[0]) {
833                Stmt::If { cond, .. } => match body.expr(*cond) {
834                    Expr::Is { ty: Some(p), .. } => *p,
835                    _ => panic!("is"),
836                },
837                _ => unreachable!(),
838            })),
839        );
840    }
841
842    #[test]
843    fn early_return_narrows_after_the_guard() {
844        // `if x == null: return` ⇒ after the if, x is NotNull (the then-branch diverged).
845        let body = func_body("func f(x):\n\tif x == null:\n\t\treturn\n\tx.free()\n");
846        let a = analyze(&body);
847        // block[0] = the if, block[1] = `x.free()`.
848        let after = a.facts_before(body.block[1]).expect("after-if facts");
849        assert_eq!(
850            after.get(&Place::Local("x".into())),
851            Some(&NarrowedTy::NotNull)
852        );
853    }
854
855    #[test]
856    fn code_after_return_is_unreachable() {
857        let body = func_body("func f():\n\treturn\n\tvar dead := 1\n");
858        let a = analyze(&body);
859        assert_eq!(a.unreachable_ranges(&body).len(), 1);
860        // The anchor is the `var dead` statement (block index 1), caused by the `return`.
861        assert_eq!(
862            a.unreachable_anchors,
863            vec![(body.block[1], UnreachableCause::AfterReturn)]
864        );
865    }
866
867    #[test]
868    fn code_after_break_is_unreachable_with_other_cause() {
869        // `break` diverges but is NOT return-caused — Godot is silent here (probed q04), so the
870        // cause keeps the analyzer-extra wording instead of Godot's "(statement after return)".
871        let body = func_body("func f():\n\tfor i in 3:\n\t\tbreak\n\t\tprint(i)\n");
872        let a = analyze(&body);
873        let anchors = &a.unreachable_anchors;
874        assert_eq!(anchors.len(), 1);
875        assert_eq!(anchors[0].1, UnreachableCause::Other);
876    }
877
878    #[test]
879    fn code_after_if_where_every_branch_returns_is_return_caused() {
880        // Godot flags this with the after-return wording (probed r42).
881        let body = func_body(
882            "func f(b):\n\tif b:\n\t\treturn 1\n\telse:\n\t\treturn 2\n\tprint(\"after\")\n",
883        );
884        let a = analyze(&body);
885        let anchors = &a.unreachable_anchors;
886        assert_eq!(anchors.len(), 1);
887        assert_eq!(anchors[0].1, UnreachableCause::AfterReturn);
888    }
889
890    #[test]
891    fn code_after_if_with_a_break_exit_is_other_caused() {
892        // One branch returns, the other breaks — not purely return-caused.
893        let body = func_body(
894            "func f(b):\n\tfor i in 3:\n\t\tif b:\n\t\t\treturn 1\n\t\telse:\n\t\t\tbreak\n\t\tprint(i)\n",
895        );
896        let a = analyze(&body);
897        let anchors = &a.unreachable_anchors;
898        assert_eq!(anchors.len(), 1);
899        assert_eq!(anchors[0].1, UnreachableCause::Other);
900    }
901
902    #[test]
903    fn reassignment_invalidates_narrowing() {
904        // After `if x is Node:` narrows x, an assignment `x = other` inside drops the fact.
905        let body = func_body("func f(x, other):\n\tif x is Node:\n\t\tx = other\n\t\tx.free()\n");
906        let a = analyze(&body);
907        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
908            panic!("if")
909        };
910        // then_branch[0] = `x = other` (narrowed on entry), then_branch[1] = `x.free()` (widened).
911        let at_free = a.facts_before(then_branch[1]).expect("facts");
912        assert_eq!(at_free.get(&Place::Local("x".into())), None);
913    }
914
915    #[test]
916    fn opaque_call_invalidates_self_members() {
917        // `if self.node is Node2D:` narrows self.node; a bare call may mutate it → invalidated.
918        let body =
919            func_body("func f():\n\tif self.node is Node2D:\n\t\tmutate()\n\t\tself.node.foo()\n");
920        let a = analyze(&body);
921        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
922            panic!("if")
923        };
924        let at_use = a.facts_before(then_branch[1]).expect("facts");
925        assert_eq!(at_use.get(&Place::SelfMember("node".into())), None);
926    }
927
928    #[test]
929    fn opaque_call_in_guard_invalidates_self_member_narrowing() {
930        // A call in the guard itself (`mutate()` in the `and`) may reassign self.node *after* the
931        // `is` test, so self.node must NOT be narrowed in the then-branch — the soundness invariant.
932        let body =
933            func_body("func f():\n\tif self.node is Node2D and mutate():\n\t\tself.node.foo()\n");
934        let a = analyze(&body);
935        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
936            panic!("if")
937        };
938        let inner = a.facts_before(then_branch[0]).expect("then facts");
939        assert_eq!(inner.get(&Place::SelfMember("node".into())), None);
940    }
941
942    #[test]
943    fn merge_drops_disagreeing_facts() {
944        // x narrowed in then but not else ⇒ dropped after the if (intersection).
945        let body =
946            func_body("func f(x):\n\tif x is Node:\n\t\tpass\n\telse:\n\t\tpass\n\tx.free()\n");
947        let a = analyze(&body);
948        let after = fact_at(&body, &a, 1);
949        assert!(
950            after.is_none(),
951            "narrowing must not survive a non-exhaustive merge"
952        );
953    }
954
955    #[test]
956    fn and_short_circuit_narrows_rhs_and_after() {
957        // `if x is Node and x.is_inside_tree():` — the whole-cond-true edge narrows x.
958        let body = func_body("func f(x):\n\tif x is Node and true:\n\t\tx.free()\n");
959        let a = analyze(&body);
960        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
961            panic!("if")
962        };
963        let inner = a.facts_before(then_branch[0]).expect("then facts");
964        assert!(matches!(
965            inner.get(&Place::Local("x".into())),
966            Some(NarrowedTy::Is(_))
967        ));
968    }
969
970    #[test]
971    fn loop_body_is_entered_widened() {
972        // A narrowing from before the loop does not survive into a body that reassigns the place.
973        let body = func_body(
974            "func f(x, other):\n\tif x is Node:\n\t\twhile true:\n\t\t\tx = other\n\t\t\tx.free()\n",
975        );
976        let a = analyze(&body);
977        // Just assert it runs without panic and produces facts for the outer then-branch.
978        let Stmt::If { then_branch, .. } = body.stmt(body.block[0]) else {
979            panic!("if")
980        };
981        assert!(a.facts_before(then_branch[0]).is_some());
982    }
983}