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