Skip to main content

mir_analyzer/stmt/
mod.rs

1/// Statement analyzer — walks statement nodes threading context through
2/// control flow (if/else, loops, try/catch, return).
3mod control_flow;
4mod declarations;
5mod expressions;
6mod flow;
7mod loops;
8mod return_type;
9
10use loops::{vars_stabilized, widen_unstable};
11pub(crate) use return_type::named_object_return_compatible;
12use return_type::resolve_union_for_file;
13
14use std::sync::Arc;
15
16use php_ast::ast::StmtKind;
17
18use mir_issues::{Issue, IssueBuffer, IssueKind, Location};
19use mir_types::Union;
20
21use crate::context::Context;
22use crate::db::MirDatabase;
23use crate::expr::ExpressionAnalyzer;
24use crate::php_version::PhpVersion;
25use crate::symbol::ResolvedSymbol;
26
27// ---------------------------------------------------------------------------
28// StatementsAnalyzer
29// ---------------------------------------------------------------------------
30
31pub struct StatementsAnalyzer<'a> {
32    pub db: &'a dyn MirDatabase,
33    pub file: Arc<str>,
34    pub source: &'a str,
35    pub source_map: &'a php_rs_parser::source_map::SourceMap,
36    pub issues: &'a mut IssueBuffer,
37    pub symbols: &'a mut Vec<ResolvedSymbol>,
38    pub php_version: PhpVersion,
39    pub inference_only: bool,
40    /// Accumulated inferred return types for the current function.
41    pub return_types: Vec<Union>,
42    /// Break-context stack: one entry per active loop nesting level.
43    /// Each entry collects the context states at every `break` in that loop.
44    break_ctx_stack: Vec<Vec<Context>>,
45}
46
47impl<'a> StatementsAnalyzer<'a> {
48    #[allow(clippy::too_many_arguments)]
49    pub fn new(
50        db: &'a dyn MirDatabase,
51        file: Arc<str>,
52        source: &'a str,
53        source_map: &'a php_rs_parser::source_map::SourceMap,
54        issues: &'a mut IssueBuffer,
55        symbols: &'a mut Vec<ResolvedSymbol>,
56        php_version: PhpVersion,
57        inference_only: bool,
58    ) -> Self {
59        Self {
60            db,
61            file,
62            source,
63            source_map,
64            issues,
65            symbols,
66            php_version,
67            inference_only,
68            return_types: Vec::new(),
69            break_ctx_stack: Vec::new(),
70        }
71    }
72
73    pub fn analyze_stmts<'arena, 'src>(
74        &mut self,
75        stmts: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Stmt<'arena, 'src>>,
76        ctx: &mut Context,
77    ) {
78        for stmt in stmts.iter() {
79            // @psalm-suppress / @suppress per-statement (call-site suppression)
80            let suppressions = self.extract_statement_suppressions(stmt.span);
81            let before = self.issues.issue_count();
82
83            if ctx.diverges {
84                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
85                let (line_end, col_end) = if stmt.span.start < stmt.span.end {
86                    let (end_line, end_col) = self.offset_to_line_col(stmt.span.end);
87                    (end_line, end_col)
88                } else {
89                    (line, col_start + 1)
90                };
91                self.issues.add(
92                    Issue::new(
93                        IssueKind::UnreachableCode,
94                        Location {
95                            file: self.file.clone(),
96                            line,
97                            line_end,
98                            col_start,
99                            col_end: col_end.max(col_start + 1),
100                        },
101                    )
102                    .with_snippet(
103                        crate::parser::span_text(self.source, stmt.span).unwrap_or_default(),
104                    ),
105                );
106                if !suppressions.is_empty() {
107                    self.issues.suppress_range(before, &suppressions);
108                }
109                break;
110            }
111
112            // Extract @var annotation for this statement.
113            let var_annotation = self.extract_var_annotation(stmt.span);
114
115            // Pre-narrow: `@var Type $varname` before any statement narrows that variable.
116            // Special cases: before `return` or before `foreach ... as $valvar` (value override).
117            if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
118                ctx.set_var(var_name.as_str(), var_ty.clone());
119            }
120
121            self.analyze_stmt(stmt, ctx);
122
123            // Post-narrow: `@var Type $varname` before `$varname = expr()` overrides
124            // the inferred type with the annotated type. Only applies when the assignment
125            // target IS the annotated variable.
126            if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
127                if let php_ast::ast::StmtKind::Expression(e) = &stmt.kind {
128                    if let php_ast::ast::ExprKind::Assign(a) = &e.kind {
129                        if matches!(&a.op, php_ast::ast::AssignOp::Assign) {
130                            if let php_ast::ast::ExprKind::Variable(lhs_name) = &a.target.kind {
131                                let lhs = lhs_name.trim_start_matches('$');
132                                if lhs == var_name.as_str() {
133                                    ctx.set_var(var_name.as_str(), var_ty.clone());
134                                }
135                            }
136                        }
137                    }
138                }
139            }
140
141            if !suppressions.is_empty() {
142                self.issues.suppress_range(before, &suppressions);
143            }
144        }
145    }
146
147    pub fn analyze_stmt<'arena, 'src>(
148        &mut self,
149        stmt: &php_ast::ast::Stmt<'arena, 'src>,
150        ctx: &mut Context,
151    ) {
152        match &stmt.kind {
153            // ---- Expression statement ----------------------------------------
154            StmtKind::Expression(expr) => {
155                self.analyze_expression_stmt(expr, ctx);
156            }
157
158            // ---- Echo ---------------------------------------------------------
159            StmtKind::Echo(exprs) => {
160                self.analyze_echo_stmt(exprs, stmt.span, ctx);
161            }
162
163            // ---- Return -------------------------------------------------------
164            StmtKind::Return(opt_expr) => {
165                self.analyze_return_stmt(opt_expr, stmt.span, ctx);
166            }
167
168            // ---- Throw --------------------------------------------------------
169            StmtKind::Throw(expr) => {
170                self.analyze_throw_stmt(expr, stmt.span, ctx);
171            }
172
173            // ---- If -----------------------------------------------------------
174            StmtKind::If(if_stmt) => {
175                self.analyze_if_stmt(if_stmt, ctx);
176            }
177
178            // ---- While --------------------------------------------------------
179            StmtKind::While(w) => {
180                self.analyze_while_stmt(w, ctx);
181            }
182
183            // ---- Do-while -----------------------------------------------------
184            StmtKind::DoWhile(dw) => {
185                self.analyze_dowhile_stmt(dw, ctx);
186            }
187
188            // ---- For ----------------------------------------------------------
189            StmtKind::For(f) => {
190                self.analyze_for_stmt(f, ctx);
191            }
192
193            // ---- Foreach ------------------------------------------------------
194            StmtKind::Foreach(fe) => {
195                self.analyze_foreach_stmt(fe, stmt.span, ctx);
196            }
197
198            // ---- Switch -------------------------------------------------------
199            StmtKind::Switch(sw) => {
200                self.analyze_switch_stmt(sw, ctx);
201            }
202
203            // ---- Try/catch/finally -------------------------------------------
204            StmtKind::TryCatch(tc) => {
205                self.analyze_trycatch_stmt(tc, ctx);
206            }
207
208            // ---- Block --------------------------------------------------------
209            StmtKind::Block(stmts) => {
210                self.analyze_stmts(stmts, ctx);
211            }
212
213            // ---- Break --------------------------------------------------------
214            StmtKind::Break(_) => {
215                self.analyze_break_stmt(ctx);
216            }
217
218            // ---- Continue ----------------------------------------------------
219            StmtKind::Continue(_) => {
220                self.analyze_continue_stmt(ctx);
221            }
222
223            // ---- Unset --------------------------------------------------------
224            StmtKind::Unset(vars) => {
225                self.analyze_unset_stmt(vars, ctx);
226            }
227
228            // ---- Static variable declaration ---------------------------------
229            StmtKind::StaticVar(vars) => {
230                self.analyze_static_var_stmt(vars, ctx);
231            }
232
233            // ---- Global declaration ------------------------------------------
234            StmtKind::Global(vars) => {
235                self.analyze_global_stmt(vars, ctx);
236            }
237
238            // ---- Declare -----------------------------------------------------
239            StmtKind::Declare(d) => {
240                self.analyze_declare_stmt(d, ctx);
241            }
242
243            // ---- Nested declarations (inside function bodies) ----------------
244            StmtKind::Function(decl) => {
245                self.analyze_function_decl_stmt(decl, ctx);
246            }
247
248            StmtKind::Class(decl) => {
249                self.analyze_class_decl_stmt(decl, ctx);
250            }
251
252            StmtKind::Interface(_) | StmtKind::Trait(_) | StmtKind::Enum(_) => {
253                // Interfaces/traits/enums are collected in Pass 1 — skip here
254            }
255
256            // ---- Namespace / use (at file level, already handled in Pass 1) --
257            StmtKind::Namespace(_) | StmtKind::Use(_) | StmtKind::Const(_) => {}
258
259            // ---- Inert --------------------------------------------------------
260            StmtKind::InlineHtml(_)
261            | StmtKind::Nop
262            | StmtKind::Goto(_)
263            | StmtKind::Label(_)
264            | StmtKind::HaltCompiler(_) => {}
265
266            StmtKind::Error => {}
267        }
268    }
269
270    // -----------------------------------------------------------------------
271    // Helper: create a short-lived ExpressionAnalyzer borrowing our fields
272    // -----------------------------------------------------------------------
273
274    fn expr_analyzer<'b>(&'b mut self, _ctx: &Context) -> ExpressionAnalyzer<'b>
275    where
276        'a: 'b,
277    {
278        ExpressionAnalyzer::new(
279            self.db,
280            self.file.clone(),
281            self.source,
282            self.source_map,
283            self.issues,
284            self.symbols,
285            self.php_version,
286            self.inference_only,
287        )
288    }
289
290    /// Convert a byte offset to a Unicode char-count column on a given line.
291    /// Returns (line, col) where col is a 0-based Unicode code-point count.
292    fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
293        let lc = self.source_map.offset_to_line_col(offset);
294        let line = lc.line + 1;
295
296        let byte_offset = offset as usize;
297        let line_start_byte = if byte_offset == 0 {
298            0
299        } else {
300            self.source[..byte_offset]
301                .rfind('\n')
302                .map(|p| p + 1)
303                .unwrap_or(0)
304        };
305
306        let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
307
308        (line, col)
309    }
310
311    /// Emit `UndefinedClass` for a `Name` AST node if the resolved class does not exist.
312    fn check_name_undefined_class(&mut self, name: &php_ast::ast::Name<'_, '_>) {
313        let raw = crate::parser::name_to_string(name);
314        let resolved = crate::db::resolve_name_via_db(self.db, &self.file, &raw);
315        if matches!(resolved.as_str(), "self" | "static" | "parent") {
316            return;
317        }
318        if crate::db::type_exists_via_db(self.db, &resolved) {
319            return;
320        }
321        let span = name.span();
322        let (line, col_start) = self.offset_to_line_col(span.start);
323        let (line_end, col_end) = self.offset_to_line_col(span.end);
324        self.issues.add(Issue::new(
325            IssueKind::UndefinedClass { name: resolved },
326            Location {
327                file: self.file.clone(),
328                line,
329                line_end,
330                col_start,
331                col_end: col_end.max(col_start + 1),
332            },
333        ));
334    }
335
336    // -----------------------------------------------------------------------
337    // @psalm-suppress / @suppress per-statement
338    // -----------------------------------------------------------------------
339
340    /// Extract suppression names from the `@psalm-suppress` / `@suppress`
341    /// annotation in the docblock immediately preceding `span`.
342    fn extract_statement_suppressions(&self, span: php_ast::Span) -> Vec<String> {
343        let Some(doc) = crate::parser::find_preceding_docblock(self.source, span.start) else {
344            return vec![];
345        };
346        let mut suppressions = Vec::new();
347        for line in doc.lines() {
348            let line = line.trim().trim_start_matches('*').trim();
349            let rest = if let Some(r) = line.strip_prefix("@psalm-suppress ") {
350                r
351            } else if let Some(r) = line.strip_prefix("@suppress ") {
352                r
353            } else {
354                continue;
355            };
356            for name in rest.split_whitespace() {
357                suppressions.push(name.to_string());
358            }
359        }
360        suppressions
361    }
362
363    /// Extract `@var Type [$varname]` from the docblock immediately preceding `span`.
364    /// Returns `(optional_var_name, resolved_type)` if an annotation exists.
365    /// The type is resolved through the codebase's file-level imports/namespace.
366    fn extract_var_annotation(
367        &self,
368        span: php_ast::Span,
369    ) -> Option<(Option<String>, mir_types::Union)> {
370        let doc = crate::parser::find_preceding_docblock(self.source, span.start)?;
371        let parsed = crate::parser::DocblockParser::parse(&doc);
372        let ty = parsed.var_type?;
373        let resolved = resolve_union_for_file(ty, self.db, &self.file);
374        Some((parsed.var_name, resolved))
375    }
376
377    // -----------------------------------------------------------------------
378    // Fixed-point loop widening (M12)
379    // -----------------------------------------------------------------------
380
381    /// Analyse a loop body with a fixed-point widening algorithm (≤ 3 passes).
382    ///
383    /// * `pre`   — context *before* the loop (used as the merge base)
384    /// * `entry` — context on first iteration entry (may be narrowed / seeded)
385    /// * `body`  — closure that analyses one loop iteration, receives `&mut Self`
386    ///   and `&mut Context` for the current iteration context
387    /// * `loop_guaranteed` — whether the loop is guaranteed to execute at least once
388    ///
389    /// Returns the post-loop context that merges:
390    ///   - the stable widened context after normal loop exit
391    ///   - any contexts captured at `break` statements
392    fn analyze_loop_widened<F>(
393        &mut self,
394        pre: &Context,
395        entry: Context,
396        mut body: F,
397        loop_guaranteed: bool,
398    ) -> Context
399    where
400        F: FnMut(&mut Self, &mut Context),
401    {
402        const MAX_ITERS: usize = 3;
403
404        // Push a fresh break-context bucket for this loop level
405        self.break_ctx_stack.push(Vec::new());
406
407        let mut current = entry;
408        current.inside_loop = true;
409
410        for _ in 0..MAX_ITERS {
411            let prev_vars = current.vars.clone();
412
413            let mut iter = current.clone();
414            body(self, &mut iter);
415
416            let next = Context::merge_branches(pre, iter, None);
417
418            if vars_stabilized(&prev_vars, &next.vars) {
419                current = next;
420                break;
421            }
422            current = next;
423        }
424
425        // Widen any variable still unstable after MAX_ITERS to the union of types
426        widen_unstable(&pre.vars, &mut current.vars, loop_guaranteed);
427
428        // Pop break contexts and merge them into the post-loop result
429        let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
430        for bctx in break_ctxs {
431            current = Context::merge_branches(pre, current, Some(bctx));
432        }
433
434        current
435    }
436}