Skip to main content

php_lsp/
type_map.rs

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