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