Skip to main content

mir_analyzer/
expr.rs

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