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