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