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}