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