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