Skip to main content

php_lsp/
type_map.rs

1/// Single-pass type inference: collects `$var = new ClassName()` assignments
2/// to map variable names to class names.  Used to scope method completions
3/// after `->`.
4use std::collections::HashMap;
5
6use php_ast::{
7    BinaryOp, ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind, TypeHint,
8    TypeHintKind,
9};
10use tower_lsp::lsp_types::Position;
11
12use crate::ast::{MethodReturnsMap, ParsedDoc, SourceView};
13use crate::docblock::{docblock_before, parse_docblock};
14use crate::phpstorm_meta::PhpStormMeta;
15
16/// Maps variable name (with `$`) → class name.
17#[derive(Debug, Default, Clone)]
18pub struct TypeMap(HashMap<String, String>);
19
20impl TypeMap {
21    /// Build from a parsed document. Method-return-type inference rebuilds
22    /// the per-doc map inline — prefer [`from_doc_with_meta`] with a salsa-
23    /// memoized `doc_returns` on hot paths.
24    #[cfg(test)]
25    pub fn from_doc(doc: &ParsedDoc) -> Self {
26        Self::from_doc_with_meta(doc, None, None)
27    }
28
29    /// Build from a parsed document, optionally enriched by PHPStorm metadata
30    /// for factory-method return type inference. `doc_returns` is the
31    /// precomputed method-return map (typically from the salsa `method_returns`
32    /// query); pass `None` to build it inline.
33    pub fn from_doc_with_meta(
34        doc: &ParsedDoc,
35        meta: Option<&PhpStormMeta>,
36        doc_returns: Option<&MethodReturnsMap>,
37    ) -> Self {
38        let owned;
39        let returns: &MethodReturnsMap = match doc_returns {
40            Some(r) => r,
41            None => {
42                owned = build_method_returns(doc);
43                &owned
44            }
45        };
46        let mut map = HashMap::new();
47        collect_types_stmts(
48            doc.source(),
49            &doc.program().stmts,
50            &mut map,
51            meta,
52            std::slice::from_ref(&returns),
53            None,
54        );
55        TypeMap(map)
56    }
57
58    /// Build from a parsed document plus cross-file docs. Callers must supply
59    /// precomputed method-return maps for the primary doc and each other doc
60    /// (typically from the salsa `method_returns` query).
61    pub fn from_docs_with_meta<'a>(
62        doc: &ParsedDoc,
63        doc_returns: &MethodReturnsMap,
64        other_docs: impl IntoIterator<Item = (&'a ParsedDoc, &'a MethodReturnsMap)>,
65        meta: Option<&'a PhpStormMeta>,
66    ) -> Self {
67        let mut all_returns: Vec<&MethodReturnsMap> = vec![doc_returns];
68        all_returns.extend(other_docs.into_iter().map(|(_, r)| r));
69        let mut map = HashMap::new();
70        collect_types_stmts(
71            doc.source(),
72            &doc.program().stmts,
73            &mut map,
74            meta,
75            &all_returns,
76            None,
77        );
78        TypeMap(map)
79    }
80
81    /// Like [`from_docs_with_meta`] but scopes method-body variable collection
82    /// to the method (or function) containing `position`. Variables local to
83    /// other method bodies are excluded so they cannot pollute the map with
84    /// wrong types at the cursor site. Sets `$this` when the position is inside
85    /// an instance method.
86    pub fn from_docs_at_position<'a>(
87        doc: &ParsedDoc,
88        doc_returns: &MethodReturnsMap,
89        other_docs: impl IntoIterator<Item = (&'a ParsedDoc, &'a MethodReturnsMap)>,
90        meta: Option<&'a PhpStormMeta>,
91        position: Position,
92    ) -> Self {
93        let cursor_byte = {
94            let line_starts = doc.line_starts();
95            let line = position.line as usize;
96            if line < line_starts.len() {
97                let line_start = line_starts[line] as usize;
98                let col_byte = crate::util::utf16_offset_to_byte(
99                    &doc.source()[line_start..],
100                    position.character as usize,
101                );
102                Some((line_start + col_byte) as u32)
103            } else {
104                None
105            }
106        };
107        let mut all_returns: Vec<&MethodReturnsMap> = vec![doc_returns];
108        all_returns.extend(other_docs.into_iter().map(|(_, r)| r));
109        let mut map = HashMap::new();
110        collect_types_stmts(
111            doc.source(),
112            &doc.program().stmts,
113            &mut map,
114            meta,
115            &all_returns,
116            cursor_byte,
117        );
118        TypeMap(map)
119    }
120
121    /// Returns the class name for a variable, e.g. `get("$obj")` → `Some("Foo")`.
122    pub fn get<'a>(&'a self, var: &str) -> Option<&'a str> {
123        self.0.get(var).map(|s| s.as_str())
124    }
125}
126
127/// Pre-build a map of class_name → method_name → return_class_name for a single doc.
128pub fn build_method_returns(doc: &ParsedDoc) -> MethodReturnsMap {
129    let mut out = HashMap::new();
130    collect_method_returns_stmts(doc.source(), &doc.program().stmts, &mut out);
131    out
132}
133
134/// Look up `class.method() -> return_class` across a stack of per-doc maps.
135/// Returns the first match — later docs override earlier ones, matching the
136/// previous merge-based behavior.
137fn lookup_method_return<'a>(
138    maps: &'a [&'a MethodReturnsMap],
139    class_name: &str,
140    method_name: &str,
141) -> Option<&'a str> {
142    for m in maps.iter().rev() {
143        if let Some(class_rets) = m.get(class_name)
144            && let Some(ret) = class_rets.get(method_name)
145        {
146            return Some(ret.as_str());
147        }
148    }
149    None
150}
151
152fn collect_method_returns_stmts(
153    source: &str,
154    stmts: &[Stmt<'_, '_>],
155    out: &mut HashMap<String, HashMap<String, String>>,
156) {
157    for stmt in stmts {
158        match &stmt.kind {
159            StmtKind::Class(c) => {
160                let class_name = match c.name {
161                    Some(n) => n.to_string(),
162                    None => continue,
163                };
164                for member in c.members.iter() {
165                    if let ClassMemberKind::Method(m) = &member.kind
166                        && let Some(ret) =
167                            extract_method_return_class(source, member.span.start, m, &class_name)
168                    {
169                        out.entry(class_name.clone())
170                            .or_default()
171                            .insert(m.name.to_string(), ret);
172                    }
173                }
174            }
175            StmtKind::Trait(t) => {
176                let trait_name = t.name.to_string();
177                for member in t.members.iter() {
178                    if let ClassMemberKind::Method(m) = &member.kind
179                        && let Some(ret) =
180                            extract_method_return_class(source, member.span.start, m, &trait_name)
181                    {
182                        out.entry(trait_name.clone())
183                            .or_default()
184                            .insert(m.name.to_string(), ret);
185                    }
186                }
187            }
188            StmtKind::Enum(e) => {
189                let enum_name = e.name.to_string();
190                for member in e.members.iter() {
191                    if let EnumMemberKind::Method(m) = &member.kind
192                        && let Some(ret) =
193                            extract_method_return_class(source, member.span.start, m, &enum_name)
194                    {
195                        out.entry(enum_name.clone())
196                            .or_default()
197                            .insert(m.name.to_string(), ret);
198                    }
199                }
200            }
201            StmtKind::Namespace(ns) => {
202                if let NamespaceBody::Braced(inner) = &ns.body {
203                    collect_method_returns_stmts(source, inner, out);
204                }
205            }
206            _ => {}
207        }
208    }
209}
210
211fn extract_method_return_class(
212    source: &str,
213    member_start: u32,
214    m: &php_ast::MethodDecl<'_, '_>,
215    enclosing_class: &str,
216) -> Option<String> {
217    // 1. AST return type hint takes priority
218    if let Some(hint) = &m.return_type
219        && let Some(s) = type_hint_to_class_string(hint, Some(enclosing_class))
220    {
221        return Some(s);
222    }
223    // 2. @return docblock fallback
224    if let Some(raw) = docblock_before(source, member_start) {
225        let db = parse_docblock(&raw);
226        if let Some(ret) = db.return_type {
227            for part in ret.type_hint.split('|') {
228                let part = part.trim().trim_start_matches('\\').trim_start_matches('?');
229                let short = part.rsplit('\\').next().unwrap_or(part);
230                if short == "self" || short == "static" {
231                    return Some(enclosing_class.to_string());
232                }
233                let first = short.chars().next().unwrap_or('_');
234                if first.is_uppercase() && !matches!(short, "void" | "never" | "null") {
235                    return Some(short.to_string());
236                }
237            }
238        }
239    }
240    None
241}
242
243/// Extract a class-name string from a type hint using mir's type resolver.
244/// - `Named(Foo)` → `"Foo"`, `Named(\App\Foo)` → `"Foo"` (short name)
245/// - `Nullable(Named(Foo))` → `"Foo"` (strips the nullable wrapper)
246/// - `Union([Named(Foo), Named(Bar)])` → `"Foo|Bar"`
247/// - `self` / `static` with `enclosing` → returns the enclosing short name
248/// - Primitives and unrecognised kinds → `None`
249fn type_hint_to_class_string(
250    hint: &TypeHint<'_, '_>,
251    enclosing_class: Option<&str>,
252) -> Option<String> {
253    use mir_types::Atomic;
254    let union = mir_analyzer::parser::type_from_hint(hint, enclosing_class);
255    let classes: Vec<String> = union
256        .types
257        .iter()
258        .filter_map(|a| match a {
259            Atomic::TNamedObject { fqcn, .. }
260            | Atomic::TSelf { fqcn }
261            | Atomic::TStaticObject { fqcn }
262            | Atomic::TParent { fqcn } => {
263                let short = fqcn.rsplit('\\').next().unwrap_or(fqcn.as_ref());
264                Some(short.to_string())
265            }
266            _ => None,
267        })
268        .collect();
269    if classes.is_empty() {
270        None
271    } else {
272        Some(classes.join("|"))
273    }
274}
275
276fn collect_types_stmts(
277    source: &str,
278    stmts: &[Stmt<'_, '_>],
279    map: &mut HashMap<String, String>,
280    meta: Option<&PhpStormMeta>,
281    method_returns: &[&MethodReturnsMap],
282    cursor_byte: Option<u32>,
283) {
284    for stmt in stmts {
285        // Check for `/** @var ClassName $varName */` docblock before this statement.
286        if let Some(raw) = docblock_before(source, stmt.span.start) {
287            let db = parse_docblock(&raw);
288            if let Some(type_str) = db.var_type {
289                // Only map object types (starts with uppercase or backslash).
290                // type_str may be a union like "Foo|null"; take the first class part.
291                let class_name = type_str
292                    .split('|')
293                    .map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
294                    .find(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
295                    .and_then(|p| p.rsplit('\\').next())
296                    .map(|p| p.to_string());
297                if let Some(class_name) = class_name {
298                    if let Some(vname) = db.var_name {
299                        // `@var Foo $obj` — explicit variable name.
300                        map.insert(format!("${}", vname.as_str()), class_name);
301                    } else if let StmtKind::Expression(e) = &stmt.kind {
302                        // `@var Foo` above `$obj = ...` — infer from the LHS.
303                        if let ExprKind::Assign(a) = &e.kind
304                            && let ExprKind::Variable(vn) = &a.target.kind
305                        {
306                            map.insert(format!("${}", vn.as_str()), class_name);
307                        }
308                    }
309                }
310            }
311        }
312
313        match &stmt.kind {
314            StmtKind::Expression(e) => {
315                collect_types_expr(source, e, map, meta, method_returns, cursor_byte)
316            }
317            StmtKind::Function(f) => {
318                // Only collect params/body when cursor is inside this function (or no cursor).
319                let in_scope =
320                    cursor_byte.is_none_or(|c| stmt.span.start <= c && c <= stmt.span.end);
321                if !in_scope {
322                    continue;
323                }
324                // Read @param docblock hints — fills in types for untyped params
325                if let Some(raw) = docblock_before(source, stmt.span.start) {
326                    let db = parse_docblock(&raw);
327                    for param in &db.params {
328                        // For union types, collect all class parts joined by |
329                        let classes: Vec<&str> = param
330                            .type_hint
331                            .split('|')
332                            .map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
333                            .filter(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
334                            .filter_map(|p| p.rsplit('\\').next())
335                            .collect();
336                        if !classes.is_empty() {
337                            let key = if param.name.starts_with('$') {
338                                param.name.clone()
339                            } else {
340                                format!("${}", param.name)
341                            };
342                            map.entry(key).or_insert_with(|| classes.join("|"));
343                        }
344                    }
345                }
346                for p in f.params.iter() {
347                    if let Some(hint) = &p.type_hint
348                        && let Some(class_str) = type_hint_to_class_string(hint, None)
349                    {
350                        map.insert(format!("${}", p.name), class_str);
351                    }
352                }
353                collect_types_stmts(source, &f.body, map, meta, method_returns, cursor_byte);
354            }
355            StmtKind::Class(c) => {
356                let class_name = c.name.map(|n| n.to_string());
357                for member in c.members.iter() {
358                    if let ClassMemberKind::Method(m) = &member.kind {
359                        // Only collect params/body when cursor is inside this method (or no cursor).
360                        let in_scope = cursor_byte
361                            .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
362                        if !in_scope {
363                            continue;
364                        }
365                        // Read @param docblock hints — fills in types for untyped params
366                        if let Some(raw) = docblock_before(source, member.span.start) {
367                            let db = parse_docblock(&raw);
368                            for param in &db.params {
369                                // For union types, collect all class parts joined by |
370                                let classes: Vec<&str> = param
371                                    .type_hint
372                                    .split('|')
373                                    .map(|p| {
374                                        p.trim().trim_start_matches('\\').trim_start_matches('?')
375                                    })
376                                    .filter(|p| {
377                                        p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
378                                    })
379                                    .filter_map(|p| p.rsplit('\\').next())
380                                    .collect();
381                                if !classes.is_empty() {
382                                    let key = if param.name.starts_with('$') {
383                                        param.name.clone()
384                                    } else {
385                                        format!("${}", param.name)
386                                    };
387                                    map.entry(key).or_insert_with(|| classes.join("|"));
388                                }
389                            }
390                        }
391                        for p in m.params.iter() {
392                            if let Some(hint) = &p.type_hint
393                                && let Some(class_str) =
394                                    type_hint_to_class_string(hint, class_name.as_deref())
395                            {
396                                map.insert(format!("${}", p.name), class_str);
397                            }
398                        }
399                        // Set $this to the enclosing class for instance methods.
400                        if !m.is_static
401                            && let Some(ref cname) = class_name
402                        {
403                            map.insert("$this".to_string(), cname.clone());
404                        }
405                        if let Some(body) = &m.body {
406                            collect_types_stmts(
407                                source,
408                                body,
409                                map,
410                                meta,
411                                method_returns,
412                                cursor_byte,
413                            );
414                        }
415                    }
416                }
417            }
418            StmtKind::Trait(t) => {
419                for member in t.members.iter() {
420                    if let ClassMemberKind::Method(m) = &member.kind {
421                        let in_scope = cursor_byte
422                            .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
423                        if !in_scope {
424                            continue;
425                        }
426                        for p in m.params.iter() {
427                            if let Some(hint) = &p.type_hint
428                                && let Some(class_str) = type_hint_to_class_string(hint, None)
429                            {
430                                map.insert(format!("${}", p.name), class_str);
431                            }
432                        }
433                        if let Some(body) = &m.body {
434                            collect_types_stmts(
435                                source,
436                                body,
437                                map,
438                                meta,
439                                method_returns,
440                                cursor_byte,
441                            );
442                        }
443                    }
444                }
445            }
446            StmtKind::Enum(e) => {
447                for member in e.members.iter() {
448                    if let EnumMemberKind::Method(m) = &member.kind {
449                        let in_scope = cursor_byte
450                            .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
451                        if !in_scope {
452                            continue;
453                        }
454                        for p in m.params.iter() {
455                            if let Some(hint) = &p.type_hint
456                                && let Some(class_str) = type_hint_to_class_string(hint, None)
457                            {
458                                map.insert(format!("${}", p.name), class_str);
459                            }
460                        }
461                        if let Some(body) = &m.body {
462                            collect_types_stmts(
463                                source,
464                                body,
465                                map,
466                                meta,
467                                method_returns,
468                                cursor_byte,
469                            );
470                        }
471                    }
472                }
473            }
474            StmtKind::Namespace(ns) => {
475                if let NamespaceBody::Braced(inner) = &ns.body {
476                    collect_types_stmts(source, inner, map, meta, method_returns, cursor_byte);
477                }
478            }
479            // if ($x instanceof Foo) — narrow $x to Foo inside the then-branch
480            StmtKind::If(if_stmt) => {
481                // Check whether the condition is a simple `$var instanceof ClassName`.
482                if let ExprKind::Binary(b) = &if_stmt.condition.kind
483                    && b.op == BinaryOp::Instanceof
484                    && let (ExprKind::Variable(var_name), ExprKind::Identifier(class)) =
485                        (&b.left.kind, &b.right.kind)
486                {
487                    let var_key = format!("${}", var_name.as_str());
488                    let narrowed = class
489                        .as_str()
490                        .trim_start_matches('\\')
491                        .rsplit('\\')
492                        .next()
493                        .unwrap_or(class)
494                        .to_string();
495                    // Insert narrowed type then recurse into then-branch.
496                    // The flat map keeps the last write, so code after the if-block
497                    // may see the narrowed type — acceptable trade-off for a simple
498                    // single-pass map.
499                    map.insert(var_key, narrowed);
500                }
501                collect_types_stmts(
502                    source,
503                    std::slice::from_ref(if_stmt.then_branch),
504                    map,
505                    meta,
506                    method_returns,
507                    cursor_byte,
508                );
509                for elseif in if_stmt.elseif_branches.iter() {
510                    collect_types_stmts(
511                        source,
512                        std::slice::from_ref(&elseif.body),
513                        map,
514                        meta,
515                        method_returns,
516                        cursor_byte,
517                    );
518                }
519                if let Some(else_branch) = if_stmt.else_branch {
520                    collect_types_stmts(
521                        source,
522                        std::slice::from_ref(else_branch),
523                        map,
524                        meta,
525                        method_returns,
526                        cursor_byte,
527                    );
528                }
529            }
530
531            // foreach ($arr as $item) — propagate element type from $arr[] to $item
532            StmtKind::Foreach(f) => {
533                if let ExprKind::Variable(arr_name) = &f.expr.kind {
534                    let elem_key = format!("${}[]", arr_name.as_str());
535                    if let Some(elem_type) = map.get(&elem_key).cloned()
536                        && let ExprKind::Variable(val_name) = &f.value.kind
537                    {
538                        map.insert(format!("${}", val_name.as_str()), elem_type);
539                    }
540                }
541                collect_types_stmts(
542                    source,
543                    std::slice::from_ref(f.body),
544                    map,
545                    meta,
546                    method_returns,
547                    cursor_byte,
548                );
549            }
550            // try { ... } catch (FooException $e) { ... }
551            // Map the catch variable to the first caught exception class.
552            StmtKind::TryCatch(t) => {
553                collect_types_stmts(source, &t.body, map, meta, method_returns, cursor_byte);
554                for catch in t.catches.iter() {
555                    if let Some(var_name) = &catch.var
556                        && let Some(first_type) = catch.types.first()
557                    {
558                        let class_name = first_type
559                            .to_string_repr()
560                            .trim_start_matches('\\')
561                            .rsplit('\\')
562                            .next()
563                            .unwrap_or("")
564                            .to_string();
565                        if !class_name.is_empty() {
566                            map.insert(format!("${}", var_name), class_name);
567                        }
568                    }
569                    collect_types_stmts(
570                        source,
571                        &catch.body,
572                        map,
573                        meta,
574                        method_returns,
575                        cursor_byte,
576                    );
577                }
578                if let Some(finally) = &t.finally {
579                    collect_types_stmts(source, finally, map, meta, method_returns, cursor_byte);
580                }
581            }
582
583            // static $var = expr — infer type from the default value expression.
584            StmtKind::StaticVar(vars) => {
585                for var in vars.iter() {
586                    let var_key = format!("${}", &var.name.to_string());
587                    if let Some(default) = &var.default {
588                        if let ExprKind::New(new_expr) = &default.kind
589                            && let Some(class_name) = extract_class_name(new_expr.class)
590                        {
591                            map.insert(var_key.clone(), class_name);
592                        }
593                        if let ExprKind::Array(_) = &default.kind {
594                            map.insert(var_key, "array".to_string());
595                        }
596                    }
597                }
598            }
599
600            _ => {}
601        }
602    }
603}
604
605fn collect_types_expr(
606    source: &str,
607    expr: &php_ast::Expr<'_, '_>,
608    map: &mut HashMap<String, String>,
609    meta: Option<&PhpStormMeta>,
610    method_returns: &[&MethodReturnsMap],
611    cursor_byte: Option<u32>,
612) {
613    match &expr.kind {
614        ExprKind::Assign(assign) => {
615            if let ExprKind::Variable(var_name) = &assign.target.kind {
616                // Handle ??= (null coalescing assignment): only assigns if null
617                // so use or_insert (existing type takes precedence)
618                if assign.op == php_ast::AssignOp::Coalesce {
619                    if let ExprKind::New(new_expr) = &assign.value.kind
620                        && let Some(class_name) = extract_class_name(new_expr.class)
621                    {
622                        map.entry(format!("${}", var_name.as_str()))
623                            .or_insert(class_name);
624                    }
625                    collect_types_expr(
626                        source,
627                        assign.value,
628                        map,
629                        meta,
630                        method_returns,
631                        cursor_byte,
632                    );
633                    return;
634                }
635                if let ExprKind::New(new_expr) = &assign.value.kind
636                    && let Some(class_name) = extract_class_name(new_expr.class)
637                {
638                    map.insert(format!("${}", var_name.as_str()), class_name);
639                }
640                // $result = $obj->method() — infer result type from method's return type
641                if let ExprKind::MethodCall(mc) = &assign.value.kind
642                    && let (ExprKind::Variable(obj_var), ExprKind::Identifier(method_name)) =
643                        (&mc.object.kind, &mc.method.kind)
644                    && let Some(obj_class) = map.get(&format!("${}", obj_var.as_str())).cloned()
645                    && let Some(ret_type) =
646                        lookup_method_return(method_returns, &obj_class, method_name.as_str())
647                {
648                    map.insert(format!("${}", var_name.as_str()), ret_type.to_string());
649                }
650                // PHPStorm meta: `$var = $obj->make(SomeClass::class)`
651                if let Some(meta) = meta
652                    && let Some(inferred) = infer_from_meta_method_call(assign.value, map, meta)
653                {
654                    map.insert(format!("${}", var_name.as_str()), inferred);
655                }
656                // $result = array_map(fn($x): Foo => ..., $arr) → $result[] = Foo
657                if let Some(elem_type) = extract_array_callback_return_type(assign.value) {
658                    map.insert(format!("${}[]", var_name.as_str()), elem_type);
659                }
660            }
661            collect_types_expr(source, assign.value, map, meta, method_returns, cursor_byte);
662        }
663
664        // Closure::bind($fn, $obj) → $this maps to $obj's class
665        ExprKind::StaticMethodCall(s) => {
666            if let ExprKind::Identifier(class) = &s.class.kind
667                && class.as_str() == "Closure"
668                && s.method.name_str() == Some("bind")
669                && let Some(obj_arg) = s.args.get(1)
670                && let Some(cls) = resolve_var_type_str(&obj_arg.value, map)
671            {
672                map.insert("$this".to_string(), cls);
673            }
674        }
675
676        // $fn->bindTo($obj) or $fn->call($obj) → $this maps to $obj's class
677        ExprKind::MethodCall(m) => {
678            if let ExprKind::Identifier(method) = &m.method.kind {
679                let mname = method.as_str();
680                if (mname == "bindTo" || mname == "call")
681                    && let Some(obj_arg) = m.args.first()
682                    && let Some(cls) = resolve_var_type_str(&obj_arg.value, map)
683                {
684                    map.insert("$this".to_string(), cls);
685                }
686            }
687        }
688
689        // Walk closure bodies so inner assignments are also captured
690        ExprKind::Closure(c) => {
691            for p in c.params.iter() {
692                if let Some(hint) = &p.type_hint
693                    && let TypeHintKind::Named(name) = &hint.kind
694                {
695                    map.insert(format!("${}", p.name), name.to_string_repr().to_string());
696                }
697            }
698            // Snapshot captured `use` variable types from the outer scope so they
699            // remain resolvable inside the closure body even if the body walk
700            // encounters assignments that would shadow them.
701            let use_var_snapshot: Vec<(String, String)> = c
702                .use_vars
703                .iter()
704                .filter_map(|uv| {
705                    let key = format!("${}", &uv.name.to_string());
706                    map.get(&key).map(|ty| (key, ty.clone()))
707                })
708                .collect();
709            collect_types_stmts(source, &c.body, map, meta, method_returns, cursor_byte);
710            // Restore captured variable types: inner assignments inside the closure
711            // body should not affect the outer scope's type for completions.
712            for (key, ty) in use_var_snapshot {
713                map.insert(key, ty);
714            }
715        }
716
717        ExprKind::ArrowFunction(af) => {
718            for p in af.params.iter() {
719                if let Some(hint) = &p.type_hint
720                    && let TypeHintKind::Named(name) = &hint.kind
721                {
722                    map.insert(format!("${}", p.name), name.to_string_repr().to_string());
723                }
724            }
725            collect_types_expr(source, af.body, map, meta, method_returns, cursor_byte);
726        }
727
728        _ => {}
729    }
730}
731
732/// For `array_map`/`array_filter` calls: extract the return type of the first
733/// (callback) argument if it has an explicit type hint, e.g.
734/// `array_map(fn($x): Foo => $x->transform(), $arr)` → `"Foo"`.
735fn extract_array_callback_return_type(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
736    let ExprKind::FunctionCall(call) = &expr.kind else {
737        return None;
738    };
739    let fn_name = match &call.name.kind {
740        ExprKind::Identifier(n) => n.as_str(),
741        _ => return None,
742    };
743    if fn_name != "array_map" && fn_name != "array_filter" {
744        return None;
745    }
746    let callback_arg = call.args.first()?;
747    extract_callback_return_type(&callback_arg.value)
748}
749
750/// Extract the return-type class name from a Closure or ArrowFunction expression.
751fn extract_callback_return_type(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
752    let hint = match &expr.kind {
753        ExprKind::Closure(c) => c.return_type.as_ref()?,
754        ExprKind::ArrowFunction(af) => af.return_type.as_ref()?,
755        _ => return None,
756    };
757    if let TypeHintKind::Named(name) = &hint.kind {
758        let s = name.to_string_repr();
759        let base = s.trim_start_matches('\\');
760        let short = base.rsplit('\\').next().unwrap_or(base);
761        if short
762            .chars()
763            .next()
764            .map(|c| c.is_uppercase())
765            .unwrap_or(false)
766        {
767            return Some(short.to_string());
768        }
769    }
770    None
771}
772
773/// Look up the class of a `$variable` expression from the current map.
774fn resolve_var_type_str(
775    expr: &php_ast::Expr<'_, '_>,
776    map: &HashMap<String, String>,
777) -> Option<String> {
778    if let ExprKind::Variable(v) = &expr.kind {
779        map.get(&format!("${}", v.as_str())).cloned()
780    } else {
781        None
782    }
783}
784
785fn extract_class_name(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
786    match &expr.kind {
787        ExprKind::Identifier(name) => Some(name.as_str().to_string()),
788        _ => None,
789    }
790}
791
792/// Try to infer the return type of `$obj->method(SomeClass::class)` using the
793/// PHPStorm meta map.  `map` is consulted to resolve `$obj`'s class.
794fn infer_from_meta_method_call(
795    expr: &php_ast::Expr<'_, '_>,
796    var_map: &HashMap<String, String>,
797    meta: &PhpStormMeta,
798) -> Option<String> {
799    let ExprKind::MethodCall(m) = &expr.kind else {
800        return None;
801    };
802    // Resolve the receiver's type.
803    let receiver_class = match &m.object.kind {
804        ExprKind::Variable(v) => {
805            let key = format!("${}", v.as_str());
806            var_map.get(&key)?.clone()
807        }
808        _ => return None,
809    };
810    // Get the method name.
811    let method_name = match &m.method.kind {
812        ExprKind::Identifier(n) => n.to_string(),
813        _ => return None,
814    };
815    // Get the first argument as a class name string.
816    let arg = m.args.first()?;
817    let arg_str = match &arg.value.kind {
818        ExprKind::String(s) => s.trim_start_matches('\\').to_string(),
819        ExprKind::ClassConstAccess(c) if c.member.name_str() == Some("class") => {
820            match &c.class.kind {
821                ExprKind::Identifier(n) => n
822                    .trim_start_matches('\\')
823                    .rsplit('\\')
824                    .next()
825                    .unwrap_or(n)
826                    .to_string(),
827                _ => return None,
828            }
829        }
830        _ => return None,
831    };
832    meta.resolve_return_type(&receiver_class, &method_name, &arg_str)
833        .map(|s| s.to_string())
834}
835
836/// Return the direct parent class name of `class_name` in `doc`, if any.
837pub fn parent_class_name(doc: &ParsedDoc, class_name: &str) -> Option<String> {
838    parent_in_stmts(&doc.program().stmts, class_name)
839}
840
841fn parent_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
842    for stmt in stmts {
843        match &stmt.kind {
844            StmtKind::Class(c)
845                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
846            {
847                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
848            }
849            StmtKind::Namespace(ns) => {
850                if let NamespaceBody::Braced(inner) = &ns.body
851                    && let found @ Some(_) = parent_in_stmts(inner, class_name)
852                {
853                    return found;
854                }
855            }
856            _ => {}
857        }
858    }
859    None
860}
861
862/// All members of a named class split by kind and static-ness.
863#[derive(Debug, Default)]
864pub struct ClassMembers {
865    /// (name, is_static)
866    pub methods: Vec<(String, bool)>,
867    /// (name, is_static)
868    pub properties: Vec<(String, bool)>,
869    /// Names of readonly properties (PHP 8.1+).
870    pub readonly_properties: Vec<String>,
871    pub constants: Vec<String>,
872    /// Direct parent class name, if any.
873    pub parent: Option<String>,
874    /// Trait names used by this class (`use Foo, Bar;`).
875    pub trait_uses: Vec<String>,
876    /// True when a class/enum/trait with this name was found in the doc.
877    /// Lets workspace-wide loops short-circuit once the defining doc is hit
878    /// instead of continuing to scan every file.
879    pub found: bool,
880}
881
882/// Return all members (methods, properties, constants) of `class_name`.
883/// Also returns the direct parent class name via `ClassMembers::parent`.
884pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
885    let mut out = ClassMembers::default();
886    out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
887    out
888}
889
890fn collect_members_stmts(
891    source: &str,
892    stmts: &[Stmt<'_, '_>],
893    class_name: &str,
894    out: &mut ClassMembers,
895) -> Option<String> {
896    for stmt in stmts {
897        match &stmt.kind {
898            StmtKind::Class(c)
899                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
900            {
901                out.found = true;
902                // Check docblock for @property and @method tags
903                if let Some(raw) = docblock_before(source, stmt.span.start) {
904                    let db = parse_docblock(&raw);
905                    for prop in &db.properties {
906                        out.properties.push((prop.name.clone(), false));
907                    }
908                    for method in &db.methods {
909                        out.methods.push((method.name.clone(), method.is_static));
910                    }
911                }
912                for member in c.members.iter() {
913                    match &member.kind {
914                        ClassMemberKind::Method(m) => {
915                            out.methods.push((m.name.to_string(), m.is_static));
916                            // Constructor-promoted params become instance properties.
917                            if m.name == "__construct" {
918                                for p in m.params.iter() {
919                                    if p.visibility.is_some() {
920                                        out.properties.push((p.name.to_string(), false));
921                                        // Detect `readonly` in the source text before the
922                                        // param name (the AST does not expose this flag on
923                                        // Param, so we scan the raw text of the param span).
924                                        let param_src =
925                                            &source[p.span.start as usize..p.span.end as usize];
926                                        if param_src.contains("readonly") {
927                                            out.readonly_properties.push(p.name.to_string());
928                                        }
929                                    }
930                                }
931                            }
932                        }
933                        ClassMemberKind::Property(p) => {
934                            out.properties.push((p.name.to_string(), p.is_static));
935                            if p.is_readonly {
936                                out.readonly_properties.push(p.name.to_string());
937                            }
938                        }
939                        ClassMemberKind::ClassConst(c) => {
940                            out.constants.push(c.name.to_string());
941                        }
942                        ClassMemberKind::TraitUse(t) => {
943                            for name in t.traits.iter() {
944                                out.trait_uses.push(name.to_string_repr().to_string());
945                            }
946                        }
947                    }
948                }
949                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
950            }
951            StmtKind::Enum(e) if e.name == class_name => {
952                out.found = true;
953                let is_backed = e.scalar_type.is_some();
954                // Every enum instance exposes `->name`; backed enums also expose `->value`.
955                out.properties.push(("name".to_string(), false));
956                if is_backed {
957                    out.properties.push(("value".to_string(), false));
958                }
959                // Built-in static methods present on every enum.
960                out.methods.push(("cases".to_string(), true));
961                if is_backed {
962                    out.methods.push(("from".to_string(), true));
963                    out.methods.push(("tryFrom".to_string(), true));
964                }
965                // User-declared cases, methods, and constants.
966                for member in e.members.iter() {
967                    match &member.kind {
968                        EnumMemberKind::Case(c) => {
969                            out.constants.push(c.name.to_string());
970                        }
971                        EnumMemberKind::Method(m) => {
972                            out.methods.push((m.name.to_string(), m.is_static));
973                        }
974                        EnumMemberKind::ClassConst(c) => {
975                            out.constants.push(c.name.to_string());
976                        }
977                        _ => {}
978                    }
979                }
980                return None; // enums have no parent class
981            }
982            StmtKind::Trait(t) if t.name == class_name => {
983                out.found = true;
984                for member in t.members.iter() {
985                    match &member.kind {
986                        ClassMemberKind::Method(m) => {
987                            out.methods.push((m.name.to_string(), m.is_static));
988                        }
989                        ClassMemberKind::Property(p) => {
990                            out.properties.push((p.name.to_string(), p.is_static));
991                        }
992                        ClassMemberKind::ClassConst(c) => {
993                            out.constants.push(c.name.to_string());
994                        }
995                        ClassMemberKind::TraitUse(t) => {
996                            for name in t.traits.iter() {
997                                out.trait_uses.push(name.to_string_repr().to_string());
998                            }
999                        }
1000                    }
1001                }
1002                return None; // traits have no parent
1003            }
1004            StmtKind::Namespace(ns) => {
1005                if let NamespaceBody::Braced(inner) = &ns.body {
1006                    let result = collect_members_stmts(source, inner, class_name, out);
1007                    if result.is_some() {
1008                        return result;
1009                    }
1010                }
1011            }
1012            _ => {}
1013        }
1014    }
1015    None
1016}
1017
1018/// Return the `@mixin` class names declared in `class_name`'s docblock.
1019pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
1020    let source = doc.source();
1021    mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
1022}
1023
1024fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
1025    for stmt in stmts {
1026        match &stmt.kind {
1027            StmtKind::Class(c)
1028                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
1029            {
1030                if let Some(raw) = docblock_before(source, stmt.span.start) {
1031                    return parse_docblock(&raw).mixins;
1032                }
1033                return vec![];
1034            }
1035            StmtKind::Namespace(ns) => {
1036                if let NamespaceBody::Braced(inner) = &ns.body {
1037                    let found = mixin_classes_in_stmts(source, inner, class_name);
1038                    if !found.is_empty() {
1039                        return found;
1040                    }
1041                }
1042            }
1043            _ => {}
1044        }
1045    }
1046    vec![]
1047}
1048
1049/// Return the name of the class whose body contains `position`, or `None`.
1050pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
1051    let sv = doc.view();
1052    enclosing_class_in_stmts(sv, &doc.program().stmts, position)
1053}
1054
1055/// Return the LSP range of the class/interface/trait/enum declaration
1056/// whose body contains `position`, or `None` if the cursor is outside any.
1057/// Used by linked-editing to scope same-name member rewrites to the
1058/// enclosing class instead of every class in the file.
1059pub fn enclosing_class_range_at(
1060    doc: &ParsedDoc,
1061    position: Position,
1062) -> Option<tower_lsp::lsp_types::Range> {
1063    let sv = doc.view();
1064    enclosing_class_range_in_stmts(sv, &doc.program().stmts, position)
1065}
1066
1067/// Return the LSP range of every class/interface/trait/enum declaration in
1068/// the file (recursing into braced-namespace bodies). Used by linked-editing
1069/// to drop highlights that fall inside an *other* class than the cursor's.
1070pub fn collect_all_class_ranges(doc: &ParsedDoc) -> Vec<tower_lsp::lsp_types::Range> {
1071    let sv = doc.view();
1072    let mut out = Vec::new();
1073    collect_class_ranges_in_stmts(sv, &doc.program().stmts, &mut out);
1074    out
1075}
1076
1077fn collect_class_ranges_in_stmts(
1078    sv: SourceView<'_>,
1079    stmts: &[Stmt<'_, '_>],
1080    out: &mut Vec<tower_lsp::lsp_types::Range>,
1081) {
1082    for stmt in stmts {
1083        match &stmt.kind {
1084            StmtKind::Class(_)
1085            | StmtKind::Interface(_)
1086            | StmtKind::Trait(_)
1087            | StmtKind::Enum(_) => {
1088                out.push(sv.range_of(stmt.span));
1089            }
1090            StmtKind::Namespace(ns) => {
1091                if let NamespaceBody::Braced(inner) = &ns.body {
1092                    collect_class_ranges_in_stmts(sv, inner, out);
1093                }
1094            }
1095            _ => {}
1096        }
1097    }
1098}
1099
1100fn enclosing_class_range_in_stmts(
1101    sv: SourceView<'_>,
1102    stmts: &[Stmt<'_, '_>],
1103    pos: Position,
1104) -> Option<tower_lsp::lsp_types::Range> {
1105    for stmt in stmts {
1106        match &stmt.kind {
1107            StmtKind::Class(_)
1108            | StmtKind::Interface(_)
1109            | StmtKind::Trait(_)
1110            | StmtKind::Enum(_) => {
1111                let r = sv.range_of(stmt.span);
1112                if pos.line >= r.start.line && pos.line <= r.end.line {
1113                    return Some(r);
1114                }
1115            }
1116            StmtKind::Namespace(ns) => {
1117                if let NamespaceBody::Braced(inner) = &ns.body
1118                    && let Some(r) = enclosing_class_range_in_stmts(sv, inner, pos)
1119                {
1120                    return Some(r);
1121                }
1122            }
1123            _ => {}
1124        }
1125    }
1126    None
1127}
1128
1129fn enclosing_class_in_stmts(
1130    sv: SourceView<'_>,
1131    stmts: &[Stmt<'_, '_>],
1132    pos: Position,
1133) -> Option<String> {
1134    for stmt in stmts {
1135        match &stmt.kind {
1136            StmtKind::Class(c) => {
1137                let start = sv.position_of(stmt.span.start).line;
1138                let end = sv.position_of(stmt.span.end).line;
1139                if pos.line >= start && pos.line <= end {
1140                    return c.name.map(|n| n.to_string());
1141                }
1142            }
1143            StmtKind::Interface(i) => {
1144                let start = sv.position_of(stmt.span.start).line;
1145                let end = sv.position_of(stmt.span.end).line;
1146                if pos.line >= start && pos.line <= end {
1147                    return Some(i.name.to_string());
1148                }
1149            }
1150            StmtKind::Trait(t) => {
1151                let start = sv.position_of(stmt.span.start).line;
1152                let end = sv.position_of(stmt.span.end).line;
1153                if pos.line >= start && pos.line <= end {
1154                    return Some(t.name.to_string());
1155                }
1156            }
1157            StmtKind::Enum(e) => {
1158                let start = sv.position_of(stmt.span.start).line;
1159                let end = sv.position_of(stmt.span.end).line;
1160                if pos.line >= start && pos.line <= end {
1161                    return Some(e.name.to_string());
1162                }
1163            }
1164            StmtKind::Namespace(ns) => {
1165                if let NamespaceBody::Braced(inner) = &ns.body
1166                    && let Some(found) = enclosing_class_in_stmts(sv, inner, pos)
1167                {
1168                    return Some(found);
1169                }
1170            }
1171            _ => {}
1172        }
1173    }
1174    None
1175}
1176
1177/// Return the parameter names of the function or method named `func_name`.
1178pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
1179    let mut out = Vec::new();
1180    collect_params_stmts(&doc.program().stmts, func_name, &mut out);
1181    out
1182}
1183
1184/// Return the parameter names of `method_name` on class `class_name`.
1185/// Primarily used to offer named-argument completions for attribute constructors.
1186pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
1187    let mut out = Vec::new();
1188    collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
1189    out
1190}
1191
1192fn collect_method_params_stmts(
1193    stmts: &[php_ast::Stmt<'_, '_>],
1194    class_name: &str,
1195    method_name: &str,
1196    out: &mut Vec<String>,
1197) {
1198    for stmt in stmts {
1199        match &stmt.kind {
1200            StmtKind::Class(c)
1201                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
1202            {
1203                for member in c.members.iter() {
1204                    if let ClassMemberKind::Method(m) = &member.kind
1205                        && m.name == method_name
1206                    {
1207                        for p in m.params.iter() {
1208                            out.push(p.name.to_string());
1209                        }
1210                        return;
1211                    }
1212                }
1213            }
1214            StmtKind::Namespace(ns) => {
1215                if let NamespaceBody::Braced(inner) = &ns.body {
1216                    collect_method_params_stmts(inner, class_name, method_name, out);
1217                }
1218            }
1219            _ => {}
1220        }
1221    }
1222}
1223
1224/// Returns `true` if `class_name` is declared as an `enum` in `doc`.
1225pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1226    is_enum_in_stmts(&doc.program().stmts, class_name)
1227}
1228
1229fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1230    for stmt in stmts {
1231        match &stmt.kind {
1232            StmtKind::Enum(e) if e.name == name => return true,
1233            StmtKind::Namespace(ns) => {
1234                if let NamespaceBody::Braced(inner) = &ns.body
1235                    && is_enum_in_stmts(inner, name)
1236                {
1237                    return true;
1238                }
1239            }
1240            _ => {}
1241        }
1242    }
1243    false
1244}
1245
1246/// Returns `true` if `class_name` is a *backed* enum (`enum Foo: string` /
1247/// `enum Foo: int`) in `doc`.  Backed enums have a `->value` property.
1248pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1249    is_backed_enum_in_stmts(&doc.program().stmts, class_name)
1250}
1251
1252fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1253    for stmt in stmts {
1254        match &stmt.kind {
1255            StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
1256            StmtKind::Namespace(ns) => {
1257                if let NamespaceBody::Braced(inner) = &ns.body
1258                    && is_backed_enum_in_stmts(inner, name)
1259                {
1260                    return true;
1261                }
1262            }
1263            _ => {}
1264        }
1265    }
1266    false
1267}
1268
1269fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
1270    for stmt in stmts {
1271        match &stmt.kind {
1272            StmtKind::Function(f) if f.name == func_name => {
1273                for p in f.params.iter() {
1274                    out.push(p.name.to_string());
1275                }
1276                return;
1277            }
1278            StmtKind::Class(c) => {
1279                for member in c.members.iter() {
1280                    if let ClassMemberKind::Method(m) = &member.kind
1281                        && m.name == func_name
1282                    {
1283                        for p in m.params.iter() {
1284                            out.push(p.name.to_string());
1285                        }
1286                        return;
1287                    }
1288                }
1289            }
1290            StmtKind::Namespace(ns) => {
1291                if let NamespaceBody::Braced(inner) = &ns.body {
1292                    collect_params_stmts(inner, func_name, out);
1293                }
1294            }
1295            _ => {}
1296        }
1297    }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303
1304    #[test]
1305    fn infers_type_from_new_expression() {
1306        let src = "<?php\n$obj = new Foo();";
1307        let doc = ParsedDoc::parse(src.to_string());
1308        let tm = TypeMap::from_doc(&doc);
1309        assert_eq!(tm.get("$obj"), Some("Foo"));
1310    }
1311
1312    #[test]
1313    fn unknown_variable_returns_none() {
1314        let src = "<?php\n$obj = new Foo();";
1315        let doc = ParsedDoc::parse(src.to_string());
1316        let tm = TypeMap::from_doc(&doc);
1317        assert!(tm.get("$other").is_none());
1318    }
1319
1320    #[test]
1321    fn multiple_assignments() {
1322        let src = "<?php\n$a = new Foo();\n$b = new Bar();";
1323        let doc = ParsedDoc::parse(src.to_string());
1324        let tm = TypeMap::from_doc(&doc);
1325        assert_eq!(tm.get("$a"), Some("Foo"));
1326        assert_eq!(tm.get("$b"), Some("Bar"));
1327    }
1328
1329    #[test]
1330    fn later_assignment_overwrites() {
1331        let src = "<?php\n$a = new Foo();\n$a = new Bar();";
1332        let doc = ParsedDoc::parse(src.to_string());
1333        let tm = TypeMap::from_doc(&doc);
1334        assert_eq!(tm.get("$a"), Some("Bar"));
1335    }
1336
1337    #[test]
1338    fn infers_type_from_typed_param() {
1339        let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
1340        let doc = ParsedDoc::parse(src.to_string());
1341        let tm = TypeMap::from_doc(&doc);
1342        assert_eq!(tm.get("$mailer"), Some("Mailer"));
1343    }
1344
1345    #[test]
1346    fn parent_class_name_finds_parent() {
1347        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1348        let doc = ParsedDoc::parse(src.to_string());
1349        assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
1350    }
1351
1352    #[test]
1353    fn parent_class_name_returns_none_for_top_level() {
1354        let src = "<?php\nclass Base {}";
1355        let doc = ParsedDoc::parse(src.to_string());
1356        assert!(parent_class_name(&doc, "Base").is_none());
1357    }
1358
1359    #[test]
1360    fn members_of_class_includes_parent_field() {
1361        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1362        let doc = ParsedDoc::parse(src.to_string());
1363        let m = members_of_class(&doc, "Child");
1364        assert_eq!(m.parent.as_deref(), Some("Base"));
1365    }
1366
1367    #[test]
1368    fn members_of_class_finds_methods() {
1369        let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
1370        let doc = ParsedDoc::parse(src.to_string());
1371        let members = members_of_class(&doc, "Calc");
1372        let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1373        assert!(names.contains(&"add"), "missing 'add'");
1374        assert!(names.contains(&"sub"), "missing 'sub'");
1375    }
1376
1377    #[test]
1378    fn members_of_unknown_class_is_empty() {
1379        let src = "<?php\nclass Calc { public function add() {} }";
1380        let doc = ParsedDoc::parse(src.to_string());
1381        let members = members_of_class(&doc, "Unknown");
1382        assert!(members.methods.is_empty());
1383    }
1384
1385    #[test]
1386    fn constructor_promoted_params_appear_as_properties() {
1387        let src = "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}";
1388        let doc = ParsedDoc::parse(src.to_string());
1389        let members = members_of_class(&doc, "Point");
1390        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1391        assert!(
1392            prop_names.contains(&"x"),
1393            "promoted param x should be a property"
1394        );
1395        assert!(
1396            prop_names.contains(&"y"),
1397            "promoted param y should be a property"
1398        );
1399    }
1400
1401    #[test]
1402    fn promoted_readonly_params_appear_in_readonly_properties() {
1403        let src = "<?php\nclass User {\n    public function __construct(\n        public readonly string $name,\n        public int $age,\n    ) {}\n}";
1404        let doc = ParsedDoc::parse(src.to_string());
1405        let members = members_of_class(&doc, "User");
1406        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1407        assert!(
1408            prop_names.contains(&"name"),
1409            "promoted param name should be a property"
1410        );
1411        assert!(
1412            prop_names.contains(&"age"),
1413            "promoted param age should be a property"
1414        );
1415        assert!(
1416            members.readonly_properties.contains(&"name".to_string()),
1417            "readonly promoted param name should be in readonly_properties"
1418        );
1419        assert!(
1420            !members.readonly_properties.contains(&"age".to_string()),
1421            "non-readonly promoted param age should not be in readonly_properties"
1422        );
1423    }
1424
1425    #[test]
1426    fn enum_instance_members_include_name() {
1427        let src = "<?php\nenum Status { case Active; case Inactive; }";
1428        let doc = ParsedDoc::parse(src.to_string());
1429        let members = members_of_class(&doc, "Status");
1430        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1431        assert!(
1432            prop_names.contains(&"name"),
1433            "pure enum should expose ->name"
1434        );
1435        assert!(
1436            !prop_names.contains(&"value"),
1437            "pure enum should not expose ->value"
1438        );
1439    }
1440
1441    #[test]
1442    fn backed_enum_exposes_value_and_factory_methods() {
1443        let src = "<?php\nenum Color: string { case Red = 'red'; }";
1444        let doc = ParsedDoc::parse(src.to_string());
1445        let members = members_of_class(&doc, "Color");
1446        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1447        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1448        assert!(
1449            prop_names.contains(&"value"),
1450            "backed enum should expose ->value"
1451        );
1452        assert!(
1453            method_names.contains(&"from"),
1454            "backed enum should have ::from()"
1455        );
1456        assert!(
1457            method_names.contains(&"tryFrom"),
1458            "backed enum should have ::tryFrom()"
1459        );
1460        assert!(
1461            method_names.contains(&"cases"),
1462            "enum should have ::cases()"
1463        );
1464    }
1465
1466    #[test]
1467    fn enum_cases_appear_as_constants() {
1468        let src = "<?php\nenum Status { case Active; case Inactive; }";
1469        let doc = ParsedDoc::parse(src.to_string());
1470        let members = members_of_class(&doc, "Status");
1471        assert!(members.constants.contains(&"Active".to_string()));
1472        assert!(members.constants.contains(&"Inactive".to_string()));
1473    }
1474
1475    #[test]
1476    fn trait_members_are_collected() {
1477        let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
1478        let doc = ParsedDoc::parse(src.to_string());
1479        let members = members_of_class(&doc, "Logging");
1480        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1481        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1482        assert!(
1483            method_names.contains(&"log"),
1484            "trait method log should be collected"
1485        );
1486        assert!(
1487            prop_names.contains(&"logFile"),
1488            "trait property logFile should be collected"
1489        );
1490    }
1491
1492    #[test]
1493    fn class_with_trait_use_lists_trait() {
1494        let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
1495        let doc = ParsedDoc::parse(src.to_string());
1496        let members = members_of_class(&doc, "App");
1497        assert!(
1498            members.trait_uses.contains(&"Logging".to_string()),
1499            "should list used trait"
1500        );
1501    }
1502
1503    #[test]
1504    fn var_docblock_with_explicit_varname_infers_type() {
1505        let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
1506        let doc = ParsedDoc::parse(src.to_string());
1507        let tm = TypeMap::from_doc(&doc);
1508        assert_eq!(
1509            tm.get("$mailer"),
1510            Some("Mailer"),
1511            "@var with explicit name should map the variable"
1512        );
1513    }
1514
1515    #[test]
1516    fn var_docblock_without_varname_infers_from_assignment() {
1517        let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
1518        let doc = ParsedDoc::parse(src.to_string());
1519        let tm = TypeMap::from_doc(&doc);
1520        assert_eq!(
1521            tm.get("$repo"),
1522            Some("Repository"),
1523            "@var without name should use assignment LHS"
1524        );
1525    }
1526
1527    #[test]
1528    fn var_docblock_does_not_map_primitive_types() {
1529        let src = "<?php\n/** @var string */\n$name = 'hello';";
1530        let doc = ParsedDoc::parse(src.to_string());
1531        let tm = TypeMap::from_doc(&doc);
1532        // Primitives (lowercase) should not be mapped as class names.
1533        assert!(
1534            tm.get("$name").is_none(),
1535            "primitive @var should not produce a class mapping"
1536        );
1537    }
1538
1539    #[test]
1540    fn var_nullable_docblock_maps_to_class() {
1541        // `@var ?Foo $x` is now normalised to `Foo|null` by the mir parser.
1542        // The type_map must still infer the class name `Foo`, not `Foo|null`.
1543        let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
1544        let doc = ParsedDoc::parse(src.to_string());
1545        let tm = TypeMap::from_doc(&doc);
1546        assert_eq!(
1547            tm.get("$mailer"),
1548            Some("Mailer"),
1549            "@var ?Foo should map to 'Foo', not 'Foo|null'"
1550        );
1551    }
1552
1553    #[test]
1554    fn var_union_docblock_maps_first_class() {
1555        // `@var Foo|null $x` — first class-type component should be used.
1556        let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
1557        let doc = ParsedDoc::parse(src.to_string());
1558        let tm = TypeMap::from_doc(&doc);
1559        assert_eq!(
1560            tm.get("$repo"),
1561            Some("Repository"),
1562            "@var Foo|null should map to 'Foo', not 'Foo|null'"
1563        );
1564    }
1565
1566    #[test]
1567    fn is_enum_pure() {
1568        let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
1569        let doc = ParsedDoc::parse(src.to_string());
1570        assert!(is_enum(&doc, "Suit"));
1571        assert!(!is_backed_enum(&doc, "Suit"));
1572    }
1573
1574    #[test]
1575    fn is_backed_enum_string() {
1576        let src = "<?php\nenum Status: string { case Active = 'active'; }";
1577        let doc = ParsedDoc::parse(src.to_string());
1578        assert!(is_enum(&doc, "Status"));
1579        assert!(is_backed_enum(&doc, "Status"));
1580    }
1581
1582    #[test]
1583    fn is_enum_false_for_class() {
1584        let src = "<?php\nclass Foo {}";
1585        let doc = ParsedDoc::parse(src.to_string());
1586        assert!(!is_enum(&doc, "Foo"));
1587        assert!(!is_backed_enum(&doc, "Foo"));
1588    }
1589
1590    #[test]
1591    fn array_map_with_typed_closure_populates_element_type() {
1592        let src = "<?php\n$objs = new Foo();\n$result = array_map(fn($x): Bar => $x->transform(), $objs);";
1593        let doc = ParsedDoc::parse(src.to_string());
1594        let tm = TypeMap::from_doc(&doc);
1595        assert_eq!(
1596            tm.get("$result[]"),
1597            Some("Bar"),
1598            "array_map with typed fn callback should store element type as $result[]"
1599        );
1600    }
1601
1602    #[test]
1603    fn foreach_propagates_array_map_element_type() {
1604        let src = "<?php\n$items = array_map(fn($x): Widget => $x, []);\nforeach ($items as $item) { $item-> }";
1605        let doc = ParsedDoc::parse(src.to_string());
1606        let tm = TypeMap::from_doc(&doc);
1607        assert_eq!(
1608            tm.get("$item"),
1609            Some("Widget"),
1610            "foreach over array_map result should propagate element type to loop variable"
1611        );
1612    }
1613
1614    #[test]
1615    fn closure_use_var_type_is_available_inside_body() {
1616        let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
1617        let doc = ParsedDoc::parse(src.to_string());
1618        let tm = TypeMap::from_doc(&doc);
1619        assert_eq!(
1620            tm.get("$svc"),
1621            Some("PaymentService"),
1622            "captured use variable should retain its outer type inside closure body"
1623        );
1624    }
1625
1626    #[test]
1627    fn closure_use_var_inner_assignment_does_not_override_outer_type() {
1628        // If inside a closure we assign $svc = new Other(), the outer $svc type
1629        // should be restored after walking the closure body (or_insert semantics).
1630        let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
1631        let doc = ParsedDoc::parse(src.to_string());
1632        let tm = TypeMap::from_doc(&doc);
1633        // The snapshot restore ensures $svc retains PaymentService for the outer scope.
1634        assert_eq!(
1635            tm.get("$svc"),
1636            Some("PaymentService"),
1637            "outer type should not be overwritten by inner assignment in closure"
1638        );
1639    }
1640
1641    #[test]
1642    fn closure_bind_maps_this_to_obj_class() {
1643        let src = "<?php\n$service = new Mailer();\n$fn = Closure::bind(function() {}, $service);";
1644        let doc = ParsedDoc::parse(src.to_string());
1645        let tm = TypeMap::from_doc(&doc);
1646        assert_eq!(
1647            tm.get("$this"),
1648            Some("Mailer"),
1649            "Closure::bind with typed object should map $this to that class"
1650        );
1651    }
1652
1653    #[test]
1654    fn instanceof_narrows_variable_type() {
1655        let src = "<?php\nif ($x instanceof Foo) { $x->foo(); }";
1656        let doc = ParsedDoc::parse(src.to_string());
1657        let tm = TypeMap::from_doc(&doc);
1658        assert_eq!(
1659            tm.get("$x"),
1660            Some("Foo"),
1661            "instanceof should narrow $x to Foo inside the if body"
1662        );
1663    }
1664
1665    #[test]
1666    fn instanceof_narrows_fqn_to_short_name() {
1667        let src = "<?php\nif ($x instanceof App\\Services\\Mailer) { $x->send(); }";
1668        let doc = ParsedDoc::parse(src.to_string());
1669        let tm = TypeMap::from_doc(&doc);
1670        assert_eq!(
1671            tm.get("$x"),
1672            Some("Mailer"),
1673            "instanceof with FQN should narrow to short name"
1674        );
1675    }
1676
1677    #[test]
1678    fn closure_bind_to_maps_this_to_obj_class() {
1679        let src = "<?php\n$svc = new Logger();\n$fn = function() {};\n$bound = $fn->bindTo($svc);";
1680        let doc = ParsedDoc::parse(src.to_string());
1681        let tm = TypeMap::from_doc(&doc);
1682        assert_eq!(
1683            tm.get("$this"),
1684            Some("Logger"),
1685            "bindTo() should map $this to the bound object's class"
1686        );
1687    }
1688
1689    #[test]
1690    fn param_docblock_type_inferred() {
1691        let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
1692        let doc = ParsedDoc::parse(src.to_string());
1693        let tm = TypeMap::from_doc(&doc);
1694        assert_eq!(tm.get("$mailer"), Some("Mailer"));
1695    }
1696
1697    #[test]
1698    fn param_docblock_does_not_override_ast_hint() {
1699        let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
1700        let doc = ParsedDoc::parse(src.to_string());
1701        let tm = TypeMap::from_doc(&doc);
1702        // AST type hint takes precedence over docblock (AST processed after, overwrites)
1703        assert_eq!(tm.get("$x"), Some("Foo"));
1704    }
1705
1706    #[test]
1707    fn method_chain_return_type_from_ast_hint() {
1708        let src = "<?php\nclass Repo {\n    public function findFirst(): User { }\n}\nclass User { public function getName(): string {} }\n$repo = new Repo();\n$user = $repo->findFirst();";
1709        let doc = ParsedDoc::parse(src.to_string());
1710        let tm = TypeMap::from_doc(&doc);
1711        assert_eq!(tm.get("$user"), Some("User"));
1712    }
1713
1714    #[test]
1715    fn method_chain_return_type_from_docblock() {
1716        let src = "<?php\nclass Repo {\n    /** @return Product */\n    public function latest() {}\n}\n$repo = new Repo();\n$product = $repo->latest();";
1717        let doc = ParsedDoc::parse(src.to_string());
1718        let tm = TypeMap::from_doc(&doc);
1719        assert_eq!(tm.get("$product"), Some("Product"));
1720    }
1721
1722    #[test]
1723    fn not_null_check_preserves_existing_type() {
1724        let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
1725        let doc = ParsedDoc::parse(src.to_string());
1726        let tm = TypeMap::from_doc(&doc);
1727        assert_eq!(tm.get("$x"), Some("Foo"));
1728    }
1729
1730    #[test]
1731    fn self_return_type_resolves_to_class() {
1732        let src = "<?php\nclass Builder {\n    public function setName(string $n): self { return $this; }\n}\n$b = new Builder();\n$b2 = $b->setName('x');";
1733        let doc = ParsedDoc::parse(src.to_string());
1734        let tm = TypeMap::from_doc(&doc);
1735        assert_eq!(tm.get("$b2"), Some("Builder"));
1736    }
1737
1738    #[test]
1739    fn null_coalesce_assign_infers_type() {
1740        let src = "<?php\n$obj ??= new Foo();";
1741        let doc = ParsedDoc::parse(src.to_string());
1742        let tm = TypeMap::from_doc(&doc);
1743        assert_eq!(tm.get("$obj"), Some("Foo"));
1744    }
1745
1746    #[test]
1747    fn docblock_property_appears_in_members() {
1748        let src =
1749            "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
1750        let doc = ParsedDoc::parse(src.to_string());
1751        let members = members_of_class(&doc, "User");
1752        let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1753        assert!(props.contains(&"email"));
1754        assert!(props.contains(&"id"));
1755    }
1756
1757    #[test]
1758    fn docblock_method_appears_in_members() {
1759        let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1760        let doc = ParsedDoc::parse(src.to_string());
1761        let members = members_of_class(&doc, "Model");
1762        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1763        assert!(method_names.contains(&"find"));
1764        assert!(method_names.contains(&"where"));
1765        let where_static = members
1766            .methods
1767            .iter()
1768            .find(|(n, _)| n == "where")
1769            .map(|(_, s)| *s);
1770        assert_eq!(where_static, Some(true));
1771    }
1772
1773    #[test]
1774    fn union_type_param_maps_both_classes() {
1775        // function f(Foo|Bar $x) — both Foo and Bar should be in the union type string
1776        let src = "<?php\nfunction f(Foo|Bar $x) {}";
1777        let doc = ParsedDoc::parse(src.to_string());
1778        let tm = TypeMap::from_doc(&doc);
1779        let val = tm.get("$x").expect("$x should be in the type map");
1780        assert!(
1781            val.contains("Foo"),
1782            "union type should contain 'Foo', got: {}",
1783            val
1784        );
1785        assert!(
1786            val.contains("Bar"),
1787            "union type should contain 'Bar', got: {}",
1788            val
1789        );
1790    }
1791
1792    #[test]
1793    fn nullable_param_resolves_to_class() {
1794        // function f(?Foo $x) — $x should map to Foo (nullable stripped)
1795        let src = "<?php\nfunction f(?Foo $x) {}";
1796        let doc = ParsedDoc::parse(src.to_string());
1797        let tm = TypeMap::from_doc(&doc);
1798        assert_eq!(
1799            tm.get("$x"),
1800            Some("Foo"),
1801            "nullable type hint ?Foo should map $x to Foo"
1802        );
1803    }
1804
1805    #[test]
1806    fn static_return_type_resolves_to_class() {
1807        // A method returning `: static` inside `class Builder` — result should map to `Builder`
1808        let src = concat!(
1809            "<?php\n",
1810            "class Builder {\n",
1811            "    public function build(): static { return $this; }\n",
1812            "}\n",
1813            "$b = new Builder();\n",
1814            "$b2 = $b->build();\n",
1815        );
1816        let doc = ParsedDoc::parse(src.to_string());
1817        let tm = TypeMap::from_doc(&doc);
1818        assert_eq!(
1819            tm.get("$b2"),
1820            Some("Builder"),
1821            "method returning :static should resolve to the enclosing class 'Builder'"
1822        );
1823    }
1824
1825    #[test]
1826    fn null_assignment_does_not_overwrite_class() {
1827        // $x = new Foo(); $x = null; — $x type should stay Foo because
1828        // assigning null does not overwrite a known class type in the single-pass map.
1829        let src = "<?php\n$x = new Foo();\n$x = null;\n";
1830        let doc = ParsedDoc::parse(src.to_string());
1831        let tm = TypeMap::from_doc(&doc);
1832        // The single-pass type map does not treat null as a class, so the last
1833        // successful class assignment (Foo) persists.
1834        assert_eq!(
1835            tm.get("$x"),
1836            Some("Foo"),
1837            "$x should retain its Foo type after being assigned null"
1838        );
1839    }
1840
1841    #[test]
1842    fn infers_type_from_assignment_inside_trait_method() {
1843        let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
1844        let doc = ParsedDoc::parse(src.to_string());
1845        let tm = TypeMap::from_doc(&doc);
1846        assert_eq!(
1847            tm.get("$obj"),
1848            Some("Widget"),
1849            "type map should walk into trait method bodies"
1850        );
1851    }
1852
1853    #[test]
1854    fn infers_type_from_assignment_inside_enum_method() {
1855        let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
1856        let doc = ParsedDoc::parse(src.to_string());
1857        let tm = TypeMap::from_doc(&doc);
1858        assert_eq!(
1859            tm.get("$obj"),
1860            Some("Palette"),
1861            "type map should walk into enum method bodies"
1862        );
1863    }
1864}