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