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            // Additional fallback: If this is an assignment and no var_annotation was found,
142            // try to extract one directly from the docblock as a fallback
143            // This handles cases where the initial extract_var_annotation might have issues
144            if var_annotation.is_none() {
145                if let php_ast::ast::StmtKind::Expression(e) = &stmt.kind {
146                    if let php_ast::ast::ExprKind::Assign(a) = &e.kind {
147                        if matches!(&a.op, php_ast::ast::AssignOp::Assign) {
148                            if let php_ast::ast::ExprKind::Variable(lhs_name) = &a.target.kind {
149                                let lhs = lhs_name.trim_start_matches('$').to_string();
150                                // Try to extract var annotation directly
151                                if let Some(doc) = crate::parser::find_preceding_docblock(
152                                    self.source,
153                                    stmt.span.start,
154                                ) {
155                                    let parsed = crate::parser::DocblockParser::parse(&doc);
156                                    if let Some(var_type) = parsed.var_type {
157                                        // Check if this annotation is for the variable we're assigning to
158                                        if let Some(var_name) = parsed.var_name {
159                                            if var_name == lhs {
160                                                let resolved = crate::stmt::return_type::resolve_union_for_file(var_type, self.db, &self.file);
161                                                ctx.set_var(&lhs, resolved);
162                                            }
163                                        }
164                                    }
165                                }
166                            }
167                        }
168                    }
169                }
170            }
171
172            if !suppressions.is_empty() {
173                self.issues.suppress_range(before, &suppressions);
174            }
175        }
176    }
177
178    pub fn analyze_stmt<'arena, 'src>(
179        &mut self,
180        stmt: &php_ast::ast::Stmt<'arena, 'src>,
181        ctx: &mut Context,
182    ) {
183        // Extract @var annotation for this statement.
184        let var_annotation = self.extract_var_annotation(stmt.span);
185
186        // Pre-narrow: `@var Type $varname` before any statement narrows that variable.
187        if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
188            ctx.set_var(var_name.as_str(), var_ty.clone());
189        }
190
191        match &stmt.kind {
192            // ---- Expression statement ----------------------------------------
193            StmtKind::Expression(expr) => {
194                self.analyze_expression_stmt(expr, ctx);
195            }
196
197            // ---- Echo ---------------------------------------------------------
198            StmtKind::Echo(exprs) => {
199                self.analyze_echo_stmt(exprs, stmt.span, ctx);
200            }
201
202            // ---- Return -------------------------------------------------------
203            StmtKind::Return(opt_expr) => {
204                self.analyze_return_stmt(opt_expr, stmt.span, ctx);
205            }
206
207            // ---- Throw --------------------------------------------------------
208            StmtKind::Throw(expr) => {
209                self.analyze_throw_stmt(expr, stmt.span, ctx);
210            }
211
212            // ---- If -----------------------------------------------------------
213            StmtKind::If(if_stmt) => {
214                self.analyze_if_stmt(if_stmt, ctx);
215            }
216
217            // ---- While --------------------------------------------------------
218            StmtKind::While(w) => {
219                self.analyze_while_stmt(w, ctx);
220            }
221
222            // ---- Do-while -----------------------------------------------------
223            StmtKind::DoWhile(dw) => {
224                self.analyze_dowhile_stmt(dw, ctx);
225            }
226
227            // ---- For ----------------------------------------------------------
228            StmtKind::For(f) => {
229                self.analyze_for_stmt(f, ctx);
230            }
231
232            // ---- Foreach ------------------------------------------------------
233            StmtKind::Foreach(fe) => {
234                self.analyze_foreach_stmt(fe, stmt.span, ctx);
235            }
236
237            // ---- Switch -------------------------------------------------------
238            StmtKind::Switch(sw) => {
239                self.analyze_switch_stmt(sw, ctx);
240            }
241
242            // ---- Try/catch/finally -------------------------------------------
243            StmtKind::TryCatch(tc) => {
244                self.analyze_trycatch_stmt(tc, ctx);
245            }
246
247            // ---- Block --------------------------------------------------------
248            StmtKind::Block(stmts) => {
249                self.analyze_stmts(stmts, ctx);
250            }
251
252            // ---- Break --------------------------------------------------------
253            StmtKind::Break(_) => {
254                self.analyze_break_stmt(ctx);
255            }
256
257            // ---- Continue ----------------------------------------------------
258            StmtKind::Continue(_) => {
259                self.analyze_continue_stmt(ctx);
260            }
261
262            // ---- Unset --------------------------------------------------------
263            StmtKind::Unset(vars) => {
264                self.analyze_unset_stmt(vars, ctx);
265            }
266
267            // ---- Static variable declaration ---------------------------------
268            StmtKind::StaticVar(vars) => {
269                self.analyze_static_var_stmt(vars, ctx);
270            }
271
272            // ---- Global declaration ------------------------------------------
273            StmtKind::Global(vars) => {
274                self.analyze_global_stmt(vars, ctx);
275            }
276
277            // ---- Declare -----------------------------------------------------
278            StmtKind::Declare(d) => {
279                self.analyze_declare_stmt(d, ctx);
280            }
281
282            // ---- Nested declarations (inside function bodies) ----------------
283            StmtKind::Function(decl) => {
284                self.analyze_function_decl_stmt(decl, ctx);
285            }
286
287            StmtKind::Class(decl) => {
288                self.analyze_class_decl_stmt(decl, ctx);
289            }
290
291            StmtKind::Interface(_) | StmtKind::Trait(_) | StmtKind::Enum(_) => {
292                // Interfaces/traits/enums are collected in Pass 1 — skip here
293            }
294
295            // ---- Namespace / use (at file level, already handled in Pass 1) --
296            StmtKind::Namespace(_) | StmtKind::Use(_) | StmtKind::Const(_) => {}
297
298            // ---- Inert --------------------------------------------------------
299            StmtKind::InlineHtml(_)
300            | StmtKind::Nop
301            | StmtKind::Goto(_)
302            | StmtKind::Label(_)
303            | StmtKind::HaltCompiler(_) => {}
304
305            StmtKind::Error => {}
306        }
307
308        // Post-narrow: `@var Type $varname` before `$varname = expr()` overrides
309        // the inferred type with the annotated type. Only applies when the assignment
310        // target IS the annotated variable.
311        if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
312            if let php_ast::ast::StmtKind::Expression(e) = &stmt.kind {
313                if let php_ast::ast::ExprKind::Assign(a) = &e.kind {
314                    if matches!(&a.op, php_ast::ast::AssignOp::Assign) {
315                        if let php_ast::ast::ExprKind::Variable(lhs_name) = &a.target.kind {
316                            let lhs = lhs_name.trim_start_matches('$');
317                            if lhs == var_name.as_str() {
318                                ctx.set_var(var_name.as_str(), var_ty.clone());
319                            }
320                        }
321                    }
322                }
323            }
324        }
325
326        // Additional fallback: If this is an assignment and no var_annotation was found,
327        // try to extract one directly from the docblock as a fallback
328        if var_annotation.is_none() {
329            if let php_ast::ast::StmtKind::Expression(e) = &stmt.kind {
330                if let php_ast::ast::ExprKind::Assign(a) = &e.kind {
331                    if matches!(&a.op, php_ast::ast::AssignOp::Assign) {
332                        if let php_ast::ast::ExprKind::Variable(lhs_name) = &a.target.kind {
333                            let lhs = lhs_name.trim_start_matches('$').to_string();
334                            if let Some(doc) =
335                                crate::parser::find_preceding_docblock(self.source, stmt.span.start)
336                            {
337                                let parsed = crate::parser::DocblockParser::parse(&doc);
338                                if let Some(var_type) = parsed.var_type {
339                                    if let Some(var_name) = parsed.var_name {
340                                        if var_name == lhs {
341                                            let resolved =
342                                                crate::stmt::return_type::resolve_union_for_file(
343                                                    var_type, self.db, &self.file,
344                                                );
345                                            ctx.set_var(&lhs, resolved);
346                                        }
347                                    }
348                                }
349                            }
350                        }
351                    }
352                }
353            }
354        }
355    }
356
357    // -----------------------------------------------------------------------
358    // Helper: create a short-lived ExpressionAnalyzer borrowing our fields
359    // -----------------------------------------------------------------------
360
361    fn expr_analyzer<'b>(&'b mut self, _ctx: &Context) -> ExpressionAnalyzer<'b>
362    where
363        'a: 'b,
364    {
365        ExpressionAnalyzer::new(
366            self.db,
367            self.file.clone(),
368            self.source,
369            self.source_map,
370            self.issues,
371            self.symbols,
372            self.php_version,
373            self.inference_only,
374        )
375    }
376
377    /// Convert a byte offset to a Unicode char-count column on a given line.
378    /// Returns (line, col) where col is a 0-based Unicode code-point count.
379    fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
380        let lc = self.source_map.offset_to_line_col(offset);
381        let line = lc.line + 1;
382
383        let byte_offset = offset as usize;
384        let line_start_byte = if byte_offset == 0 {
385            0
386        } else {
387            self.source[..byte_offset]
388                .rfind('\n')
389                .map(|p| p + 1)
390                .unwrap_or(0)
391        };
392
393        let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
394
395        (line, col)
396    }
397
398    /// Convert a span to Location (line, line_end, col_start, col_end).
399    fn span_to_location(&self, span: php_ast::Span) -> (u32, u32, u16, u16) {
400        let (line, col_start) = self.offset_to_line_col(span.start);
401        let (line_end, col_end) = if span.start < span.end {
402            self.offset_to_line_col(span.end)
403        } else {
404            (line, col_start)
405        };
406        (line, line_end, col_start, col_end)
407    }
408
409    /// Emit `UndefinedClass` for a `Name` AST node if the resolved class does not exist.
410    fn check_name_undefined_class(&mut self, name: &php_ast::ast::Name<'_, '_>) {
411        let raw = crate::parser::name_to_string(name);
412        let resolved = crate::db::resolve_name_via_db(self.db, &self.file, &raw);
413        if matches!(resolved.as_str(), "self" | "static" | "parent") {
414            return;
415        }
416        if crate::db::type_exists_via_db(self.db, &resolved) {
417            return;
418        }
419        let span = name.span();
420        let (line, col_start) = self.offset_to_line_col(span.start);
421        let (line_end, col_end) = self.offset_to_line_col(span.end);
422        self.issues.add(Issue::new(
423            IssueKind::UndefinedClass { name: resolved },
424            Location {
425                file: self.file.clone(),
426                line,
427                line_end,
428                col_start,
429                col_end: col_end.max(col_start + 1),
430            },
431        ));
432    }
433
434    // -----------------------------------------------------------------------
435    // @psalm-suppress / @suppress per-statement
436    // -----------------------------------------------------------------------
437
438    /// Extract suppression names from the `@psalm-suppress` / `@suppress`
439    /// annotation in the docblock immediately preceding `span`.
440    fn extract_statement_suppressions(&self, span: php_ast::Span) -> Vec<String> {
441        let Some(doc) = crate::parser::find_preceding_docblock(self.source, span.start) else {
442            return vec![];
443        };
444        let mut suppressions = Vec::new();
445        for line in doc.lines() {
446            let line = line.trim().trim_start_matches('*').trim();
447            let rest = if let Some(r) = line.strip_prefix("@psalm-suppress ") {
448                r
449            } else if let Some(r) = line.strip_prefix("@suppress ") {
450                r
451            } else {
452                continue;
453            };
454            for name in rest.split_whitespace() {
455                suppressions.push(name.to_string());
456            }
457        }
458        suppressions
459    }
460
461    /// Extract `@var Type [$varname]` from the docblock immediately preceding `span`.
462    /// Returns `(optional_var_name, resolved_type)` if an annotation exists.
463    /// The type is resolved through the codebase's file-level imports/namespace.
464    fn extract_var_annotation(
465        &self,
466        span: php_ast::Span,
467    ) -> Option<(Option<String>, mir_types::Union)> {
468        let doc = crate::parser::find_preceding_docblock(self.source, span.start)?;
469        let parsed = crate::parser::DocblockParser::parse(&doc);
470        let ty = parsed.var_type?;
471        let resolved = resolve_union_for_file(ty, self.db, &self.file);
472        Some((parsed.var_name, resolved))
473    }
474
475    // -----------------------------------------------------------------------
476    // Fixed-point loop widening (M12)
477    // -----------------------------------------------------------------------
478
479    /// Analyse a loop body with a fixed-point widening algorithm (≤ 3 passes).
480    ///
481    /// * `pre`   — context *before* the loop (used as the merge base)
482    /// * `entry` — context on first iteration entry (may be narrowed / seeded)
483    /// * `body`  — closure that analyses one loop iteration, receives `&mut Self`
484    ///   and `&mut Context` for the current iteration context
485    /// * `loop_guaranteed` — whether the loop is guaranteed to execute at least once
486    ///
487    /// Returns the post-loop context that merges:
488    ///   - the stable widened context after normal loop exit
489    ///   - any contexts captured at `break` statements
490    fn analyze_loop_widened<F>(
491        &mut self,
492        pre: &Context,
493        entry: Context,
494        mut body: F,
495        loop_guaranteed: bool,
496    ) -> Context
497    where
498        F: FnMut(&mut Self, &mut Context),
499    {
500        const MAX_ITERS: usize = 3;
501
502        // Push a fresh break-context bucket for this loop level
503        self.break_ctx_stack.push(Vec::new());
504
505        let mut current = entry;
506        current.inside_loop = true;
507
508        for _ in 0..MAX_ITERS {
509            let prev_vars = current.vars.clone();
510
511            let mut iter = current.clone();
512            body(self, &mut iter);
513
514            let next = Context::merge_branches(pre, iter, None);
515
516            if vars_stabilized(&prev_vars, &next.vars) {
517                current = next;
518                break;
519            }
520            current = next;
521        }
522
523        // Widen any variable still unstable after MAX_ITERS to the union of types
524        widen_unstable(&pre.vars, &mut current.vars, loop_guaranteed);
525
526        // Pop break contexts and merge them into the post-loop result
527        let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
528        for bctx in break_ctxs {
529            current = Context::merge_branches(pre, current, Some(bctx));
530        }
531
532        current
533    }
534}