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