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