Skip to main content

mir_analyzer/
stmt.rs

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