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);
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);
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) if c.name == Some(class_name) => {
845                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
846            }
847            StmtKind::Namespace(ns) => {
848                if let NamespaceBody::Braced(inner) = &ns.body
849                    && let found @ Some(_) = parent_in_stmts(inner, class_name)
850                {
851                    return found;
852                }
853            }
854            _ => {}
855        }
856    }
857    None
858}
859
860/// All members of a named class split by kind and static-ness.
861#[derive(Debug, Default)]
862pub struct ClassMembers {
863    /// (name, is_static)
864    pub methods: Vec<(String, bool)>,
865    /// (name, is_static)
866    pub properties: Vec<(String, bool)>,
867    /// Names of readonly properties (PHP 8.1+).
868    pub readonly_properties: Vec<String>,
869    pub constants: Vec<String>,
870    /// Direct parent class name, if any.
871    pub parent: Option<String>,
872    /// Trait names used by this class (`use Foo, Bar;`).
873    pub trait_uses: Vec<String>,
874}
875
876/// Return all members (methods, properties, constants) of `class_name`.
877/// Also returns the direct parent class name via `ClassMembers::parent`.
878pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
879    let mut out = ClassMembers::default();
880    out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
881    out
882}
883
884fn collect_members_stmts(
885    source: &str,
886    stmts: &[Stmt<'_, '_>],
887    class_name: &str,
888    out: &mut ClassMembers,
889) -> Option<String> {
890    for stmt in stmts {
891        match &stmt.kind {
892            StmtKind::Class(c) if c.name == Some(class_name) => {
893                // Check docblock for @property and @method tags
894                if let Some(raw) = docblock_before(source, stmt.span.start) {
895                    let db = parse_docblock(&raw);
896                    for prop in &db.properties {
897                        out.properties.push((prop.name.clone(), false));
898                    }
899                    for method in &db.methods {
900                        out.methods.push((method.name.clone(), method.is_static));
901                    }
902                }
903                for member in c.members.iter() {
904                    match &member.kind {
905                        ClassMemberKind::Method(m) => {
906                            out.methods.push((m.name.to_string(), m.is_static));
907                            // Constructor-promoted params become instance properties.
908                            if m.name == "__construct" {
909                                for p in m.params.iter() {
910                                    if p.visibility.is_some() {
911                                        out.properties.push((p.name.to_string(), false));
912                                        // Detect `readonly` in the source text before the
913                                        // param name (the AST does not expose this flag on
914                                        // Param, so we scan the raw text of the param span).
915                                        let param_src =
916                                            &source[p.span.start as usize..p.span.end as usize];
917                                        if param_src.contains("readonly") {
918                                            out.readonly_properties.push(p.name.to_string());
919                                        }
920                                    }
921                                }
922                            }
923                        }
924                        ClassMemberKind::Property(p) => {
925                            out.properties.push((p.name.to_string(), p.is_static));
926                            if p.is_readonly {
927                                out.readonly_properties.push(p.name.to_string());
928                            }
929                        }
930                        ClassMemberKind::ClassConst(c) => {
931                            out.constants.push(c.name.to_string());
932                        }
933                        ClassMemberKind::TraitUse(t) => {
934                            for name in t.traits.iter() {
935                                out.trait_uses.push(name.to_string_repr().to_string());
936                            }
937                        }
938                    }
939                }
940                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
941            }
942            StmtKind::Enum(e) if e.name == class_name => {
943                let is_backed = e.scalar_type.is_some();
944                // Every enum instance exposes `->name`; backed enums also expose `->value`.
945                out.properties.push(("name".to_string(), false));
946                if is_backed {
947                    out.properties.push(("value".to_string(), false));
948                }
949                // Built-in static methods present on every enum.
950                out.methods.push(("cases".to_string(), true));
951                if is_backed {
952                    out.methods.push(("from".to_string(), true));
953                    out.methods.push(("tryFrom".to_string(), true));
954                }
955                // User-declared cases, methods, and constants.
956                for member in e.members.iter() {
957                    match &member.kind {
958                        EnumMemberKind::Case(c) => {
959                            out.constants.push(c.name.to_string());
960                        }
961                        EnumMemberKind::Method(m) => {
962                            out.methods.push((m.name.to_string(), m.is_static));
963                        }
964                        EnumMemberKind::ClassConst(c) => {
965                            out.constants.push(c.name.to_string());
966                        }
967                        _ => {}
968                    }
969                }
970                return None; // enums have no parent class
971            }
972            StmtKind::Trait(t) if t.name == class_name => {
973                for member in t.members.iter() {
974                    match &member.kind {
975                        ClassMemberKind::Method(m) => {
976                            out.methods.push((m.name.to_string(), m.is_static));
977                        }
978                        ClassMemberKind::Property(p) => {
979                            out.properties.push((p.name.to_string(), p.is_static));
980                        }
981                        ClassMemberKind::ClassConst(c) => {
982                            out.constants.push(c.name.to_string());
983                        }
984                        ClassMemberKind::TraitUse(t) => {
985                            for name in t.traits.iter() {
986                                out.trait_uses.push(name.to_string_repr().to_string());
987                            }
988                        }
989                    }
990                }
991                return None; // traits have no parent
992            }
993            StmtKind::Namespace(ns) => {
994                if let NamespaceBody::Braced(inner) = &ns.body {
995                    let result = collect_members_stmts(source, inner, class_name, out);
996                    if result.is_some() {
997                        return result;
998                    }
999                }
1000            }
1001            _ => {}
1002        }
1003    }
1004    None
1005}
1006
1007/// Return the `@mixin` class names declared in `class_name`'s docblock.
1008pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
1009    let source = doc.source();
1010    mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
1011}
1012
1013fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
1014    for stmt in stmts {
1015        match &stmt.kind {
1016            StmtKind::Class(c) if c.name == Some(class_name) => {
1017                if let Some(raw) = docblock_before(source, stmt.span.start) {
1018                    return parse_docblock(&raw).mixins;
1019                }
1020                return vec![];
1021            }
1022            StmtKind::Namespace(ns) => {
1023                if let NamespaceBody::Braced(inner) = &ns.body {
1024                    let found = mixin_classes_in_stmts(source, inner, class_name);
1025                    if !found.is_empty() {
1026                        return found;
1027                    }
1028                }
1029            }
1030            _ => {}
1031        }
1032    }
1033    vec![]
1034}
1035
1036/// Return the name of the class whose body contains `position`, or `None`.
1037pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
1038    let sv = doc.view();
1039    enclosing_class_in_stmts(sv, &doc.program().stmts, position)
1040}
1041
1042fn enclosing_class_in_stmts(
1043    sv: SourceView<'_>,
1044    stmts: &[Stmt<'_, '_>],
1045    pos: Position,
1046) -> Option<String> {
1047    for stmt in stmts {
1048        match &stmt.kind {
1049            StmtKind::Class(c) => {
1050                let start = sv.position_of(stmt.span.start).line;
1051                let end = sv.position_of(stmt.span.end).line;
1052                if pos.line >= start && pos.line <= end {
1053                    return c.name.map(|n| n.to_string());
1054                }
1055            }
1056            StmtKind::Interface(i) => {
1057                let start = sv.position_of(stmt.span.start).line;
1058                let end = sv.position_of(stmt.span.end).line;
1059                if pos.line >= start && pos.line <= end {
1060                    return Some(i.name.to_string());
1061                }
1062            }
1063            StmtKind::Trait(t) => {
1064                let start = sv.position_of(stmt.span.start).line;
1065                let end = sv.position_of(stmt.span.end).line;
1066                if pos.line >= start && pos.line <= end {
1067                    return Some(t.name.to_string());
1068                }
1069            }
1070            StmtKind::Enum(e) => {
1071                let start = sv.position_of(stmt.span.start).line;
1072                let end = sv.position_of(stmt.span.end).line;
1073                if pos.line >= start && pos.line <= end {
1074                    return Some(e.name.to_string());
1075                }
1076            }
1077            StmtKind::Namespace(ns) => {
1078                if let NamespaceBody::Braced(inner) = &ns.body
1079                    && let Some(found) = enclosing_class_in_stmts(sv, inner, pos)
1080                {
1081                    return Some(found);
1082                }
1083            }
1084            _ => {}
1085        }
1086    }
1087    None
1088}
1089
1090/// Return the parameter names of the function or method named `func_name`.
1091pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
1092    let mut out = Vec::new();
1093    collect_params_stmts(&doc.program().stmts, func_name, &mut out);
1094    out
1095}
1096
1097/// Return the parameter names of `method_name` on class `class_name`.
1098/// Primarily used to offer named-argument completions for attribute constructors.
1099pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
1100    let mut out = Vec::new();
1101    collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
1102    out
1103}
1104
1105fn collect_method_params_stmts(
1106    stmts: &[php_ast::Stmt<'_, '_>],
1107    class_name: &str,
1108    method_name: &str,
1109    out: &mut Vec<String>,
1110) {
1111    for stmt in stmts {
1112        match &stmt.kind {
1113            StmtKind::Class(c) if c.name == Some(class_name) => {
1114                for member in c.members.iter() {
1115                    if let ClassMemberKind::Method(m) = &member.kind
1116                        && m.name == method_name
1117                    {
1118                        for p in m.params.iter() {
1119                            out.push(p.name.to_string());
1120                        }
1121                        return;
1122                    }
1123                }
1124            }
1125            StmtKind::Namespace(ns) => {
1126                if let NamespaceBody::Braced(inner) = &ns.body {
1127                    collect_method_params_stmts(inner, class_name, method_name, out);
1128                }
1129            }
1130            _ => {}
1131        }
1132    }
1133}
1134
1135/// Returns `true` if `class_name` is declared as an `enum` in `doc`.
1136pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1137    is_enum_in_stmts(&doc.program().stmts, class_name)
1138}
1139
1140fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1141    for stmt in stmts {
1142        match &stmt.kind {
1143            StmtKind::Enum(e) if e.name == name => return true,
1144            StmtKind::Namespace(ns) => {
1145                if let NamespaceBody::Braced(inner) = &ns.body
1146                    && is_enum_in_stmts(inner, name)
1147                {
1148                    return true;
1149                }
1150            }
1151            _ => {}
1152        }
1153    }
1154    false
1155}
1156
1157/// Returns `true` if `class_name` is a *backed* enum (`enum Foo: string` /
1158/// `enum Foo: int`) in `doc`.  Backed enums have a `->value` property.
1159pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
1160    is_backed_enum_in_stmts(&doc.program().stmts, class_name)
1161}
1162
1163fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
1164    for stmt in stmts {
1165        match &stmt.kind {
1166            StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
1167            StmtKind::Namespace(ns) => {
1168                if let NamespaceBody::Braced(inner) = &ns.body
1169                    && is_backed_enum_in_stmts(inner, name)
1170                {
1171                    return true;
1172                }
1173            }
1174            _ => {}
1175        }
1176    }
1177    false
1178}
1179
1180fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
1181    for stmt in stmts {
1182        match &stmt.kind {
1183            StmtKind::Function(f) if f.name == func_name => {
1184                for p in f.params.iter() {
1185                    out.push(p.name.to_string());
1186                }
1187                return;
1188            }
1189            StmtKind::Class(c) => {
1190                for member in c.members.iter() {
1191                    if let ClassMemberKind::Method(m) = &member.kind
1192                        && m.name == func_name
1193                    {
1194                        for p in m.params.iter() {
1195                            out.push(p.name.to_string());
1196                        }
1197                        return;
1198                    }
1199                }
1200            }
1201            StmtKind::Namespace(ns) => {
1202                if let NamespaceBody::Braced(inner) = &ns.body {
1203                    collect_params_stmts(inner, func_name, out);
1204                }
1205            }
1206            _ => {}
1207        }
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214
1215    #[test]
1216    fn infers_type_from_new_expression() {
1217        let src = "<?php\n$obj = new Foo();";
1218        let doc = ParsedDoc::parse(src.to_string());
1219        let tm = TypeMap::from_doc(&doc);
1220        assert_eq!(tm.get("$obj"), Some("Foo"));
1221    }
1222
1223    #[test]
1224    fn unknown_variable_returns_none() {
1225        let src = "<?php\n$obj = new Foo();";
1226        let doc = ParsedDoc::parse(src.to_string());
1227        let tm = TypeMap::from_doc(&doc);
1228        assert!(tm.get("$other").is_none());
1229    }
1230
1231    #[test]
1232    fn multiple_assignments() {
1233        let src = "<?php\n$a = new Foo();\n$b = new Bar();";
1234        let doc = ParsedDoc::parse(src.to_string());
1235        let tm = TypeMap::from_doc(&doc);
1236        assert_eq!(tm.get("$a"), Some("Foo"));
1237        assert_eq!(tm.get("$b"), Some("Bar"));
1238    }
1239
1240    #[test]
1241    fn later_assignment_overwrites() {
1242        let src = "<?php\n$a = new Foo();\n$a = new Bar();";
1243        let doc = ParsedDoc::parse(src.to_string());
1244        let tm = TypeMap::from_doc(&doc);
1245        assert_eq!(tm.get("$a"), Some("Bar"));
1246    }
1247
1248    #[test]
1249    fn infers_type_from_typed_param() {
1250        let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
1251        let doc = ParsedDoc::parse(src.to_string());
1252        let tm = TypeMap::from_doc(&doc);
1253        assert_eq!(tm.get("$mailer"), Some("Mailer"));
1254    }
1255
1256    #[test]
1257    fn parent_class_name_finds_parent() {
1258        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1259        let doc = ParsedDoc::parse(src.to_string());
1260        assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
1261    }
1262
1263    #[test]
1264    fn parent_class_name_returns_none_for_top_level() {
1265        let src = "<?php\nclass Base {}";
1266        let doc = ParsedDoc::parse(src.to_string());
1267        assert!(parent_class_name(&doc, "Base").is_none());
1268    }
1269
1270    #[test]
1271    fn members_of_class_includes_parent_field() {
1272        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1273        let doc = ParsedDoc::parse(src.to_string());
1274        let m = members_of_class(&doc, "Child");
1275        assert_eq!(m.parent.as_deref(), Some("Base"));
1276    }
1277
1278    #[test]
1279    fn members_of_class_finds_methods() {
1280        let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
1281        let doc = ParsedDoc::parse(src.to_string());
1282        let members = members_of_class(&doc, "Calc");
1283        let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1284        assert!(names.contains(&"add"), "missing 'add'");
1285        assert!(names.contains(&"sub"), "missing 'sub'");
1286    }
1287
1288    #[test]
1289    fn members_of_unknown_class_is_empty() {
1290        let src = "<?php\nclass Calc { public function add() {} }";
1291        let doc = ParsedDoc::parse(src.to_string());
1292        let members = members_of_class(&doc, "Unknown");
1293        assert!(members.methods.is_empty());
1294    }
1295
1296    #[test]
1297    fn constructor_promoted_params_appear_as_properties() {
1298        let src = "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}";
1299        let doc = ParsedDoc::parse(src.to_string());
1300        let members = members_of_class(&doc, "Point");
1301        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1302        assert!(
1303            prop_names.contains(&"x"),
1304            "promoted param x should be a property"
1305        );
1306        assert!(
1307            prop_names.contains(&"y"),
1308            "promoted param y should be a property"
1309        );
1310    }
1311
1312    #[test]
1313    fn promoted_readonly_params_appear_in_readonly_properties() {
1314        let src = "<?php\nclass User {\n    public function __construct(\n        public readonly string $name,\n        public int $age,\n    ) {}\n}";
1315        let doc = ParsedDoc::parse(src.to_string());
1316        let members = members_of_class(&doc, "User");
1317        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1318        assert!(
1319            prop_names.contains(&"name"),
1320            "promoted param name should be a property"
1321        );
1322        assert!(
1323            prop_names.contains(&"age"),
1324            "promoted param age should be a property"
1325        );
1326        assert!(
1327            members.readonly_properties.contains(&"name".to_string()),
1328            "readonly promoted param name should be in readonly_properties"
1329        );
1330        assert!(
1331            !members.readonly_properties.contains(&"age".to_string()),
1332            "non-readonly promoted param age should not be in readonly_properties"
1333        );
1334    }
1335
1336    #[test]
1337    fn enum_instance_members_include_name() {
1338        let src = "<?php\nenum Status { case Active; case Inactive; }";
1339        let doc = ParsedDoc::parse(src.to_string());
1340        let members = members_of_class(&doc, "Status");
1341        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1342        assert!(
1343            prop_names.contains(&"name"),
1344            "pure enum should expose ->name"
1345        );
1346        assert!(
1347            !prop_names.contains(&"value"),
1348            "pure enum should not expose ->value"
1349        );
1350    }
1351
1352    #[test]
1353    fn backed_enum_exposes_value_and_factory_methods() {
1354        let src = "<?php\nenum Color: string { case Red = 'red'; }";
1355        let doc = ParsedDoc::parse(src.to_string());
1356        let members = members_of_class(&doc, "Color");
1357        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1358        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1359        assert!(
1360            prop_names.contains(&"value"),
1361            "backed enum should expose ->value"
1362        );
1363        assert!(
1364            method_names.contains(&"from"),
1365            "backed enum should have ::from()"
1366        );
1367        assert!(
1368            method_names.contains(&"tryFrom"),
1369            "backed enum should have ::tryFrom()"
1370        );
1371        assert!(
1372            method_names.contains(&"cases"),
1373            "enum should have ::cases()"
1374        );
1375    }
1376
1377    #[test]
1378    fn enum_cases_appear_as_constants() {
1379        let src = "<?php\nenum Status { case Active; case Inactive; }";
1380        let doc = ParsedDoc::parse(src.to_string());
1381        let members = members_of_class(&doc, "Status");
1382        assert!(members.constants.contains(&"Active".to_string()));
1383        assert!(members.constants.contains(&"Inactive".to_string()));
1384    }
1385
1386    #[test]
1387    fn trait_members_are_collected() {
1388        let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
1389        let doc = ParsedDoc::parse(src.to_string());
1390        let members = members_of_class(&doc, "Logging");
1391        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1392        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1393        assert!(
1394            method_names.contains(&"log"),
1395            "trait method log should be collected"
1396        );
1397        assert!(
1398            prop_names.contains(&"logFile"),
1399            "trait property logFile should be collected"
1400        );
1401    }
1402
1403    #[test]
1404    fn class_with_trait_use_lists_trait() {
1405        let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
1406        let doc = ParsedDoc::parse(src.to_string());
1407        let members = members_of_class(&doc, "App");
1408        assert!(
1409            members.trait_uses.contains(&"Logging".to_string()),
1410            "should list used trait"
1411        );
1412    }
1413
1414    #[test]
1415    fn var_docblock_with_explicit_varname_infers_type() {
1416        let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
1417        let doc = ParsedDoc::parse(src.to_string());
1418        let tm = TypeMap::from_doc(&doc);
1419        assert_eq!(
1420            tm.get("$mailer"),
1421            Some("Mailer"),
1422            "@var with explicit name should map the variable"
1423        );
1424    }
1425
1426    #[test]
1427    fn var_docblock_without_varname_infers_from_assignment() {
1428        let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
1429        let doc = ParsedDoc::parse(src.to_string());
1430        let tm = TypeMap::from_doc(&doc);
1431        assert_eq!(
1432            tm.get("$repo"),
1433            Some("Repository"),
1434            "@var without name should use assignment LHS"
1435        );
1436    }
1437
1438    #[test]
1439    fn var_docblock_does_not_map_primitive_types() {
1440        let src = "<?php\n/** @var string */\n$name = 'hello';";
1441        let doc = ParsedDoc::parse(src.to_string());
1442        let tm = TypeMap::from_doc(&doc);
1443        // Primitives (lowercase) should not be mapped as class names.
1444        assert!(
1445            tm.get("$name").is_none(),
1446            "primitive @var should not produce a class mapping"
1447        );
1448    }
1449
1450    #[test]
1451    fn var_nullable_docblock_maps_to_class() {
1452        // `@var ?Foo $x` is now normalised to `Foo|null` by the mir parser.
1453        // The type_map must still infer the class name `Foo`, not `Foo|null`.
1454        let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
1455        let doc = ParsedDoc::parse(src.to_string());
1456        let tm = TypeMap::from_doc(&doc);
1457        assert_eq!(
1458            tm.get("$mailer"),
1459            Some("Mailer"),
1460            "@var ?Foo should map to 'Foo', not 'Foo|null'"
1461        );
1462    }
1463
1464    #[test]
1465    fn var_union_docblock_maps_first_class() {
1466        // `@var Foo|null $x` — first class-type component should be used.
1467        let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
1468        let doc = ParsedDoc::parse(src.to_string());
1469        let tm = TypeMap::from_doc(&doc);
1470        assert_eq!(
1471            tm.get("$repo"),
1472            Some("Repository"),
1473            "@var Foo|null should map to 'Foo', not 'Foo|null'"
1474        );
1475    }
1476
1477    #[test]
1478    fn is_enum_pure() {
1479        let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
1480        let doc = ParsedDoc::parse(src.to_string());
1481        assert!(is_enum(&doc, "Suit"));
1482        assert!(!is_backed_enum(&doc, "Suit"));
1483    }
1484
1485    #[test]
1486    fn is_backed_enum_string() {
1487        let src = "<?php\nenum Status: string { case Active = 'active'; }";
1488        let doc = ParsedDoc::parse(src.to_string());
1489        assert!(is_enum(&doc, "Status"));
1490        assert!(is_backed_enum(&doc, "Status"));
1491    }
1492
1493    #[test]
1494    fn is_enum_false_for_class() {
1495        let src = "<?php\nclass Foo {}";
1496        let doc = ParsedDoc::parse(src.to_string());
1497        assert!(!is_enum(&doc, "Foo"));
1498        assert!(!is_backed_enum(&doc, "Foo"));
1499    }
1500
1501    #[test]
1502    fn array_map_with_typed_closure_populates_element_type() {
1503        let src = "<?php\n$objs = new Foo();\n$result = array_map(fn($x): Bar => $x->transform(), $objs);";
1504        let doc = ParsedDoc::parse(src.to_string());
1505        let tm = TypeMap::from_doc(&doc);
1506        assert_eq!(
1507            tm.get("$result[]"),
1508            Some("Bar"),
1509            "array_map with typed fn callback should store element type as $result[]"
1510        );
1511    }
1512
1513    #[test]
1514    fn foreach_propagates_array_map_element_type() {
1515        let src = "<?php\n$items = array_map(fn($x): Widget => $x, []);\nforeach ($items as $item) { $item-> }";
1516        let doc = ParsedDoc::parse(src.to_string());
1517        let tm = TypeMap::from_doc(&doc);
1518        assert_eq!(
1519            tm.get("$item"),
1520            Some("Widget"),
1521            "foreach over array_map result should propagate element type to loop variable"
1522        );
1523    }
1524
1525    #[test]
1526    fn closure_use_var_type_is_available_inside_body() {
1527        let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
1528        let doc = ParsedDoc::parse(src.to_string());
1529        let tm = TypeMap::from_doc(&doc);
1530        assert_eq!(
1531            tm.get("$svc"),
1532            Some("PaymentService"),
1533            "captured use variable should retain its outer type inside closure body"
1534        );
1535    }
1536
1537    #[test]
1538    fn closure_use_var_inner_assignment_does_not_override_outer_type() {
1539        // If inside a closure we assign $svc = new Other(), the outer $svc type
1540        // should be restored after walking the closure body (or_insert semantics).
1541        let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
1542        let doc = ParsedDoc::parse(src.to_string());
1543        let tm = TypeMap::from_doc(&doc);
1544        // The snapshot restore ensures $svc retains PaymentService for the outer scope.
1545        assert_eq!(
1546            tm.get("$svc"),
1547            Some("PaymentService"),
1548            "outer type should not be overwritten by inner assignment in closure"
1549        );
1550    }
1551
1552    #[test]
1553    fn closure_bind_maps_this_to_obj_class() {
1554        let src = "<?php\n$service = new Mailer();\n$fn = Closure::bind(function() {}, $service);";
1555        let doc = ParsedDoc::parse(src.to_string());
1556        let tm = TypeMap::from_doc(&doc);
1557        assert_eq!(
1558            tm.get("$this"),
1559            Some("Mailer"),
1560            "Closure::bind with typed object should map $this to that class"
1561        );
1562    }
1563
1564    #[test]
1565    fn instanceof_narrows_variable_type() {
1566        let src = "<?php\nif ($x instanceof Foo) { $x->foo(); }";
1567        let doc = ParsedDoc::parse(src.to_string());
1568        let tm = TypeMap::from_doc(&doc);
1569        assert_eq!(
1570            tm.get("$x"),
1571            Some("Foo"),
1572            "instanceof should narrow $x to Foo inside the if body"
1573        );
1574    }
1575
1576    #[test]
1577    fn instanceof_narrows_fqn_to_short_name() {
1578        let src = "<?php\nif ($x instanceof App\\Services\\Mailer) { $x->send(); }";
1579        let doc = ParsedDoc::parse(src.to_string());
1580        let tm = TypeMap::from_doc(&doc);
1581        assert_eq!(
1582            tm.get("$x"),
1583            Some("Mailer"),
1584            "instanceof with FQN should narrow to short name"
1585        );
1586    }
1587
1588    #[test]
1589    fn closure_bind_to_maps_this_to_obj_class() {
1590        let src = "<?php\n$svc = new Logger();\n$fn = function() {};\n$bound = $fn->bindTo($svc);";
1591        let doc = ParsedDoc::parse(src.to_string());
1592        let tm = TypeMap::from_doc(&doc);
1593        assert_eq!(
1594            tm.get("$this"),
1595            Some("Logger"),
1596            "bindTo() should map $this to the bound object's class"
1597        );
1598    }
1599
1600    #[test]
1601    fn param_docblock_type_inferred() {
1602        let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
1603        let doc = ParsedDoc::parse(src.to_string());
1604        let tm = TypeMap::from_doc(&doc);
1605        assert_eq!(tm.get("$mailer"), Some("Mailer"));
1606    }
1607
1608    #[test]
1609    fn param_docblock_does_not_override_ast_hint() {
1610        let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
1611        let doc = ParsedDoc::parse(src.to_string());
1612        let tm = TypeMap::from_doc(&doc);
1613        // AST type hint takes precedence over docblock (AST processed after, overwrites)
1614        assert_eq!(tm.get("$x"), Some("Foo"));
1615    }
1616
1617    #[test]
1618    fn method_chain_return_type_from_ast_hint() {
1619        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();";
1620        let doc = ParsedDoc::parse(src.to_string());
1621        let tm = TypeMap::from_doc(&doc);
1622        assert_eq!(tm.get("$user"), Some("User"));
1623    }
1624
1625    #[test]
1626    fn method_chain_return_type_from_docblock() {
1627        let src = "<?php\nclass Repo {\n    /** @return Product */\n    public function latest() {}\n}\n$repo = new Repo();\n$product = $repo->latest();";
1628        let doc = ParsedDoc::parse(src.to_string());
1629        let tm = TypeMap::from_doc(&doc);
1630        assert_eq!(tm.get("$product"), Some("Product"));
1631    }
1632
1633    #[test]
1634    fn not_null_check_preserves_existing_type() {
1635        let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
1636        let doc = ParsedDoc::parse(src.to_string());
1637        let tm = TypeMap::from_doc(&doc);
1638        assert_eq!(tm.get("$x"), Some("Foo"));
1639    }
1640
1641    #[test]
1642    fn self_return_type_resolves_to_class() {
1643        let src = "<?php\nclass Builder {\n    public function setName(string $n): self { return $this; }\n}\n$b = new Builder();\n$b2 = $b->setName('x');";
1644        let doc = ParsedDoc::parse(src.to_string());
1645        let tm = TypeMap::from_doc(&doc);
1646        assert_eq!(tm.get("$b2"), Some("Builder"));
1647    }
1648
1649    #[test]
1650    fn null_coalesce_assign_infers_type() {
1651        let src = "<?php\n$obj ??= new Foo();";
1652        let doc = ParsedDoc::parse(src.to_string());
1653        let tm = TypeMap::from_doc(&doc);
1654        assert_eq!(tm.get("$obj"), Some("Foo"));
1655    }
1656
1657    #[test]
1658    fn docblock_property_appears_in_members() {
1659        let src =
1660            "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
1661        let doc = ParsedDoc::parse(src.to_string());
1662        let members = members_of_class(&doc, "User");
1663        let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1664        assert!(props.contains(&"email"));
1665        assert!(props.contains(&"id"));
1666    }
1667
1668    #[test]
1669    fn docblock_method_appears_in_members() {
1670        let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1671        let doc = ParsedDoc::parse(src.to_string());
1672        let members = members_of_class(&doc, "Model");
1673        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1674        assert!(method_names.contains(&"find"));
1675        assert!(method_names.contains(&"where"));
1676        let where_static = members
1677            .methods
1678            .iter()
1679            .find(|(n, _)| n == "where")
1680            .map(|(_, s)| *s);
1681        assert_eq!(where_static, Some(true));
1682    }
1683
1684    #[test]
1685    fn union_type_param_maps_both_classes() {
1686        // function f(Foo|Bar $x) — both Foo and Bar should be in the union type string
1687        let src = "<?php\nfunction f(Foo|Bar $x) {}";
1688        let doc = ParsedDoc::parse(src.to_string());
1689        let tm = TypeMap::from_doc(&doc);
1690        let val = tm.get("$x").expect("$x should be in the type map");
1691        assert!(
1692            val.contains("Foo"),
1693            "union type should contain 'Foo', got: {}",
1694            val
1695        );
1696        assert!(
1697            val.contains("Bar"),
1698            "union type should contain 'Bar', got: {}",
1699            val
1700        );
1701    }
1702
1703    #[test]
1704    fn nullable_param_resolves_to_class() {
1705        // function f(?Foo $x) — $x should map to Foo (nullable stripped)
1706        let src = "<?php\nfunction f(?Foo $x) {}";
1707        let doc = ParsedDoc::parse(src.to_string());
1708        let tm = TypeMap::from_doc(&doc);
1709        assert_eq!(
1710            tm.get("$x"),
1711            Some("Foo"),
1712            "nullable type hint ?Foo should map $x to Foo"
1713        );
1714    }
1715
1716    #[test]
1717    fn static_return_type_resolves_to_class() {
1718        // A method returning `: static` inside `class Builder` — result should map to `Builder`
1719        let src = concat!(
1720            "<?php\n",
1721            "class Builder {\n",
1722            "    public function build(): static { return $this; }\n",
1723            "}\n",
1724            "$b = new Builder();\n",
1725            "$b2 = $b->build();\n",
1726        );
1727        let doc = ParsedDoc::parse(src.to_string());
1728        let tm = TypeMap::from_doc(&doc);
1729        assert_eq!(
1730            tm.get("$b2"),
1731            Some("Builder"),
1732            "method returning :static should resolve to the enclosing class 'Builder'"
1733        );
1734    }
1735
1736    #[test]
1737    fn null_assignment_does_not_overwrite_class() {
1738        // $x = new Foo(); $x = null; — $x type should stay Foo because
1739        // assigning null does not overwrite a known class type in the single-pass map.
1740        let src = "<?php\n$x = new Foo();\n$x = null;\n";
1741        let doc = ParsedDoc::parse(src.to_string());
1742        let tm = TypeMap::from_doc(&doc);
1743        // The single-pass type map does not treat null as a class, so the last
1744        // successful class assignment (Foo) persists.
1745        assert_eq!(
1746            tm.get("$x"),
1747            Some("Foo"),
1748            "$x should retain its Foo type after being assigned null"
1749        );
1750    }
1751
1752    #[test]
1753    fn infers_type_from_assignment_inside_trait_method() {
1754        let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
1755        let doc = ParsedDoc::parse(src.to_string());
1756        let tm = TypeMap::from_doc(&doc);
1757        assert_eq!(
1758            tm.get("$obj"),
1759            Some("Widget"),
1760            "type map should walk into trait method bodies"
1761        );
1762    }
1763
1764    #[test]
1765    fn infers_type_from_assignment_inside_enum_method() {
1766        let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
1767        let doc = ParsedDoc::parse(src.to_string());
1768        let tm = TypeMap::from_doc(&doc);
1769        assert_eq!(
1770            tm.get("$obj"),
1771            Some("Palette"),
1772            "type map should walk into enum method bodies"
1773        );
1774    }
1775}