Skip to main content

php_lsp/types/
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    ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind, TypeHint,
8    TypeHintKind,
9};
10use tower_lsp::lsp_types::Position;
11
12use crate::document::ast::{ParsedDoc, SourceView};
13use crate::lang::docblock::{docblock_before, parse_docblock};
14use crate::lang::phpstorm_meta::PhpStormMeta;
15use crate::text::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::text::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 let (Some(doc), Some(enc_class)) = (doc, enclosing_class) {
110                    if let Some(parent) = parent_class_name(doc, enc_class) {
111                        let short = fqn_short_name(&parent);
112                        Some(short.to_string())
113                    } else {
114                        let short = fqn_short_name(fqcn);
115                        Some(short.to_string())
116                    }
117                } else {
118                    let short = fqn_short_name(fqcn);
119                    Some(short.to_string())
120                }
121            }
122            Atomic::TIntersection { parts } => {
123                let intersection_classes: Vec<String> = parts
124                    .iter()
125                    .flat_map(|part| {
126                        part.types.iter().filter_map(|a| match a {
127                            Atomic::TNamedObject { fqcn, .. }
128                            | Atomic::TSelf { fqcn }
129                            | Atomic::TStaticObject { fqcn } => {
130                                let short = fqn_short_name(fqcn);
131                                Some(short.to_string())
132                            }
133                            Atomic::TParent { fqcn } => {
134                                if let (Some(doc), Some(enc_class)) = (doc, enclosing_class) {
135                                    if let Some(parent) = parent_class_name(doc, enc_class) {
136                                        let short = fqn_short_name(&parent);
137                                        Some(short.to_string())
138                                    } else {
139                                        let short =
140                                            fqcn.rsplit('\\').next().unwrap_or(fqcn.as_ref());
141                                        Some(short.to_string())
142                                    }
143                                } else {
144                                    let short = fqn_short_name(fqcn);
145                                    Some(short.to_string())
146                                }
147                            }
148                            _ => None,
149                        })
150                    })
151                    .collect();
152                if intersection_classes.is_empty() {
153                    None
154                } else {
155                    Some(intersection_classes.join("|"))
156                }
157            }
158            _ => None,
159        })
160        .collect();
161    if classes.is_empty() {
162        None
163    } else {
164        Some(classes.join("|"))
165    }
166}
167
168/// Extract class names from a docblock type hint, in declaration order.
169///
170/// Splits a union (`Foo|Bar|null`), trims a leading `\` and nullable `?`, keeps
171/// only parts that begin with an uppercase letter (class-like, not `int` /
172/// `string` / `null`), and reduces each to its short name (`A\B\Foo` → `Foo`).
173fn docblock_class_parts(type_hint: &str) -> Vec<String> {
174    type_hint
175        .split('|')
176        .map(|p| p.trim().trim_start_matches('\\').trim_start_matches('?'))
177        .filter(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
178        .filter_map(|p| p.rsplit('\\').next())
179        .map(|p| p.to_string())
180        .collect()
181}
182
183/// Apply `@param` class-type hints from the docblock preceding `span_start` to
184/// `map`, keyed by `$name`. Uses `or_insert` so real signature type hints
185/// (collected separately) take precedence.
186fn collect_param_docblock_types(source: &str, span_start: u32, map: &mut HashMap<String, String>) {
187    let Some(raw) = docblock_before(source, span_start) else {
188        return;
189    };
190    let db = parse_docblock(&raw);
191    for param in &db.params {
192        let classes = docblock_class_parts(&param.type_hint);
193        if classes.is_empty() {
194            continue;
195        }
196        let key = if param.name.starts_with('$') {
197            param.name.clone()
198        } else {
199            format!("${}", param.name)
200        };
201        map.entry(key).or_insert_with(|| classes.join("|"));
202    }
203}
204
205#[allow(clippy::too_many_arguments)]
206fn collect_types_stmts(
207    source: &str,
208    stmts: &[Stmt<'_, '_>],
209    map: &mut HashMap<String, String>,
210    meta: Option<&PhpStormMeta>,
211    cursor_byte: Option<u32>,
212    doc: &ParsedDoc,
213) {
214    for stmt in stmts {
215        if let Some(raw) = docblock_before(source, stmt.span.start) {
216            let db = parse_docblock(&raw);
217            if let Some(type_str) = db.var_type {
218                let class_name = docblock_class_parts(&type_str).into_iter().next();
219                if let Some(class_name) = class_name {
220                    if let Some(vname) = db.var_name {
221                        map.insert(format!("${}", vname.as_str()), class_name);
222                    } else if let StmtKind::Expression(e) = &stmt.kind
223                        && let ExprKind::Assign(a) = &e.kind
224                        && let ExprKind::Variable(vn) = &a.target.kind
225                    {
226                        map.insert(format!("${}", vn.as_str()), class_name);
227                    }
228                }
229            }
230        }
231
232        match &stmt.kind {
233            StmtKind::Expression(e) => collect_types_expr(source, e, map, meta, cursor_byte, doc),
234            StmtKind::Function(f) => {
235                let in_scope =
236                    cursor_byte.is_none_or(|c| stmt.span.start <= c && c <= stmt.span.end);
237                if !in_scope {
238                    continue;
239                }
240                collect_param_docblock_types(source, stmt.span.start, map);
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                        let in_scope = cursor_byte
255                            .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
256                        if !in_scope {
257                            continue;
258                        }
259                        collect_param_docblock_types(source, member.span.start, map);
260                        for p in m.params.iter() {
261                            if let Some(hint) = &p.type_hint
262                                && let Some(class_str) = type_hint_to_class_string(
263                                    hint,
264                                    class_name.as_deref(),
265                                    Some(doc),
266                                )
267                            {
268                                map.insert(format!("${}", p.name), class_str);
269                            }
270                        }
271                        if !m.is_static
272                            && let Some(ref cname) = class_name
273                        {
274                            map.insert("$this".to_string(), cname.clone());
275                        }
276                        if let Some(body) = &m.body {
277                            collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
278                        }
279                    }
280                }
281            }
282            StmtKind::Trait(t) => {
283                for member in t.body.members.iter() {
284                    if let ClassMemberKind::Method(m) = &member.kind {
285                        let in_scope = cursor_byte
286                            .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
287                        if !in_scope {
288                            continue;
289                        }
290                        for p in m.params.iter() {
291                            if let Some(hint) = &p.type_hint
292                                && let Some(class_str) =
293                                    type_hint_to_class_string(hint, None, Some(doc))
294                            {
295                                map.insert(format!("${}", p.name), class_str);
296                            }
297                        }
298                        if let Some(body) = &m.body {
299                            collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
300                        }
301                    }
302                }
303            }
304            StmtKind::Enum(e) => {
305                for member in e.body.members.iter() {
306                    if let EnumMemberKind::Method(m) = &member.kind {
307                        let in_scope = cursor_byte
308                            .is_none_or(|cb| member.span.start <= cb && cb <= member.span.end);
309                        if !in_scope {
310                            continue;
311                        }
312                        for p in m.params.iter() {
313                            if let Some(hint) = &p.type_hint
314                                && let Some(class_str) =
315                                    type_hint_to_class_string(hint, None, Some(doc))
316                            {
317                                map.insert(format!("${}", p.name), class_str);
318                            }
319                        }
320                        if let Some(body) = &m.body {
321                            collect_types_stmts(source, &body.stmts, map, meta, cursor_byte, doc);
322                        }
323                    }
324                }
325            }
326            StmtKind::Namespace(ns) => {
327                if let NamespaceBody::Braced(inner) = &ns.body {
328                    collect_types_stmts(source, &inner.stmts, map, meta, cursor_byte, doc);
329                }
330            }
331            // `instanceof` narrowing is handled by mir's flow-sensitive analysis; we only recurse into the branches.
332            StmtKind::If(if_stmt) => {
333                collect_types_stmts(
334                    source,
335                    std::slice::from_ref(if_stmt.then_branch),
336                    map,
337                    meta,
338                    cursor_byte,
339                    doc,
340                );
341                for elseif in if_stmt.elseif_branches.iter() {
342                    collect_types_stmts(
343                        source,
344                        std::slice::from_ref(&elseif.body),
345                        map,
346                        meta,
347                        cursor_byte,
348                        doc,
349                    );
350                }
351                if let Some(else_branch) = if_stmt.else_branch {
352                    collect_types_stmts(
353                        source,
354                        std::slice::from_ref(else_branch),
355                        map,
356                        meta,
357                        cursor_byte,
358                        doc,
359                    );
360                }
361            }
362
363            StmtKind::Foreach(f) => {
364                collect_types_stmts(
365                    source,
366                    std::slice::from_ref(f.body),
367                    map,
368                    meta,
369                    cursor_byte,
370                    doc,
371                );
372            }
373            StmtKind::TryCatch(t) => {
374                collect_types_stmts(source, &t.body.stmts, map, meta, cursor_byte, doc);
375                for catch in t.catches.iter() {
376                    collect_types_stmts(source, &catch.body.stmts, map, meta, cursor_byte, doc);
377                }
378                if let Some(finally) = &t.finally {
379                    collect_types_stmts(source, &finally.stmts, map, meta, cursor_byte, doc);
380                }
381            }
382
383            StmtKind::StaticVar(vars) => {
384                for var in vars.iter() {
385                    let var_key = format!("${}", &var.name.to_string());
386                    if let Some(default) = &var.default {
387                        if let ExprKind::New(new_expr) = &default.kind
388                            && let Some(class_name) = extract_class_name(new_expr.class)
389                        {
390                            map.insert(var_key.clone(), class_name);
391                        }
392                        if let ExprKind::Array(_) = &default.kind {
393                            map.insert(var_key, "array".to_string());
394                        }
395                    }
396                }
397            }
398
399            _ => {}
400        }
401    }
402}
403
404fn collect_types_expr(
405    source: &str,
406    expr: &php_ast::Expr<'_, '_>,
407    map: &mut HashMap<String, String>,
408    meta: Option<&PhpStormMeta>,
409    cursor_byte: Option<u32>,
410    doc: &ParsedDoc,
411) {
412    match &expr.kind {
413        ExprKind::Assign(assign) => {
414            if let ExprKind::Variable(var_name) = &assign.target.kind {
415                if let ExprKind::New(new_expr) = &assign.value.kind
416                    && let Some(class_name) = extract_class_name(new_expr.class)
417                {
418                    map.insert(format!("${}", var_name.as_str()), class_name);
419                }
420                // `clone($obj, [...])` (PHP 8.5 clone-with) preserves the object's
421                // type; mir resolves this directly, so no TypeMap branch is needed.
422                // PHPStorm meta: `$var = $obj->make(SomeClass::class)`
423                if let Some(meta) = meta
424                    && let Some(inferred) = infer_from_meta_method_call(assign.value, map, meta)
425                {
426                    map.insert(format!("${}", var_name.as_str()), inferred);
427                }
428            }
429            collect_types_expr(source, assign.value, map, meta, cursor_byte, doc);
430        }
431
432        ExprKind::Closure(c) => {
433            for p in c.params.iter() {
434                if let Some(hint) = &p.type_hint
435                    && let TypeHintKind::Named(name) = &hint.kind
436                {
437                    map.insert(format!("${}", p.name), name.to_string_repr().to_string());
438                }
439            }
440            // Snapshot captured `use` variable types from the outer scope so they
441            // remain resolvable inside the closure body even if the body walk
442            // encounters assignments that would shadow them.
443            let use_var_snapshot: Vec<(String, String)> = c
444                .use_vars
445                .iter()
446                .filter_map(|uv| {
447                    let key = format!("${}", &uv.name.to_string());
448                    map.get(&key).map(|ty| (key, ty.clone()))
449                })
450                .collect();
451            collect_types_stmts(source, &c.body.stmts, map, meta, cursor_byte, doc);
452            // Restore captured variable types: inner assignments inside the closure
453            // body should not affect the outer scope's type for completions.
454            for (key, ty) in use_var_snapshot {
455                map.insert(key, ty);
456            }
457        }
458
459        ExprKind::ArrowFunction(af) => {
460            for p in af.params.iter() {
461                if let Some(hint) = &p.type_hint
462                    && let TypeHintKind::Named(name) = &hint.kind
463                {
464                    map.insert(format!("${}", p.name), name.to_string_repr().to_string());
465                }
466            }
467            collect_types_expr(source, af.body, map, meta, cursor_byte, doc);
468        }
469
470        _ => {}
471    }
472}
473
474fn extract_class_name(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
475    match &expr.kind {
476        ExprKind::Identifier(name) => Some(name.as_str().to_string()),
477        _ => None,
478    }
479}
480
481/// Try to infer the return type of `$obj->method(SomeClass::class)` using the
482/// PHPStorm meta map.  `map` is consulted to resolve `$obj`'s class.
483fn infer_from_meta_method_call(
484    expr: &php_ast::Expr<'_, '_>,
485    var_map: &HashMap<String, String>,
486    meta: &PhpStormMeta,
487) -> Option<String> {
488    let ExprKind::MethodCall(m) = &expr.kind else {
489        return None;
490    };
491    // Resolve the receiver's type.
492    let receiver_class = match &m.object.kind {
493        ExprKind::Variable(v) => {
494            let key = format!("${}", v.as_str());
495            var_map.get(&key)?.clone()
496        }
497        _ => return None,
498    };
499    // Get the method name.
500    let method_name = match &m.method.kind {
501        ExprKind::Identifier(n) => n.to_string(),
502        _ => return None,
503    };
504    // Get the first argument as a class name string.
505    let arg = m.args.first()?;
506    let arg_str = match &arg.value.kind {
507        ExprKind::String(s) => s.trim_start_matches('\\').to_string(),
508        ExprKind::ClassConstAccess(c) if c.member.name_str() == Some("class") => {
509            match &c.class.kind {
510                ExprKind::Identifier(n) => n
511                    .trim_start_matches('\\')
512                    .rsplit('\\')
513                    .next()
514                    .unwrap_or(n)
515                    .to_string(),
516                _ => return None,
517            }
518        }
519        _ => return None,
520    };
521    meta.resolve_return_type(&receiver_class, &method_name, &arg_str)
522        .map(|s| s.to_string())
523}
524
525/// Return the direct parent class name of `class_name` in `doc`, if any.
526pub fn parent_class_name(doc: &ParsedDoc, class_name: &str) -> Option<String> {
527    parent_in_stmts(&doc.program().stmts, class_name)
528}
529
530fn parent_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
531    for stmt in stmts {
532        match &stmt.kind {
533            StmtKind::Class(c)
534                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
535            {
536                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
537            }
538            StmtKind::Namespace(ns) => {
539                if let NamespaceBody::Braced(inner) = &ns.body
540                    && let found @ Some(_) = parent_in_stmts(&inner.stmts, class_name)
541                {
542                    return found;
543                }
544            }
545            _ => {}
546        }
547    }
548    None
549}
550
551/// All members of a named class split by kind and static-ness.
552#[derive(Debug, Default)]
553pub struct ClassMembers {
554    /// (name, is_static)
555    pub methods: Vec<(String, bool)>,
556    /// (name, is_static)
557    pub properties: Vec<(String, bool)>,
558    /// Names of readonly properties (PHP 8.1+).
559    pub readonly_properties: Vec<String>,
560    pub constants: Vec<String>,
561    /// Direct parent class name, if any.
562    pub parent: Option<String>,
563    /// Trait names used by this class (`use Foo, Bar;`).
564    pub trait_uses: Vec<String>,
565    /// True when a class/enum/trait with this name was found in the doc.
566    /// Lets workspace-wide loops short-circuit once the defining doc is hit
567    /// instead of continuing to scan every file.
568    pub found: bool,
569}
570
571/// Return all members (methods, properties, constants) of `class_name`.
572/// Also returns the direct parent class name via `ClassMembers::parent`.
573pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
574    let mut out = ClassMembers::default();
575    out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
576    out
577}
578
579fn collect_members_stmts(
580    source: &str,
581    stmts: &[Stmt<'_, '_>],
582    class_name: &str,
583    out: &mut ClassMembers,
584) -> Option<String> {
585    for stmt in stmts {
586        match &stmt.kind {
587            StmtKind::Class(c)
588                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
589            {
590                out.found = true;
591                // Check docblock for @property and @method tags
592                if let Some(raw) = docblock_before(source, stmt.span.start) {
593                    let db = parse_docblock(&raw);
594                    for prop in &db.properties {
595                        out.properties.push((prop.name.clone(), false));
596                    }
597                    for method in &db.methods {
598                        out.methods.push((method.name.clone(), method.is_static));
599                    }
600                }
601                for member in c.body.members.iter() {
602                    match &member.kind {
603                        ClassMemberKind::Method(m) => {
604                            out.methods.push((m.name.to_string(), m.is_static));
605                            if m.name == "__construct" {
606                                for p in m.params.iter() {
607                                    if p.visibility.is_some() {
608                                        out.properties.push((p.name.to_string(), false));
609                                        // AST does not expose the `readonly` flag on Param; scan the raw span text.
610                                        let param_src =
611                                            &source[p.span.start as usize..p.span.end as usize];
612                                        if param_src.contains("readonly") {
613                                            out.readonly_properties.push(p.name.to_string());
614                                        }
615                                    }
616                                }
617                            }
618                        }
619                        ClassMemberKind::Property(p) => {
620                            out.properties.push((p.name.to_string(), p.is_static));
621                            if p.is_readonly {
622                                out.readonly_properties.push(p.name.to_string());
623                            }
624                        }
625                        ClassMemberKind::ClassConst(c) => {
626                            out.constants.push(c.name.to_string());
627                        }
628                        ClassMemberKind::TraitUse(t) => {
629                            for name in t.traits.iter() {
630                                out.trait_uses.push(name.to_string_repr().to_string());
631                            }
632                        }
633                    }
634                }
635                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
636            }
637            StmtKind::Enum(e) if e.name == class_name => {
638                out.found = true;
639                let is_backed = e.scalar_type.is_some();
640                out.properties.push(("name".to_string(), false));
641                if is_backed {
642                    out.properties.push(("value".to_string(), false));
643                }
644                out.methods.push(("cases".to_string(), true));
645                if is_backed {
646                    out.methods.push(("from".to_string(), true));
647                    out.methods.push(("tryFrom".to_string(), true));
648                }
649                for member in e.body.members.iter() {
650                    match &member.kind {
651                        EnumMemberKind::Case(c) => {
652                            out.constants.push(c.name.to_string());
653                        }
654                        EnumMemberKind::Method(m) => {
655                            out.methods.push((m.name.to_string(), m.is_static));
656                        }
657                        EnumMemberKind::ClassConst(c) => {
658                            out.constants.push(c.name.to_string());
659                        }
660                        _ => {}
661                    }
662                }
663                return None; // enums have no parent class
664            }
665            StmtKind::Trait(t) if t.name == class_name => {
666                out.found = true;
667                for member in t.body.members.iter() {
668                    match &member.kind {
669                        ClassMemberKind::Method(m) => {
670                            out.methods.push((m.name.to_string(), m.is_static));
671                        }
672                        ClassMemberKind::Property(p) => {
673                            out.properties.push((p.name.to_string(), p.is_static));
674                        }
675                        ClassMemberKind::ClassConst(c) => {
676                            out.constants.push(c.name.to_string());
677                        }
678                        ClassMemberKind::TraitUse(t) => {
679                            for name in t.traits.iter() {
680                                out.trait_uses.push(name.to_string_repr().to_string());
681                            }
682                        }
683                    }
684                }
685                return None; // traits have no parent
686            }
687            StmtKind::Namespace(ns) => {
688                if let NamespaceBody::Braced(inner) = &ns.body {
689                    let result = collect_members_stmts(source, &inner.stmts, class_name, out);
690                    if result.is_some() {
691                        return result;
692                    }
693                }
694            }
695            _ => {}
696        }
697    }
698    None
699}
700
701/// Return the `@mixin` class names declared in `class_name`'s docblock.
702pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
703    let source = doc.source();
704    mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
705}
706
707fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
708    for stmt in stmts {
709        match &stmt.kind {
710            StmtKind::Class(c)
711                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
712            {
713                if let Some(raw) = docblock_before(source, stmt.span.start) {
714                    return parse_docblock(&raw).mixins;
715                }
716                return vec![];
717            }
718            StmtKind::Namespace(ns) => {
719                if let NamespaceBody::Braced(inner) = &ns.body {
720                    let found = mixin_classes_in_stmts(source, &inner.stmts, class_name);
721                    if !found.is_empty() {
722                        return found;
723                    }
724                }
725            }
726            _ => {}
727        }
728    }
729    vec![]
730}
731
732/// Return the name of the class whose body contains `position`, or `None`.
733pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
734    let sv = doc.view();
735    enclosing_class_in_stmts(sv, &doc.program().stmts, position)
736}
737
738/// Return the LSP range of the class/interface/trait/enum declaration
739/// whose body contains `position`, or `None` if the cursor is outside any.
740/// Used by linked-editing to scope same-name member rewrites to the
741/// enclosing class instead of every class in the file.
742pub fn enclosing_class_range_at(
743    doc: &ParsedDoc,
744    position: Position,
745) -> Option<tower_lsp::lsp_types::Range> {
746    let sv = doc.view();
747    enclosing_class_range_in_stmts(sv, &doc.program().stmts, position)
748}
749
750/// Return the LSP range of every class/interface/trait/enum declaration in
751/// the file (recursing into braced-namespace bodies). Used by linked-editing
752/// to drop highlights that fall inside an *other* class than the cursor's.
753pub fn collect_all_class_ranges(doc: &ParsedDoc) -> Vec<tower_lsp::lsp_types::Range> {
754    let sv = doc.view();
755    let mut out = Vec::new();
756    collect_class_ranges_in_stmts(sv, &doc.program().stmts, &mut out);
757    out
758}
759
760fn collect_class_ranges_in_stmts(
761    sv: SourceView<'_>,
762    stmts: &[Stmt<'_, '_>],
763    out: &mut Vec<tower_lsp::lsp_types::Range>,
764) {
765    for stmt in stmts {
766        match &stmt.kind {
767            StmtKind::Class(_)
768            | StmtKind::Interface(_)
769            | StmtKind::Trait(_)
770            | StmtKind::Enum(_) => {
771                out.push(sv.range_of(stmt.span));
772            }
773            StmtKind::Namespace(ns) => {
774                if let NamespaceBody::Braced(inner) = &ns.body {
775                    collect_class_ranges_in_stmts(sv, &inner.stmts, out);
776                }
777            }
778            _ => {}
779        }
780    }
781}
782
783fn enclosing_class_range_in_stmts(
784    sv: SourceView<'_>,
785    stmts: &[Stmt<'_, '_>],
786    pos: Position,
787) -> Option<tower_lsp::lsp_types::Range> {
788    for stmt in stmts {
789        match &stmt.kind {
790            StmtKind::Class(_)
791            | StmtKind::Interface(_)
792            | StmtKind::Trait(_)
793            | StmtKind::Enum(_) => {
794                let r = sv.range_of(stmt.span);
795                if pos.line >= r.start.line && pos.line <= r.end.line {
796                    return Some(r);
797                }
798            }
799            StmtKind::Namespace(ns) => {
800                if let NamespaceBody::Braced(inner) = &ns.body
801                    && let Some(r) = enclosing_class_range_in_stmts(sv, &inner.stmts, pos)
802                {
803                    return Some(r);
804                }
805            }
806            _ => {}
807        }
808    }
809    None
810}
811
812fn enclosing_class_in_stmts(
813    sv: SourceView<'_>,
814    stmts: &[Stmt<'_, '_>],
815    pos: Position,
816) -> Option<String> {
817    for stmt in stmts {
818        match &stmt.kind {
819            StmtKind::Class(c) => {
820                let start = sv.position_of(stmt.span.start).line;
821                let end = sv.position_of(stmt.span.end).line;
822                if pos.line >= start && pos.line <= end {
823                    return c.name.map(|n| n.to_string());
824                }
825            }
826            StmtKind::Interface(i) => {
827                let start = sv.position_of(stmt.span.start).line;
828                let end = sv.position_of(stmt.span.end).line;
829                if pos.line >= start && pos.line <= end {
830                    return Some(i.name.to_string());
831                }
832            }
833            StmtKind::Trait(t) => {
834                let start = sv.position_of(stmt.span.start).line;
835                let end = sv.position_of(stmt.span.end).line;
836                if pos.line >= start && pos.line <= end {
837                    return Some(t.name.to_string());
838                }
839            }
840            StmtKind::Enum(e) => {
841                let start = sv.position_of(stmt.span.start).line;
842                let end = sv.position_of(stmt.span.end).line;
843                if pos.line >= start && pos.line <= end {
844                    return Some(e.name.to_string());
845                }
846            }
847            StmtKind::Namespace(ns) => {
848                if let NamespaceBody::Braced(inner) = &ns.body
849                    && let Some(found) = enclosing_class_in_stmts(sv, &inner.stmts, pos)
850                {
851                    return Some(found);
852                }
853            }
854            _ => {}
855        }
856    }
857    None
858}
859
860/// Return the parameter names of the function or method named `func_name`.
861pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
862    let mut out = Vec::new();
863    collect_params_stmts(&doc.program().stmts, func_name, &mut out);
864    out
865}
866
867/// Return the parameter names of `method_name` on class `class_name`.
868/// Primarily used to offer named-argument completions for attribute constructors.
869pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
870    let mut out = Vec::new();
871    collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
872    out
873}
874
875fn collect_method_params_stmts(
876    stmts: &[php_ast::Stmt<'_, '_>],
877    class_name: &str,
878    method_name: &str,
879    out: &mut Vec<String>,
880) {
881    for stmt in stmts {
882        match &stmt.kind {
883            StmtKind::Class(c)
884                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
885            {
886                for member in c.body.members.iter() {
887                    if let ClassMemberKind::Method(m) = &member.kind
888                        && m.name == method_name
889                    {
890                        for p in m.params.iter() {
891                            out.push(p.name.to_string());
892                        }
893                        return;
894                    }
895                }
896            }
897            StmtKind::Namespace(ns) => {
898                if let NamespaceBody::Braced(inner) = &ns.body {
899                    collect_method_params_stmts(&inner.stmts, class_name, method_name, out);
900                }
901            }
902            _ => {}
903        }
904    }
905}
906
907/// Returns `true` if `class_name` is declared as an `enum` in `doc`.
908pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
909    is_enum_in_stmts(&doc.program().stmts, class_name)
910}
911
912fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
913    for stmt in stmts {
914        match &stmt.kind {
915            StmtKind::Enum(e) if e.name == name => return true,
916            StmtKind::Namespace(ns) => {
917                if let NamespaceBody::Braced(inner) = &ns.body
918                    && is_enum_in_stmts(&inner.stmts, name)
919                {
920                    return true;
921                }
922            }
923            _ => {}
924        }
925    }
926    false
927}
928
929/// Returns `true` if `class_name` is a *backed* enum (`enum Foo: string` /
930/// `enum Foo: int`) in `doc`.  Backed enums have a `->value` property.
931pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
932    is_backed_enum_in_stmts(&doc.program().stmts, class_name)
933}
934
935fn is_backed_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
936    for stmt in stmts {
937        match &stmt.kind {
938            StmtKind::Enum(e) if e.name == name => return e.scalar_type.is_some(),
939            StmtKind::Namespace(ns) => {
940                if let NamespaceBody::Braced(inner) = &ns.body
941                    && is_backed_enum_in_stmts(&inner.stmts, name)
942                {
943                    return true;
944                }
945            }
946            _ => {}
947        }
948    }
949    false
950}
951
952fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
953    for stmt in stmts {
954        match &stmt.kind {
955            StmtKind::Function(f) if f.name == func_name => {
956                for p in f.params.iter() {
957                    out.push(p.name.to_string());
958                }
959                return;
960            }
961            StmtKind::Class(c) => {
962                for member in c.body.members.iter() {
963                    if let ClassMemberKind::Method(m) = &member.kind
964                        && m.name == func_name
965                    {
966                        for p in m.params.iter() {
967                            out.push(p.name.to_string());
968                        }
969                        return;
970                    }
971                }
972            }
973            StmtKind::Namespace(ns) => {
974                if let NamespaceBody::Braced(inner) = &ns.body {
975                    collect_params_stmts(&inner.stmts, func_name, out);
976                }
977            }
978            _ => {}
979        }
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    #[test]
988    fn infers_type_from_new_expression() {
989        let src = "<?php\n$obj = new Foo();";
990        let doc = ParsedDoc::parse(src.to_string());
991        let tm = TypeMap::from_doc(&doc);
992        assert_eq!(tm.get("$obj"), Some("Foo"));
993    }
994
995    #[test]
996    fn unknown_variable_returns_none() {
997        let src = "<?php\n$obj = new Foo();";
998        let doc = ParsedDoc::parse(src.to_string());
999        let tm = TypeMap::from_doc(&doc);
1000        assert!(tm.get("$other").is_none());
1001    }
1002
1003    #[test]
1004    fn multiple_assignments() {
1005        let src = "<?php\n$a = new Foo();\n$b = new Bar();";
1006        let doc = ParsedDoc::parse(src.to_string());
1007        let tm = TypeMap::from_doc(&doc);
1008        assert_eq!(tm.get("$a"), Some("Foo"));
1009        assert_eq!(tm.get("$b"), Some("Bar"));
1010    }
1011
1012    #[test]
1013    fn later_assignment_overwrites() {
1014        let src = "<?php\n$a = new Foo();\n$a = new Bar();";
1015        let doc = ParsedDoc::parse(src.to_string());
1016        let tm = TypeMap::from_doc(&doc);
1017        assert_eq!(tm.get("$a"), Some("Bar"));
1018    }
1019
1020    #[test]
1021    fn infers_type_from_typed_param() {
1022        let src = "<?php\nfunction process(Mailer $mailer): void { $mailer-> }";
1023        let doc = ParsedDoc::parse(src.to_string());
1024        let tm = TypeMap::from_doc(&doc);
1025        assert_eq!(tm.get("$mailer"), Some("Mailer"));
1026    }
1027
1028    #[test]
1029    fn parent_class_name_finds_parent() {
1030        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1031        let doc = ParsedDoc::parse(src.to_string());
1032        assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
1033    }
1034
1035    #[test]
1036    fn parent_class_name_returns_none_for_top_level() {
1037        let src = "<?php\nclass Base {}";
1038        let doc = ParsedDoc::parse(src.to_string());
1039        assert!(parent_class_name(&doc, "Base").is_none());
1040    }
1041
1042    #[test]
1043    fn members_of_class_includes_parent_field() {
1044        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
1045        let doc = ParsedDoc::parse(src.to_string());
1046        let m = members_of_class(&doc, "Child");
1047        assert_eq!(m.parent.as_deref(), Some("Base"));
1048    }
1049
1050    #[test]
1051    fn members_of_class_finds_methods() {
1052        let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
1053        let doc = ParsedDoc::parse(src.to_string());
1054        let members = members_of_class(&doc, "Calc");
1055        let names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1056        assert!(names.contains(&"add"), "missing 'add'");
1057        assert!(names.contains(&"sub"), "missing 'sub'");
1058    }
1059
1060    #[test]
1061    fn members_of_unknown_class_is_empty() {
1062        let src = "<?php\nclass Calc { public function add() {} }";
1063        let doc = ParsedDoc::parse(src.to_string());
1064        let members = members_of_class(&doc, "Unknown");
1065        assert!(members.methods.is_empty());
1066    }
1067
1068    #[test]
1069    fn constructor_promoted_params_appear_as_properties() {
1070        let src = "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}";
1071        let doc = ParsedDoc::parse(src.to_string());
1072        let members = members_of_class(&doc, "Point");
1073        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1074        assert!(
1075            prop_names.contains(&"x"),
1076            "promoted param x should be a property"
1077        );
1078        assert!(
1079            prop_names.contains(&"y"),
1080            "promoted param y should be a property"
1081        );
1082    }
1083
1084    #[test]
1085    fn promoted_readonly_params_appear_in_readonly_properties() {
1086        let src = "<?php\nclass User {\n    public function __construct(\n        public readonly string $name,\n        public int $age,\n    ) {}\n}";
1087        let doc = ParsedDoc::parse(src.to_string());
1088        let members = members_of_class(&doc, "User");
1089        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1090        assert!(
1091            prop_names.contains(&"name"),
1092            "promoted param name should be a property"
1093        );
1094        assert!(
1095            prop_names.contains(&"age"),
1096            "promoted param age should be a property"
1097        );
1098        assert!(
1099            members.readonly_properties.contains(&"name".to_string()),
1100            "readonly promoted param name should be in readonly_properties"
1101        );
1102        assert!(
1103            !members.readonly_properties.contains(&"age".to_string()),
1104            "non-readonly promoted param age should not be in readonly_properties"
1105        );
1106    }
1107
1108    #[test]
1109    fn enum_instance_members_include_name() {
1110        let src = "<?php\nenum Status { case Active; case Inactive; }";
1111        let doc = ParsedDoc::parse(src.to_string());
1112        let members = members_of_class(&doc, "Status");
1113        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1114        assert!(
1115            prop_names.contains(&"name"),
1116            "pure enum should expose ->name"
1117        );
1118        assert!(
1119            !prop_names.contains(&"value"),
1120            "pure enum should not expose ->value"
1121        );
1122    }
1123
1124    #[test]
1125    fn backed_enum_exposes_value_and_factory_methods() {
1126        let src = "<?php\nenum Color: string { case Red = 'red'; }";
1127        let doc = ParsedDoc::parse(src.to_string());
1128        let members = members_of_class(&doc, "Color");
1129        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1130        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1131        assert!(
1132            prop_names.contains(&"value"),
1133            "backed enum should expose ->value"
1134        );
1135        assert!(
1136            method_names.contains(&"from"),
1137            "backed enum should have ::from()"
1138        );
1139        assert!(
1140            method_names.contains(&"tryFrom"),
1141            "backed enum should have ::tryFrom()"
1142        );
1143        assert!(
1144            method_names.contains(&"cases"),
1145            "enum should have ::cases()"
1146        );
1147    }
1148
1149    #[test]
1150    fn enum_cases_appear_as_constants() {
1151        let src = "<?php\nenum Status { case Active; case Inactive; }";
1152        let doc = ParsedDoc::parse(src.to_string());
1153        let members = members_of_class(&doc, "Status");
1154        assert!(members.constants.contains(&"Active".to_string()));
1155        assert!(members.constants.contains(&"Inactive".to_string()));
1156    }
1157
1158    #[test]
1159    fn trait_members_are_collected() {
1160        let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
1161        let doc = ParsedDoc::parse(src.to_string());
1162        let members = members_of_class(&doc, "Logging");
1163        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1164        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1165        assert!(
1166            method_names.contains(&"log"),
1167            "trait method log should be collected"
1168        );
1169        assert!(
1170            prop_names.contains(&"logFile"),
1171            "trait property logFile should be collected"
1172        );
1173    }
1174
1175    #[test]
1176    fn class_with_trait_use_lists_trait() {
1177        let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
1178        let doc = ParsedDoc::parse(src.to_string());
1179        let members = members_of_class(&doc, "App");
1180        assert!(
1181            members.trait_uses.contains(&"Logging".to_string()),
1182            "should list used trait"
1183        );
1184    }
1185
1186    #[test]
1187    fn var_docblock_with_explicit_varname_infers_type() {
1188        let src = "<?php\n/** @var Mailer $mailer */\n$mailer = $container->get('mailer');";
1189        let doc = ParsedDoc::parse(src.to_string());
1190        let tm = TypeMap::from_doc(&doc);
1191        assert_eq!(
1192            tm.get("$mailer"),
1193            Some("Mailer"),
1194            "@var with explicit name should map the variable"
1195        );
1196    }
1197
1198    #[test]
1199    fn var_docblock_without_varname_infers_from_assignment() {
1200        let src = "<?php\n/** @var Repository */\n$repo = $this->getRepository();";
1201        let doc = ParsedDoc::parse(src.to_string());
1202        let tm = TypeMap::from_doc(&doc);
1203        assert_eq!(
1204            tm.get("$repo"),
1205            Some("Repository"),
1206            "@var without name should use assignment LHS"
1207        );
1208    }
1209
1210    #[test]
1211    fn var_docblock_does_not_map_primitive_types() {
1212        let src = "<?php\n/** @var string */\n$name = 'hello';";
1213        let doc = ParsedDoc::parse(src.to_string());
1214        let tm = TypeMap::from_doc(&doc);
1215        // Primitives (lowercase) should not be mapped as class names.
1216        assert!(
1217            tm.get("$name").is_none(),
1218            "primitive @var should not produce a class mapping"
1219        );
1220    }
1221
1222    #[test]
1223    fn var_nullable_docblock_maps_to_class() {
1224        // `@var ?Foo $x` is now normalised to `Foo|null` by the mir parser.
1225        // The type_map must still infer the class name `Foo`, not `Foo|null`.
1226        let src = "<?php\n/** @var ?Mailer $mailer */\n$mailer = null;";
1227        let doc = ParsedDoc::parse(src.to_string());
1228        let tm = TypeMap::from_doc(&doc);
1229        assert_eq!(
1230            tm.get("$mailer"),
1231            Some("Mailer"),
1232            "@var ?Foo should map to 'Foo', not 'Foo|null'"
1233        );
1234    }
1235
1236    #[test]
1237    fn var_union_docblock_maps_first_class() {
1238        // `@var Foo|null $x` — first class-type component should be used.
1239        let src = "<?php\n/** @var Repository|null $repo */\n$repo = null;";
1240        let doc = ParsedDoc::parse(src.to_string());
1241        let tm = TypeMap::from_doc(&doc);
1242        assert_eq!(
1243            tm.get("$repo"),
1244            Some("Repository"),
1245            "@var Foo|null should map to 'Foo', not 'Foo|null'"
1246        );
1247    }
1248
1249    #[test]
1250    fn is_enum_pure() {
1251        let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
1252        let doc = ParsedDoc::parse(src.to_string());
1253        assert!(is_enum(&doc, "Suit"));
1254        assert!(!is_backed_enum(&doc, "Suit"));
1255    }
1256
1257    #[test]
1258    fn is_backed_enum_string() {
1259        let src = "<?php\nenum Status: string { case Active = 'active'; }";
1260        let doc = ParsedDoc::parse(src.to_string());
1261        assert!(is_enum(&doc, "Status"));
1262        assert!(is_backed_enum(&doc, "Status"));
1263    }
1264
1265    #[test]
1266    fn is_enum_false_for_class() {
1267        let src = "<?php\nclass Foo {}";
1268        let doc = ParsedDoc::parse(src.to_string());
1269        assert!(!is_enum(&doc, "Foo"));
1270        assert!(!is_backed_enum(&doc, "Foo"));
1271    }
1272
1273    #[test]
1274    fn closure_use_var_type_is_available_inside_body() {
1275        let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc->process(); };";
1276        let doc = ParsedDoc::parse(src.to_string());
1277        let tm = TypeMap::from_doc(&doc);
1278        assert_eq!(
1279            tm.get("$svc"),
1280            Some("PaymentService"),
1281            "captured use variable should retain its outer type inside closure body"
1282        );
1283    }
1284
1285    #[test]
1286    fn closure_use_var_inner_assignment_does_not_override_outer_type() {
1287        // If inside a closure we assign $svc = new Other(), the outer $svc type
1288        // should be restored after walking the closure body (or_insert semantics).
1289        let src = "<?php\n$svc = new PaymentService();\n$fn = function() use ($svc) { $svc = new OtherService(); };";
1290        let doc = ParsedDoc::parse(src.to_string());
1291        let tm = TypeMap::from_doc(&doc);
1292        // The snapshot restore ensures $svc retains PaymentService for the outer scope.
1293        assert_eq!(
1294            tm.get("$svc"),
1295            Some("PaymentService"),
1296            "outer type should not be overwritten by inner assignment in closure"
1297        );
1298    }
1299
1300    #[test]
1301    fn param_docblock_type_inferred() {
1302        let src = "<?php\n/**\n * @param Mailer $mailer\n */\nfunction send($mailer) { $mailer-> }";
1303        let doc = ParsedDoc::parse(src.to_string());
1304        let tm = TypeMap::from_doc(&doc);
1305        assert_eq!(tm.get("$mailer"), Some("Mailer"));
1306    }
1307
1308    #[test]
1309    fn param_docblock_does_not_override_ast_hint() {
1310        let src = "<?php\n/**\n * @param OtherClass $x\n */\nfunction foo(Foo $x) {}";
1311        let doc = ParsedDoc::parse(src.to_string());
1312        let tm = TypeMap::from_doc(&doc);
1313        // AST type hint takes precedence over docblock (AST processed after, overwrites)
1314        assert_eq!(tm.get("$x"), Some("Foo"));
1315    }
1316
1317    #[test]
1318    fn not_null_check_preserves_existing_type() {
1319        let src = "<?php\n$x = new Foo();\nif ($x !== null) { $x-> }";
1320        let doc = ParsedDoc::parse(src.to_string());
1321        let tm = TypeMap::from_doc(&doc);
1322        assert_eq!(tm.get("$x"), Some("Foo"));
1323    }
1324
1325    #[test]
1326    fn docblock_property_appears_in_members() {
1327        let src =
1328            "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
1329        let doc = ParsedDoc::parse(src.to_string());
1330        let members = members_of_class(&doc, "User");
1331        let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
1332        assert!(props.contains(&"email"));
1333        assert!(props.contains(&"id"));
1334    }
1335
1336    #[test]
1337    fn docblock_method_appears_in_members() {
1338        let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
1339        let doc = ParsedDoc::parse(src.to_string());
1340        let members = members_of_class(&doc, "Model");
1341        let method_names: Vec<&str> = members.methods.iter().map(|(n, _)| n.as_str()).collect();
1342        assert!(method_names.contains(&"find"));
1343        assert!(method_names.contains(&"where"));
1344        let where_static = members
1345            .methods
1346            .iter()
1347            .find(|(n, _)| n == "where")
1348            .map(|(_, s)| *s);
1349        assert_eq!(where_static, Some(true));
1350    }
1351
1352    #[test]
1353    fn union_type_param_maps_both_classes() {
1354        // function f(Foo|Bar $x) — both Foo and Bar should be in the union type string
1355        let src = "<?php\nfunction f(Foo|Bar $x) {}";
1356        let doc = ParsedDoc::parse(src.to_string());
1357        let tm = TypeMap::from_doc(&doc);
1358        let val = tm.get("$x").expect("$x should be in the type map");
1359        assert!(
1360            val.contains("Foo"),
1361            "union type should contain 'Foo', got: {}",
1362            val
1363        );
1364        assert!(
1365            val.contains("Bar"),
1366            "union type should contain 'Bar', got: {}",
1367            val
1368        );
1369    }
1370
1371    #[test]
1372    fn nullable_param_resolves_to_class() {
1373        // function f(?Foo $x) — $x should map to Foo (nullable stripped)
1374        let src = "<?php\nfunction f(?Foo $x) {}";
1375        let doc = ParsedDoc::parse(src.to_string());
1376        let tm = TypeMap::from_doc(&doc);
1377        assert_eq!(
1378            tm.get("$x"),
1379            Some("Foo"),
1380            "nullable type hint ?Foo should map $x to Foo"
1381        );
1382    }
1383
1384    #[test]
1385    fn null_assignment_does_not_overwrite_class() {
1386        // $x = new Foo(); $x = null; — $x type should stay Foo because
1387        // assigning null does not overwrite a known class type in the single-pass map.
1388        let src = "<?php\n$x = new Foo();\n$x = null;\n";
1389        let doc = ParsedDoc::parse(src.to_string());
1390        let tm = TypeMap::from_doc(&doc);
1391        // The single-pass type map does not treat null as a class, so the last
1392        // successful class assignment (Foo) persists.
1393        assert_eq!(
1394            tm.get("$x"),
1395            Some("Foo"),
1396            "$x should retain its Foo type after being assigned null"
1397        );
1398    }
1399
1400    #[test]
1401    fn infers_type_from_assignment_inside_trait_method() {
1402        let src = "<?php\ntrait Builder { public function make(): void { $obj = new Widget(); } }";
1403        let doc = ParsedDoc::parse(src.to_string());
1404        let tm = TypeMap::from_doc(&doc);
1405        assert_eq!(
1406            tm.get("$obj"),
1407            Some("Widget"),
1408            "type map should walk into trait method bodies"
1409        );
1410    }
1411
1412    #[test]
1413    fn infers_type_from_assignment_inside_enum_method() {
1414        let src = "<?php\nenum Color { case Red; public function make(): void { $obj = new Palette(); } }";
1415        let doc = ParsedDoc::parse(src.to_string());
1416        let tm = TypeMap::from_doc(&doc);
1417        assert_eq!(
1418            tm.get("$obj"),
1419            Some("Palette"),
1420            "type map should walk into enum method bodies"
1421        );
1422    }
1423}