Skip to main content

mir_analyzer/
expr.rs

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