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