Skip to main content

mir_analyzer/
expr.rs

1/// Expression analyzer — infers the `Union` type of any PHP expression.
2use std::sync::Arc;
3
4use php_ast::ast::{
5    AssignOp, BinaryOp, CastKind, ExprKind, MagicConstKind, UnaryPostfixOp, UnaryPrefixOp,
6};
7
8use mir_codebase::Codebase;
9use mir_issues::{Issue, IssueBuffer, IssueKind, Location, Severity};
10use mir_types::{Atomic, Union};
11
12use crate::call::CallAnalyzer;
13use crate::context::Context;
14use crate::symbol::{ResolvedSymbol, SymbolKind};
15
16// ---------------------------------------------------------------------------
17// ExpressionAnalyzer
18// ---------------------------------------------------------------------------
19
20pub struct ExpressionAnalyzer<'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}
28
29impl<'a> ExpressionAnalyzer<'a> {
30    pub fn new(
31        codebase: &'a Codebase,
32        file: Arc<str>,
33        source: &'a str,
34        source_map: &'a php_rs_parser::source_map::SourceMap,
35        issues: &'a mut IssueBuffer,
36        symbols: &'a mut Vec<ResolvedSymbol>,
37    ) -> Self {
38        Self {
39            codebase,
40            file,
41            source,
42            source_map,
43            issues,
44            symbols,
45        }
46    }
47
48    /// Record a resolved symbol.
49    pub fn record_symbol(&mut self, span: php_ast::Span, kind: SymbolKind, resolved_type: Union) {
50        self.symbols.push(ResolvedSymbol {
51            file: self.file.clone(),
52            span,
53            kind,
54            resolved_type,
55        });
56    }
57
58    pub fn analyze<'arena, 'src>(
59        &mut self,
60        expr: &php_ast::ast::Expr<'arena, 'src>,
61        ctx: &mut Context,
62    ) -> Union {
63        match &expr.kind {
64            // --- Literals ---------------------------------------------------
65            ExprKind::Int(n) => Union::single(Atomic::TLiteralInt(*n)),
66            ExprKind::Float(f) => {
67                let bits = f.to_bits();
68                Union::single(Atomic::TLiteralFloat(
69                    (bits >> 32) as i64,
70                    (bits & 0xFFFF_FFFF) as i64,
71                ))
72            }
73            ExprKind::String(s) => Union::single(Atomic::TLiteralString((*s).into())),
74            ExprKind::Bool(b) => {
75                if *b {
76                    Union::single(Atomic::TTrue)
77                } else {
78                    Union::single(Atomic::TFalse)
79                }
80            }
81            ExprKind::Null => Union::single(Atomic::TNull),
82
83            // Interpolated strings always produce TString
84            ExprKind::InterpolatedString(parts) | ExprKind::Heredoc { parts, .. } => {
85                for part in parts.iter() {
86                    if let php_ast::StringPart::Expr(e) = part {
87                        self.analyze(e, ctx);
88                    }
89                }
90                Union::single(Atomic::TString)
91            }
92
93            ExprKind::Nowdoc { .. } => Union::single(Atomic::TString),
94            ExprKind::ShellExec(_) => Union::single(Atomic::TString),
95
96            // --- Variables --------------------------------------------------
97            ExprKind::Variable(name) => {
98                let name_str = name.as_str().trim_start_matches('$');
99                if !ctx.var_is_defined(name_str) {
100                    if ctx.var_possibly_defined(name_str) {
101                        self.emit(
102                            IssueKind::PossiblyUndefinedVariable {
103                                name: name_str.to_string(),
104                            },
105                            Severity::Info,
106                            expr.span,
107                        );
108                    } else if name_str == "this" {
109                        self.emit(
110                            IssueKind::InvalidScope {
111                                in_class: ctx.self_fqcn.is_some(),
112                            },
113                            Severity::Error,
114                            expr.span,
115                        );
116                    } else {
117                        self.emit(
118                            IssueKind::UndefinedVariable {
119                                name: name_str.to_string(),
120                            },
121                            Severity::Error,
122                            expr.span,
123                        );
124                    }
125                }
126                ctx.read_vars.insert(name_str.to_string());
127                let ty = if name_str == "this" && !ctx.var_is_defined("this") {
128                    Union::never()
129                } else {
130                    ctx.get_var(name_str)
131                };
132                self.record_symbol(
133                    expr.span,
134                    SymbolKind::Variable(name_str.to_string()),
135                    ty.clone(),
136                );
137                ty
138            }
139
140            ExprKind::VariableVariable(_) => Union::mixed(), // $$x — unknowable
141
142            ExprKind::Identifier(name) => {
143                // Bare identifier used as value — a global constant reference.
144                let name_str: &str = name.as_ref();
145
146                // Strip leading backslash for absolute constant references (e.g. \PHP_EOL)
147                let name_str = name_str.strip_prefix('\\').unwrap_or(name_str);
148
149                // Try namespace-qualified name first, then fall back to global
150                let found = {
151                    let ns_qualified = self
152                        .codebase
153                        .file_namespaces
154                        .get(self.file.as_ref())
155                        .map(|ns| format!("{}\\{}", *ns, name_str));
156
157                    ns_qualified
158                        .as_deref()
159                        .map(|q| self.codebase.constants.contains_key(q))
160                        .unwrap_or(false)
161                        || self.codebase.constants.contains_key(name_str)
162                };
163
164                if !found {
165                    self.emit(
166                        IssueKind::UndefinedConstant {
167                            name: name_str.to_string(),
168                        },
169                        Severity::Error,
170                        expr.span,
171                    );
172                }
173                Union::mixed()
174            }
175
176            // --- Assignment -------------------------------------------------
177            ExprKind::Assign(a) => {
178                let rhs_tainted = crate::taint::is_expr_tainted(a.value, ctx);
179                let rhs_ty = self.analyze(a.value, ctx);
180                match a.op {
181                    AssignOp::Assign => {
182                        self.assign_to_target(a.target, rhs_ty.clone(), ctx, expr.span);
183                        // Propagate taint: if RHS is tainted, taint LHS variable (M19)
184                        if rhs_tainted {
185                            if let ExprKind::Variable(name) = &a.target.kind {
186                                ctx.taint_var(name.as_ref());
187                            }
188                        }
189                        rhs_ty
190                    }
191                    AssignOp::Concat => {
192                        // .= always produces string
193                        if let Some(var_name) = extract_simple_var(a.target) {
194                            ctx.set_var(&var_name, Union::single(Atomic::TString));
195                        }
196                        Union::single(Atomic::TString)
197                    }
198                    AssignOp::Plus
199                    | AssignOp::Minus
200                    | AssignOp::Mul
201                    | AssignOp::Div
202                    | AssignOp::Mod
203                    | AssignOp::Pow => {
204                        let lhs_ty = self.analyze(a.target, ctx);
205                        let result_ty = infer_arithmetic(&lhs_ty, &rhs_ty);
206                        if let Some(var_name) = extract_simple_var(a.target) {
207                            ctx.set_var(&var_name, result_ty.clone());
208                        }
209                        result_ty
210                    }
211                    AssignOp::Coalesce => {
212                        // ??= — assign only if null
213                        let lhs_ty = self.analyze(a.target, ctx);
214                        let merged = Union::merge(&lhs_ty.remove_null(), &rhs_ty);
215                        if let Some(var_name) = extract_simple_var(a.target) {
216                            ctx.set_var(&var_name, merged.clone());
217                        }
218                        merged
219                    }
220                    _ => {
221                        if let Some(var_name) = extract_simple_var(a.target) {
222                            ctx.set_var(&var_name, Union::mixed());
223                        }
224                        Union::mixed()
225                    }
226                }
227            }
228
229            // --- Binary operations ------------------------------------------
230            ExprKind::Binary(b) => self.analyze_binary(b, expr.span, ctx),
231
232            // --- Unary ------------------------------------------------------
233            ExprKind::UnaryPrefix(u) => {
234                let operand_ty = self.analyze(u.operand, ctx);
235                match u.op {
236                    UnaryPrefixOp::BooleanNot => Union::single(Atomic::TBool),
237                    UnaryPrefixOp::Negate => {
238                        if operand_ty.contains(|t| t.is_int()) {
239                            Union::single(Atomic::TInt)
240                        } else {
241                            Union::single(Atomic::TFloat)
242                        }
243                    }
244                    UnaryPrefixOp::Plus => operand_ty,
245                    UnaryPrefixOp::BitwiseNot => Union::single(Atomic::TInt),
246                    UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement => {
247                        // ++$x / --$x: increment and return new value
248                        if let Some(var_name) = extract_simple_var(u.operand) {
249                            let ty = ctx.get_var(&var_name);
250                            let new_ty = if ty.contains(|t| {
251                                matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
252                            }) {
253                                Union::single(Atomic::TFloat)
254                            } else {
255                                Union::single(Atomic::TInt)
256                            };
257                            ctx.set_var(&var_name, new_ty.clone());
258                            new_ty
259                        } else {
260                            Union::single(Atomic::TInt)
261                        }
262                    }
263                }
264            }
265
266            ExprKind::UnaryPostfix(u) => {
267                let operand_ty = self.analyze(u.operand, ctx);
268                // $x++ / $x-- returns original value, but mutates variable
269                match u.op {
270                    UnaryPostfixOp::PostIncrement | UnaryPostfixOp::PostDecrement => {
271                        if let Some(var_name) = extract_simple_var(u.operand) {
272                            let new_ty = if operand_ty.contains(|t| {
273                                matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..))
274                            }) {
275                                Union::single(Atomic::TFloat)
276                            } else {
277                                Union::single(Atomic::TInt)
278                            };
279                            ctx.set_var(&var_name, new_ty);
280                        }
281                        operand_ty // returns original value
282                    }
283                }
284            }
285
286            // --- Ternary / null coalesce ------------------------------------
287            ExprKind::Ternary(t) => {
288                let cond_ty = self.analyze(t.condition, ctx);
289                match &t.then_expr {
290                    Some(then_expr) => {
291                        let mut then_ctx = ctx.fork();
292                        crate::narrowing::narrow_from_condition(
293                            t.condition,
294                            &mut then_ctx,
295                            true,
296                            self.codebase,
297                            &self.file,
298                        );
299                        let then_ty =
300                            self.with_ctx(&mut then_ctx, |ea, c| ea.analyze(then_expr, c));
301
302                        let mut else_ctx = ctx.fork();
303                        crate::narrowing::narrow_from_condition(
304                            t.condition,
305                            &mut else_ctx,
306                            false,
307                            self.codebase,
308                            &self.file,
309                        );
310                        let else_ty =
311                            self.with_ctx(&mut else_ctx, |ea, c| ea.analyze(t.else_expr, c));
312
313                        // Propagate variable reads from both branches
314                        for name in then_ctx.read_vars.iter().chain(else_ctx.read_vars.iter()) {
315                            ctx.read_vars.insert(name.clone());
316                        }
317
318                        Union::merge(&then_ty, &else_ty)
319                    }
320                    None => {
321                        // $x ?: $y — short ternary: if $x truthy, return $x; else return $y
322                        let else_ty = self.analyze(t.else_expr, ctx);
323                        let truthy_ty = cond_ty.narrow_to_truthy();
324                        if truthy_ty.is_empty() {
325                            else_ty
326                        } else {
327                            Union::merge(&truthy_ty, &else_ty)
328                        }
329                    }
330                }
331            }
332
333            ExprKind::NullCoalesce(nc) => {
334                let left_ty = self.analyze(nc.left, ctx);
335                let right_ty = self.analyze(nc.right, ctx);
336                // result = remove_null(left) | right
337                let non_null_left = left_ty.remove_null();
338                if non_null_left.is_empty() {
339                    right_ty
340                } else {
341                    Union::merge(&non_null_left, &right_ty)
342                }
343            }
344
345            // --- Casts ------------------------------------------------------
346            ExprKind::Cast(kind, inner) => {
347                let _inner_ty = self.analyze(inner, ctx);
348                match kind {
349                    CastKind::Int => Union::single(Atomic::TInt),
350                    CastKind::Float => Union::single(Atomic::TFloat),
351                    CastKind::String => Union::single(Atomic::TString),
352                    CastKind::Bool => Union::single(Atomic::TBool),
353                    CastKind::Array => Union::single(Atomic::TArray {
354                        key: Box::new(Union::single(Atomic::TMixed)),
355                        value: Box::new(Union::mixed()),
356                    }),
357                    CastKind::Object => Union::single(Atomic::TObject),
358                    CastKind::Unset | CastKind::Void => Union::single(Atomic::TNull),
359                }
360            }
361
362            // --- Error suppression ------------------------------------------
363            ExprKind::ErrorSuppress(inner) => self.analyze(inner, ctx),
364
365            // --- Parenthesized ----------------------------------------------
366            ExprKind::Parenthesized(inner) => self.analyze(inner, ctx),
367
368            // --- Array literals ---------------------------------------------
369            ExprKind::Array(elements) => {
370                use mir_types::atomic::{ArrayKey, KeyedProperty};
371
372                if elements.is_empty() {
373                    return Union::single(Atomic::TKeyedArray {
374                        properties: indexmap::IndexMap::new(),
375                        is_open: false,
376                        is_list: true,
377                    });
378                }
379
380                // Try to build a TKeyedArray when all keys are literal strings/ints
381                // (or no keys — pure list). Fall back to TArray on spread or dynamic keys.
382                let mut keyed_props: indexmap::IndexMap<ArrayKey, KeyedProperty> =
383                    indexmap::IndexMap::new();
384                let mut is_list = true;
385                let mut can_be_keyed = true;
386                let mut next_int_key: i64 = 0;
387
388                for elem in elements.iter() {
389                    if elem.unpack {
390                        self.analyze(&elem.value, ctx);
391                        can_be_keyed = false;
392                        break;
393                    }
394                    let value_ty = self.analyze(&elem.value, ctx);
395                    let array_key = if let Some(key_expr) = &elem.key {
396                        is_list = false;
397                        let key_ty = self.analyze(key_expr, ctx);
398                        // Only build keyed array if key is a string or int literal
399                        match key_ty.types.as_slice() {
400                            [Atomic::TLiteralString(s)] => ArrayKey::String(s.clone()),
401                            [Atomic::TLiteralInt(i)] => {
402                                next_int_key = *i + 1;
403                                ArrayKey::Int(*i)
404                            }
405                            _ => {
406                                can_be_keyed = false;
407                                break;
408                            }
409                        }
410                    } else {
411                        let k = ArrayKey::Int(next_int_key);
412                        next_int_key += 1;
413                        k
414                    };
415                    keyed_props.insert(
416                        array_key,
417                        KeyedProperty {
418                            ty: value_ty,
419                            optional: false,
420                        },
421                    );
422                }
423
424                if can_be_keyed {
425                    return Union::single(Atomic::TKeyedArray {
426                        properties: keyed_props,
427                        is_open: false,
428                        is_list,
429                    });
430                }
431
432                // Fallback: generic TArray — re-evaluate elements to build merged types
433                let mut all_value_types = Union::empty();
434                let mut key_union = Union::empty();
435                let mut has_unpack = false;
436                for elem in elements.iter() {
437                    let value_ty = self.analyze(&elem.value, ctx);
438                    if elem.unpack {
439                        has_unpack = true;
440                    } else {
441                        all_value_types = Union::merge(&all_value_types, &value_ty);
442                        if let Some(key_expr) = &elem.key {
443                            let key_ty = self.analyze(key_expr, ctx);
444                            key_union = Union::merge(&key_union, &key_ty);
445                        } else {
446                            key_union.add_type(Atomic::TInt);
447                        }
448                    }
449                }
450                if has_unpack {
451                    return Union::single(Atomic::TArray {
452                        key: Box::new(Union::single(Atomic::TMixed)),
453                        value: Box::new(Union::mixed()),
454                    });
455                }
456                if key_union.is_empty() {
457                    key_union.add_type(Atomic::TInt);
458                }
459                Union::single(Atomic::TArray {
460                    key: Box::new(key_union),
461                    value: Box::new(all_value_types),
462                })
463            }
464
465            // --- Array access -----------------------------------------------
466            ExprKind::ArrayAccess(aa) => {
467                let arr_ty = self.analyze(aa.array, ctx);
468
469                // Analyze the index expression for variable read tracking
470                if let Some(idx) = &aa.index {
471                    self.analyze(idx, ctx);
472                }
473
474                // Check for null access
475                if arr_ty.contains(|t| matches!(t, Atomic::TNull)) && arr_ty.is_single() {
476                    self.emit(IssueKind::NullArrayAccess, Severity::Error, expr.span);
477                    return Union::mixed();
478                }
479                if arr_ty.is_nullable() {
480                    self.emit(
481                        IssueKind::PossiblyNullArrayAccess,
482                        Severity::Info,
483                        expr.span,
484                    );
485                }
486
487                // Determine the key being accessed (if it's a literal)
488                let literal_key: Option<mir_types::atomic::ArrayKey> =
489                    aa.index.as_ref().and_then(|idx| match &idx.kind {
490                        ExprKind::String(s) => {
491                            Some(mir_types::atomic::ArrayKey::String(Arc::from(&**s)))
492                        }
493                        ExprKind::Int(i) => Some(mir_types::atomic::ArrayKey::Int(*i)),
494                        _ => None,
495                    });
496
497                // Infer element type
498                for atomic in &arr_ty.types {
499                    match atomic {
500                        Atomic::TKeyedArray { properties, .. } => {
501                            // If we know the key, look it up precisely
502                            if let Some(ref key) = literal_key {
503                                if let Some(prop) = properties.get(key) {
504                                    return prop.ty.clone();
505                                }
506                            }
507                            // Unknown key — return union of all value types
508                            let mut result = Union::empty();
509                            for prop in properties.values() {
510                                result = Union::merge(&result, &prop.ty);
511                            }
512                            return if result.types.is_empty() {
513                                Union::mixed()
514                            } else {
515                                result
516                            };
517                        }
518                        Atomic::TArray { value, .. } | Atomic::TNonEmptyArray { value, .. } => {
519                            return *value.clone();
520                        }
521                        Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
522                            return *value.clone();
523                        }
524                        Atomic::TString | Atomic::TLiteralString(_) => {
525                            return Union::single(Atomic::TString);
526                        }
527                        _ => {}
528                    }
529                }
530                Union::mixed()
531            }
532
533            // --- isset / empty ----------------------------------------------
534            ExprKind::Isset(exprs) => {
535                for e in exprs.iter() {
536                    self.analyze(e, ctx);
537                }
538                Union::single(Atomic::TBool)
539            }
540            ExprKind::Empty(inner) => {
541                self.analyze(inner, ctx);
542                Union::single(Atomic::TBool)
543            }
544
545            // --- print ------------------------------------------------------
546            ExprKind::Print(inner) => {
547                self.analyze(inner, ctx);
548                Union::single(Atomic::TLiteralInt(1))
549            }
550
551            // --- clone ------------------------------------------------------
552            ExprKind::Clone(inner) => self.analyze(inner, ctx),
553            ExprKind::CloneWith(inner, _props) => self.analyze(inner, ctx),
554
555            // --- new ClassName(...) ----------------------------------------
556            ExprKind::New(n) => {
557                // Evaluate args first (needed for taint / type check)
558                let arg_types: Vec<Union> = n
559                    .args
560                    .iter()
561                    .map(|a| {
562                        let ty = self.analyze(&a.value, ctx);
563                        if a.unpack {
564                            crate::call::spread_element_type(&ty)
565                        } else {
566                            ty
567                        }
568                    })
569                    .collect();
570                let arg_spans: Vec<php_ast::Span> = n.args.iter().map(|a| a.span).collect();
571                let arg_names: Vec<Option<String>> = n
572                    .args
573                    .iter()
574                    .map(|a| a.name.as_ref().map(|nm| nm.to_string_repr().into_owned()))
575                    .collect();
576
577                let class_ty = match &n.class.kind {
578                    ExprKind::Identifier(name) => {
579                        let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
580                        // `self`, `static`, `parent` resolve to the current class — use ctx
581                        let fqcn: Arc<str> = match resolved.as_str() {
582                            "self" | "static" => ctx
583                                .self_fqcn
584                                .clone()
585                                .or_else(|| ctx.static_fqcn.clone())
586                                .unwrap_or_else(|| Arc::from(resolved.as_str())),
587                            "parent" => ctx
588                                .parent_fqcn
589                                .clone()
590                                .unwrap_or_else(|| Arc::from(resolved.as_str())),
591                            _ => Arc::from(resolved.as_str()),
592                        };
593                        if !matches!(resolved.as_str(), "self" | "static" | "parent")
594                            && !self.codebase.type_exists(&fqcn)
595                        {
596                            self.emit(
597                                IssueKind::UndefinedClass {
598                                    name: resolved.clone(),
599                                },
600                                Severity::Error,
601                                n.class.span,
602                            );
603                        } else if self.codebase.type_exists(&fqcn) {
604                            if let Some(cls) = self.codebase.classes.get(fqcn.as_ref()) {
605                                if cls.is_deprecated {
606                                    self.emit(
607                                        IssueKind::DeprecatedClass {
608                                            name: fqcn.to_string(),
609                                        },
610                                        Severity::Info,
611                                        n.class.span,
612                                    );
613                                }
614                            }
615                            // Check constructor arguments
616                            if let Some(ctor) = self.codebase.get_method(&fqcn, "__construct") {
617                                crate::call::check_constructor_args(
618                                    self,
619                                    &fqcn,
620                                    crate::call::CheckArgsParams {
621                                        fn_name: "__construct",
622                                        params: &ctor.params,
623                                        arg_types: &arg_types,
624                                        arg_spans: &arg_spans,
625                                        arg_names: &arg_names,
626                                        call_span: expr.span,
627                                        has_spread: n.args.iter().any(|a| a.unpack),
628                                    },
629                                );
630                            }
631                        }
632                        let ty = Union::single(Atomic::TNamedObject {
633                            fqcn: fqcn.clone(),
634                            type_params: vec![],
635                        });
636                        self.record_symbol(
637                            n.class.span,
638                            SymbolKind::ClassReference(fqcn.clone()),
639                            ty.clone(),
640                        );
641                        // Record class instantiation as a reference so LSP
642                        // "find references" for a class includes new Foo() sites.
643                        self.codebase.mark_class_referenced_at(
644                            &fqcn,
645                            self.file.clone(),
646                            n.class.span.start,
647                            n.class.span.end,
648                        );
649                        ty
650                    }
651                    _ => {
652                        self.analyze(n.class, ctx);
653                        Union::single(Atomic::TObject)
654                    }
655                };
656                class_ty
657            }
658
659            ExprKind::AnonymousClass(_) => Union::single(Atomic::TObject),
660
661            // --- Property access -------------------------------------------
662            ExprKind::PropertyAccess(pa) => {
663                let obj_ty = self.analyze(pa.object, ctx);
664                let prop_name = extract_string_from_expr(pa.property)
665                    .unwrap_or_else(|| "<dynamic>".to_string());
666
667                if obj_ty.contains(|t| matches!(t, Atomic::TNull)) && obj_ty.is_single() {
668                    self.emit(
669                        IssueKind::NullPropertyFetch {
670                            property: prop_name.clone(),
671                        },
672                        Severity::Error,
673                        expr.span,
674                    );
675                    return Union::mixed();
676                }
677                if obj_ty.is_nullable() {
678                    self.emit(
679                        IssueKind::PossiblyNullPropertyFetch {
680                            property: prop_name.clone(),
681                        },
682                        Severity::Info,
683                        expr.span,
684                    );
685                }
686
687                // Dynamic property access ($obj->$varName) — can't resolve statically.
688                if prop_name == "<dynamic>" {
689                    return Union::mixed();
690                }
691                // Use pa.property.span (the identifier only), not the full expression span,
692                // so the LSP highlights just the property name (e.g. `count` in `$c->count`).
693                let resolved = self.resolve_property_type(&obj_ty, &prop_name, pa.property.span);
694                // Record property access symbol for each named object in the receiver type
695                for atomic in &obj_ty.types {
696                    if let Atomic::TNamedObject { fqcn, .. } = atomic {
697                        self.record_symbol(
698                            pa.property.span,
699                            SymbolKind::PropertyAccess {
700                                class: fqcn.clone(),
701                                property: Arc::from(prop_name.as_str()),
702                            },
703                            resolved.clone(),
704                        );
705                        break;
706                    }
707                }
708                resolved
709            }
710
711            ExprKind::NullsafePropertyAccess(pa) => {
712                let obj_ty = self.analyze(pa.object, ctx);
713                let prop_name = extract_string_from_expr(pa.property)
714                    .unwrap_or_else(|| "<dynamic>".to_string());
715                if prop_name == "<dynamic>" {
716                    return Union::mixed();
717                }
718                // ?-> strips null from receiver
719                let non_null_ty = obj_ty.remove_null();
720                // Use pa.property.span (the identifier only), not the full expression span,
721                // so the LSP highlights just the property name (e.g. `val` in `$b?->val`).
722                let mut prop_ty =
723                    self.resolve_property_type(&non_null_ty, &prop_name, pa.property.span);
724                prop_ty.add_type(Atomic::TNull); // result is nullable because receiver may be null
725                                                 // Record symbol so symbol_at() resolves ?-> accesses the same way as ->.
726                for atomic in &non_null_ty.types {
727                    if let Atomic::TNamedObject { fqcn, .. } = atomic {
728                        self.record_symbol(
729                            pa.property.span,
730                            SymbolKind::PropertyAccess {
731                                class: fqcn.clone(),
732                                property: Arc::from(prop_name.as_str()),
733                            },
734                            prop_ty.clone(),
735                        );
736                        break;
737                    }
738                }
739                prop_ty
740            }
741
742            ExprKind::StaticPropertyAccess(_spa) => {
743                // Class::$prop
744                Union::mixed()
745            }
746
747            ExprKind::ClassConstAccess(cca) => {
748                // Foo::CONST or Foo::class
749                if cca.member.name_str() == Some("class") {
750                    // Resolve the class name so Foo::class gives the correct FQCN string
751                    let fqcn = if let ExprKind::Identifier(id) = &cca.class.kind {
752                        let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
753                        Some(Arc::from(resolved.as_str()))
754                    } else {
755                        None
756                    };
757                    return Union::single(Atomic::TClassString(fqcn));
758                }
759
760                let const_name = match cca.member.name_str() {
761                    Some(n) => n.to_string(),
762                    None => return Union::mixed(),
763                };
764
765                let fqcn = match &cca.class.kind {
766                    ExprKind::Identifier(id) => {
767                        let resolved = self.codebase.resolve_class_name(&self.file, id.as_ref());
768                        // self/static/parent: can't validate without full type narrowing
769                        if matches!(resolved.as_str(), "self" | "static" | "parent") {
770                            return Union::mixed();
771                        }
772                        resolved
773                    }
774                    _ => return Union::mixed(),
775                };
776
777                if !self.codebase.type_exists(&fqcn) {
778                    // UndefinedClass is reported elsewhere; avoid double-reporting
779                    return Union::mixed();
780                }
781
782                if self
783                    .codebase
784                    .get_class_constant(&fqcn, &const_name)
785                    .is_none()
786                    && !self.codebase.has_unknown_ancestor(&fqcn)
787                {
788                    self.emit(
789                        IssueKind::UndefinedConstant {
790                            name: format!("{}::{}", fqcn, const_name),
791                        },
792                        Severity::Error,
793                        expr.span,
794                    );
795                }
796                Union::mixed()
797            }
798
799            ExprKind::ClassConstAccessDynamic { .. } => Union::mixed(),
800            ExprKind::StaticPropertyAccessDynamic { .. } => Union::mixed(),
801
802            // --- Method calls ----------------------------------------------
803            ExprKind::MethodCall(mc) => {
804                CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, false)
805            }
806
807            ExprKind::NullsafeMethodCall(mc) => {
808                CallAnalyzer::analyze_method_call(self, mc, ctx, expr.span, true)
809            }
810
811            ExprKind::StaticMethodCall(smc) => {
812                CallAnalyzer::analyze_static_method_call(self, smc, ctx, expr.span)
813            }
814
815            ExprKind::StaticDynMethodCall(smc) => {
816                CallAnalyzer::analyze_static_dyn_method_call(self, smc, ctx)
817            }
818
819            // --- Function calls --------------------------------------------
820            ExprKind::FunctionCall(fc) => {
821                CallAnalyzer::analyze_function_call(self, fc, ctx, expr.span)
822            }
823
824            // --- Closures / arrow functions --------------------------------
825            ExprKind::Closure(c) => {
826                let params = ast_params_to_fn_params_resolved(
827                    &c.params,
828                    ctx.self_fqcn.as_deref(),
829                    self.codebase,
830                    &self.file,
831                );
832                let return_ty_hint = c
833                    .return_type
834                    .as_ref()
835                    .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
836                    .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
837
838                // Build closure context — capture declared use-vars from outer scope.
839                // Static closures (`static function() {}`) do not bind $this even when
840                // declared inside a non-static method.
841                let mut closure_ctx = crate::context::Context::for_function(
842                    &params,
843                    return_ty_hint.clone(),
844                    ctx.self_fqcn.clone(),
845                    ctx.parent_fqcn.clone(),
846                    ctx.static_fqcn.clone(),
847                    ctx.strict_types,
848                    c.is_static,
849                );
850                for use_var in c.use_vars.iter() {
851                    let name = use_var.name.trim_start_matches('$');
852                    closure_ctx.set_var(name, ctx.get_var(name));
853                    if ctx.is_tainted(name) {
854                        closure_ctx.taint_var(name);
855                    }
856                }
857
858                // Analyze closure body, collecting issues into the same buffer
859                let inferred_return = {
860                    let mut sa = crate::stmt::StatementsAnalyzer::new(
861                        self.codebase,
862                        self.file.clone(),
863                        self.source,
864                        self.source_map,
865                        self.issues,
866                        self.symbols,
867                    );
868                    sa.analyze_stmts(&c.body, &mut closure_ctx);
869                    let ret = crate::project::merge_return_types(&sa.return_types);
870                    drop(sa);
871                    ret
872                };
873
874                // Propagate variable reads from closure back to outer scope
875                for name in &closure_ctx.read_vars {
876                    ctx.read_vars.insert(name.clone());
877                }
878
879                let return_ty = return_ty_hint.unwrap_or(inferred_return);
880                let closure_params: Vec<mir_types::atomic::FnParam> = params
881                    .iter()
882                    .map(|p| mir_types::atomic::FnParam {
883                        name: p.name.clone(),
884                        ty: p.ty.clone(),
885                        default: p.default.clone(),
886                        is_variadic: p.is_variadic,
887                        is_byref: p.is_byref,
888                        is_optional: p.is_optional,
889                    })
890                    .collect();
891
892                Union::single(Atomic::TClosure {
893                    params: closure_params,
894                    return_type: Box::new(return_ty),
895                    this_type: ctx.self_fqcn.clone().map(|f| {
896                        Box::new(Union::single(Atomic::TNamedObject {
897                            fqcn: f,
898                            type_params: vec![],
899                        }))
900                    }),
901                })
902            }
903
904            ExprKind::ArrowFunction(af) => {
905                let params = ast_params_to_fn_params_resolved(
906                    &af.params,
907                    ctx.self_fqcn.as_deref(),
908                    self.codebase,
909                    &self.file,
910                );
911                let return_ty_hint = af
912                    .return_type
913                    .as_ref()
914                    .map(|h| crate::parser::type_from_hint(h, ctx.self_fqcn.as_deref()))
915                    .map(|u| resolve_named_objects_in_union(u, self.codebase, &self.file));
916
917                // Arrow functions implicitly capture the outer scope by value.
918                // Static arrow functions (`static fn() =>`) do not bind $this.
919                let mut arrow_ctx = crate::context::Context::for_function(
920                    &params,
921                    return_ty_hint.clone(),
922                    ctx.self_fqcn.clone(),
923                    ctx.parent_fqcn.clone(),
924                    ctx.static_fqcn.clone(),
925                    ctx.strict_types,
926                    af.is_static,
927                );
928                // Copy outer vars into arrow context (implicit capture)
929                for (name, ty) in &ctx.vars {
930                    if !arrow_ctx.vars.contains_key(name) {
931                        arrow_ctx.set_var(name, ty.clone());
932                    }
933                }
934
935                // Analyze single-expression body
936                let inferred_return = self.analyze(af.body, &mut arrow_ctx);
937
938                // Propagate variable reads from arrow function back to outer scope
939                for name in &arrow_ctx.read_vars {
940                    ctx.read_vars.insert(name.clone());
941                }
942
943                let return_ty = return_ty_hint.unwrap_or(inferred_return);
944                let closure_params: Vec<mir_types::atomic::FnParam> = params
945                    .iter()
946                    .map(|p| mir_types::atomic::FnParam {
947                        name: p.name.clone(),
948                        ty: p.ty.clone(),
949                        default: p.default.clone(),
950                        is_variadic: p.is_variadic,
951                        is_byref: p.is_byref,
952                        is_optional: p.is_optional,
953                    })
954                    .collect();
955
956                Union::single(Atomic::TClosure {
957                    params: closure_params,
958                    return_type: Box::new(return_ty),
959                    this_type: if af.is_static {
960                        None
961                    } else {
962                        ctx.self_fqcn.clone().map(|f| {
963                            Box::new(Union::single(Atomic::TNamedObject {
964                                fqcn: f,
965                                type_params: vec![],
966                            }))
967                        })
968                    },
969                })
970            }
971
972            ExprKind::CallableCreate(_) => Union::single(Atomic::TCallable {
973                params: None,
974                return_type: None,
975            }),
976
977            // --- Match expression ------------------------------------------
978            ExprKind::Match(m) => {
979                let subject_ty = self.analyze(m.subject, ctx);
980                // Extract the variable name of the subject for narrowing
981                let subject_var = match &m.subject.kind {
982                    ExprKind::Variable(name) => {
983                        Some(name.as_str().trim_start_matches('$').to_string())
984                    }
985                    _ => None,
986                };
987
988                let mut result = Union::empty();
989                for arm in m.arms.iter() {
990                    // Fork context for each arm so arms don't bleed into each other
991                    let mut arm_ctx = ctx.fork();
992
993                    // Narrow the subject variable in this arm's context
994                    if let (Some(var), Some(conditions)) = (&subject_var, &arm.conditions) {
995                        // Build a union of all condition types for this arm
996                        let mut arm_ty = Union::empty();
997                        for cond in conditions.iter() {
998                            let cond_ty = self.analyze(cond, ctx);
999                            arm_ty = Union::merge(&arm_ty, &cond_ty);
1000                        }
1001                        // Intersect subject type with the arm condition types
1002                        if !arm_ty.is_empty() && !arm_ty.is_mixed() {
1003                            // Narrow to the matched literal/type if possible
1004                            let narrowed = subject_ty.intersect_with(&arm_ty);
1005                            if !narrowed.is_empty() {
1006                                arm_ctx.set_var(var, narrowed);
1007                            }
1008                        }
1009                    }
1010
1011                    // For `match(true) { $x instanceof Y => ... }` patterns:
1012                    // narrow from each condition expression even when subject is not a simple var.
1013                    if let Some(conditions) = &arm.conditions {
1014                        for cond in conditions.iter() {
1015                            crate::narrowing::narrow_from_condition(
1016                                cond,
1017                                &mut arm_ctx,
1018                                true,
1019                                self.codebase,
1020                                &self.file,
1021                            );
1022                        }
1023                    }
1024
1025                    let arm_body_ty = self.analyze(&arm.body, &mut arm_ctx);
1026                    result = Union::merge(&result, &arm_body_ty);
1027
1028                    // Propagate variable reads from arm back to outer scope
1029                    for name in &arm_ctx.read_vars {
1030                        ctx.read_vars.insert(name.clone());
1031                    }
1032                }
1033                if result.is_empty() {
1034                    Union::mixed()
1035                } else {
1036                    result
1037                }
1038            }
1039
1040            // --- Throw as expression (PHP 8) --------------------------------
1041            ExprKind::ThrowExpr(e) => {
1042                self.analyze(e, ctx);
1043                Union::single(Atomic::TNever)
1044            }
1045
1046            // --- Yield -----------------------------------------------------
1047            ExprKind::Yield(y) => {
1048                if let Some(key) = &y.key {
1049                    self.analyze(key, ctx);
1050                }
1051                if let Some(value) = &y.value {
1052                    self.analyze(value, ctx);
1053                }
1054                Union::mixed()
1055            }
1056
1057            // --- Magic constants -------------------------------------------
1058            ExprKind::MagicConst(kind) => match kind {
1059                MagicConstKind::Line => Union::single(Atomic::TInt),
1060                MagicConstKind::File
1061                | MagicConstKind::Dir
1062                | MagicConstKind::Function
1063                | MagicConstKind::Class
1064                | MagicConstKind::Method
1065                | MagicConstKind::Namespace
1066                | MagicConstKind::Trait
1067                | MagicConstKind::Property => Union::single(Atomic::TString),
1068            },
1069
1070            // --- Include/require --------------------------------------------
1071            ExprKind::Include(_, inner) => {
1072                self.analyze(inner, ctx);
1073                Union::mixed()
1074            }
1075
1076            // --- Eval -------------------------------------------------------
1077            ExprKind::Eval(inner) => {
1078                self.analyze(inner, ctx);
1079                Union::mixed()
1080            }
1081
1082            // --- Exit -------------------------------------------------------
1083            ExprKind::Exit(opt) => {
1084                if let Some(e) = opt {
1085                    self.analyze(e, ctx);
1086                }
1087                Union::single(Atomic::TNever)
1088            }
1089
1090            // --- Error node (parse error placeholder) ----------------------
1091            ExprKind::Error => Union::mixed(),
1092
1093            // --- Omitted array slot (e.g. [, $b] destructuring) ------------
1094            ExprKind::Omit => Union::single(Atomic::TNull),
1095        }
1096    }
1097
1098    // -----------------------------------------------------------------------
1099    // Binary operations
1100    // -----------------------------------------------------------------------
1101
1102    fn analyze_binary<'arena, 'src>(
1103        &mut self,
1104        b: &php_ast::ast::BinaryExpr<'arena, 'src>,
1105        _span: php_ast::Span,
1106        ctx: &mut Context,
1107    ) -> Union {
1108        // Short-circuit operators: narrow the context for the right operand based on
1109        // the left operand's truthiness (just like the then/else branches of an if).
1110        // We evaluate the right side in a forked context so that the narrowing
1111        // (e.g. `instanceof`) applies to method/property calls on the right side
1112        // without permanently mutating the caller's context.
1113        use php_ast::ast::BinaryOp as B;
1114        if matches!(
1115            b.op,
1116            B::BooleanAnd | B::LogicalAnd | B::BooleanOr | B::LogicalOr
1117        ) {
1118            let _left_ty = self.analyze(b.left, ctx);
1119            let mut right_ctx = ctx.fork();
1120            let is_and = matches!(b.op, B::BooleanAnd | B::LogicalAnd);
1121            crate::narrowing::narrow_from_condition(
1122                b.left,
1123                &mut right_ctx,
1124                is_and,
1125                self.codebase,
1126                &self.file,
1127            );
1128            // If narrowing made the right side statically unreachable, skip it
1129            // (e.g. `$x === null || $x->method()` — right is dead when $x is only null).
1130            if !right_ctx.diverges {
1131                let _right_ty = self.analyze(b.right, &mut right_ctx);
1132            }
1133            // Propagate read-var tracking and any new variable assignments back.
1134            // New assignments from the right side are only "possibly" made (short-circuit),
1135            // so mark them in possibly_assigned_vars but not assigned_vars.
1136            for v in right_ctx.read_vars {
1137                ctx.read_vars.insert(v.clone());
1138            }
1139            for (name, ty) in &right_ctx.vars {
1140                if !ctx.vars.contains_key(name.as_str()) {
1141                    // Variable first assigned in the right side — possibly assigned
1142                    ctx.vars.insert(name.clone(), ty.clone());
1143                    ctx.possibly_assigned_vars.insert(name.clone());
1144                }
1145            }
1146            return Union::single(Atomic::TBool);
1147        }
1148
1149        // `instanceof` right-hand side is a class name, not a value expression to analyze.
1150        if b.op == B::Instanceof {
1151            let _left_ty = self.analyze(b.left, ctx);
1152            if let ExprKind::Identifier(name) = &b.right.kind {
1153                let resolved = self.codebase.resolve_class_name(&self.file, name.as_ref());
1154                let fqcn: std::sync::Arc<str> = std::sync::Arc::from(resolved.as_str());
1155                if !matches!(resolved.as_str(), "self" | "static" | "parent")
1156                    && !self.codebase.type_exists(&fqcn)
1157                {
1158                    self.emit(
1159                        IssueKind::UndefinedClass { name: resolved },
1160                        Severity::Error,
1161                        b.right.span,
1162                    );
1163                }
1164            }
1165            return Union::single(Atomic::TBool);
1166        }
1167
1168        let left_ty = self.analyze(b.left, ctx);
1169        let right_ty = self.analyze(b.right, ctx);
1170
1171        match b.op {
1172            // Arithmetic
1173            BinaryOp::Add
1174            | BinaryOp::Sub
1175            | BinaryOp::Mul
1176            | BinaryOp::Div
1177            | BinaryOp::Mod
1178            | BinaryOp::Pow => infer_arithmetic(&left_ty, &right_ty),
1179
1180            // String concatenation
1181            BinaryOp::Concat => Union::single(Atomic::TString),
1182
1183            // Comparisons always return bool
1184            BinaryOp::Equal
1185            | BinaryOp::NotEqual
1186            | BinaryOp::Identical
1187            | BinaryOp::NotIdentical
1188            | BinaryOp::Less
1189            | BinaryOp::Greater
1190            | BinaryOp::LessOrEqual
1191            | BinaryOp::GreaterOrEqual => Union::single(Atomic::TBool),
1192
1193            // Spaceship returns -1|0|1
1194            BinaryOp::Spaceship => Union::single(Atomic::TIntRange {
1195                min: Some(-1),
1196                max: Some(1),
1197            }),
1198
1199            // Logical
1200            BinaryOp::BooleanAnd
1201            | BinaryOp::BooleanOr
1202            | BinaryOp::LogicalAnd
1203            | BinaryOp::LogicalOr
1204            | BinaryOp::LogicalXor => Union::single(Atomic::TBool),
1205
1206            // Bitwise
1207            BinaryOp::BitwiseAnd
1208            | BinaryOp::BitwiseOr
1209            | BinaryOp::BitwiseXor
1210            | BinaryOp::ShiftLeft
1211            | BinaryOp::ShiftRight => Union::single(Atomic::TInt),
1212
1213            // Pipe (FirstClassCallable-style) — rare
1214            BinaryOp::Pipe => right_ty,
1215
1216            // Handled before analyze(b.right) — unreachable here
1217            BinaryOp::Instanceof => Union::single(Atomic::TBool),
1218        }
1219    }
1220
1221    // -----------------------------------------------------------------------
1222    // Property resolution
1223    // -----------------------------------------------------------------------
1224
1225    fn resolve_property_type(
1226        &mut self,
1227        obj_ty: &Union,
1228        prop_name: &str,
1229        span: php_ast::Span,
1230    ) -> Union {
1231        for atomic in &obj_ty.types {
1232            match atomic {
1233                Atomic::TNamedObject { fqcn, .. }
1234                    if self.codebase.classes.contains_key(fqcn.as_ref()) =>
1235                {
1236                    if let Some(prop) = self.codebase.get_property(fqcn.as_ref(), prop_name) {
1237                        // Record reference for dead-code detection (M18)
1238                        self.codebase.mark_property_referenced_at(
1239                            fqcn,
1240                            prop_name,
1241                            self.file.clone(),
1242                            span.start,
1243                            span.end,
1244                        );
1245                        return prop.ty.clone().unwrap_or_else(Union::mixed);
1246                    }
1247                    // Only emit UndefinedProperty if all ancestors are known and no __get magic.
1248                    if !self.codebase.has_unknown_ancestor(fqcn.as_ref())
1249                        && !self.codebase.has_magic_get(fqcn.as_ref())
1250                    {
1251                        self.emit(
1252                            IssueKind::UndefinedProperty {
1253                                class: fqcn.to_string(),
1254                                property: prop_name.to_string(),
1255                            },
1256                            Severity::Warning,
1257                            span,
1258                        );
1259                    }
1260                    return Union::mixed();
1261                }
1262                Atomic::TMixed => return Union::mixed(),
1263                _ => {}
1264            }
1265        }
1266        Union::mixed()
1267    }
1268
1269    // -----------------------------------------------------------------------
1270    // Assignment helpers
1271    // -----------------------------------------------------------------------
1272
1273    fn assign_to_target<'arena, 'src>(
1274        &mut self,
1275        target: &php_ast::ast::Expr<'arena, 'src>,
1276        ty: Union,
1277        ctx: &mut Context,
1278        span: php_ast::Span,
1279    ) {
1280        match &target.kind {
1281            ExprKind::Variable(name) => {
1282                let name_str = name.as_str().trim_start_matches('$').to_string();
1283                ctx.set_var(name_str, ty);
1284            }
1285            ExprKind::Array(elements) => {
1286                // [$a, $b] = $arr  — destructuring
1287                // If the RHS can be false/null (e.g. unpack() returns array|false),
1288                // the destructuring may fail → PossiblyInvalidArrayAccess.
1289                let has_non_array = ty.contains(|a| matches!(a, Atomic::TFalse | Atomic::TNull));
1290                let has_array = ty.contains(|a| {
1291                    matches!(
1292                        a,
1293                        Atomic::TArray { .. }
1294                            | Atomic::TList { .. }
1295                            | Atomic::TNonEmptyArray { .. }
1296                            | Atomic::TNonEmptyList { .. }
1297                            | Atomic::TKeyedArray { .. }
1298                    )
1299                });
1300                if has_non_array && has_array {
1301                    let actual = format!("{}", ty);
1302                    self.emit(
1303                        IssueKind::PossiblyInvalidArrayOffset {
1304                            expected: "array".to_string(),
1305                            actual,
1306                        },
1307                        Severity::Warning,
1308                        span,
1309                    );
1310                }
1311
1312                // Extract the element value type from the RHS array type (if known).
1313                let value_ty: Union = ty
1314                    .types
1315                    .iter()
1316                    .find_map(|a| match a {
1317                        Atomic::TArray { value, .. }
1318                        | Atomic::TList { value }
1319                        | Atomic::TNonEmptyArray { value, .. }
1320                        | Atomic::TNonEmptyList { value } => Some(*value.clone()),
1321                        _ => None,
1322                    })
1323                    .unwrap_or_else(Union::mixed);
1324
1325                for elem in elements.iter() {
1326                    self.assign_to_target(&elem.value, value_ty.clone(), ctx, span);
1327                }
1328            }
1329            ExprKind::PropertyAccess(pa) => {
1330                // Check readonly (M19 readonly enforcement)
1331                let obj_ty = self.analyze(pa.object, ctx);
1332                if let Some(prop_name) = extract_string_from_expr(pa.property) {
1333                    for atomic in &obj_ty.types {
1334                        if let Atomic::TNamedObject { fqcn, .. } = atomic {
1335                            if let Some(cls) = self.codebase.classes.get(fqcn.as_ref()) {
1336                                if let Some(prop) = cls.get_property(&prop_name) {
1337                                    if prop.is_readonly && !ctx.inside_constructor {
1338                                        self.emit(
1339                                            IssueKind::ReadonlyPropertyAssignment {
1340                                                class: fqcn.to_string(),
1341                                                property: prop_name.clone(),
1342                                            },
1343                                            Severity::Error,
1344                                            span,
1345                                        );
1346                                    }
1347                                }
1348                            }
1349                        }
1350                    }
1351                }
1352            }
1353            ExprKind::StaticPropertyAccess(_) => {
1354                // static property assignment — could add readonly check here too
1355            }
1356            ExprKind::ArrayAccess(aa) => {
1357                // $arr[$k] = v  — PHP auto-initialises $arr as an array if undefined.
1358                // Analyze the index expression for variable read tracking.
1359                if let Some(idx) = &aa.index {
1360                    self.analyze(idx, ctx);
1361                }
1362                // Walk the base to find the root variable and update its type to include
1363                // the new value, so loop analysis can widen correctly.
1364                let mut base = aa.array;
1365                loop {
1366                    match &base.kind {
1367                        ExprKind::Variable(name) => {
1368                            let name_str = name.as_str().trim_start_matches('$');
1369                            if !ctx.var_is_defined(name_str) {
1370                                ctx.vars.insert(
1371                                    name_str.to_string(),
1372                                    Union::single(Atomic::TArray {
1373                                        key: Box::new(Union::mixed()),
1374                                        value: Box::new(ty.clone()),
1375                                    }),
1376                                );
1377                                ctx.assigned_vars.insert(name_str.to_string());
1378                            } else {
1379                                // Widen the existing array type to include the new value type.
1380                                // This ensures loop analysis can see the type change and widen properly.
1381                                let current = ctx.get_var(name_str);
1382                                let updated = widen_array_with_value(&current, &ty);
1383                                ctx.set_var(name_str, updated);
1384                            }
1385                            break;
1386                        }
1387                        ExprKind::ArrayAccess(inner) => {
1388                            if let Some(idx) = &inner.index {
1389                                self.analyze(idx, ctx);
1390                            }
1391                            base = inner.array;
1392                        }
1393                        _ => break,
1394                    }
1395                }
1396            }
1397            _ => {}
1398        }
1399    }
1400
1401    // -----------------------------------------------------------------------
1402    // Issue emission
1403    // -----------------------------------------------------------------------
1404
1405    /// Convert a byte offset to a Unicode char-count column on a given line.
1406    /// Returns (line, col) where col is a 0-based Unicode code-point count.
1407    fn offset_to_line_col(&self, offset: u32) -> (u32, u16) {
1408        let lc = self.source_map.offset_to_line_col(offset);
1409        let line = lc.line + 1;
1410
1411        let byte_offset = offset as usize;
1412        let line_start_byte = if byte_offset == 0 {
1413            0
1414        } else {
1415            self.source[..byte_offset]
1416                .rfind('\n')
1417                .map(|p| p + 1)
1418                .unwrap_or(0)
1419        };
1420
1421        let col = self.source[line_start_byte..byte_offset].chars().count() as u16;
1422
1423        (line, col)
1424    }
1425
1426    pub fn emit(&mut self, kind: IssueKind, severity: Severity, span: php_ast::Span) {
1427        let (line, col_start) = self.offset_to_line_col(span.start);
1428
1429        // Calculate col_end: if span.end is on the same line, use its char-count column;
1430        // otherwise use col_start (single-line range for diagnostics)
1431        let col_end = if span.start < span.end {
1432            let (_end_line, end_col) = self.offset_to_line_col(span.end);
1433            end_col
1434        } else {
1435            col_start
1436        };
1437
1438        let mut issue = Issue::new(
1439            kind,
1440            Location {
1441                file: self.file.clone(),
1442                line,
1443                col_start,
1444                col_end: col_end.max(col_start + 1),
1445            },
1446        );
1447        issue.severity = severity;
1448        // Store the source snippet for baseline matching.
1449        if span.start < span.end {
1450            let s = span.start as usize;
1451            let e = (span.end as usize).min(self.source.len());
1452            if let Some(text) = self.source.get(s..e) {
1453                let trimmed = text.trim();
1454                if !trimmed.is_empty() {
1455                    issue.snippet = Some(trimmed.to_string());
1456                }
1457            }
1458        }
1459        self.issues.add(issue);
1460    }
1461
1462    // Helper to call a closure with a mutable context reference while holding &mut self.
1463    fn with_ctx<F, R>(&mut self, ctx: &mut Context, f: F) -> R
1464    where
1465        F: FnOnce(&mut ExpressionAnalyzer<'a>, &mut Context) -> R,
1466    {
1467        f(self, ctx)
1468    }
1469}
1470
1471// ---------------------------------------------------------------------------
1472// Free functions
1473// ---------------------------------------------------------------------------
1474
1475/// Widen an array type to include a new element value type.
1476/// Used when `$arr[$k] = $val` is analyzed — updates the array's value type
1477/// so loop analysis can detect the change and widen properly.
1478fn widen_array_with_value(current: &Union, new_value: &Union) -> Union {
1479    let mut result = Union::empty();
1480    result.possibly_undefined = current.possibly_undefined;
1481    result.from_docblock = current.from_docblock;
1482    let mut found_array = false;
1483    for atomic in &current.types {
1484        match atomic {
1485            Atomic::TKeyedArray { properties, .. } => {
1486                // Merge all existing keyed values with the new value type, converting to TArray
1487                let mut all_values = new_value.clone();
1488                for prop in properties.values() {
1489                    all_values = Union::merge(&all_values, &prop.ty);
1490                }
1491                result.add_type(Atomic::TArray {
1492                    key: Box::new(Union::mixed()),
1493                    value: Box::new(all_values),
1494                });
1495                found_array = true;
1496            }
1497            Atomic::TArray { key, value } => {
1498                let merged = Union::merge(value, new_value);
1499                result.add_type(Atomic::TArray {
1500                    key: key.clone(),
1501                    value: Box::new(merged),
1502                });
1503                found_array = true;
1504            }
1505            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1506                let merged = Union::merge(value, new_value);
1507                result.add_type(Atomic::TList {
1508                    value: Box::new(merged),
1509                });
1510                found_array = true;
1511            }
1512            Atomic::TMixed => {
1513                return Union::mixed();
1514            }
1515            other => {
1516                result.add_type(other.clone());
1517            }
1518        }
1519    }
1520    if !found_array {
1521        // Current type has no array component — don't introduce one.
1522        // (e.g. typed object; return the original type unchanged.)
1523        return current.clone();
1524    }
1525    result
1526}
1527
1528pub fn infer_arithmetic(left: &Union, right: &Union) -> Union {
1529    // If either operand is mixed, result is mixed (could be numeric or array addition)
1530    if left.is_mixed() || right.is_mixed() {
1531        return Union::mixed();
1532    }
1533
1534    // PHP array union: array + array → array (union of keys)
1535    let left_is_array = left.contains(|t| {
1536        matches!(
1537            t,
1538            Atomic::TArray { .. }
1539                | Atomic::TNonEmptyArray { .. }
1540                | Atomic::TList { .. }
1541                | Atomic::TNonEmptyList { .. }
1542                | Atomic::TKeyedArray { .. }
1543        )
1544    });
1545    let right_is_array = right.contains(|t| {
1546        matches!(
1547            t,
1548            Atomic::TArray { .. }
1549                | Atomic::TNonEmptyArray { .. }
1550                | Atomic::TList { .. }
1551                | Atomic::TNonEmptyList { .. }
1552                | Atomic::TKeyedArray { .. }
1553        )
1554    });
1555    if left_is_array || right_is_array {
1556        // Merge the two array types (simplified: return mixed array)
1557        let merged_left = if left_is_array {
1558            left.clone()
1559        } else {
1560            Union::single(Atomic::TArray {
1561                key: Box::new(Union::single(Atomic::TMixed)),
1562                value: Box::new(Union::mixed()),
1563            })
1564        };
1565        return merged_left;
1566    }
1567
1568    let left_is_float = left.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1569    let right_is_float =
1570        right.contains(|t| matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)));
1571    if left_is_float || right_is_float {
1572        Union::single(Atomic::TFloat)
1573    } else if left.contains(|t| t.is_int()) && right.contains(|t| t.is_int()) {
1574        Union::single(Atomic::TInt)
1575    } else {
1576        // Could be int or float (e.g. mixed + int)
1577        let mut u = Union::empty();
1578        u.add_type(Atomic::TInt);
1579        u.add_type(Atomic::TFloat);
1580        u
1581    }
1582}
1583
1584pub fn extract_simple_var<'arena, 'src>(expr: &php_ast::ast::Expr<'arena, 'src>) -> Option<String> {
1585    match &expr.kind {
1586        ExprKind::Variable(name) => Some(name.as_str().trim_start_matches('$').to_string()),
1587        ExprKind::Parenthesized(inner) => extract_simple_var(inner),
1588        _ => None,
1589    }
1590}
1591
1592/// Extract all variable names from a list/array destructure pattern.
1593/// e.g. `[$a, $b]` or `list($a, $b)` → `["a", "b"]`
1594/// Returns an empty vec if the expression is not a destructure.
1595pub fn extract_destructure_vars<'arena, 'src>(
1596    expr: &php_ast::ast::Expr<'arena, 'src>,
1597) -> Vec<String> {
1598    match &expr.kind {
1599        ExprKind::Array(elements) => {
1600            let mut vars = vec![];
1601            for elem in elements.iter() {
1602                // Nested destructure or simple variable
1603                let sub = extract_destructure_vars(&elem.value);
1604                if sub.is_empty() {
1605                    if let Some(v) = extract_simple_var(&elem.value) {
1606                        vars.push(v);
1607                    }
1608                } else {
1609                    vars.extend(sub);
1610                }
1611            }
1612            vars
1613        }
1614        _ => vec![],
1615    }
1616}
1617
1618/// Like `ast_params_to_fn_params` but resolves type names through the file's import table.
1619fn ast_params_to_fn_params_resolved<'arena, 'src>(
1620    params: &php_ast::ast::ArenaVec<'arena, php_ast::ast::Param<'arena, 'src>>,
1621    self_fqcn: Option<&str>,
1622    codebase: &mir_codebase::Codebase,
1623    file: &str,
1624) -> Vec<mir_codebase::FnParam> {
1625    params
1626        .iter()
1627        .map(|p| {
1628            let ty = p
1629                .type_hint
1630                .as_ref()
1631                .map(|h| crate::parser::type_from_hint(h, self_fqcn))
1632                .map(|u| resolve_named_objects_in_union(u, codebase, file));
1633            mir_codebase::FnParam {
1634                name: p.name.trim_start_matches('$').into(),
1635                ty,
1636                default: p.default.as_ref().map(|_| Union::mixed()),
1637                is_variadic: p.variadic,
1638                is_byref: p.by_ref,
1639                is_optional: p.default.is_some() || p.variadic,
1640            }
1641        })
1642        .collect()
1643}
1644
1645/// Resolve TNamedObject fqcns in a union through the file's import table.
1646fn resolve_named_objects_in_union(
1647    union: Union,
1648    codebase: &mir_codebase::Codebase,
1649    file: &str,
1650) -> Union {
1651    use mir_types::Atomic;
1652    let from_docblock = union.from_docblock;
1653    let possibly_undefined = union.possibly_undefined;
1654    let types: Vec<Atomic> = union
1655        .types
1656        .into_iter()
1657        .map(|a| match a {
1658            Atomic::TNamedObject { fqcn, type_params } => {
1659                let resolved = codebase.resolve_class_name(file, fqcn.as_ref());
1660                Atomic::TNamedObject {
1661                    fqcn: resolved.into(),
1662                    type_params,
1663                }
1664            }
1665            other => other,
1666        })
1667        .collect();
1668    let mut result = Union::from_vec(types);
1669    result.from_docblock = from_docblock;
1670    result.possibly_undefined = possibly_undefined;
1671    result
1672}
1673
1674fn extract_string_from_expr<'arena, 'src>(
1675    expr: &php_ast::ast::Expr<'arena, 'src>,
1676) -> Option<String> {
1677    match &expr.kind {
1678        ExprKind::Identifier(s) => Some(s.trim_start_matches('$').to_string()),
1679        // Variable in property position means dynamic access ($obj->$prop) — not a literal name.
1680        ExprKind::Variable(_) => None,
1681        ExprKind::String(s) => Some(s.to_string()),
1682        _ => None,
1683    }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    /// Helper to create a SourceMap from PHP source code
1689    fn create_source_map(source: &str) -> php_rs_parser::source_map::SourceMap {
1690        let bump = bumpalo::Bump::new();
1691        let result = php_rs_parser::parse(&bump, source);
1692        result.source_map
1693    }
1694
1695    /// Helper to test offset_to_line_col conversion (Unicode char-count columns).
1696    fn test_offset_conversion(source: &str, offset: u32) -> (u32, u16) {
1697        let source_map = create_source_map(source);
1698        let lc = source_map.offset_to_line_col(offset);
1699        let line = lc.line + 1;
1700
1701        let byte_offset = offset as usize;
1702        let line_start_byte = if byte_offset == 0 {
1703            0
1704        } else {
1705            source[..byte_offset]
1706                .rfind('\n')
1707                .map(|p| p + 1)
1708                .unwrap_or(0)
1709        };
1710
1711        let col = source[line_start_byte..byte_offset].chars().count() as u16;
1712
1713        (line, col)
1714    }
1715
1716    #[test]
1717    fn col_conversion_simple_ascii() {
1718        let source = "<?php\n$var = 123;";
1719
1720        // '$' on line 2, column 0
1721        let (line, col) = test_offset_conversion(source, 6);
1722        assert_eq!(line, 2);
1723        assert_eq!(col, 0);
1724
1725        // 'v' on line 2, column 1
1726        let (line, col) = test_offset_conversion(source, 7);
1727        assert_eq!(line, 2);
1728        assert_eq!(col, 1);
1729    }
1730
1731    #[test]
1732    fn col_conversion_different_lines() {
1733        let source = "<?php\n$x = 1;\n$y = 2;";
1734        // Line 1: <?php     (bytes 0-4, newline at 5)
1735        // Line 2: $x = 1;  (bytes 6-12, newline at 13)
1736        // Line 3: $y = 2;  (bytes 14-20)
1737
1738        let (line, col) = test_offset_conversion(source, 0);
1739        assert_eq!((line, col), (1, 0));
1740
1741        let (line, col) = test_offset_conversion(source, 6);
1742        assert_eq!((line, col), (2, 0));
1743
1744        let (line, col) = test_offset_conversion(source, 14);
1745        assert_eq!((line, col), (3, 0));
1746    }
1747
1748    #[test]
1749    fn col_conversion_accented_characters() {
1750        // é is 2 UTF-8 bytes but 1 Unicode char (and 1 UTF-16 unit — same result either way)
1751        let source = "<?php\n$café = 1;";
1752        // Line 2: $ c a f é ...
1753        // bytes:  6 7 8 9 10(2 bytes)
1754
1755        // 'f' at byte 9 → char col 3
1756        let (line, col) = test_offset_conversion(source, 9);
1757        assert_eq!((line, col), (2, 3));
1758
1759        // 'é' at byte 10 → char col 4
1760        let (line, col) = test_offset_conversion(source, 10);
1761        assert_eq!((line, col), (2, 4));
1762    }
1763
1764    #[test]
1765    fn col_conversion_emoji_counts_as_one_char() {
1766        // 🎉 (U+1F389) is 4 UTF-8 bytes and 2 UTF-16 units, but 1 Unicode char.
1767        // A char after the emoji must land at col 7, not col 8.
1768        let source = "<?php\n$y = \"🎉x\";";
1769        // Line 2: $ y   =   " 🎉 x " ;
1770        // chars:  0 1 2 3 4 5  6  7 8 9
1771
1772        let emoji_start = source.find("🎉").unwrap();
1773        let after_emoji = emoji_start + "🎉".len(); // skip 4 bytes
1774
1775        // position at 'x' (right after the emoji)
1776        let (line, col) = test_offset_conversion(source, after_emoji as u32);
1777        assert_eq!(line, 2);
1778        assert_eq!(col, 7); // emoji counts as 1, not 2
1779    }
1780
1781    #[test]
1782    fn col_conversion_emoji_start_position() {
1783        // The opening quote is at col 5; the emoji immediately follows at col 6.
1784        let source = "<?php\n$y = \"🎉\";";
1785        // Line 2: $ y   =   " 🎉 " ;
1786        // chars:  0 1 2 3 4 5  6  7 8
1787
1788        let quote_pos = source.find('"').unwrap();
1789        let emoji_pos = quote_pos + 1; // byte after opening quote = emoji start
1790
1791        let (line, col) = test_offset_conversion(source, quote_pos as u32);
1792        assert_eq!(line, 2);
1793        assert_eq!(col, 5); // '"' is the 6th char on line 2 (0-based: col 5)
1794
1795        let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1796        assert_eq!(line, 2);
1797        assert_eq!(col, 6); // emoji follows the quote
1798    }
1799
1800    #[test]
1801    fn col_end_minimum_width() {
1802        // Ensure col_end is at least col_start + 1 (1 character minimum)
1803        let col_start = 0u16;
1804        let col_end = 0u16; // Would happen if span.start == span.end
1805        let effective_col_end = col_end.max(col_start + 1);
1806
1807        assert_eq!(
1808            effective_col_end, 1,
1809            "col_end should be at least col_start + 1"
1810        );
1811    }
1812
1813    #[test]
1814    fn col_conversion_multiline_span() {
1815        // Test span that starts on one line and ends on another
1816        let source = "<?php\n$x = [\n  'a',\n  'b'\n];";
1817        //           Line 1: <?php
1818        //           Line 2: $x = [
1819        //           Line 3:   'a',
1820        //           Line 4:   'b'
1821        //           Line 5: ];
1822
1823        // Start of array bracket on line 2
1824        let bracket_open = source.find('[').unwrap();
1825        let (line_start, _col_start) = test_offset_conversion(source, bracket_open as u32);
1826        assert_eq!(line_start, 2);
1827
1828        // End of array bracket on line 5
1829        let bracket_close = source.rfind(']').unwrap();
1830        let (line_end, col_end) = test_offset_conversion(source, bracket_close as u32);
1831        assert_eq!(line_end, 5);
1832        assert_eq!(col_end, 0); // ']' is at column 0 on line 5
1833    }
1834
1835    #[test]
1836    fn col_end_handles_emoji_in_span() {
1837        // Test that col_end correctly handles emoji spanning
1838        let source = "<?php\n$greeting = \"Hello 🎉\";";
1839
1840        // Find emoji position
1841        let emoji_pos = source.find('🎉').unwrap();
1842        let hello_pos = source.find("Hello").unwrap();
1843
1844        // Column at "Hello" on line 2
1845        let (line, col) = test_offset_conversion(source, hello_pos as u32);
1846        assert_eq!(line, 2);
1847        assert_eq!(col, 13); // Position of 'H' after "$greeting = \""
1848
1849        // Column at emoji
1850        let (line, col) = test_offset_conversion(source, emoji_pos as u32);
1851        assert_eq!(line, 2);
1852        // Should be after "Hello " (13 + 5 + 1 = 19 chars)
1853        assert_eq!(col, 19);
1854    }
1855}