Skip to main content

php_lsp/types/
type_map.rs

1//! AST-level class-structure queries: parent class, members (methods,
2//! properties, constants, mixins), enclosing class at a cursor position, enum
3//! backing type, and function/method parameter lists. These answer
4//! structural facts directly from the parsed source and don't depend on mir.
5use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
6use tower_lsp::lsp_types::Position;
7
8use crate::document::ast::{ParsedDoc, SourceView};
9use crate::lang::docblock::{docblock_before, parse_docblock};
10
11pub fn parent_class_name(doc: &ParsedDoc, class_name: &str) -> Option<String> {
12    parent_in_stmts(&doc.program().stmts, class_name)
13}
14
15fn parent_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
16    for stmt in stmts {
17        match &stmt.kind {
18            StmtKind::Class(c)
19                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
20            {
21                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
22            }
23            StmtKind::Namespace(ns) => {
24                if let NamespaceBody::Braced(inner) = &ns.body
25                    && let found @ Some(_) = parent_in_stmts(&inner.stmts, class_name)
26                {
27                    return found;
28                }
29            }
30            _ => {}
31        }
32    }
33    None
34}
35
36/// All members of a named class split by kind and static-ness.
37#[derive(Debug, Default)]
38pub struct ClassMembers {
39    /// (name, is_static, has_params)
40    pub methods: Vec<(String, bool, bool)>,
41    /// (name, is_static)
42    pub properties: Vec<(String, bool)>,
43    /// Names of readonly properties (PHP 8.1+).
44    pub readonly_properties: Vec<String>,
45    pub constants: Vec<String>,
46    /// Direct parent class name, if any.
47    pub parent: Option<String>,
48    /// Trait names used by this class (`use Foo, Bar;`).
49    pub trait_uses: Vec<String>,
50    /// True when a class/enum/trait with this name was found in the doc.
51    /// Lets workspace-wide loops short-circuit once the defining doc is hit
52    /// instead of continuing to scan every file.
53    pub found: bool,
54}
55
56/// Return all members (methods, properties, constants) of `class_name`.
57/// Also returns the direct parent class name via `ClassMembers::parent`.
58pub fn members_of_class(doc: &ParsedDoc, class_name: &str) -> ClassMembers {
59    let mut out = ClassMembers::default();
60    out.parent = collect_members_stmts(doc.source(), &doc.program().stmts, class_name, &mut out);
61    out
62}
63
64fn collect_members_stmts(
65    source: &str,
66    stmts: &[Stmt<'_, '_>],
67    class_name: &str,
68    out: &mut ClassMembers,
69) -> Option<String> {
70    for stmt in stmts {
71        match &stmt.kind {
72            StmtKind::Class(c)
73                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
74            {
75                out.found = true;
76                // Check docblock for @property and @method tags
77                if let Some(raw) = docblock_before(source, stmt.span.start) {
78                    let db = parse_docblock(&raw);
79                    for prop in &db.properties {
80                        out.properties.push((prop.name.clone(), false));
81                    }
82                    for method in &db.methods {
83                        out.methods.push((
84                            method.name.clone(),
85                            method.is_static,
86                            !method.params.is_empty(),
87                        ));
88                    }
89                }
90                for member in c.body.members.iter() {
91                    match &member.kind {
92                        ClassMemberKind::Method(m) => {
93                            out.methods.push((
94                                m.name.to_string(),
95                                m.is_static,
96                                !m.params.is_empty(),
97                            ));
98                            if m.name == "__construct" {
99                                for p in m.params.iter() {
100                                    if p.visibility.is_some() {
101                                        out.properties.push((p.name.to_string(), false));
102                                        // AST does not expose the `readonly` flag on Param; scan the raw span text.
103                                        let param_src =
104                                            &source[p.span.start as usize..p.span.end as usize];
105                                        if param_src.contains("readonly") {
106                                            out.readonly_properties.push(p.name.to_string());
107                                        }
108                                    }
109                                }
110                            }
111                        }
112                        ClassMemberKind::Property(p) => {
113                            out.properties.push((p.name.to_string(), p.is_static));
114                            if p.is_readonly {
115                                out.readonly_properties.push(p.name.to_string());
116                            }
117                        }
118                        ClassMemberKind::ClassConst(c) => {
119                            out.constants.push(c.name.to_string());
120                        }
121                        ClassMemberKind::TraitUse(t) => {
122                            for name in t.traits.iter() {
123                                out.trait_uses.push(name.to_string_repr().to_string());
124                            }
125                        }
126                    }
127                }
128                return c.extends.as_ref().map(|n| n.to_string_repr().to_string());
129            }
130            StmtKind::Enum(e) if e.name == class_name => {
131                out.found = true;
132                let is_backed = e.scalar_type.is_some();
133                out.properties.push(("name".to_string(), false));
134                if is_backed {
135                    out.properties.push(("value".to_string(), false));
136                }
137                out.methods.push(("cases".to_string(), true, false));
138                if is_backed {
139                    out.methods.push(("from".to_string(), true, true));
140                    out.methods.push(("tryFrom".to_string(), true, true));
141                }
142                for member in e.body.members.iter() {
143                    match &member.kind {
144                        EnumMemberKind::Case(c) => {
145                            out.constants.push(c.name.to_string());
146                        }
147                        EnumMemberKind::Method(m) => {
148                            out.methods.push((
149                                m.name.to_string(),
150                                m.is_static,
151                                !m.params.is_empty(),
152                            ));
153                        }
154                        EnumMemberKind::ClassConst(c) => {
155                            out.constants.push(c.name.to_string());
156                        }
157                        _ => {}
158                    }
159                }
160                return None; // enums have no parent class
161            }
162            StmtKind::Trait(t) if t.name == class_name => {
163                out.found = true;
164                for member in t.body.members.iter() {
165                    match &member.kind {
166                        ClassMemberKind::Method(m) => {
167                            out.methods.push((
168                                m.name.to_string(),
169                                m.is_static,
170                                !m.params.is_empty(),
171                            ));
172                        }
173                        ClassMemberKind::Property(p) => {
174                            out.properties.push((p.name.to_string(), p.is_static));
175                        }
176                        ClassMemberKind::ClassConst(c) => {
177                            out.constants.push(c.name.to_string());
178                        }
179                        ClassMemberKind::TraitUse(t) => {
180                            for name in t.traits.iter() {
181                                out.trait_uses.push(name.to_string_repr().to_string());
182                            }
183                        }
184                    }
185                }
186                return None; // traits have no parent
187            }
188            StmtKind::Namespace(ns) => {
189                if let NamespaceBody::Braced(inner) = &ns.body {
190                    let result = collect_members_stmts(source, &inner.stmts, class_name, out);
191                    if result.is_some() {
192                        return result;
193                    }
194                }
195            }
196            _ => {}
197        }
198    }
199    None
200}
201
202/// Return the `@mixin` class names declared in `class_name`'s docblock.
203pub fn mixin_classes_of(doc: &ParsedDoc, class_name: &str) -> Vec<String> {
204    let source = doc.source();
205    mixin_classes_in_stmts(source, &doc.program().stmts, class_name)
206}
207
208fn mixin_classes_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], class_name: &str) -> Vec<String> {
209    for stmt in stmts {
210        match &stmt.kind {
211            StmtKind::Class(c)
212                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
213            {
214                if let Some(raw) = docblock_before(source, stmt.span.start) {
215                    return parse_docblock(&raw).mixins;
216                }
217                return vec![];
218            }
219            StmtKind::Namespace(ns) => {
220                if let NamespaceBody::Braced(inner) = &ns.body {
221                    let found = mixin_classes_in_stmts(source, &inner.stmts, class_name);
222                    if !found.is_empty() {
223                        return found;
224                    }
225                }
226            }
227            _ => {}
228        }
229    }
230    vec![]
231}
232
233/// Return the name of the class whose body contains `position`, or `None`.
234pub fn enclosing_class_at(_source: &str, doc: &ParsedDoc, position: Position) -> Option<String> {
235    let sv = doc.view();
236    enclosing_class_in_stmts(sv, &doc.program().stmts, position)
237}
238
239/// Like [`enclosing_class_at`] but returns the fully-qualified name
240/// (`"Ns\\ClassName"`) when the class lives inside a namespace.
241/// Used by the `__construct` call-site path so that `construct_references`
242/// can apply namespace-level filtering and avoid matching a same-short-named
243/// class in a different namespace.
244pub fn enclosing_class_fqn_at(
245    _source: &str,
246    doc: &ParsedDoc,
247    position: Position,
248) -> Option<String> {
249    let sv = doc.view();
250    enclosing_class_fqn_in_stmts(sv, &doc.program().stmts, position, "")
251}
252
253fn enclosing_class_fqn_in_stmts(
254    sv: SourceView<'_>,
255    stmts: &[Stmt<'_, '_>],
256    pos: Position,
257    ns_prefix: &str,
258) -> Option<String> {
259    let make_fqn = |ns: &str, short: &str| -> String {
260        if ns.is_empty() {
261            short.to_owned()
262        } else {
263            format!("{}\\{}", ns, short)
264        }
265    };
266    let mut current_ns = ns_prefix.to_owned();
267    for stmt in stmts {
268        match &stmt.kind {
269            StmtKind::Class(c) => {
270                let start = sv.position_of(stmt.span.start).line;
271                let end = sv.position_of(stmt.span.end).line;
272                if pos.line >= start && pos.line <= end {
273                    return c.name.map(|n| make_fqn(&current_ns, &n.to_string()));
274                }
275            }
276            StmtKind::Interface(i) => {
277                let start = sv.position_of(stmt.span.start).line;
278                let end = sv.position_of(stmt.span.end).line;
279                if pos.line >= start && pos.line <= end {
280                    return Some(make_fqn(&current_ns, &i.name.to_string()));
281                }
282            }
283            StmtKind::Trait(t) => {
284                let start = sv.position_of(stmt.span.start).line;
285                let end = sv.position_of(stmt.span.end).line;
286                if pos.line >= start && pos.line <= end {
287                    return Some(make_fqn(&current_ns, &t.name.to_string()));
288                }
289            }
290            StmtKind::Enum(e) => {
291                let start = sv.position_of(stmt.span.start).line;
292                let end = sv.position_of(stmt.span.end).line;
293                if pos.line >= start && pos.line <= end {
294                    return Some(make_fqn(&current_ns, &e.name.to_string()));
295                }
296            }
297            StmtKind::Namespace(ns) => {
298                let ns_name = ns
299                    .name
300                    .as_ref()
301                    .map(|n| n.to_string_repr().to_string())
302                    .unwrap_or_default();
303                match &ns.body {
304                    NamespaceBody::Braced(inner) => {
305                        if let Some(found) =
306                            enclosing_class_fqn_in_stmts(sv, &inner.stmts, pos, &ns_name)
307                        {
308                            return Some(found);
309                        }
310                    }
311                    NamespaceBody::Simple => {
312                        current_ns = ns_name;
313                    }
314                }
315            }
316            _ => {}
317        }
318    }
319    None
320}
321
322/// Return the LSP range of the class/interface/trait/enum declaration
323/// whose body contains `position`, or `None` if the cursor is outside any.
324/// Used by linked-editing to scope same-name member rewrites to the
325/// enclosing class instead of every class in the file.
326pub fn enclosing_class_range_at(
327    doc: &ParsedDoc,
328    position: Position,
329) -> Option<tower_lsp::lsp_types::Range> {
330    let sv = doc.view();
331    enclosing_class_range_in_stmts(sv, &doc.program().stmts, position)
332}
333
334/// Return the LSP range of every class/interface/trait/enum declaration in
335/// the file (recursing into braced-namespace bodies). Used by linked-editing
336/// to drop highlights that fall inside an *other* class than the cursor's.
337pub fn collect_all_class_ranges(doc: &ParsedDoc) -> Vec<tower_lsp::lsp_types::Range> {
338    let sv = doc.view();
339    let mut out = Vec::new();
340    collect_class_ranges_in_stmts(sv, &doc.program().stmts, &mut out);
341    out
342}
343
344fn collect_class_ranges_in_stmts(
345    sv: SourceView<'_>,
346    stmts: &[Stmt<'_, '_>],
347    out: &mut Vec<tower_lsp::lsp_types::Range>,
348) {
349    for stmt in stmts {
350        match &stmt.kind {
351            StmtKind::Class(_)
352            | StmtKind::Interface(_)
353            | StmtKind::Trait(_)
354            | StmtKind::Enum(_) => {
355                out.push(sv.range_of(stmt.span));
356            }
357            StmtKind::Namespace(ns) => {
358                if let NamespaceBody::Braced(inner) = &ns.body {
359                    collect_class_ranges_in_stmts(sv, &inner.stmts, out);
360                }
361            }
362            _ => {}
363        }
364    }
365}
366
367fn enclosing_class_range_in_stmts(
368    sv: SourceView<'_>,
369    stmts: &[Stmt<'_, '_>],
370    pos: Position,
371) -> Option<tower_lsp::lsp_types::Range> {
372    for stmt in stmts {
373        match &stmt.kind {
374            StmtKind::Class(_)
375            | StmtKind::Interface(_)
376            | StmtKind::Trait(_)
377            | StmtKind::Enum(_) => {
378                let r = sv.range_of(stmt.span);
379                if pos.line >= r.start.line && pos.line <= r.end.line {
380                    return Some(r);
381                }
382            }
383            StmtKind::Namespace(ns) => {
384                if let NamespaceBody::Braced(inner) = &ns.body
385                    && let Some(r) = enclosing_class_range_in_stmts(sv, &inner.stmts, pos)
386                {
387                    return Some(r);
388                }
389            }
390            _ => {}
391        }
392    }
393    None
394}
395
396fn enclosing_class_in_stmts(
397    sv: SourceView<'_>,
398    stmts: &[Stmt<'_, '_>],
399    pos: Position,
400) -> Option<String> {
401    for stmt in stmts {
402        match &stmt.kind {
403            StmtKind::Class(c) => {
404                let start = sv.position_of(stmt.span.start).line;
405                let end = sv.position_of(stmt.span.end).line;
406                if pos.line >= start && pos.line <= end {
407                    return c.name.map(|n| n.to_string());
408                }
409            }
410            StmtKind::Interface(i) => {
411                let start = sv.position_of(stmt.span.start).line;
412                let end = sv.position_of(stmt.span.end).line;
413                if pos.line >= start && pos.line <= end {
414                    return Some(i.name.to_string());
415                }
416            }
417            StmtKind::Trait(t) => {
418                let start = sv.position_of(stmt.span.start).line;
419                let end = sv.position_of(stmt.span.end).line;
420                if pos.line >= start && pos.line <= end {
421                    return Some(t.name.to_string());
422                }
423            }
424            StmtKind::Enum(e) => {
425                let start = sv.position_of(stmt.span.start).line;
426                let end = sv.position_of(stmt.span.end).line;
427                if pos.line >= start && pos.line <= end {
428                    return Some(e.name.to_string());
429                }
430            }
431            StmtKind::Namespace(ns) => {
432                if let NamespaceBody::Braced(inner) = &ns.body
433                    && let Some(found) = enclosing_class_in_stmts(sv, &inner.stmts, pos)
434                {
435                    return Some(found);
436                }
437            }
438            _ => {}
439        }
440    }
441    None
442}
443
444/// Return the parameter names of the function or method named `func_name`.
445pub fn params_of_function(doc: &ParsedDoc, func_name: &str) -> Vec<String> {
446    let mut out = Vec::new();
447    collect_params_stmts(&doc.program().stmts, func_name, &mut out);
448    out
449}
450
451/// Return the parameter names of `method_name` on class `class_name`.
452/// Primarily used to offer named-argument completions for attribute constructors.
453pub fn params_of_method(doc: &ParsedDoc, class_name: &str, method_name: &str) -> Vec<String> {
454    let mut out = Vec::new();
455    collect_method_params_stmts(&doc.program().stmts, class_name, method_name, &mut out);
456    out
457}
458
459fn collect_method_params_stmts(
460    stmts: &[php_ast::Stmt<'_, '_>],
461    class_name: &str,
462    method_name: &str,
463    out: &mut Vec<String>,
464) {
465    for stmt in stmts {
466        match &stmt.kind {
467            StmtKind::Class(c)
468                if c.name.as_ref().map(|n| n.to_string()) == Some(class_name.to_string()) =>
469            {
470                for member in c.body.members.iter() {
471                    if let ClassMemberKind::Method(m) = &member.kind
472                        && m.name == method_name
473                    {
474                        for p in m.params.iter() {
475                            out.push(p.name.to_string());
476                        }
477                        return;
478                    }
479                }
480            }
481            StmtKind::Namespace(ns) => {
482                if let NamespaceBody::Braced(inner) = &ns.body {
483                    collect_method_params_stmts(&inner.stmts, class_name, method_name, out);
484                }
485            }
486            _ => {}
487        }
488    }
489}
490
491/// Returns `true` if `class_name` is declared as an `enum` in `doc`.
492pub fn is_enum(doc: &ParsedDoc, class_name: &str) -> bool {
493    is_enum_in_stmts(&doc.program().stmts, class_name)
494}
495
496fn is_enum_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> bool {
497    for stmt in stmts {
498        match &stmt.kind {
499            StmtKind::Enum(e) if e.name == name => return true,
500            StmtKind::Namespace(ns) => {
501                if let NamespaceBody::Braced(inner) = &ns.body
502                    && is_enum_in_stmts(&inner.stmts, name)
503                {
504                    return true;
505                }
506            }
507            _ => {}
508        }
509    }
510    false
511}
512
513/// Returns `true` if `class_name` is a *backed* enum (`enum Foo: string` /
514/// `enum Foo: int`) in `doc`.  Backed enums have a `->value` property.
515pub fn is_backed_enum(doc: &ParsedDoc, class_name: &str) -> bool {
516    enum_backing_type(doc, class_name).is_some()
517}
518
519/// Returns the declared backing type (`"string"` / `"int"`) of `class_name`
520/// if it is a backed enum in `doc`, or `None` if it is not an enum, or is an
521/// unbacked (pure) enum.
522pub fn enum_backing_type(doc: &ParsedDoc, class_name: &str) -> Option<String> {
523    enum_backing_type_in_stmts(&doc.program().stmts, class_name)
524}
525
526fn enum_backing_type_in_stmts(stmts: &[Stmt<'_, '_>], name: &str) -> Option<String> {
527    for stmt in stmts {
528        match &stmt.kind {
529            StmtKind::Enum(e) if e.name == name => {
530                return e
531                    .scalar_type
532                    .as_ref()
533                    .map(|t| t.to_string_repr().to_string());
534            }
535            StmtKind::Namespace(ns) => {
536                if let NamespaceBody::Braced(inner) = &ns.body
537                    && let Some(ty) = enum_backing_type_in_stmts(&inner.stmts, name)
538                {
539                    return Some(ty);
540                }
541            }
542            _ => {}
543        }
544    }
545    None
546}
547
548fn collect_params_stmts(stmts: &[Stmt<'_, '_>], func_name: &str, out: &mut Vec<String>) {
549    for stmt in stmts {
550        match &stmt.kind {
551            StmtKind::Function(f) if f.name == func_name => {
552                for p in f.params.iter() {
553                    out.push(p.name.to_string());
554                }
555                return;
556            }
557            StmtKind::Class(c) => {
558                for member in c.body.members.iter() {
559                    if let ClassMemberKind::Method(m) = &member.kind
560                        && m.name == func_name
561                    {
562                        for p in m.params.iter() {
563                            out.push(p.name.to_string());
564                        }
565                        return;
566                    }
567                }
568            }
569            StmtKind::Namespace(ns) => {
570                if let NamespaceBody::Braced(inner) = &ns.body {
571                    collect_params_stmts(&inner.stmts, func_name, out);
572                }
573            }
574            _ => {}
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn parent_class_name_finds_parent() {
585        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
586        let doc = ParsedDoc::parse(src.to_string());
587        assert_eq!(parent_class_name(&doc, "Child"), Some("Base".to_string()));
588    }
589
590    #[test]
591    fn parent_class_name_returns_none_for_top_level() {
592        let src = "<?php\nclass Base {}";
593        let doc = ParsedDoc::parse(src.to_string());
594        assert!(parent_class_name(&doc, "Base").is_none());
595    }
596
597    #[test]
598    fn members_of_class_includes_parent_field() {
599        let src = "<?php\nclass Base {}\nclass Child extends Base {}";
600        let doc = ParsedDoc::parse(src.to_string());
601        let m = members_of_class(&doc, "Child");
602        assert_eq!(m.parent.as_deref(), Some("Base"));
603    }
604
605    #[test]
606    fn members_of_class_finds_methods() {
607        let src = "<?php\nclass Calc { public function add() {} public function sub() {} }";
608        let doc = ParsedDoc::parse(src.to_string());
609        let members = members_of_class(&doc, "Calc");
610        let names: Vec<&str> = members.methods.iter().map(|(n, _, _)| n.as_str()).collect();
611        assert!(names.contains(&"add"), "missing 'add'");
612        assert!(names.contains(&"sub"), "missing 'sub'");
613    }
614
615    #[test]
616    fn members_of_class_tracks_has_params_per_method() {
617        let src = "<?php\nclass Calc { public function reset() {} public function add(int $x) {} }";
618        let doc = ParsedDoc::parse(src.to_string());
619        let members = members_of_class(&doc, "Calc");
620        let has_params = |name: &str| {
621            members
622                .methods
623                .iter()
624                .find(|(n, _, _)| n == name)
625                .map(|(_, _, p)| *p)
626        };
627        assert_eq!(has_params("reset"), Some(false));
628        assert_eq!(has_params("add"), Some(true));
629    }
630
631    #[test]
632    fn members_of_unknown_class_is_empty() {
633        let src = "<?php\nclass Calc { public function add() {} }";
634        let doc = ParsedDoc::parse(src.to_string());
635        let members = members_of_class(&doc, "Unknown");
636        assert!(members.methods.is_empty());
637    }
638
639    #[test]
640    fn constructor_promoted_params_appear_as_properties() {
641        let src = "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}";
642        let doc = ParsedDoc::parse(src.to_string());
643        let members = members_of_class(&doc, "Point");
644        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
645        assert!(
646            prop_names.contains(&"x"),
647            "promoted param x should be a property"
648        );
649        assert!(
650            prop_names.contains(&"y"),
651            "promoted param y should be a property"
652        );
653    }
654
655    #[test]
656    fn promoted_readonly_params_appear_in_readonly_properties() {
657        let src = "<?php\nclass User {\n    public function __construct(\n        public readonly string $name,\n        public int $age,\n    ) {}\n}";
658        let doc = ParsedDoc::parse(src.to_string());
659        let members = members_of_class(&doc, "User");
660        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
661        assert!(
662            prop_names.contains(&"name"),
663            "promoted param name should be a property"
664        );
665        assert!(
666            prop_names.contains(&"age"),
667            "promoted param age should be a property"
668        );
669        assert!(
670            members.readonly_properties.contains(&"name".to_string()),
671            "readonly promoted param name should be in readonly_properties"
672        );
673        assert!(
674            !members.readonly_properties.contains(&"age".to_string()),
675            "non-readonly promoted param age should not be in readonly_properties"
676        );
677    }
678
679    #[test]
680    fn enum_instance_members_include_name() {
681        let src = "<?php\nenum Status { case Active; case Inactive; }";
682        let doc = ParsedDoc::parse(src.to_string());
683        let members = members_of_class(&doc, "Status");
684        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
685        assert!(
686            prop_names.contains(&"name"),
687            "pure enum should expose ->name"
688        );
689        assert!(
690            !prop_names.contains(&"value"),
691            "pure enum should not expose ->value"
692        );
693    }
694
695    #[test]
696    fn backed_enum_exposes_value_and_factory_methods() {
697        let src = "<?php\nenum Color: string { case Red = 'red'; }";
698        let doc = ParsedDoc::parse(src.to_string());
699        let members = members_of_class(&doc, "Color");
700        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
701        let method_names: Vec<&str> = members.methods.iter().map(|(n, _, _)| n.as_str()).collect();
702        assert!(
703            prop_names.contains(&"value"),
704            "backed enum should expose ->value"
705        );
706        assert!(
707            method_names.contains(&"from"),
708            "backed enum should have ::from()"
709        );
710        assert!(
711            method_names.contains(&"tryFrom"),
712            "backed enum should have ::tryFrom()"
713        );
714        assert!(
715            method_names.contains(&"cases"),
716            "enum should have ::cases()"
717        );
718    }
719
720    #[test]
721    fn enum_cases_appear_as_constants() {
722        let src = "<?php\nenum Status { case Active; case Inactive; }";
723        let doc = ParsedDoc::parse(src.to_string());
724        let members = members_of_class(&doc, "Status");
725        assert!(members.constants.contains(&"Active".to_string()));
726        assert!(members.constants.contains(&"Inactive".to_string()));
727    }
728
729    #[test]
730    fn trait_members_are_collected() {
731        let src = "<?php\ntrait Logging { public function log() {} public string $logFile; }";
732        let doc = ParsedDoc::parse(src.to_string());
733        let members = members_of_class(&doc, "Logging");
734        let method_names: Vec<&str> = members.methods.iter().map(|(n, _, _)| n.as_str()).collect();
735        let prop_names: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
736        assert!(
737            method_names.contains(&"log"),
738            "trait method log should be collected"
739        );
740        assert!(
741            prop_names.contains(&"logFile"),
742            "trait property logFile should be collected"
743        );
744    }
745
746    #[test]
747    fn class_with_trait_use_lists_trait() {
748        let src = "<?php\ntrait Logging { public function log() {} }\nclass App { use Logging; }";
749        let doc = ParsedDoc::parse(src.to_string());
750        let members = members_of_class(&doc, "App");
751        assert!(
752            members.trait_uses.contains(&"Logging".to_string()),
753            "should list used trait"
754        );
755    }
756
757    #[test]
758    fn is_enum_pure() {
759        let src = "<?php\nenum Suit { case Hearts; case Clubs; }";
760        let doc = ParsedDoc::parse(src.to_string());
761        assert!(is_enum(&doc, "Suit"));
762        assert!(!is_backed_enum(&doc, "Suit"));
763    }
764
765    #[test]
766    fn is_backed_enum_string() {
767        let src = "<?php\nenum Status: string { case Active = 'active'; }";
768        let doc = ParsedDoc::parse(src.to_string());
769        assert!(is_enum(&doc, "Status"));
770        assert!(is_backed_enum(&doc, "Status"));
771    }
772
773    #[test]
774    fn is_enum_false_for_class() {
775        let src = "<?php\nclass Foo {}";
776        let doc = ParsedDoc::parse(src.to_string());
777        assert!(!is_enum(&doc, "Foo"));
778        assert!(!is_backed_enum(&doc, "Foo"));
779    }
780
781    #[test]
782    fn docblock_property_appears_in_members() {
783        let src =
784            "<?php\n/**\n * @property string $email\n * @property-read int $id\n */\nclass User {}";
785        let doc = ParsedDoc::parse(src.to_string());
786        let members = members_of_class(&doc, "User");
787        let props: Vec<&str> = members.properties.iter().map(|(n, _)| n.as_str()).collect();
788        assert!(props.contains(&"email"));
789        assert!(props.contains(&"id"));
790    }
791
792    #[test]
793    fn docblock_method_appears_in_members() {
794        let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
795        let doc = ParsedDoc::parse(src.to_string());
796        let members = members_of_class(&doc, "Model");
797        let method_names: Vec<&str> = members.methods.iter().map(|(n, _, _)| n.as_str()).collect();
798        assert!(method_names.contains(&"find"));
799        assert!(method_names.contains(&"where"));
800        let where_static = members
801            .methods
802            .iter()
803            .find(|(n, _, _)| n == "where")
804            .map(|(_, s, _)| *s);
805        assert_eq!(where_static, Some(true));
806        let find_has_params = members
807            .methods
808            .iter()
809            .find(|(n, _, _)| n == "find")
810            .map(|(_, _, p)| *p);
811        assert_eq!(find_has_params, Some(true));
812    }
813}