Skip to main content

php_lsp/
type_map.rs

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