Skip to main content

mir_analyzer/
stmt.rs

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