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