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::{ArrayKey, 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_rs_parser::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_rs_parser::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_start) = self.offset_to_line_col(stmt.span.start);
133 let col_end = if stmt.span.start < stmt.span.end {
134 let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
135 end_col
136 } else {
137 col_start
138 };
139 let mut issue = mir_issues::Issue::new(
140 IssueKind::TaintedHtml,
141 mir_issues::Location {
142 file: self.file.clone(),
143 line,
144 col_start,
145 col_end: col_end.max(col_start + 1),
146 },
147 );
148 // Extract snippet from the echo statement span.
149 let start = stmt.span.start as usize;
150 let end = stmt.span.end as usize;
151 if start < self.source.len() {
152 let end = end.min(self.source.len());
153 let span_text = &self.source[start..end];
154 if let Some(first_line) = span_text.lines().next() {
155 issue = issue.with_snippet(first_line.trim().to_string());
156 }
157 }
158 self.issues.add(issue);
159 }
160 self.expr_analyzer(ctx).analyze(expr, ctx);
161 }
162 }
163
164 // ---- Return -------------------------------------------------------
165 StmtKind::Return(opt_expr) => {
166 if let Some(expr) = opt_expr {
167 let ret_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
168
169 // If there's a bare `@var Type` (no variable name) on the return statement,
170 // use the annotated type for the return-type compatibility check.
171 // `@var Type $name` with a variable name narrows the variable (handled in
172 // analyze_stmts loop), not the return type.
173 let check_ty =
174 if let Some((None, var_ty)) = self.extract_var_annotation(stmt.span) {
175 var_ty
176 } else {
177 ret_ty.clone()
178 };
179
180 // Check against declared return type
181 if let Some(declared) = &ctx.fn_return_type.clone() {
182 // Check return type compatibility. Special case: `void` functions must not
183 // return any value (named_object_return_compatible considers TVoid compatible
184 // with TNull, so handle void separately to avoid false suppression).
185 if (declared.is_void() && !check_ty.is_void() && !check_ty.is_mixed())
186 || (!check_ty.is_subtype_of_simple(declared)
187 && !declared.is_mixed()
188 && !check_ty.is_mixed()
189 && !named_object_return_compatible(&check_ty, declared, self.codebase, &self.file)
190 // Also check without null (handles `null|T` where T implements declared).
191 // Guard: if check_ty is purely null, remove_null() is empty and would
192 // vacuously return true, incorrectly suppressing the error.
193 && (check_ty.remove_null().is_empty() || !named_object_return_compatible(&check_ty.remove_null(), declared, self.codebase, &self.file))
194 && !declared_return_has_template(declared, self.codebase)
195 && !declared_return_has_template(&check_ty, self.codebase)
196 && !return_arrays_compatible(&check_ty, declared, self.codebase, &self.file)
197 // Skip coercions: declared is more specific than actual
198 && !declared.is_subtype_of_simple(&check_ty)
199 && !declared.remove_null().is_subtype_of_simple(&check_ty)
200 // Skip when actual is compatible after removing null/false.
201 // Guard against empty union (e.g. pure-null type): removing null
202 // from `null` alone gives an empty union which vacuously passes
203 // is_subtype_of_simple — that would incorrectly suppress the error.
204 && (check_ty.remove_null().is_empty() || !check_ty.remove_null().is_subtype_of_simple(declared))
205 && !check_ty.remove_false().is_subtype_of_simple(declared)
206 // Suppress LessSpecificReturnStatement (level 4): actual is a
207 // supertype of declared (not flagged at default error level).
208 && !named_object_return_compatible(declared, &check_ty, self.codebase, &self.file)
209 && !named_object_return_compatible(&declared.remove_null(), &check_ty.remove_null(), self.codebase, &self.file))
210 {
211 let (line, col_start) = self.offset_to_line_col(stmt.span.start);
212 let col_end = if stmt.span.start < stmt.span.end {
213 let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
214 end_col
215 } else {
216 col_start
217 };
218 self.issues.add(
219 mir_issues::Issue::new(
220 IssueKind::InvalidReturnType {
221 expected: format!("{}", declared),
222 actual: format!("{}", ret_ty),
223 },
224 mir_issues::Location {
225 file: self.file.clone(),
226 line,
227 col_start,
228 col_end: col_end.max(col_start + 1),
229 },
230 )
231 .with_snippet(
232 crate::parser::span_text(self.source, stmt.span)
233 .unwrap_or_default(),
234 ),
235 );
236 }
237 }
238 self.return_types.push(ret_ty);
239 } else {
240 self.return_types.push(Union::single(Atomic::TVoid));
241 // Bare `return;` from a non-void declared function is an error.
242 if let Some(declared) = &ctx.fn_return_type.clone() {
243 if !declared.is_void() && !declared.is_mixed() {
244 let (line, col_start) = self.offset_to_line_col(stmt.span.start);
245 let col_end = if stmt.span.start < stmt.span.end {
246 let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
247 end_col
248 } else {
249 col_start
250 };
251 self.issues.add(
252 mir_issues::Issue::new(
253 IssueKind::InvalidReturnType {
254 expected: format!("{}", declared),
255 actual: "void".to_string(),
256 },
257 mir_issues::Location {
258 file: self.file.clone(),
259 line,
260 col_start,
261 col_end: col_end.max(col_start + 1),
262 },
263 )
264 .with_snippet(
265 crate::parser::span_text(self.source, stmt.span)
266 .unwrap_or_default(),
267 ),
268 );
269 }
270 }
271 }
272 ctx.diverges = true;
273 }
274
275 // ---- Throw --------------------------------------------------------
276 StmtKind::Throw(expr) => {
277 let thrown_ty = self.expr_analyzer(ctx).analyze(expr, ctx);
278 // Validate that the thrown type extends Throwable
279 for atomic in &thrown_ty.types {
280 match atomic {
281 mir_types::Atomic::TNamedObject { fqcn, .. } => {
282 let resolved = self.codebase.resolve_class_name(&self.file, fqcn);
283 let is_throwable = resolved == "Throwable"
284 || resolved == "Exception"
285 || resolved == "Error"
286 || fqcn.as_ref() == "Throwable"
287 || fqcn.as_ref() == "Exception"
288 || fqcn.as_ref() == "Error"
289 || self.codebase.extends_or_implements(&resolved, "Throwable")
290 || self.codebase.extends_or_implements(&resolved, "Exception")
291 || self.codebase.extends_or_implements(&resolved, "Error")
292 || self.codebase.extends_or_implements(fqcn, "Throwable")
293 || self.codebase.extends_or_implements(fqcn, "Exception")
294 || self.codebase.extends_or_implements(fqcn, "Error")
295 // Suppress if class has unknown ancestors (might be Throwable)
296 || self.codebase.has_unknown_ancestor(&resolved)
297 || self.codebase.has_unknown_ancestor(fqcn)
298 // Suppress if class is not in codebase at all (could be extension class)
299 || (!self.codebase.type_exists(&resolved) && !self.codebase.type_exists(fqcn));
300 if !is_throwable {
301 let (line, col_start) = self.offset_to_line_col(stmt.span.start);
302 let col_end = if stmt.span.start < stmt.span.end {
303 let (_end_line, end_col) =
304 self.offset_to_line_col(stmt.span.end);
305 end_col
306 } else {
307 col_start
308 };
309 self.issues.add(mir_issues::Issue::new(
310 IssueKind::InvalidThrow {
311 ty: fqcn.to_string(),
312 },
313 mir_issues::Location {
314 file: self.file.clone(),
315 line,
316 col_start,
317 col_end: col_end.max(col_start + 1),
318 },
319 ));
320 }
321 }
322 // self/static/parent resolve to the class itself — check via fqcn
323 mir_types::Atomic::TSelf { fqcn }
324 | mir_types::Atomic::TStaticObject { fqcn }
325 | mir_types::Atomic::TParent { fqcn } => {
326 let resolved = self.codebase.resolve_class_name(&self.file, fqcn);
327 let is_throwable = resolved == "Throwable"
328 || resolved == "Exception"
329 || resolved == "Error"
330 || self.codebase.extends_or_implements(&resolved, "Throwable")
331 || self.codebase.extends_or_implements(&resolved, "Exception")
332 || self.codebase.extends_or_implements(&resolved, "Error")
333 || self.codebase.extends_or_implements(fqcn, "Throwable")
334 || self.codebase.extends_or_implements(fqcn, "Exception")
335 || self.codebase.extends_or_implements(fqcn, "Error")
336 || self.codebase.has_unknown_ancestor(&resolved)
337 || self.codebase.has_unknown_ancestor(fqcn);
338 if !is_throwable {
339 let (line, col_start) = self.offset_to_line_col(stmt.span.start);
340 let col_end = if stmt.span.start < stmt.span.end {
341 let (_end_line, end_col) =
342 self.offset_to_line_col(stmt.span.end);
343 end_col
344 } else {
345 col_start
346 };
347 self.issues.add(mir_issues::Issue::new(
348 IssueKind::InvalidThrow {
349 ty: fqcn.to_string(),
350 },
351 mir_issues::Location {
352 file: self.file.clone(),
353 line,
354 col_start,
355 col_end: col_end.max(col_start + 1),
356 },
357 ));
358 }
359 }
360 mir_types::Atomic::TMixed | mir_types::Atomic::TObject => {}
361 _ => {
362 let (line, col_start) = self.offset_to_line_col(stmt.span.start);
363 let col_end = if stmt.span.start < stmt.span.end {
364 let (_end_line, end_col) = self.offset_to_line_col(stmt.span.end);
365 end_col
366 } else {
367 col_start
368 };
369 self.issues.add(mir_issues::Issue::new(
370 IssueKind::InvalidThrow {
371 ty: format!("{}", thrown_ty),
372 },
373 mir_issues::Location {
374 file: self.file.clone(),
375 line,
376 col_start,
377 col_end: col_end.max(col_start + 1),
378 },
379 ));
380 }
381 }
382 }
383 ctx.diverges = true;
384 }
385
386 // ---- If -----------------------------------------------------------
387 StmtKind::If(if_stmt) => {
388 let pre_ctx = ctx.clone();
389
390 // Analyse condition expression
391 let cond_type = self.expr_analyzer(ctx).analyze(&if_stmt.condition, ctx);
392 let pre_diverges = ctx.diverges;
393
394 // True branch
395 let mut then_ctx = ctx.fork();
396 narrow_from_condition(
397 &if_stmt.condition,
398 &mut then_ctx,
399 true,
400 self.codebase,
401 &self.file,
402 );
403 // Capture narrowing-only unreachability before body analysis —
404 // body divergence (continue/return/throw) must not trigger
405 // RedundantCondition for valid conditions.
406 let then_unreachable_from_narrowing = then_ctx.diverges;
407 // Skip analyzing a statically-unreachable branch (prevents false
408 // positives in dead branches caused by overly conservative types).
409 if !then_ctx.diverges {
410 self.analyze_stmt(if_stmt.then_branch, &mut then_ctx);
411 }
412
413 // ElseIf branches (flatten into separate else-if chain)
414 let mut elseif_ctxs: Vec<Context> = vec![];
415 for elseif in if_stmt.elseif_branches.iter() {
416 // Start from the pre-if context narrowed by the if condition being false
417 // (an elseif body only runs when the if condition is false).
418 let mut pre_elseif = ctx.fork();
419 narrow_from_condition(
420 &if_stmt.condition,
421 &mut pre_elseif,
422 false,
423 self.codebase,
424 &self.file,
425 );
426 let pre_elseif_diverges = pre_elseif.diverges;
427
428 // Check reachability of the elseif body (condition narrowed true)
429 // and its implicit "skip" path (condition narrowed false) to detect
430 // redundant elseif conditions.
431 let mut elseif_true_ctx = pre_elseif.clone();
432 narrow_from_condition(
433 &elseif.condition,
434 &mut elseif_true_ctx,
435 true,
436 self.codebase,
437 &self.file,
438 );
439 let mut elseif_false_ctx = pre_elseif.clone();
440 narrow_from_condition(
441 &elseif.condition,
442 &mut elseif_false_ctx,
443 false,
444 self.codebase,
445 &self.file,
446 );
447 if !pre_elseif_diverges
448 && (elseif_true_ctx.diverges || elseif_false_ctx.diverges)
449 {
450 let (line, col_start) =
451 self.offset_to_line_col(elseif.condition.span.start);
452 let col_end = if elseif.condition.span.start < elseif.condition.span.end {
453 let (_end_line, end_col) =
454 self.offset_to_line_col(elseif.condition.span.end);
455 end_col
456 } else {
457 col_start
458 };
459 let elseif_cond_type = self
460 .expr_analyzer(ctx)
461 .analyze(&elseif.condition, &mut ctx.fork());
462 self.issues.add(
463 mir_issues::Issue::new(
464 IssueKind::RedundantCondition {
465 ty: format!("{}", elseif_cond_type),
466 },
467 mir_issues::Location {
468 file: self.file.clone(),
469 line,
470 col_start,
471 col_end: col_end.max(col_start + 1),
472 },
473 )
474 .with_snippet(
475 crate::parser::span_text(self.source, elseif.condition.span)
476 .unwrap_or_default(),
477 ),
478 );
479 }
480
481 // Analyze the elseif body using the narrowed-true context.
482 let mut branch_ctx = elseif_true_ctx;
483 self.expr_analyzer(&branch_ctx)
484 .analyze(&elseif.condition, &mut branch_ctx);
485 if !branch_ctx.diverges {
486 self.analyze_stmt(&elseif.body, &mut branch_ctx);
487 }
488 elseif_ctxs.push(branch_ctx);
489 }
490
491 // Else branch
492 let mut else_ctx = ctx.fork();
493 narrow_from_condition(
494 &if_stmt.condition,
495 &mut else_ctx,
496 false,
497 self.codebase,
498 &self.file,
499 );
500 let else_unreachable_from_narrowing = else_ctx.diverges;
501 if !else_ctx.diverges {
502 if let Some(else_branch) = &if_stmt.else_branch {
503 self.analyze_stmt(else_branch, &mut else_ctx);
504 }
505 }
506
507 // Emit RedundantCondition if narrowing proves one branch is statically unreachable.
508 if !pre_diverges
509 && (then_unreachable_from_narrowing || else_unreachable_from_narrowing)
510 {
511 let (line, col_start) = self.offset_to_line_col(if_stmt.condition.span.start);
512 let col_end = if if_stmt.condition.span.start < if_stmt.condition.span.end {
513 let (_end_line, end_col) =
514 self.offset_to_line_col(if_stmt.condition.span.end);
515 end_col
516 } else {
517 col_start
518 };
519 self.issues.add(
520 mir_issues::Issue::new(
521 IssueKind::RedundantCondition {
522 ty: format!("{}", cond_type),
523 },
524 mir_issues::Location {
525 file: self.file.clone(),
526 line,
527 col_start,
528 col_end: col_end.max(col_start + 1),
529 },
530 )
531 .with_snippet(
532 crate::parser::span_text(self.source, if_stmt.condition.span)
533 .unwrap_or_default(),
534 ),
535 );
536 }
537
538 // Merge all branches: start with the if/else pair, then fold each
539 // elseif in as an additional possible execution path. Using the
540 // accumulated ctx (not pre_ctx) as the "else" argument ensures every
541 // branch contributes to the final type environment.
542 *ctx = Context::merge_branches(&pre_ctx, then_ctx, Some(else_ctx));
543 for ec in elseif_ctxs {
544 *ctx = Context::merge_branches(&pre_ctx, ec, Some(ctx.clone()));
545 }
546 }
547
548 // ---- While --------------------------------------------------------
549 StmtKind::While(w) => {
550 self.expr_analyzer(ctx).analyze(&w.condition, ctx);
551 let pre = ctx.clone();
552
553 // Entry context: narrow on true condition
554 let mut entry = ctx.fork();
555 narrow_from_condition(&w.condition, &mut entry, true, self.codebase, &self.file);
556
557 let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
558 sa.analyze_stmt(w.body, iter);
559 sa.expr_analyzer(iter).analyze(&w.condition, iter);
560 });
561 *ctx = post;
562 }
563
564 // ---- Do-while -----------------------------------------------------
565 StmtKind::DoWhile(dw) => {
566 let pre = ctx.clone();
567 let entry = ctx.fork();
568 let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
569 sa.analyze_stmt(dw.body, iter);
570 sa.expr_analyzer(iter).analyze(&dw.condition, iter);
571 });
572 *ctx = post;
573 }
574
575 // ---- For ----------------------------------------------------------
576 StmtKind::For(f) => {
577 // Init expressions run once before the loop
578 for init in f.init.iter() {
579 self.expr_analyzer(ctx).analyze(init, ctx);
580 }
581 let pre = ctx.clone();
582 let mut entry = ctx.fork();
583 for cond in f.condition.iter() {
584 self.expr_analyzer(&entry).analyze(cond, &mut entry);
585 }
586
587 let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
588 sa.analyze_stmt(f.body, iter);
589 for update in f.update.iter() {
590 sa.expr_analyzer(iter).analyze(update, iter);
591 }
592 for cond in f.condition.iter() {
593 sa.expr_analyzer(iter).analyze(cond, iter);
594 }
595 });
596 *ctx = post;
597 }
598
599 // ---- Foreach ------------------------------------------------------
600 StmtKind::Foreach(fe) => {
601 let arr_ty = self.expr_analyzer(ctx).analyze(&fe.expr, ctx);
602 let (key_ty, mut value_ty) = infer_foreach_types(&arr_ty);
603
604 // Apply `@var Type $varname` annotation on the foreach value variable.
605 // The annotation always wins — it is the developer's explicit type assertion.
606 if let Some(vname) = crate::expr::extract_simple_var(&fe.value) {
607 if let Some((Some(ann_var), ann_ty)) = self.extract_var_annotation(stmt.span) {
608 if ann_var == vname {
609 value_ty = ann_ty;
610 }
611 }
612 }
613
614 let pre = ctx.clone();
615 let mut entry = ctx.fork();
616
617 // Bind key variable on loop entry
618 if let Some(key_expr) = &fe.key {
619 if let Some(var_name) = crate::expr::extract_simple_var(key_expr) {
620 entry.set_var(var_name, key_ty.clone());
621 }
622 }
623 // Bind value variable on loop entry.
624 // The value may be a simple variable or a list/array destructure pattern.
625 let value_var = crate::expr::extract_simple_var(&fe.value);
626 let value_destructure_vars = crate::expr::extract_destructure_vars(&fe.value);
627 if let Some(ref vname) = value_var {
628 entry.set_var(vname.as_str(), value_ty.clone());
629 } else {
630 for vname in &value_destructure_vars {
631 entry.set_var(vname, Union::mixed());
632 }
633 }
634
635 let post = self.analyze_loop_widened(&pre, entry, |sa, iter| {
636 // Re-bind key/value each iteration (array may change)
637 if let Some(key_expr) = &fe.key {
638 if let Some(var_name) = crate::expr::extract_simple_var(key_expr) {
639 iter.set_var(var_name, key_ty.clone());
640 }
641 }
642 if let Some(ref vname) = value_var {
643 iter.set_var(vname.as_str(), value_ty.clone());
644 } else {
645 for vname in &value_destructure_vars {
646 iter.set_var(vname, Union::mixed());
647 }
648 }
649 sa.analyze_stmt(fe.body, iter);
650 });
651 *ctx = post;
652 }
653
654 // ---- Switch -------------------------------------------------------
655 StmtKind::Switch(sw) => {
656 let _subject_ty = self.expr_analyzer(ctx).analyze(&sw.expr, ctx);
657 // Extract the subject variable name for narrowing (if it's a simple var)
658 let subject_var: Option<String> = match &sw.expr.kind {
659 php_ast::ast::ExprKind::Variable(name) => {
660 Some(name.as_str().trim_start_matches('$').to_string())
661 }
662 _ => None,
663 };
664 // Detect `switch(true)` — case conditions are used as narrowing expressions
665 let switch_on_true = matches!(&sw.expr.kind, php_ast::ast::ExprKind::Bool(true));
666
667 let pre_ctx = ctx.clone();
668 // Push a break-context bucket so that `break` inside cases saves
669 // the case's context for merging into the post-switch result.
670 self.break_ctx_stack.push(Vec::new());
671
672 let has_default = sw.cases.iter().any(|c| c.value.is_none());
673
674 // First pass: analyse each case body independently from pre_ctx.
675 // Break statements inside a body save their context to break_ctx_stack
676 // automatically; we just collect the per-case contexts here.
677 let mut case_results: Vec<Context> = Vec::new();
678 for case in sw.cases.iter() {
679 let mut case_ctx = pre_ctx.fork();
680 if let Some(val) = &case.value {
681 if switch_on_true {
682 // `switch(true) { case $x instanceof Y: }` — narrow from condition
683 narrow_from_condition(
684 val,
685 &mut case_ctx,
686 true,
687 self.codebase,
688 &self.file,
689 );
690 } else if let Some(ref var_name) = subject_var {
691 // Narrow subject var to the literal type of the case value
692 let narrow_ty = match &val.kind {
693 php_ast::ast::ExprKind::Int(n) => {
694 Some(Union::single(Atomic::TLiteralInt(*n)))
695 }
696 php_ast::ast::ExprKind::String(s) => {
697 Some(Union::single(Atomic::TLiteralString(Arc::from(&**s))))
698 }
699 php_ast::ast::ExprKind::Bool(b) => Some(Union::single(if *b {
700 Atomic::TTrue
701 } else {
702 Atomic::TFalse
703 })),
704 php_ast::ast::ExprKind::Null => Some(Union::single(Atomic::TNull)),
705 _ => None,
706 };
707 if let Some(narrowed) = narrow_ty {
708 case_ctx.set_var(var_name, narrowed);
709 }
710 }
711 self.expr_analyzer(&case_ctx).analyze(val, &mut case_ctx);
712 }
713 for stmt in case.body.iter() {
714 self.analyze_stmt(stmt, &mut case_ctx);
715 }
716 case_results.push(case_ctx);
717 }
718
719 // Second pass: propagate divergence backwards through the fallthrough
720 // chain. A non-diverging case (no break/return/throw) flows into the
721 // next case at runtime, so if that next case effectively diverges, this
722 // case effectively diverges too.
723 //
724 // Example:
725 // case 1: $y = "a"; // no break — chains into case 2
726 // case 2: return; // diverges
727 //
728 // Case 1 is effectively diverging because its only exit is through
729 // case 2's return. Adding case 1 to fallthrough_ctxs would be wrong.
730 let n = case_results.len();
731 let mut effective_diverges = vec![false; n];
732 for i in (0..n).rev() {
733 if case_results[i].diverges {
734 effective_diverges[i] = true;
735 } else if i + 1 < n {
736 // Non-diverging body: falls through to the next case.
737 effective_diverges[i] = effective_diverges[i + 1];
738 }
739 // else: last case with no break/return — falls to end of switch.
740 }
741
742 // Build fallthrough_ctxs from cases that truly exit via the end of
743 // the switch (not through a subsequent diverging case).
744 let mut all_cases_diverge = true;
745 let mut fallthrough_ctxs: Vec<Context> = Vec::new();
746 for (i, case_ctx) in case_results.into_iter().enumerate() {
747 if !effective_diverges[i] {
748 all_cases_diverge = false;
749 fallthrough_ctxs.push(case_ctx);
750 }
751 }
752
753 // Pop break contexts — each `break` in a case body pushed its
754 // context here, representing that case's effect on post-switch state.
755 let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
756
757 // Build the post-switch merged context:
758 // Start with pre_ctx if no default case (switch might not match anything)
759 // or if not all cases diverge via return/throw.
760 let mut merged = if has_default
761 && all_cases_diverge
762 && break_ctxs.is_empty()
763 && fallthrough_ctxs.is_empty()
764 {
765 // All paths return/throw — post-switch is unreachable
766 let mut m = pre_ctx.clone();
767 m.diverges = true;
768 m
769 } else {
770 // Start from pre_ctx (covers the "no case matched" path when there
771 // is no default, plus ensures pre-existing variables are preserved).
772 pre_ctx.clone()
773 };
774
775 for bctx in break_ctxs {
776 merged = Context::merge_branches(&pre_ctx, bctx, Some(merged));
777 }
778 for fctx in fallthrough_ctxs {
779 merged = Context::merge_branches(&pre_ctx, fctx, Some(merged));
780 }
781
782 *ctx = merged;
783 }
784
785 // ---- Try/catch/finally -------------------------------------------
786 StmtKind::TryCatch(tc) => {
787 let pre_ctx = ctx.clone();
788 let mut try_ctx = ctx.fork();
789 for stmt in tc.body.iter() {
790 self.analyze_stmt(stmt, &mut try_ctx);
791 }
792
793 // Build a base context for catch blocks that merges pre and try contexts.
794 // Variables that might have been set during the try body are "possibly assigned"
795 // in the catch (they may or may not have been set before the exception fired).
796 let catch_base = Context::merge_branches(&pre_ctx, try_ctx.clone(), None);
797
798 let mut non_diverging_catches: Vec<Context> = vec![];
799 for catch in tc.catches.iter() {
800 let mut catch_ctx = catch_base.clone();
801 if let Some(var) = catch.var {
802 // Bind the caught exception variable; union all caught types
803 let exc_ty = if catch.types.is_empty() {
804 Union::single(Atomic::TObject)
805 } else {
806 let mut u = Union::empty();
807 for catch_ty in catch.types.iter() {
808 let raw = crate::parser::name_to_string(catch_ty);
809 let resolved = self.codebase.resolve_class_name(&self.file, &raw);
810 u.add_type(Atomic::TNamedObject {
811 fqcn: resolved.into(),
812 type_params: vec![],
813 });
814 }
815 u
816 };
817 catch_ctx.set_var(var.trim_start_matches('$'), exc_ty);
818 }
819 for stmt in catch.body.iter() {
820 self.analyze_stmt(stmt, &mut catch_ctx);
821 }
822 if !catch_ctx.diverges {
823 non_diverging_catches.push(catch_ctx);
824 }
825 }
826
827 // If ALL catch branches diverge (return/throw/continue/break),
828 // code after the try/catch is only reachable from the try body.
829 // Use try_ctx directly so variables assigned in try are definitely set.
830 let result = if non_diverging_catches.is_empty() {
831 let mut r = try_ctx;
832 r.diverges = false; // the try body itself may not have diverged
833 r
834 } else {
835 // Some catches don't diverge — merge try with all non-diverging catches.
836 // Chain the merges: start with try_ctx, then fold in each catch branch.
837 let mut r = try_ctx;
838 for catch_ctx in non_diverging_catches {
839 r = Context::merge_branches(&pre_ctx, r, Some(catch_ctx));
840 }
841 r
842 };
843
844 // Finally runs unconditionally — analyze but don't merge vars
845 if let Some(finally_stmts) = &tc.finally {
846 let mut finally_ctx = result.clone();
847 finally_ctx.inside_finally = true;
848 for stmt in finally_stmts.iter() {
849 self.analyze_stmt(stmt, &mut finally_ctx);
850 }
851 }
852
853 *ctx = result;
854 }
855
856 // ---- Block --------------------------------------------------------
857 StmtKind::Block(stmts) => {
858 self.analyze_stmts(stmts, ctx);
859 }
860
861 // ---- Break --------------------------------------------------------
862 StmtKind::Break(_) => {
863 // Save the context at the break point so the post-loop context
864 // accounts for this early-exit path.
865 if let Some(break_ctxs) = self.break_ctx_stack.last_mut() {
866 break_ctxs.push(ctx.clone());
867 }
868 // Context after an unconditional break is dead; don't continue
869 // emitting issues for code after this point.
870 ctx.diverges = true;
871 }
872
873 // ---- Continue ----------------------------------------------------
874 StmtKind::Continue(_) => {
875 // continue goes back to the loop condition — no context to save,
876 // the widening pass already re-analyses the body.
877 ctx.diverges = true;
878 }
879
880 // ---- Unset --------------------------------------------------------
881 StmtKind::Unset(vars) => {
882 for var in vars.iter() {
883 if let php_ast::ast::ExprKind::Variable(name) = &var.kind {
884 ctx.unset_var(name.as_str().trim_start_matches('$'));
885 }
886 }
887 }
888
889 // ---- Static variable declaration ---------------------------------
890 StmtKind::StaticVar(vars) => {
891 for sv in vars.iter() {
892 let ty = Union::mixed(); // static vars are indeterminate on entry
893 ctx.set_var(sv.name.trim_start_matches('$'), ty);
894 }
895 }
896
897 // ---- Global declaration ------------------------------------------
898 StmtKind::Global(vars) => {
899 for var in vars.iter() {
900 if let php_ast::ast::ExprKind::Variable(name) = &var.kind {
901 let var_name = name.as_str().trim_start_matches('$');
902 let ty = self
903 .codebase
904 .global_vars
905 .get(var_name)
906 .map(|r| r.clone())
907 .unwrap_or_else(Union::mixed);
908 ctx.set_var(var_name, ty);
909 }
910 }
911 }
912
913 // ---- Declare -----------------------------------------------------
914 StmtKind::Declare(d) => {
915 for (name, _val) in d.directives.iter() {
916 if *name == "strict_types" {
917 ctx.strict_types = true;
918 }
919 }
920 if let Some(body) = &d.body {
921 self.analyze_stmt(body, ctx);
922 }
923 }
924
925 // ---- Nested declarations (inside function bodies) ----------------
926 StmtKind::Function(decl) => {
927 // Nested named function — analyze its body in the same issue buffer
928 // so that undefined-function/class calls inside it are reported.
929 let params: Vec<mir_codebase::FnParam> = decl
930 .params
931 .iter()
932 .map(|p| mir_codebase::FnParam {
933 name: std::sync::Arc::from(p.name.trim_start_matches('$')),
934 ty: None,
935 default: p.default.as_ref().map(|_| Union::mixed()),
936 is_variadic: p.variadic,
937 is_byref: p.by_ref,
938 is_optional: p.default.is_some() || p.variadic,
939 })
940 .collect();
941 let mut fn_ctx =
942 Context::for_function(¶ms, None, None, None, None, ctx.strict_types, true);
943 let mut sa = StatementsAnalyzer::new(
944 self.codebase,
945 self.file.clone(),
946 self.source,
947 self.source_map,
948 self.issues,
949 self.symbols,
950 );
951 sa.analyze_stmts(&decl.body, &mut fn_ctx);
952 }
953
954 StmtKind::Class(decl) => {
955 // Nested class declaration — analyze each method body in the same
956 // issue buffer so that undefined-function/class calls are reported.
957 let class_name = decl.name.unwrap_or("<anonymous>");
958 let resolved = self.codebase.resolve_class_name(&self.file, class_name);
959 let fqcn: Arc<str> = Arc::from(resolved.as_str());
960 let parent_fqcn = self
961 .codebase
962 .classes
963 .get(fqcn.as_ref())
964 .and_then(|c| c.parent.clone());
965
966 for member in decl.members.iter() {
967 let php_ast::ast::ClassMemberKind::Method(method) = &member.kind else {
968 continue;
969 };
970 let Some(body) = &method.body else { continue };
971 let (params, return_ty) = self
972 .codebase
973 .get_method(fqcn.as_ref(), method.name)
974 .as_deref()
975 .map(|m| (m.params.clone(), m.return_type.clone()))
976 .unwrap_or_else(|| {
977 let ast_params = method
978 .params
979 .iter()
980 .map(|p| mir_codebase::FnParam {
981 name: p.name.trim_start_matches('$').into(),
982 ty: None,
983 default: p.default.as_ref().map(|_| mir_types::Union::mixed()),
984 is_variadic: p.variadic,
985 is_byref: p.by_ref,
986 is_optional: p.default.is_some() || p.variadic,
987 })
988 .collect();
989 (ast_params, None)
990 });
991 let is_ctor = method.name == "__construct";
992 let mut method_ctx = Context::for_method(
993 ¶ms,
994 return_ty,
995 Some(fqcn.clone()),
996 parent_fqcn.clone(),
997 Some(fqcn.clone()),
998 ctx.strict_types,
999 is_ctor,
1000 method.is_static,
1001 );
1002 let mut sa = StatementsAnalyzer::new(
1003 self.codebase,
1004 self.file.clone(),
1005 self.source,
1006 self.source_map,
1007 self.issues,
1008 self.symbols,
1009 );
1010 sa.analyze_stmts(body, &mut method_ctx);
1011 }
1012 }
1013
1014 StmtKind::Interface(_) | StmtKind::Trait(_) | StmtKind::Enum(_) => {
1015 // Interfaces/traits/enums are collected in Pass 1 — skip here
1016 }
1017
1018 // ---- Namespace / use (at file level, already handled in Pass 1) --
1019 StmtKind::Namespace(_) | StmtKind::Use(_) | StmtKind::Const(_) => {}
1020
1021 // ---- Inert --------------------------------------------------------
1022 StmtKind::InlineHtml(_)
1023 | StmtKind::Nop
1024 | StmtKind::Goto(_)
1025 | StmtKind::Label(_)
1026 | StmtKind::HaltCompiler(_) => {}
1027
1028 StmtKind::Error => {}
1029 }
1030 }
1031
1032 // -----------------------------------------------------------------------
1033 // Helper: create a short-lived ExpressionAnalyzer borrowing our fields
1034 // -----------------------------------------------------------------------
1035
1036 fn expr_analyzer<'b>(&'b mut self, _ctx: &Context) -> ExpressionAnalyzer<'b>
1037 where
1038 'a: 'b,
1039 {
1040 ExpressionAnalyzer::new(
1041 self.codebase,
1042 self.file.clone(),
1043 self.source,
1044 self.source_map,
1045 self.issues,
1046 self.symbols,
1047 )
1048 }
1049
1050 /// Convert a byte offset to a Unicode char-count column on a given line.
1051 /// Returns (line, col) where col is a 0-based Unicode code-point count.
1052 fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1053 let lc = self.source_map.offset_to_line_col(offset);
1054 let line = lc.line + 1;
1055
1056 let byte_offset = offset as usize;
1057 let line_start_byte = if byte_offset == 0 {
1058 0
1059 } else {
1060 self.source[..byte_offset]
1061 .rfind('\n')
1062 .map(|p| p + 1)
1063 .unwrap_or(0)
1064 };
1065
1066 let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1067
1068 (line, col)
1069 }
1070
1071 // -----------------------------------------------------------------------
1072 // @psalm-suppress / @suppress per-statement
1073 // -----------------------------------------------------------------------
1074
1075 /// Extract suppression names from the `@psalm-suppress` / `@suppress`
1076 /// annotation in the docblock immediately preceding `span`.
1077 fn extract_statement_suppressions(&self, span: php_ast::Span) -> Vec<String> {
1078 let Some(doc) = crate::parser::find_preceding_docblock(self.source, span.start) else {
1079 return vec![];
1080 };
1081 let mut suppressions = Vec::new();
1082 for line in doc.lines() {
1083 let line = line.trim().trim_start_matches('*').trim();
1084 let rest = if let Some(r) = line.strip_prefix("@psalm-suppress ") {
1085 r
1086 } else if let Some(r) = line.strip_prefix("@suppress ") {
1087 r
1088 } else {
1089 continue;
1090 };
1091 for name in rest.split_whitespace() {
1092 suppressions.push(name.to_string());
1093 }
1094 }
1095 suppressions
1096 }
1097
1098 /// Extract `@var Type [$varname]` from the docblock immediately preceding `span`.
1099 /// Returns `(optional_var_name, resolved_type)` if an annotation exists.
1100 /// The type is resolved through the codebase's file-level imports/namespace.
1101 fn extract_var_annotation(
1102 &self,
1103 span: php_ast::Span,
1104 ) -> Option<(Option<String>, mir_types::Union)> {
1105 let doc = crate::parser::find_preceding_docblock(self.source, span.start)?;
1106 let parsed = crate::parser::DocblockParser::parse(&doc);
1107 let ty = parsed.var_type?;
1108 let resolved = resolve_union_for_file(ty, self.codebase, &self.file);
1109 Some((parsed.var_name, resolved))
1110 }
1111
1112 // -----------------------------------------------------------------------
1113 // Fixed-point loop widening (M12)
1114 // -----------------------------------------------------------------------
1115
1116 /// Analyse a loop body with a fixed-point widening algorithm (≤ 3 passes).
1117 ///
1118 /// * `pre` — context *before* the loop (used as the merge base)
1119 /// * `entry` — context on first iteration entry (may be narrowed / seeded)
1120 /// * `body` — closure that analyses one loop iteration, receives `&mut Self`
1121 /// and `&mut Context` for the current iteration context
1122 ///
1123 /// Returns the post-loop context that merges:
1124 /// - the stable widened context after normal loop exit
1125 /// - any contexts captured at `break` statements
1126 fn analyze_loop_widened<F>(&mut self, pre: &Context, entry: Context, mut body: F) -> Context
1127 where
1128 F: FnMut(&mut Self, &mut Context),
1129 {
1130 const MAX_ITERS: usize = 3;
1131
1132 // Push a fresh break-context bucket for this loop level
1133 self.break_ctx_stack.push(Vec::new());
1134
1135 let mut current = entry;
1136 current.inside_loop = true;
1137
1138 for _ in 0..MAX_ITERS {
1139 let prev_vars = current.vars.clone();
1140
1141 let mut iter = current.clone();
1142 body(self, &mut iter);
1143
1144 let next = Context::merge_branches(pre, iter, None);
1145
1146 if vars_stabilized(&prev_vars, &next.vars) {
1147 current = next;
1148 break;
1149 }
1150 current = next;
1151 }
1152
1153 // Widen any variable still unstable after MAX_ITERS to `mixed`
1154 widen_unstable(&pre.vars, &mut current.vars);
1155
1156 // Pop break contexts and merge them into the post-loop result
1157 let break_ctxs = self.break_ctx_stack.pop().unwrap_or_default();
1158 for bctx in break_ctxs {
1159 current = Context::merge_branches(pre, current, Some(bctx));
1160 }
1161
1162 current
1163 }
1164}
1165
1166// ---------------------------------------------------------------------------
1167// Loop widening helpers
1168// ---------------------------------------------------------------------------
1169
1170/// Returns true when every variable present in `prev` has the same type in
1171/// `next`, indicating the fixed-point has been reached.
1172fn vars_stabilized(
1173 prev: &indexmap::IndexMap<String, Union>,
1174 next: &indexmap::IndexMap<String, Union>,
1175) -> bool {
1176 if prev.len() != next.len() {
1177 return false;
1178 }
1179 prev.iter()
1180 .all(|(k, v)| next.get(k).map(|u| u == v).unwrap_or(false))
1181}
1182
1183/// For any variable whose type changed relative to `pre_vars`, widen to
1184/// `mixed`. Called after MAX_ITERS to avoid non-termination.
1185fn widen_unstable(
1186 pre_vars: &indexmap::IndexMap<String, Union>,
1187 current_vars: &mut indexmap::IndexMap<String, Union>,
1188) {
1189 for (name, ty) in current_vars.iter_mut() {
1190 if pre_vars.get(name).map(|p| p != ty).unwrap_or(true) && !ty.is_mixed() {
1191 *ty = Union::mixed();
1192 }
1193 }
1194}
1195
1196// ---------------------------------------------------------------------------
1197// foreach key/value type inference
1198// ---------------------------------------------------------------------------
1199
1200fn infer_foreach_types(arr_ty: &Union) -> (Union, Union) {
1201 if arr_ty.is_mixed() {
1202 return (Union::mixed(), Union::mixed());
1203 }
1204 for atomic in &arr_ty.types {
1205 match atomic {
1206 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
1207 return (*key.clone(), *value.clone());
1208 }
1209 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1210 return (Union::single(Atomic::TInt), *value.clone());
1211 }
1212 Atomic::TKeyedArray { properties, .. } => {
1213 let mut keys = Union::empty();
1214 let mut values = Union::empty();
1215 for (k, prop) in properties {
1216 let key_atomic = match k {
1217 ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1218 ArrayKey::Int(i) => Atomic::TLiteralInt(*i),
1219 };
1220 keys = Union::merge(&keys, &Union::single(key_atomic));
1221 values = Union::merge(&values, &prop.ty);
1222 }
1223 // Empty keyed array (e.g. `$arr = []` before push) — treat both as
1224 // mixed to avoid propagating Union::empty() as a variable type.
1225 let keys = if keys.is_empty() {
1226 Union::mixed()
1227 } else {
1228 keys
1229 };
1230 let values = if values.is_empty() {
1231 Union::mixed()
1232 } else {
1233 values
1234 };
1235 return (keys, values);
1236 }
1237 Atomic::TString => {
1238 return (Union::single(Atomic::TInt), Union::single(Atomic::TString));
1239 }
1240 _ => {}
1241 }
1242 }
1243 (Union::mixed(), Union::mixed())
1244}
1245
1246// ---------------------------------------------------------------------------
1247// Named-object return type compatibility check
1248// ---------------------------------------------------------------------------
1249
1250/// Returns true if `actual` is compatible with `declared` considering class
1251/// hierarchy, self/static resolution, and short-name vs FQCN mismatches.
1252fn named_object_return_compatible(
1253 actual: &Union,
1254 declared: &Union,
1255 codebase: &Codebase,
1256 file: &str,
1257) -> bool {
1258 actual.types.iter().all(|actual_atom| {
1259 // Extract the actual FQCN — handles TNamedObject, TSelf, TStaticObject, TParent
1260 let actual_fqcn: &Arc<str> = match actual_atom {
1261 Atomic::TNamedObject { fqcn, .. } => fqcn,
1262 Atomic::TSelf { fqcn } => fqcn,
1263 Atomic::TStaticObject { fqcn } => fqcn,
1264 Atomic::TParent { fqcn } => fqcn,
1265 // TNull: compatible if declared also includes null
1266 Atomic::TNull => return declared.types.iter().any(|d| matches!(d, Atomic::TNull)),
1267 // TVoid: compatible with void declared
1268 Atomic::TVoid => {
1269 return declared
1270 .types
1271 .iter()
1272 .any(|d| matches!(d, Atomic::TVoid | Atomic::TNull))
1273 }
1274 // TNever is the bottom type — compatible with anything
1275 Atomic::TNever => return true,
1276 // class-string<X> is compatible with class-string<Y> if X extends/implements Y
1277 Atomic::TClassString(Some(actual_cls)) => {
1278 return declared.types.iter().any(|d| match d {
1279 Atomic::TClassString(None) => true,
1280 Atomic::TClassString(Some(declared_cls)) => {
1281 actual_cls == declared_cls
1282 || codebase
1283 .extends_or_implements(actual_cls.as_ref(), declared_cls.as_ref())
1284 }
1285 Atomic::TString => true,
1286 _ => false,
1287 });
1288 }
1289 Atomic::TClassString(None) => {
1290 return declared
1291 .types
1292 .iter()
1293 .any(|d| matches!(d, Atomic::TClassString(_) | Atomic::TString));
1294 }
1295 // Non-object types: not handled here (fall through to simple subtype check)
1296 _ => return false,
1297 };
1298
1299 declared.types.iter().any(|declared_atom| {
1300 // Extract declared FQCN — also handle self/static/parent in declared type
1301 let declared_fqcn: &Arc<str> = match declared_atom {
1302 Atomic::TNamedObject { fqcn, .. } => fqcn,
1303 Atomic::TSelf { fqcn } => fqcn,
1304 Atomic::TStaticObject { fqcn } => fqcn,
1305 Atomic::TParent { fqcn } => fqcn,
1306 _ => return false,
1307 };
1308
1309 let resolved_declared = codebase.resolve_class_name(file, declared_fqcn.as_ref());
1310 let resolved_actual = codebase.resolve_class_name(file, actual_fqcn.as_ref());
1311
1312 // Self/static always compatible with the class itself
1313 if matches!(
1314 actual_atom,
1315 Atomic::TSelf { .. } | Atomic::TStaticObject { .. }
1316 ) && (resolved_actual == resolved_declared
1317 || actual_fqcn.as_ref() == declared_fqcn.as_ref()
1318 || actual_fqcn.as_ref() == resolved_declared.as_str()
1319 || resolved_actual.as_str() == declared_fqcn.as_ref()
1320 || codebase.extends_or_implements(actual_fqcn.as_ref(), &resolved_declared)
1321 || codebase.extends_or_implements(actual_fqcn.as_ref(), declared_fqcn.as_ref())
1322 || codebase.extends_or_implements(&resolved_actual, &resolved_declared)
1323 || codebase.extends_or_implements(&resolved_actual, declared_fqcn.as_ref())
1324 // static(X) is compatible with declared Y if Y extends X
1325 // (because when called on Y, static = Y which satisfies declared Y)
1326 || codebase.extends_or_implements(&resolved_declared, actual_fqcn.as_ref())
1327 || codebase.extends_or_implements(&resolved_declared, &resolved_actual)
1328 || codebase.extends_or_implements(declared_fqcn.as_ref(), actual_fqcn.as_ref()))
1329 {
1330 return true;
1331 }
1332
1333 // Same class after resolution — check generic type params with variance
1334 let is_same_class = resolved_actual == resolved_declared
1335 || actual_fqcn.as_ref() == declared_fqcn.as_ref()
1336 || actual_fqcn.as_ref() == resolved_declared.as_str()
1337 || resolved_actual.as_str() == declared_fqcn.as_ref();
1338
1339 if is_same_class {
1340 let actual_type_params = match actual_atom {
1341 Atomic::TNamedObject { type_params, .. } => type_params.as_slice(),
1342 _ => &[],
1343 };
1344 let declared_type_params = match declared_atom {
1345 Atomic::TNamedObject { type_params, .. } => type_params.as_slice(),
1346 _ => &[],
1347 };
1348 if !actual_type_params.is_empty() || !declared_type_params.is_empty() {
1349 let class_tps = codebase.get_class_template_params(&resolved_declared);
1350 return return_type_params_compatible(
1351 actual_type_params,
1352 declared_type_params,
1353 &class_tps,
1354 );
1355 }
1356 return true;
1357 }
1358
1359 // Inheritance check
1360 codebase.extends_or_implements(actual_fqcn.as_ref(), &resolved_declared)
1361 || codebase.extends_or_implements(actual_fqcn.as_ref(), declared_fqcn.as_ref())
1362 || codebase.extends_or_implements(&resolved_actual, &resolved_declared)
1363 || codebase.extends_or_implements(&resolved_actual, declared_fqcn.as_ref())
1364 })
1365 })
1366}
1367
1368/// Check whether generic return type parameters are compatible according to each parameter's
1369/// declared variance. Simpler than the arg-checking version — uses only structural subtyping
1370/// since we don't have access to ExpressionAnalyzer here.
1371fn return_type_params_compatible(
1372 actual_params: &[Union],
1373 declared_params: &[Union],
1374 template_params: &[mir_codebase::storage::TemplateParam],
1375) -> bool {
1376 if actual_params.len() != declared_params.len() {
1377 return true;
1378 }
1379 if actual_params.is_empty() {
1380 return true;
1381 }
1382
1383 for (i, (actual_p, declared_p)) in actual_params.iter().zip(declared_params.iter()).enumerate()
1384 {
1385 let variance = template_params
1386 .get(i)
1387 .map(|tp| tp.variance)
1388 .unwrap_or(mir_types::Variance::Invariant);
1389
1390 let compatible = match variance {
1391 mir_types::Variance::Covariant => {
1392 actual_p.is_subtype_of_simple(declared_p)
1393 || declared_p.is_mixed()
1394 || actual_p.is_mixed()
1395 }
1396 mir_types::Variance::Contravariant => {
1397 declared_p.is_subtype_of_simple(actual_p)
1398 || actual_p.is_mixed()
1399 || declared_p.is_mixed()
1400 }
1401 mir_types::Variance::Invariant => {
1402 actual_p == declared_p
1403 || actual_p.is_mixed()
1404 || declared_p.is_mixed()
1405 || (actual_p.is_subtype_of_simple(declared_p)
1406 && declared_p.is_subtype_of_simple(actual_p))
1407 }
1408 };
1409
1410 if !compatible {
1411 return false;
1412 }
1413 }
1414
1415 true
1416}
1417
1418/// Returns true if the declared return type contains template-like types (unknown FQCNs
1419/// without namespace separator that don't exist in the codebase) — we can't validate
1420/// return types against generic type parameters without full template instantiation.
1421fn declared_return_has_template(declared: &Union, codebase: &Codebase) -> bool {
1422 declared.types.iter().any(|atomic| match atomic {
1423 Atomic::TTemplateParam { .. } => true,
1424 // Generic class instantiation (e.g. Result<string, void>) — skip without full template inference.
1425 // Also skip when the named class doesn't exist in the codebase (e.g. type aliases
1426 // that were resolved to a fully-qualified name but aren't real classes).
1427 // Also skip when the type is an interface — concrete implementations may satisfy the
1428 // declared type in ways we don't track (not flagged at default error level).
1429 Atomic::TNamedObject { fqcn, type_params } => {
1430 !type_params.is_empty()
1431 || !codebase.type_exists(fqcn.as_ref())
1432 || codebase.interfaces.contains_key(fqcn.as_ref())
1433 }
1434 Atomic::TArray { value, .. }
1435 | Atomic::TList { value }
1436 | Atomic::TNonEmptyArray { value, .. }
1437 | Atomic::TNonEmptyList { value } => value.types.iter().any(|v| match v {
1438 Atomic::TTemplateParam { .. } => true,
1439 Atomic::TNamedObject { fqcn, .. } => {
1440 !fqcn.contains('\\') && !codebase.type_exists(fqcn.as_ref())
1441 }
1442 _ => false,
1443 }),
1444 _ => false,
1445 })
1446}
1447
1448/// Resolve all TNamedObject FQCNs in a Union using the codebase's file-level imports/namespace.
1449/// Used to fix up `@var` annotation types that were parsed without namespace context.
1450fn resolve_union_for_file(union: Union, codebase: &Codebase, file: &str) -> Union {
1451 let mut result = Union::empty();
1452 result.possibly_undefined = union.possibly_undefined;
1453 result.from_docblock = union.from_docblock;
1454 for atomic in union.types {
1455 let resolved = resolve_atomic_for_file(atomic, codebase, file);
1456 result.types.push(resolved);
1457 }
1458 result
1459}
1460
1461fn is_resolvable_class_name(s: &str) -> bool {
1462 !s.is_empty()
1463 && s.chars()
1464 .all(|c| c.is_alphanumeric() || c == '_' || c == '\\')
1465}
1466
1467fn resolve_atomic_for_file(atomic: Atomic, codebase: &Codebase, file: &str) -> Atomic {
1468 match atomic {
1469 Atomic::TNamedObject { fqcn, type_params } => {
1470 if !is_resolvable_class_name(fqcn.as_ref()) {
1471 return Atomic::TNamedObject { fqcn, type_params };
1472 }
1473 let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1474 Atomic::TNamedObject {
1475 fqcn: resolved.into(),
1476 type_params,
1477 }
1478 }
1479 Atomic::TClassString(Some(cls)) => {
1480 let resolved = codebase.resolve_class_name(file, cls.as_ref());
1481 Atomic::TClassString(Some(resolved.into()))
1482 }
1483 Atomic::TList { value } => Atomic::TList {
1484 value: Box::new(resolve_union_for_file(*value, codebase, file)),
1485 },
1486 Atomic::TNonEmptyList { value } => Atomic::TNonEmptyList {
1487 value: Box::new(resolve_union_for_file(*value, codebase, file)),
1488 },
1489 Atomic::TArray { key, value } => Atomic::TArray {
1490 key: Box::new(resolve_union_for_file(*key, codebase, file)),
1491 value: Box::new(resolve_union_for_file(*value, codebase, file)),
1492 },
1493 Atomic::TSelf { fqcn } if fqcn.is_empty() => {
1494 // Sentinel from docblock parser — leave as-is; caller handles it
1495 Atomic::TSelf { fqcn }
1496 }
1497 other => other,
1498 }
1499}
1500
1501/// Returns true if both actual and declared are array/list types whose value types are
1502/// compatible with FQCN resolution (to avoid short-name vs FQCN mismatches in return types).
1503fn return_arrays_compatible(
1504 actual: &Union,
1505 declared: &Union,
1506 codebase: &Codebase,
1507 file: &str,
1508) -> bool {
1509 actual.types.iter().all(|a_atomic| {
1510 let act_val: &Union = match a_atomic {
1511 Atomic::TArray { value, .. }
1512 | Atomic::TNonEmptyArray { value, .. }
1513 | Atomic::TList { value }
1514 | Atomic::TNonEmptyList { value } => value,
1515 Atomic::TKeyedArray { .. } => return true,
1516 _ => return false,
1517 };
1518
1519 declared.types.iter().any(|d_atomic| {
1520 let dec_val: &Union = match d_atomic {
1521 Atomic::TArray { value, .. }
1522 | Atomic::TNonEmptyArray { value, .. }
1523 | Atomic::TList { value }
1524 | Atomic::TNonEmptyList { value } => value,
1525 _ => return false,
1526 };
1527
1528 act_val.types.iter().all(|av| {
1529 match av {
1530 Atomic::TNever => return true,
1531 Atomic::TClassString(Some(av_cls)) => {
1532 return dec_val.types.iter().any(|dv| match dv {
1533 Atomic::TClassString(None) | Atomic::TString => true,
1534 Atomic::TClassString(Some(dv_cls)) => {
1535 av_cls == dv_cls
1536 || codebase
1537 .extends_or_implements(av_cls.as_ref(), dv_cls.as_ref())
1538 }
1539 _ => false,
1540 });
1541 }
1542 _ => {}
1543 }
1544 let av_fqcn: &Arc<str> = match av {
1545 Atomic::TNamedObject { fqcn, .. } => fqcn,
1546 Atomic::TSelf { fqcn } | Atomic::TStaticObject { fqcn } => fqcn,
1547 Atomic::TClosure { .. } => return true,
1548 _ => return Union::single(av.clone()).is_subtype_of_simple(dec_val),
1549 };
1550 dec_val.types.iter().any(|dv| {
1551 let dv_fqcn: &Arc<str> = match dv {
1552 Atomic::TNamedObject { fqcn, .. } => fqcn,
1553 Atomic::TClosure { .. } => return true,
1554 _ => return false,
1555 };
1556 if !dv_fqcn.contains('\\') && !codebase.type_exists(dv_fqcn.as_ref()) {
1557 return true; // template param wildcard
1558 }
1559 let res_dec = codebase.resolve_class_name(file, dv_fqcn.as_ref());
1560 let res_act = codebase.resolve_class_name(file, av_fqcn.as_ref());
1561 res_dec == res_act
1562 || codebase.extends_or_implements(av_fqcn.as_ref(), &res_dec)
1563 || codebase.extends_or_implements(&res_act, &res_dec)
1564 })
1565 })
1566 })
1567 })
1568}