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 loops;
4mod return_type;
5
6use loops::{infer_foreach_types, vars_stabilized, widen_unstable};
7pub(crate) use return_type::named_object_return_compatible;
8use return_type::{declared_return_has_template, resolve_union_for_file, return_arrays_compatible};
9
10use std::sync::Arc;
11
12use php_ast::ast::StmtKind;
13
14use mir_codebase::Codebase;
15use mir_issues::{Issue, IssueBuffer, IssueKind, Location};
16use mir_types::{Atomic, Union};
17
18use crate::context::Context;
19use crate::expr::ExpressionAnalyzer;
20use crate::narrowing::narrow_from_condition;
21use crate::php_version::PhpVersion;
22use crate::symbol::ResolvedSymbol;
23
24// ---------------------------------------------------------------------------
25// StatementsAnalyzer
26// ---------------------------------------------------------------------------
27
28pub struct StatementsAnalyzer<'a> {
29    pub codebase: &'a Codebase,
30    pub file: Arc<str>,
31    pub source: &'a str,
32    pub source_map: &'a php_rs_parser::source_map::SourceMap,
33    pub issues: &'a mut IssueBuffer,
34    pub symbols: &'a mut Vec<ResolvedSymbol>,
35    pub php_version: PhpVersion,
36    /// Accumulated inferred return types for the current function.
37    pub return_types: Vec<Union>,
38    /// Break-context stack: one entry per active loop nesting level.
39    /// Each entry collects the context states at every `break` in that loop.
40    break_ctx_stack: Vec<Vec<Context>>,
41}
42
43impl<'a> StatementsAnalyzer<'a> {
44    pub fn new(
45        codebase: &'a Codebase,
46        file: Arc<str>,
47        source: &'a str,
48        source_map: &'a php_rs_parser::source_map::SourceMap,
49        issues: &'a mut IssueBuffer,
50        symbols: &'a mut Vec<ResolvedSymbol>,
51        php_version: PhpVersion,
52    ) -> Self {
53        Self {
54            codebase,
55            file,
56            source,
57            source_map,
58            issues,
59            symbols,
60            php_version,
61            return_types: Vec::new(),
62            break_ctx_stack: Vec::new(),
63        }
64    }
65
66    pub fn analyze_stmts<'arena, 'src>(
67        &mut self,
68        stmts: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Stmt<'arena, 'src>>,
69        ctx: &mut Context,
70    ) {
71        for stmt in stmts.iter() {
72            // @psalm-suppress / @suppress per-statement (call-site suppression)
73            let suppressions = self.extract_statement_suppressions(stmt.span);
74            let before = self.issues.issue_count();
75
76            if ctx.diverges {
77                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
78                let (line_end, col_end) = if stmt.span.start < stmt.span.end {
79                    let (end_line, end_col) = self.offset_to_line_col(stmt.span.end);
80                    (end_line, end_col)
81                } else {
82                    (line, col_start + 1)
83                };
84                self.issues.add(
85                    Issue::new(
86                        IssueKind::UnreachableCode,
87                        Location {
88                            file: self.file.clone(),
89                            line,
90                            line_end,
91                            col_start,
92                            col_end: col_end.max(col_start + 1),
93                        },
94                    )
95                    .with_snippet(
96                        crate::parser::span_text(self.source, stmt.span).unwrap_or_default(),
97                    ),
98                );
99                if !suppressions.is_empty() {
100                    self.issues.suppress_range(before, &suppressions);
101                }
102                break;
103            }
104
105            // Extract @var annotation for this statement.
106            let var_annotation = self.extract_var_annotation(stmt.span);
107
108            // Pre-narrow: `@var Type $varname` before any statement narrows that variable.
109            // Special cases: before `return` or before `foreach ... as $valvar` (value override).
110            if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
111                ctx.set_var(var_name.as_str(), var_ty.clone());
112            }
113
114            self.analyze_stmt(stmt, ctx);
115
116            // Post-narrow: `@var Type $varname` before `$varname = expr()` overrides
117            // the inferred type with the annotated type. Only applies when the assignment
118            // target IS the annotated variable.
119            if let Some((Some(ref var_name), ref var_ty)) = var_annotation {
120                if let php_ast::ast::StmtKind::Expression(e) = &stmt.kind {
121                    if let php_ast::ast::ExprKind::Assign(a) = &e.kind {
122                        if matches!(&a.op, php_ast::ast::AssignOp::Assign) {
123                            if let php_ast::ast::ExprKind::Variable(lhs_name) = &a.target.kind {
124                                let lhs = lhs_name.trim_start_matches('$');
125                                if lhs == var_name.as_str() {
126                                    ctx.set_var(var_name.as_str(), var_ty.clone());
127                                }
128                            }
129                        }
130                    }
131                }
132            }
133
134            if !suppressions.is_empty() {
135                self.issues.suppress_range(before, &suppressions);
136            }
137        }
138    }
139
140    pub fn analyze_stmt<'arena, 'src>(
141        &mut self,
142        stmt: &php_ast::ast::Stmt<'arena, 'src>,
143        ctx: &mut Context,
144    ) {
145        match &stmt.kind {
146            // ---- Expression statement ----------------------------------------
147            StmtKind::Expression(expr) => {
148                let expr_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
149                if expr_ty.is_never() {
150                    ctx.diverges = true;
151                }
152                // For standalone assert($condition) calls, narrow from the condition.
153                if let php_ast::ast::ExprKind::FunctionCall(call) = &expr.kind {
154                    if let php_ast::ast::ExprKind::Identifier(fn_name) = &call.name.kind {
155                        if fn_name.eq_ignore_ascii_case("assert") {
156                            if let Some(arg) = call.args.first() {
157                                narrow_from_condition(
158                                    &arg.value,
159                                    ctx,
160                                    true,
161                                    self.codebase,
162                                    &self.file,
163                                );
164                            }
165                        }
166                    }
167                }
168            }
169
170            // ---- Echo ---------------------------------------------------------
171            StmtKind::Echo(exprs) => {
172                for expr in exprs.iter() {
173                    // Taint check (M19): echoing tainted data → XSS
174                    if crate::taint::is_expr_tainted(expr, ctx) {
175                        let (line, col_start) = self.offset_to_line_col(stmt.span.start);
176                        let (line_end, col_end) = if stmt.span.start < stmt.span.end {
177                            let (end_line, end_col) = self.offset_to_line_col(stmt.span.end);
178                            (end_line, end_col)
179                        } else {
180                            (line, col_start)
181                        };
182                        let mut issue = mir_issues::Issue::new(
183                            IssueKind::TaintedHtml,
184                            mir_issues::Location {
185                                file: self.file.clone(),
186                                line,
187                                line_end,
188                                col_start,
189                                col_end: col_end.max(col_start + 1),
190                            },
191                        );
192                        // Extract snippet from the echo statement span.
193                        let start = stmt.span.start as usize;
194                        let end = stmt.span.end as usize;
195                        if start < self.source.len() {
196                            let end = end.min(self.source.len());
197                            let span_text = &self.source[start..end];
198                            if let Some(first_line) = span_text.lines().next() {
199                                issue = issue.with_snippet(first_line.trim().to_string());
200                            }
201                        }
202                        self.issues.add(issue);
203                    }
204                    self.expr_analyzer(ctx).analyze(expr, ctx);
205                }
206            }
207
208            // ---- Return -------------------------------------------------------
209            StmtKind::Return(opt_expr) => {
210                if let Some(expr) = opt_expr {
211                    let ret_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
212
213                    // If there's a bare `@var Type` (no variable name) on the return statement,
214                    // use the annotated type for the return-type compatibility check.
215                    // `@var Type $name` with a variable name narrows the variable (handled in
216                    // analyze_stmts loop), not the return type.
217                    let check_ty =
218                        if let Some((None, var_ty)) = self.extract_var_annotation(stmt.span) {
219                            var_ty
220                        } else {
221                            ret_ty.clone()
222                        };
223
224                    // Check against declared return type
225                    if let Some(declared) = &ctx.fn_return_type.clone() {
226                        // Check return type compatibility. Special case: `void` functions must not
227                        // return any value (named_object_return_compatible considers TVoid compatible
228                        // with TNull, so handle void separately to avoid false suppression).
229                        if (declared.is_void() && !check_ty.is_void() && !check_ty.is_mixed())
230                            || (!check_ty.is_subtype_of_simple(declared)
231                                && !declared.is_mixed()
232                                && !check_ty.is_mixed()
233                                && !named_object_return_compatible(&check_ty, declared, self.codebase, &self.file)
234                                // Also check without null (handles `null|T` where T implements declared).
235                                // Guard: if check_ty is purely null, remove_null() is empty and would
236                                // vacuously return true, incorrectly suppressing the error.
237                                && (check_ty.remove_null().is_empty() || !named_object_return_compatible(&check_ty.remove_null(), declared, self.codebase, &self.file))
238                                && !declared_return_has_template(declared, self.codebase)
239                                && !declared_return_has_template(&check_ty, self.codebase)
240                                && !return_arrays_compatible(&check_ty, declared, self.codebase, &self.file)
241                                // Skip coercions: declared is more specific than actual
242                                && !declared.is_subtype_of_simple(&check_ty)
243                                && !declared.remove_null().is_subtype_of_simple(&check_ty)
244                                // Skip when actual is compatible after removing null/false.
245                                // Guard against empty union (e.g. pure-null type): removing null
246                                // from `null` alone gives an empty union which vacuously passes
247                                // is_subtype_of_simple — that would incorrectly suppress the error.
248                                && (check_ty.remove_null().is_empty() || !check_ty.remove_null().is_subtype_of_simple(declared))
249                                && !check_ty.remove_false().is_subtype_of_simple(declared)
250                                // Suppress LessSpecificReturnStatement (level 4): actual is a
251                                // supertype of declared (not flagged at default error level).
252                                && !named_object_return_compatible(declared, &check_ty, self.codebase, &self.file)
253                                && !named_object_return_compatible(&declared.remove_null(), &check_ty.remove_null(), self.codebase, &self.file))
254                        {
255                            let (line, col_start) = self.offset_to_line_col(stmt.span.start);
256                            let (line_end, col_end) = if stmt.span.start < stmt.span.end {
257                                let (end_line, end_col) = self.offset_to_line_col(stmt.span.end);
258                                (end_line, end_col)
259                            } else {
260                                (line, col_start)
261                            };
262                            self.issues.add(
263                                mir_issues::Issue::new(
264                                    IssueKind::InvalidReturnType {
265                                        expected: format!("{declared}"),
266                                        actual: format!("{ret_ty}"),
267                                    },
268                                    mir_issues::Location {
269                                        file: self.file.clone(),
270                                        line,
271                                        line_end,
272                                        col_start,
273                                        col_end: col_end.max(col_start + 1),
274                                    },
275                                )
276                                .with_snippet(
277                                    crate::parser::span_text(self.source, stmt.span)
278                                        .unwrap_or_default(),
279                                ),
280                            );
281                        }
282                    }
283                    self.return_types.push(ret_ty);
284                } else {
285                    self.return_types.push(Union::single(Atomic::TVoid));
286                    // Bare `return;` from a non-void declared function is an error.
287                    if let Some(declared) = &ctx.fn_return_type.clone() {
288                        if !declared.is_void() && !declared.is_mixed() {
289                            let (line, col_start) = self.offset_to_line_col(stmt.span.start);
290                            let (line_end, col_end) = if stmt.span.start < stmt.span.end {
291                                let (end_line, end_col) = self.offset_to_line_col(stmt.span.end);
292                                (end_line, end_col)
293                            } else {
294                                (line, col_start)
295                            };
296                            self.issues.add(
297                                mir_issues::Issue::new(
298                                    IssueKind::InvalidReturnType {
299                                        expected: format!("{declared}"),
300                                        actual: "void".to_string(),
301                                    },
302                                    mir_issues::Location {
303                                        file: self.file.clone(),
304                                        line,
305                                        line_end,
306                                        col_start,
307                                        col_end: col_end.max(col_start + 1),
308                                    },
309                                )
310                                .with_snippet(
311                                    crate::parser::span_text(self.source, stmt.span)
312                                        .unwrap_or_default(),
313                                ),
314                            );
315                        }
316                    }
317                }
318                ctx.diverges = true;
319            }
320
321            // ---- Throw --------------------------------------------------------
322            StmtKind::Throw(expr) => {
323                let thrown_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
324                // Validate that the thrown type extends Throwable
325                for atomic in &thrown_ty.types {
326                    match atomic {
327                        mir_types::Atomic::TNamedObject { fqcn, .. } => {
328                            let resolved = self.codebase.resolve_class_name(&self.file, fqcn);
329                            let is_throwable = resolved == "Throwable"
330                                || resolved == "Exception"
331                                || resolved == "Error"
332                                || fqcn.as_ref() == "Throwable"
333                                || fqcn.as_ref() == "Exception"
334                                || fqcn.as_ref() == "Error"
335                                || self.codebase.extends_or_implements(&resolved, "Throwable")
336                                || self.codebase.extends_or_implements(&resolved, "Exception")
337                                || self.codebase.extends_or_implements(&resolved, "Error")
338                                || self.codebase.extends_or_implements(fqcn, "Throwable")
339                                || self.codebase.extends_or_implements(fqcn, "Exception")
340                                || self.codebase.extends_or_implements(fqcn, "Error")
341                                // Suppress if class has unknown ancestors (might be Throwable)
342                                || self.codebase.has_unknown_ancestor(&resolved)
343                                || self.codebase.has_unknown_ancestor(fqcn)
344                                // Suppress if class is not in codebase at all (could be extension class)
345                                || (!self.codebase.type_exists(&resolved) && !self.codebase.type_exists(fqcn));
346                            if !is_throwable {
347                                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
348                                let (line_end, col_end) = if stmt.span.start < stmt.span.end {
349                                    let (end_line, end_col) =
350                                        self.offset_to_line_col(stmt.span.end);
351                                    (end_line, end_col)
352                                } else {
353                                    (line, col_start)
354                                };
355                                self.issues.add(mir_issues::Issue::new(
356                                    IssueKind::InvalidThrow {
357                                        ty: fqcn.to_string(),
358                                    },
359                                    mir_issues::Location {
360                                        file: self.file.clone(),
361                                        line,
362                                        line_end,
363                                        col_start,
364                                        col_end: col_end.max(col_start + 1),
365                                    },
366                                ));
367                            }
368                        }
369                        // self/static/parent resolve to the class itself — check via fqcn
370                        mir_types::Atomic::TSelf { fqcn }
371                        | mir_types::Atomic::TStaticObject { fqcn }
372                        | mir_types::Atomic::TParent { fqcn } => {
373                            let resolved = self.codebase.resolve_class_name(&self.file, fqcn);
374                            let is_throwable = resolved == "Throwable"
375                                || resolved == "Exception"
376                                || resolved == "Error"
377                                || self.codebase.extends_or_implements(&resolved, "Throwable")
378                                || self.codebase.extends_or_implements(&resolved, "Exception")
379                                || self.codebase.extends_or_implements(&resolved, "Error")
380                                || self.codebase.extends_or_implements(fqcn, "Throwable")
381                                || self.codebase.extends_or_implements(fqcn, "Exception")
382                                || self.codebase.extends_or_implements(fqcn, "Error")
383                                || self.codebase.has_unknown_ancestor(&resolved)
384                                || self.codebase.has_unknown_ancestor(fqcn);
385                            if !is_throwable {
386                                let (line, col_start) = self.offset_to_line_col(stmt.span.start);
387                                let (line_end, col_end) = if stmt.span.start < stmt.span.end {
388                                    let (end_line, end_col) =
389                                        self.offset_to_line_col(stmt.span.end);
390                                    (end_line, end_col)
391                                } else {
392                                    (line, col_start)
393                                };
394                                self.issues.add(mir_issues::Issue::new(
395                                    IssueKind::InvalidThrow {
396                                        ty: fqcn.to_string(),
397                                    },
398                                    mir_issues::Location {
399                                        file: self.file.clone(),
400                                        line,
401                                        line_end,
402                                        col_start,
403                                        col_end: col_end.max(col_start + 1),
404                                    },
405                                ));
406                            }
407                        }
408                        mir_types::Atomic::TMixed | mir_types::Atomic::TObject => {}
409                        _ => {
410                            let (line, col_start) = self.offset_to_line_col(stmt.span.start);
411                            let (line_end, col_end) = if stmt.span.start < stmt.span.end {
412                                let (end_line, end_col) = self.offset_to_line_col(stmt.span.end);
413                                (end_line, end_col)
414                            } else {
415                                (line, col_start)
416                            };
417                            self.issues.add(mir_issues::Issue::new(
418                                IssueKind::InvalidThrow {
419                                    ty: format!("{thrown_ty}"),
420                                },
421                                mir_issues::Location {
422                                    file: self.file.clone(),
423                                    line,
424                                    line_end,
425                                    col_start,
426                                    col_end: col_end.max(col_start + 1),
427                                },
428                            ));
429                        }
430                    }
431                }
432                ctx.diverges = true;
433            }
434
435            // ---- If -----------------------------------------------------------
436            StmtKind::If(if_stmt) => {
437                let pre_ctx = ctx.clone();
438
439                // Analyse condition expression
440                let cond_type = self.expr_analyzer(ctx).analyze(&if_stmt.condition, ctx);
441                let pre_diverges = ctx.diverges;
442
443                // True branch
444                let mut then_ctx = ctx.fork();
445                narrow_from_condition(
446                    &if_stmt.condition,
447                    &mut then_ctx,
448                    true,
449                    self.codebase,
450                    &self.file,
451                );
452                // Capture narrowing-only unreachability before body analysis —
453                // body divergence (continue/return/throw) must not trigger
454                // RedundantCondition for valid conditions.
455                let then_unreachable_from_narrowing = then_ctx.diverges;
456                // Skip analyzing a statically-unreachable branch (prevents false
457                // positives in dead branches caused by overly conservative types).
458                if !then_ctx.diverges {
459                    self.analyze_stmt(if_stmt.then_branch, &mut then_ctx);
460                }
461
462                // ElseIf branches (flatten into separate else-if chain)
463                let mut elseif_ctxs: Vec<Context> = vec![];
464                for elseif in if_stmt.elseif_branches.iter() {
465                    // Start from the pre-if context narrowed by the if condition being false
466                    // (an elseif body only runs when the if condition is false).
467                    let mut pre_elseif = ctx.fork();
468                    narrow_from_condition(
469                        &if_stmt.condition,
470                        &mut pre_elseif,
471                        false,
472                        self.codebase,
473                        &self.file,
474                    );
475                    let pre_elseif_diverges = pre_elseif.diverges;
476
477                    // Check reachability of the elseif body (condition narrowed true)
478                    // and its implicit "skip" path (condition narrowed false) to detect
479                    // redundant elseif conditions.
480                    let mut elseif_true_ctx = pre_elseif.clone();
481                    narrow_from_condition(
482                        &elseif.condition,
483                        &mut elseif_true_ctx,
484                        true,
485                        self.codebase,
486                        &self.file,
487                    );
488                    let mut elseif_false_ctx = pre_elseif.clone();
489                    narrow_from_condition(
490                        &elseif.condition,
491                        &mut elseif_false_ctx,
492                        false,
493                        self.codebase,
494                        &self.file,
495                    );
496                    if !pre_elseif_diverges
497                        && (elseif_true_ctx.diverges || elseif_false_ctx.diverges)
498                    {
499                        let (line, col_start) =
500                            self.offset_to_line_col(elseif.condition.span.start);
501                        let (line_end, col_end) =
502                            if elseif.condition.span.start < elseif.condition.span.end {
503                                let (end_line, end_col) =
504                                    self.offset_to_line_col(elseif.condition.span.end);
505                                (end_line, end_col)
506                            } else {
507                                (line, col_start)
508                            };
509                        let elseif_cond_type = self
510                            .expr_analyzer(ctx)
511                            .analyze(&elseif.condition, &mut ctx.fork());
512                        self.issues.add(
513                            mir_issues::Issue::new(
514                                IssueKind::RedundantCondition {
515                                    ty: format!("{elseif_cond_type}"),
516                                },
517                                mir_issues::Location {
518                                    file: self.file.clone(),
519                                    line,
520                                    line_end,
521                                    col_start,
522                                    col_end: col_end.max(col_start + 1),
523                                },
524                            )
525                            .with_snippet(
526                                crate::parser::span_text(self.source, elseif.condition.span)
527                                    .unwrap_or_default(),
528                            ),
529                        );
530                    }
531
532                    // Analyze the elseif body using the narrowed-true context.
533                    let mut branch_ctx = elseif_true_ctx;
534                    self.expr_analyzer(&branch_ctx)
535                        .analyze(&elseif.condition, &mut branch_ctx);
536                    if !branch_ctx.diverges {
537                        self.analyze_stmt(&elseif.body, &mut branch_ctx);
538                    }
539                    elseif_ctxs.push(branch_ctx);
540                }
541
542                // Else branch
543                let mut else_ctx = ctx.fork();
544                narrow_from_condition(
545                    &if_stmt.condition,
546                    &mut else_ctx,
547                    false,
548                    self.codebase,
549                    &self.file,
550                );
551                let else_unreachable_from_narrowing = else_ctx.diverges;
552                if !else_ctx.diverges {
553                    if let Some(else_branch) = &if_stmt.else_branch {
554                        self.analyze_stmt(else_branch, &mut else_ctx);
555                    }
556                }
557
558                // Emit RedundantCondition if narrowing proves one branch is statically unreachable.
559                if !pre_diverges
560                    && (then_unreachable_from_narrowing || else_unreachable_from_narrowing)
561                {
562                    let (line, col_start) = self.offset_to_line_col(if_stmt.condition.span.start);
563                    let (line_end, col_end) =
564                        if if_stmt.condition.span.start < if_stmt.condition.span.end {
565                            let (end_line, end_col) =
566                                self.offset_to_line_col(if_stmt.condition.span.end);
567                            (end_line, end_col)
568                        } else {
569                            (line, col_start)
570                        };
571                    self.issues.add(
572                        mir_issues::Issue::new(
573                            IssueKind::RedundantCondition {
574                                ty: format!("{cond_type}"),
575                            },
576                            mir_issues::Location {
577                                file: self.file.clone(),
578                                line,
579                                line_end,
580                                col_start,
581                                col_end: col_end.max(col_start + 1),
582                            },
583                        )
584                        .with_snippet(
585                            crate::parser::span_text(self.source, if_stmt.condition.span)
586                                .unwrap_or_default(),
587                        ),
588                    );
589                }
590
591                // Merge all branches: start with the if/else pair, then fold each
592                // elseif in as an additional possible execution path.  Using the
593                // accumulated ctx (not pre_ctx) as the "else" argument ensures every
594                // branch contributes to the final type environment.
595                *ctx = Context::merge_branches(&pre_ctx, then_ctx, Some(else_ctx));
596                for ec in elseif_ctxs {
597                    *ctx = Context::merge_branches(&pre_ctx, ec, Some(ctx.clone()));
598                }
599            }
600
601            // ---- While --------------------------------------------------------
602            StmtKind::While(w) => {
603                self.expr_analyzer(ctx).analyze(&w.condition, ctx);
604                let pre = ctx.clone();
605
606                // Entry context: narrow on true condition
607                let mut entry = ctx.fork();
608                narrow_from_condition(&w.condition, &mut entry, true, self.codebase, &self.file);
609
610                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
611                    sa.analyze_stmt(w.body, iter);
612                    sa.expr_analyzer(iter).analyze(&w.condition, iter);
613                });
614                *ctx = post;
615            }
616
617            // ---- Do-while -----------------------------------------------------
618            StmtKind::DoWhile(dw) => {
619                let pre = ctx.clone();
620                let entry = ctx.fork();
621                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
622                    sa.analyze_stmt(dw.body, iter);
623                    sa.expr_analyzer(iter).analyze(&dw.condition, iter);
624                });
625                *ctx = post;
626            }
627
628            // ---- For ----------------------------------------------------------
629            StmtKind::For(f) => {
630                // Init expressions run once before the loop
631                for init in f.init.iter() {
632                    self.expr_analyzer(ctx).analyze(init, ctx);
633                }
634                let pre = ctx.clone();
635                let mut entry = ctx.fork();
636                for cond in f.condition.iter() {
637                    self.expr_analyzer(&entry).analyze(cond, &mut entry);
638                }
639
640                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
641                    sa.analyze_stmt(f.body, iter);
642                    for update in f.update.iter() {
643                        sa.expr_analyzer(iter).analyze(update, iter);
644                    }
645                    for cond in f.condition.iter() {
646                        sa.expr_analyzer(iter).analyze(cond, iter);
647                    }
648                });
649                *ctx = post;
650            }
651
652            // ---- Foreach ------------------------------------------------------
653            StmtKind::Foreach(fe) => {
654                let arr_ty = self.expr_analyzer(ctx).analyze(&fe.expr, ctx);
655                let (key_ty, mut value_ty) = infer_foreach_types(&arr_ty);
656
657                // Apply `@var Type $varname` annotation on the foreach value variable.
658                // The annotation always wins — it is the developer's explicit type assertion.
659                if let Some(vname) = crate::expr::extract_simple_var(&fe.value) {
660                    if let Some((Some(ann_var), ann_ty)) = self.extract_var_annotation(stmt.span) {
661                        if ann_var == vname {
662                            value_ty = ann_ty;
663                        }
664                    }
665                }
666
667                let pre = ctx.clone();
668                let mut entry = ctx.fork();
669
670                // Bind key variable on loop entry
671                if let Some(key_expr) = &fe.key {
672                    if let Some(var_name) = crate::expr::extract_simple_var(key_expr) {
673                        entry.set_var(var_name, key_ty.clone());
674                    }
675                }
676                // Bind value variable on loop entry.
677                // The value may be a simple variable or a list/array destructure pattern.
678                let value_var = crate::expr::extract_simple_var(&fe.value);
679                let value_destructure_vars = crate::expr::extract_destructure_vars(&fe.value);
680                if let Some(ref vname) = value_var {
681                    entry.set_var(vname.as_str(), value_ty.clone());
682                } else {
683                    for vname in &value_destructure_vars {
684                        entry.set_var(vname, Union::mixed());
685                    }
686                }
687
688                let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
689                    // Re-bind key/value each iteration (array may change)
690                    if let Some(key_expr) = &fe.key {
691                        if let Some(var_name) = crate::expr::extract_simple_var(key_expr) {
692                            iter.set_var(var_name, key_ty.clone());
693                        }
694                    }
695                    if let Some(ref vname) = value_var {
696                        iter.set_var(vname.as_str(), value_ty.clone());
697                    } else {
698                        for vname in &value_destructure_vars {
699                            iter.set_var(vname, Union::mixed());
700                        }
701                    }
702                    sa.analyze_stmt(fe.body, iter);
703                });
704                *ctx = post;
705            }
706
707            // ---- Switch -------------------------------------------------------
708            StmtKind::Switch(sw) => {
709                let _subject_ty = self.expr_analyzer(ctx).analyze(&sw.expr, ctx);
710                // Extract the subject variable name for narrowing (if it's a simple var)
711                let subject_var: Option<String> = match &sw.expr.kind {
712                    php_ast::ast::ExprKind::Variable(name) => {
713                        Some(name.as_str().trim_start_matches('$').to_string())
714                    }
715                    _ => None,
716                };
717                // Detect `switch(true)` — case conditions are used as narrowing expressions
718                let switch_on_true = matches!(&sw.expr.kind, php_ast::ast::ExprKind::Bool(true));
719
720                let pre_ctx = ctx.clone();
721                // Push a break-context bucket so that `break` inside cases saves
722                // the case's context for merging into the post-switch result.
723                self.break_ctx_stack.push(Vec::new());
724
725                let has_default = sw.cases.iter().any(|c| c.value.is_none());
726
727                // First pass: analyse each case body independently from pre_ctx.
728                // Break statements inside a body save their context to break_ctx_stack
729                // automatically; we just collect the per-case contexts here.
730                let mut case_results: Vec<Context> = Vec::new();
731                for case in sw.cases.iter() {
732                    let mut case_ctx = pre_ctx.fork();
733                    if let Some(val) = &case.value {
734                        if switch_on_true {
735                            // `switch(true) { case $x instanceof Y: }` — narrow from condition
736                            narrow_from_condition(
737                                val,
738                                &mut case_ctx,
739                                true,
740                                self.codebase,
741                                &self.file,
742                            );
743                        } else if let Some(ref var_name) = subject_var {
744                            // Narrow subject var to the literal type of the case value
745                            let narrow_ty = match &val.kind {
746                                php_ast::ast::ExprKind::Int(n) => {
747                                    Some(Union::single(Atomic::TLiteralInt(*n)))
748                                }
749                                php_ast::ast::ExprKind::String(s) => {
750                                    Some(Union::single(Atomic::TLiteralString(Arc::from(&**s))))
751                                }
752                                php_ast::ast::ExprKind::Bool(b) => Some(Union::single(if *b {
753                                    Atomic::TTrue
754                                } else {
755                                    Atomic::TFalse
756                                })),
757                                php_ast::ast::ExprKind::Null => Some(Union::single(Atomic::TNull)),
758                                _ => None,
759                            };
760                            if let Some(narrowed) = narrow_ty {
761                                case_ctx.set_var(var_name, narrowed);
762                            }
763                        }
764                        self.expr_analyzer(&case_ctx).analyze(val, &mut case_ctx);
765                    }
766                    self.analyze_stmts(&case.body, &mut case_ctx);
767                    case_results.push(case_ctx);
768                }
769
770                // Second pass: propagate divergence backwards through the fallthrough
771                // chain. A non-diverging case (no break/return/throw) flows into the
772                // next case at runtime, so if that next case effectively diverges, this
773                // case effectively diverges too.
774                //
775                // Example:
776                //   case 1: $y = "a";   // no break — chains into case 2
777                //   case 2: return;     // diverges
778                //
779                // Case 1 is effectively diverging because its only exit is through
780                // case 2's return. Adding case 1 to fallthrough_ctxs would be wrong.
781                let n = case_results.len();
782                let mut effective_diverges = vec![false; n];
783                for i in (0..n).rev() {
784                    if case_results[i].diverges {
785                        effective_diverges[i] = true;
786                    } else if i + 1 < n {
787                        // Non-diverging body: falls through to the next case.
788                        effective_diverges[i] = effective_diverges[i + 1];
789                    }
790                    // else: last case with no break/return — falls to end of switch.
791                }
792
793                // Build fallthrough_ctxs from cases that truly exit via the end of
794                // the switch (not through a subsequent diverging case).
795                let mut all_cases_diverge = true;
796                let mut fallthrough_ctxs: Vec<Context> = Vec::new();
797                for (i, case_ctx) in case_results.into_iter().enumerate() {
798                    if !effective_diverges[i] {
799                        all_cases_diverge = false;
800                        fallthrough_ctxs.push(case_ctx);
801                    }
802                }
803
804                // Pop break contexts — each `break` in a case body pushed its
805                // context here, representing that case's effect on post-switch state.
806                let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
807
808                // Build the post-switch merged context:
809                // Start with pre_ctx if no default case (switch might not match anything)
810                // or if not all cases diverge via return/throw.
811                let mut merged = if has_default
812                    && all_cases_diverge
813                    && break_ctxs.is_empty()
814                    && fallthrough_ctxs.is_empty()
815                {
816                    // All paths return/throw — post-switch is unreachable
817                    let mut m = pre_ctx.clone();
818                    m.diverges = true;
819                    m
820                } else {
821                    // Start from pre_ctx (covers the "no case matched" path when there
822                    // is no default, plus ensures pre-existing variables are preserved).
823                    pre_ctx.clone()
824                };
825
826                for bctx in break_ctxs {
827                    merged = Context::merge_branches(&pre_ctx, bctx, Some(merged));
828                }
829                for fctx in fallthrough_ctxs {
830                    merged = Context::merge_branches(&pre_ctx, fctx, Some(merged));
831                }
832
833                *ctx = merged;
834            }
835
836            // ---- Try/catch/finally -------------------------------------------
837            StmtKind::TryCatch(tc) => {
838                let pre_ctx = ctx.clone();
839                let mut try_ctx = ctx.fork();
840                self.analyze_stmts(&tc.body, &mut try_ctx);
841
842                // Build a base context for catch blocks that merges pre and try contexts.
843                // Variables that might have been set during the try body are "possibly assigned"
844                // in the catch (they may or may not have been set before the exception fired).
845                let catch_base = Context::merge_branches(&pre_ctx, try_ctx.clone(), None);
846
847                let mut non_diverging_catches: Vec<Context> = vec![];
848                for catch in tc.catches.iter() {
849                    let mut catch_ctx = catch_base.clone();
850                    // Check that all caught exception types exist.
851                    for catch_ty in catch.types.iter() {
852                        self.check_name_undefined_class(catch_ty);
853                    }
854                    if let Some(var) = catch.var {
855                        // Bind the caught exception variable; union all caught types
856                        let exc_ty = if catch.types.is_empty() {
857                            Union::single(Atomic::TObject)
858                        } else {
859                            let mut u = Union::empty();
860                            for catch_ty in catch.types.iter() {
861                                let raw = crate::parser::name_to_string(catch_ty);
862                                let resolved = self.codebase.resolve_class_name(&self.file, &raw);
863                                u.add_type(Atomic::TNamedObject {
864                                    fqcn: resolved.into(),
865                                    type_params: vec![],
866                                });
867                            }
868                            u
869                        };
870                        catch_ctx.set_var(var.trim_start_matches('$'), exc_ty);
871                    }
872                    self.analyze_stmts(&catch.body, &mut catch_ctx);
873                    if !catch_ctx.diverges {
874                        non_diverging_catches.push(catch_ctx);
875                    }
876                }
877
878                // If ALL catch branches diverge (return/throw/continue/break),
879                // code after the try/catch is only reachable from the try body.
880                // Use try_ctx directly so variables assigned in try are definitely set.
881                let mut result = if non_diverging_catches.is_empty() {
882                    let mut r = try_ctx;
883                    r.diverges = false; // the try body itself may not have diverged
884                    r
885                } else {
886                    // Some catches don't diverge — merge try with all non-diverging catches.
887                    // Chain the merges: start with try_ctx, then fold in each catch branch.
888                    let mut r = try_ctx;
889                    for catch_ctx in non_diverging_catches {
890                        r = Context::merge_branches(&pre_ctx, r, Some(catch_ctx));
891                    }
892                    r
893                };
894
895                // Finally runs unconditionally — analyze but don't merge vars
896                if let Some(finally_stmts) = &tc.finally {
897                    let mut finally_ctx = result.clone();
898                    finally_ctx.inside_finally = true;
899                    self.analyze_stmts(finally_stmts, &mut finally_ctx);
900                    if finally_ctx.diverges {
901                        result.diverges = true;
902                    }
903                }
904
905                *ctx = result;
906            }
907
908            // ---- Block --------------------------------------------------------
909            StmtKind::Block(stmts) => {
910                self.analyze_stmts(stmts, ctx);
911            }
912
913            // ---- Break --------------------------------------------------------
914            StmtKind::Break(_) => {
915                // Save the context at the break point so the post-loop context
916                // accounts for this early-exit path.
917                if let Some(break_ctxs) = self.break_ctx_stack.last_mut() {
918                    break_ctxs.push(ctx.clone());
919                }
920                // Context after an unconditional break is dead; don't continue
921                // emitting issues for code after this point.
922                ctx.diverges = true;
923            }
924
925            // ---- Continue ----------------------------------------------------
926            StmtKind::Continue(_) => {
927                // continue goes back to the loop condition — no context to save,
928                // the widening pass already re-analyses the body.
929                ctx.diverges = true;
930            }
931
932            // ---- Unset --------------------------------------------------------
933            StmtKind::Unset(vars) => {
934                for var in vars.iter() {
935                    if let php_ast::ast::ExprKind::Variable(name) = &var.kind {
936                        ctx.unset_var(name.as_str().trim_start_matches('$'));
937                    }
938                }
939            }
940
941            // ---- Static variable declaration ---------------------------------
942            StmtKind::StaticVar(vars) => {
943                for sv in vars.iter() {
944                    let ty = Union::mixed(); // static vars are indeterminate on entry
945                    ctx.set_var(sv.name.trim_start_matches('$'), ty);
946                }
947            }
948
949            // ---- Global declaration ------------------------------------------
950            StmtKind::Global(vars) => {
951                for var in vars.iter() {
952                    if let php_ast::ast::ExprKind::Variable(name) = &var.kind {
953                        let var_name = name.as_str().trim_start_matches('$');
954                        let ty = self
955                            .codebase
956                            .global_vars
957                            .get(var_name)
958                            .map(|r| r.clone())
959                            .unwrap_or_else(Union::mixed);
960                        ctx.set_var(var_name, ty);
961                    }
962                }
963            }
964
965            // ---- Declare -----------------------------------------------------
966            StmtKind::Declare(d) => {
967                for (name, _val) in d.directives.iter() {
968                    if *name == "strict_types" {
969                        ctx.strict_types = true;
970                    }
971                }
972                if let Some(body) = &d.body {
973                    self.analyze_stmt(body, ctx);
974                }
975            }
976
977            // ---- Nested declarations (inside function bodies) ----------------
978            StmtKind::Function(decl) => {
979                // Nested named function — analyze its body in the same issue buffer
980                // so that undefined-function/class calls inside it are reported.
981                let params: Vec<mir_codebase::FnParam> = decl
982                    .params
983                    .iter()
984                    .map(|p| mir_codebase::FnParam {
985                        name: std::sync::Arc::from(p.name.trim_start_matches('$')),
986                        ty: None,
987                        default: p.default.as_ref().map(|_| Union::mixed()),
988                        is_variadic: p.variadic,
989                        is_byref: p.by_ref,
990                        is_optional: p.default.is_some() || p.variadic,
991                    })
992                    .collect();
993                let mut fn_ctx =
994                    Context::for_function(&params, None, None, None, None, ctx.strict_types, true);
995                let mut sa = StatementsAnalyzer::new(
996                    self.codebase,
997                    self.file.clone(),
998                    self.source,
999                    self.source_map,
1000                    self.issues,
1001                    self.symbols,
1002                    self.php_version,
1003                );
1004                sa.analyze_stmts(&decl.body, &mut fn_ctx);
1005            }
1006
1007            StmtKind::Class(decl) => {
1008                // Nested class declaration — analyze each method body in the same
1009                // issue buffer so that undefined-function/class calls are reported.
1010                let class_name = decl.name.unwrap_or("<anonymous>");
1011                let resolved = self.codebase.resolve_class_name(&self.file, class_name);
1012                let fqcn: Arc<str> = Arc::from(resolved.as_str());
1013                let parent_fqcn = self
1014                    .codebase
1015                    .classes
1016                    .get(fqcn.as_ref())
1017                    .and_then(|c| c.parent.clone());
1018
1019                for member in decl.members.iter() {
1020                    let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
1021                        continue;
1022                    };
1023                    let Some(body) = &method.body else { continue };
1024                    let (params, return_ty) = self
1025                        .codebase
1026                        .get_method(fqcn.as_ref(), method.name)
1027                        .as_deref()
1028                        .map(|m| (m.params.clone(), m.return_type.clone()))
1029                        .unwrap_or_else(|| {
1030                            let ast_params = method
1031                                .params
1032                                .iter()
1033                                .map(|p| mir_codebase::FnParam {
1034                                    name: p.name.trim_start_matches('$').into(),
1035                                    ty: None,
1036                                    default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
1037                                    is_variadic: p.variadic,
1038                                    is_byref: p.by_ref,
1039                                    is_optional: p.default.is_some() || p.variadic,
1040                                })
1041                                .collect();
1042                            (ast_params, None)
1043                        });
1044                    let is_ctor = method.name == "__construct";
1045                    let mut method_ctx = Context::for_method(
1046                        &params,
1047                        return_ty,
1048                        Some(fqcn.clone()),
1049                        parent_fqcn.clone(),
1050                        Some(fqcn.clone()),
1051                        ctx.strict_types,
1052                        is_ctor,
1053                        method.is_static,
1054                    );
1055                    let mut sa = StatementsAnalyzer::new(
1056                        self.codebase,
1057                        self.file.clone(),
1058                        self.source,
1059                        self.source_map,
1060                        self.issues,
1061                        self.symbols,
1062                        self.php_version,
1063                    );
1064                    sa.analyze_stmts(body, &mut method_ctx);
1065                }
1066            }
1067
1068            StmtKind::Interface(_) | StmtKind::Trait(_) | StmtKind::Enum(_) => {
1069                // Interfaces/traits/enums are collected in Pass 1 — skip here
1070            }
1071
1072            // ---- Namespace / use (at file level, already handled in Pass 1) --
1073            StmtKind::Namespace(_) | StmtKind::Use(_) | StmtKind::Const(_) => {}
1074
1075            // ---- Inert --------------------------------------------------------
1076            StmtKind::InlineHtml(_)
1077            | StmtKind::Nop
1078            | StmtKind::Goto(_)
1079            | StmtKind::Label(_)
1080            | StmtKind::HaltCompiler(_) => {}
1081
1082            StmtKind::Error => {}
1083        }
1084    }
1085
1086    // -----------------------------------------------------------------------
1087    // Helper: create a short-lived ExpressionAnalyzer borrowing our fields
1088    // -----------------------------------------------------------------------
1089
1090    fn expr_analyzer<'b>(&'b mut self, _ctx: &Context) -> ExpressionAnalyzer<'b>
1091    where
1092        'a: 'b,
1093    {
1094        ExpressionAnalyzer::new(
1095            self.codebase,
1096            self.file.clone(),
1097            self.source,
1098            self.source_map,
1099            self.issues,
1100            self.symbols,
1101            self.php_version,
1102        )
1103    }
1104
1105    /// Convert a byte offset to a Unicode char-count column on a given line.
1106    /// Returns (line, col) where col is a 0-based Unicode code-point count.
1107    fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1108        let lc = self.source_map.offset_to_line_col(offset);
1109        let line = lc.line + 1;
1110
1111        let byte_offset = offset as usize;
1112        let line_start_byte = if byte_offset == 0 {
1113            0
1114        } else {
1115            self.source[..byte_offset]
1116                .rfind('\n')
1117                .map(|p| p + 1)
1118                .unwrap_or(0)
1119        };
1120
1121        let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1122
1123        (line, col)
1124    }
1125
1126    /// Emit `UndefinedClass` for a `Name` AST node if the resolved class does not exist.
1127    fn check_name_undefined_class(&mut self, name: &php_ast::ast::Name<'_, '_>) {
1128        let raw = crate::parser::name_to_string(name);
1129        let resolved = self.codebase.resolve_class_name(&self.file, &raw);
1130        if matches!(resolved.as_str(), "self" | "static" | "parent") {
1131            return;
1132        }
1133        if self.codebase.type_exists(&resolved) {
1134            return;
1135        }
1136        let span = name.span();
1137        let (line, col_start) = self.offset_to_line_col(span.start);
1138        let (line_end, col_end) = self.offset_to_line_col(span.end);
1139        self.issues.add(Issue::new(
1140            IssueKind::UndefinedClass { name: resolved },
1141            Location {
1142                file: self.file.clone(),
1143                line,
1144                line_end,
1145                col_start,
1146                col_end: col_end.max(col_start + 1),
1147            },
1148        ));
1149    }
1150
1151    // -----------------------------------------------------------------------
1152    // @psalm-suppress / @suppress per-statement
1153    // -----------------------------------------------------------------------
1154
1155    /// Extract suppression names from the `@psalm-suppress` / `@suppress`
1156    /// annotation in the docblock immediately preceding `span`.
1157    fn extract_statement_suppressions(&self, span: php_ast::Span) -> Vec<String> {
1158        let Some(doc) = crate::parser::find_preceding_docblock(self.source, span.start) else {
1159            return vec![];
1160        };
1161        let mut suppressions = Vec::new();
1162        for line in doc.lines() {
1163            let line = line.trim().trim_start_matches('*').trim();
1164            let rest = if let Some(r) = line.strip_prefix("@psalm-suppress ") {
1165                r
1166            } else if let Some(r) = line.strip_prefix("@suppress ") {
1167                r
1168            } else {
1169                continue;
1170            };
1171            for name in rest.split_whitespace() {
1172                suppressions.push(name.to_string());
1173            }
1174        }
1175        suppressions
1176    }
1177
1178    /// Extract `@var Type [$varname]` from the docblock immediately preceding `span`.
1179    /// Returns `(optional_var_name, resolved_type)` if an annotation exists.
1180    /// The type is resolved through the codebase's file-level imports/namespace.
1181    fn extract_var_annotation(
1182        &self,
1183        span: php_ast::Span,
1184    ) -> Option<(Option<String>, mir_types::Union)> {
1185        let doc = crate::parser::find_preceding_docblock(self.source, span.start)?;
1186        let parsed = crate::parser::DocblockParser::parse(&doc);
1187        let ty = parsed.var_type?;
1188        let resolved = resolve_union_for_file(ty, self.codebase, &self.file);
1189        Some((parsed.var_name, resolved))
1190    }
1191
1192    // -----------------------------------------------------------------------
1193    // Fixed-point loop widening (M12)
1194    // -----------------------------------------------------------------------
1195
1196    /// Analyse a loop body with a fixed-point widening algorithm (≤ 3 passes).
1197    ///
1198    /// * `pre`   — context *before* the loop (used as the merge base)
1199    /// * `entry` — context on first iteration entry (may be narrowed / seeded)
1200    /// * `body`  — closure that analyses one loop iteration, receives `&mut Self`
1201    ///   and `&mut Context` for the current iteration context
1202    ///
1203    /// Returns the post-loop context that merges:
1204    ///   - the stable widened context after normal loop exit
1205    ///   - any contexts captured at `break` statements
1206    fn analyze_loop_widened<F>(&mut self, pre: &Context, entry: Context, mut body: F) -> Context
1207    where
1208        F: FnMut(&mut Self, &mut Context),
1209    {
1210        const MAX_ITERS: usize = 3;
1211
1212        // Push a fresh break-context bucket for this loop level
1213        self.break_ctx_stack.push(Vec::new());
1214
1215        let mut current = entry;
1216        current.inside_loop = true;
1217
1218        for _ in 0..MAX_ITERS {
1219            let prev_vars = current.vars.clone();
1220
1221            let mut iter = current.clone();
1222            body(self, &mut iter);
1223
1224            let next = Context::merge_branches(pre, iter, None);
1225
1226            if vars_stabilized(&prev_vars, &next.vars) {
1227                current = next;
1228                break;
1229            }
1230            current = next;
1231        }
1232
1233        // Widen any variable still unstable after MAX_ITERS to `mixed`
1234        widen_unstable(&pre.vars, &mut current.vars);
1235
1236        // Pop break contexts and merge them into the post-loop result
1237        let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
1238        for bctx in break_ctxs {
1239            current = Context::merge_branches(pre, current, Some(bctx));
1240        }
1241
1242        current
1243    }
1244}