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