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