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