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