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