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