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