Skip to main content

php_lsp/
file_index.rs

1/// Compact symbol index extracted from a parsed PHP file.
2///
3/// A `FileIndex` captures only the declaration-level information needed for
4/// cross-file features (go-to-definition, workspace symbols, hover signatures,
5/// find-implementations, etc.).  It is ~2 KB per file compared to ~100 KB for
6/// a full `ParsedDoc`, allowing the LSP to keep thousands of background files
7/// in memory without exhausting RAM.
8///
9/// Call [`FileIndex::extract`] right after parsing; the `ParsedDoc` (and its
10/// bumpalo arena) can be dropped immediately after extraction.
11use std::sync::Arc;
12
13use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
14
15use crate::ast::{ParsedDoc, format_type_hint};
16use crate::docblock::parse_docblock;
17
18// ── Public types ──────────────────────────────────────────────────────────────
19
20#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
21pub struct FileIndex {
22    pub namespace: Option<Box<str>>,
23    pub functions: Vec<FunctionDef>,
24    pub classes: Vec<ClassDef>,
25    pub constants: Vec<Box<str>>,
26}
27
28#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29pub struct FunctionDef {
30    pub name: Box<str>,
31    /// Fully-qualified name: `\Namespace\function_name` or just `function_name`.
32    pub fqn: Box<str>,
33    pub params: Vec<ParamDef>,
34    pub return_type: Option<Box<str>>,
35    /// Raw docblock text (the `/** … */` comment before the declaration).
36    pub doc: Option<Box<str>>,
37    pub start_line: u32,
38    /// Character position of the function name on its line (UTF-16 code units).
39    pub name_char: u32,
40}
41
42#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
43pub struct ParamDef {
44    pub name: Box<str>,
45    pub type_hint: Option<Box<str>>,
46    pub has_default: bool,
47    pub variadic: bool,
48}
49
50#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
51pub struct ClassDef {
52    pub name: Box<str>,
53    /// Fully-qualified name.
54    pub fqn: Box<str>,
55    pub kind: ClassKind,
56    pub is_abstract: bool,
57    /// `extends` clause as written in source (may be short name or FQN).
58    pub parent: Option<Arc<str>>,
59    pub implements: Vec<Arc<str>>,
60    pub traits: Vec<Arc<str>>,
61    pub methods: Vec<MethodDef>,
62    pub properties: Vec<PropertyDef>,
63    pub constants: Vec<Box<str>>,
64    /// Enum case names (only populated for `ClassKind::Enum`).
65    pub cases: Vec<Box<str>>,
66    pub start_line: u32,
67    /// Character position of the class/interface/trait/enum name on its line (UTF-16 code units).
68    pub name_char: u32,
69    /// Virtual methods declared via `@method` docblock tags.
70    pub doc_methods: Vec<DocMethodEntry>,
71    /// Classes/traits pulled in via `@mixin ClassName` docblock tags.
72    pub mixins: Vec<Arc<str>>,
73}
74
75/// A method declared only via a `@method` docblock tag (no real body).
76/// Kept separate from `MethodDef` so consumers that build signatures or inlay
77/// hints don't accidentally iterate over methods with no parameter information.
78#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79pub struct DocMethodEntry {
80    pub name: Box<str>,
81    pub is_static: bool,
82    /// Return type as written in the `@method` tag, e.g. `"User"` or `"static"`.
83    pub return_type: Option<Box<str>>,
84    /// Source line of the `@method` tag (0-based).
85    pub start_line: u32,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub enum ClassKind {
90    Class,
91    Interface,
92    Trait,
93    Enum,
94}
95
96#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
97pub struct MethodDef {
98    pub name: Box<str>,
99    pub is_static: bool,
100    pub is_abstract: bool,
101    pub visibility: Visibility,
102    pub params: Vec<ParamDef>,
103    pub return_type: Option<Box<str>>,
104    pub doc: Option<Box<str>>,
105    pub start_line: u32,
106    /// Character position of the method name on its line (UTF-16 code units).
107    pub name_char: u32,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111pub enum Visibility {
112    Public,
113    Protected,
114    Private,
115}
116
117#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
118pub struct PropertyDef {
119    pub name: Box<str>,
120    pub is_static: bool,
121    pub type_hint: Option<Box<str>>,
122    pub visibility: Visibility,
123    pub start_line: u32,
124    /// Character position of the property name on its line (UTF-16 code units).
125    pub name_char: u32,
126}
127
128// ── Extract ───────────────────────────────────────────────────────────────────
129
130impl FileIndex {
131    /// Walk `doc.program().stmts` once and build a compact symbol index.
132    pub fn extract(doc: &ParsedDoc) -> Self {
133        let source = doc.source();
134        let view = doc.view();
135        let mut index = FileIndex::default();
136        collect_stmts(source, &view, &doc.program().stmts, None, &mut index);
137        index
138    }
139}
140
141// ── Internal helpers ─────────────────────────────────────────────────────────
142
143fn fqn(namespace: Option<&str>, name: &str) -> Box<str> {
144    match namespace {
145        Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, name).into(),
146        _ => name.into(),
147    }
148}
149
150fn collect_stmts(
151    source: &str,
152    view: &crate::ast::SourceView<'_>,
153    stmts: &[Stmt<'_, '_>],
154    namespace: Option<&str>,
155    index: &mut FileIndex,
156) {
157    use crate::ast::str_offset;
158
159    let name_char = |name: &str| -> u32 {
160        str_offset(source, name)
161            .map(|off| view.position_of(off).character)
162            .unwrap_or(0)
163    };
164
165    // Track the current namespace for unbraced `namespace Foo;` statements.
166    let mut cur_ns: Option<Box<str>> = namespace.map(|s| s.into());
167
168    for stmt in stmts {
169        match &stmt.kind {
170            // ── Namespace ────────────────────────────────────────────────────
171            StmtKind::Namespace(ns) => {
172                let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into());
173
174                match &ns.body {
175                    NamespaceBody::Braced(inner) => {
176                        // Braced namespace: recurse with its name as context.
177                        let ns_str = ns_name.as_deref();
178                        // Update the top-level namespace if not already set.
179                        if index.namespace.is_none() {
180                            index.namespace = ns_name.clone();
181                        }
182                        collect_stmts(source, view, &inner.stmts, ns_str, index);
183                    }
184                    NamespaceBody::Simple => {
185                        // Unbraced namespace: all following stmts belong to it.
186                        if index.namespace.is_none() {
187                            index.namespace = ns_name.clone();
188                        }
189                        cur_ns = ns_name;
190                    }
191                }
192            }
193
194            // ── Top-level function ───────────────────────────────────────────
195            StmtKind::Function(f) => {
196                let doc_text = f.doc_comment.as_ref().map(|c| c.text.into());
197                let start_line = view.position_of(stmt.span.start).line;
198                let ns = cur_ns.as_deref();
199                let f_name = f.name.or_error();
200                index.functions.push(FunctionDef {
201                    name: Box::from(f_name),
202                    fqn: fqn(ns, f_name),
203                    params: extract_params(&f.params),
204                    return_type: f.return_type.as_ref().map(|t| format_type_hint(t).into()),
205                    doc: doc_text,
206                    start_line,
207                    name_char: name_char(f_name),
208                });
209            }
210
211            // ── Class ────────────────────────────────────────────────────────
212            StmtKind::Class(c) => {
213                let Some(class_name) = c.name else { continue };
214                let class_name_str = class_name.or_error();
215                let start_line = view.position_of(stmt.span.start).line;
216                let ns = cur_ns.as_deref();
217
218                let mut class_def = ClassDef {
219                    name: Box::from(class_name_str),
220                    fqn: fqn(ns, class_name_str),
221                    kind: ClassKind::Class,
222                    is_abstract: c.modifiers.is_abstract,
223                    parent: c
224                        .extends
225                        .as_ref()
226                        .map(|e| Arc::from(e.to_string_repr().as_ref())),
227                    implements: c
228                        .implements
229                        .iter()
230                        .map(|i| Arc::from(i.to_string_repr().as_ref()))
231                        .collect(),
232                    traits: Vec::new(),
233                    methods: Vec::new(),
234                    properties: Vec::new(),
235                    constants: Vec::new(),
236                    cases: Vec::new(),
237                    start_line,
238                    name_char: name_char(class_name_str),
239                    doc_methods: Vec::new(),
240                    mixins: Vec::new(),
241                };
242
243                for member in c.body.members.iter() {
244                    match &member.kind {
245                        ClassMemberKind::Method(m) => {
246                            let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
247                            let mstart = view.position_of(member.span.start).line;
248                            let vis = method_visibility(m.visibility);
249                            let method_params = extract_params(&m.params);
250                            // Constructor-promoted params → also add as PropertyDef.
251                            for ast_param in m.params.iter() {
252                                if ast_param.visibility.is_some() {
253                                    let pvis = method_visibility(ast_param.visibility);
254                                    let pstart = view.position_of(ast_param.span.start).line;
255                                    let p_name = ast_param.name.or_error();
256                                    class_def.properties.push(PropertyDef {
257                                        name: Box::from(p_name),
258                                        is_static: false,
259                                        type_hint: ast_param
260                                            .type_hint
261                                            .as_ref()
262                                            .map(|t| format_type_hint(t).into()),
263                                        visibility: pvis,
264                                        start_line: pstart,
265                                        name_char: name_char(p_name),
266                                    });
267                                }
268                            }
269                            let m_name = m.name.or_error();
270                            class_def.methods.push(MethodDef {
271                                name: Box::from(m_name),
272                                is_static: m.is_static,
273                                is_abstract: m.is_abstract,
274                                visibility: vis,
275                                params: method_params,
276                                return_type: m
277                                    .return_type
278                                    .as_ref()
279                                    .map(|t| format_type_hint(t).into()),
280                                doc: mdoc,
281                                start_line: mstart,
282                                name_char: name_char(m_name),
283                            });
284                        }
285                        ClassMemberKind::Property(p) => {
286                            let vis = method_visibility(p.visibility);
287                            let pstart = view.position_of(member.span.start).line;
288                            let p_name = p.name.or_error();
289                            class_def.properties.push(PropertyDef {
290                                name: Box::from(p_name),
291                                is_static: p.is_static,
292                                type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
293                                visibility: vis,
294                                start_line: pstart,
295                                name_char: name_char(p_name),
296                            });
297                        }
298                        ClassMemberKind::ClassConst(cc) => {
299                            class_def.constants.push(Box::from(cc.name.or_error()));
300                        }
301                        ClassMemberKind::TraitUse(tu) => {
302                            for t in tu.traits.iter() {
303                                class_def
304                                    .traits
305                                    .push(Arc::from(t.to_string_repr().as_ref()));
306                            }
307                        }
308                    }
309                }
310                // Extract `@method` and `@mixin` docblock tags.
311                // `@method` tags become virtual method entries for go-to-definition.
312                // `@mixin` tags extend the class hierarchy walked by find_method_in_class_hierarchy.
313                if let Some(doc) = &c.doc_comment {
314                    let db = parse_docblock(doc.text);
315                    for dm in &db.methods {
316                        let line = doc_method_tag_line(view, doc, &dm.name);
317                        let ret = if dm.return_type.is_empty() {
318                            None
319                        } else {
320                            Some(Box::from(dm.return_type.as_str()))
321                        };
322                        class_def.doc_methods.push(DocMethodEntry {
323                            name: Box::from(dm.name.as_str()),
324                            is_static: dm.is_static,
325                            return_type: ret,
326                            start_line: line,
327                        });
328                    }
329                    for mixin in &db.mixins {
330                        class_def.mixins.push(Arc::from(mixin.as_str()));
331                    }
332                }
333                index.classes.push(class_def);
334            }
335
336            // ── Interface ────────────────────────────────────────────────────
337            StmtKind::Interface(i) => {
338                let start_line = view.position_of(stmt.span.start).line;
339                let ns = cur_ns.as_deref();
340
341                let i_name = i.name.or_error();
342                let mut iface_def = ClassDef {
343                    name: Box::from(i_name),
344                    fqn: fqn(ns, i_name),
345                    kind: ClassKind::Interface,
346                    is_abstract: true,
347                    parent: None,
348                    implements: i
349                        .extends
350                        .iter()
351                        .map(|e| Arc::from(e.to_string_repr().as_ref()))
352                        .collect(),
353                    traits: Vec::new(),
354                    methods: Vec::new(),
355                    properties: Vec::new(),
356                    constants: Vec::new(),
357                    cases: Vec::new(),
358                    start_line,
359                    name_char: name_char(i_name),
360                    doc_methods: Vec::new(),
361                    mixins: Vec::new(),
362                };
363
364                for member in i.body.members.iter() {
365                    match &member.kind {
366                        ClassMemberKind::Method(m) => {
367                            let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
368                            let mstart = view.position_of(member.span.start).line;
369                            let m_name = m.name.or_error();
370                            iface_def.methods.push(MethodDef {
371                                name: Box::from(m_name),
372                                is_static: m.is_static,
373                                is_abstract: true,
374                                visibility: Visibility::Public,
375                                params: extract_params(&m.params),
376                                return_type: m
377                                    .return_type
378                                    .as_ref()
379                                    .map(|t| format_type_hint(t).into()),
380                                doc: mdoc,
381                                start_line: mstart,
382                                name_char: name_char(m_name),
383                            });
384                        }
385                        ClassMemberKind::ClassConst(cc) => {
386                            iface_def.constants.push(Box::from(cc.name.or_error()));
387                        }
388                        _ => {}
389                    }
390                }
391                index.classes.push(iface_def);
392            }
393
394            // ── Trait ────────────────────────────────────────────────────────
395            StmtKind::Trait(t) => {
396                let start_line = view.position_of(stmt.span.start).line;
397                let ns = cur_ns.as_deref();
398
399                let t_name = t.name.or_error();
400                let mut trait_def = ClassDef {
401                    name: Box::from(t_name),
402                    fqn: fqn(ns, t_name),
403                    kind: ClassKind::Trait,
404                    is_abstract: false,
405                    parent: None,
406                    implements: Vec::new(),
407                    traits: Vec::new(),
408                    methods: Vec::new(),
409                    properties: Vec::new(),
410                    constants: Vec::new(),
411                    cases: Vec::new(),
412                    start_line,
413                    name_char: name_char(t_name),
414                    doc_methods: Vec::new(),
415                    mixins: Vec::new(),
416                };
417
418                for member in t.body.members.iter() {
419                    match &member.kind {
420                        ClassMemberKind::Method(m) => {
421                            let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
422                            let mstart = view.position_of(member.span.start).line;
423                            let vis = method_visibility(m.visibility);
424                            let m_name = m.name.or_error();
425                            trait_def.methods.push(MethodDef {
426                                name: Box::from(m_name),
427                                is_static: m.is_static,
428                                is_abstract: m.is_abstract,
429                                visibility: vis,
430                                params: extract_params(&m.params),
431                                return_type: m
432                                    .return_type
433                                    .as_ref()
434                                    .map(|t| format_type_hint(t).into()),
435                                doc: mdoc,
436                                start_line: mstart,
437                                name_char: name_char(m_name),
438                            });
439                        }
440                        ClassMemberKind::Property(p) => {
441                            let vis = method_visibility(p.visibility);
442                            let pstart = view.position_of(member.span.start).line;
443                            let p_name = p.name.or_error();
444                            trait_def.properties.push(PropertyDef {
445                                name: Box::from(p_name),
446                                is_static: p.is_static,
447                                type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
448                                visibility: vis,
449                                start_line: pstart,
450                                name_char: name_char(p_name),
451                            });
452                        }
453                        ClassMemberKind::ClassConst(cc) => {
454                            trait_def.constants.push(Box::from(cc.name.or_error()));
455                        }
456                        ClassMemberKind::TraitUse(tu) => {
457                            for tr in tu.traits.iter() {
458                                trait_def
459                                    .traits
460                                    .push(Arc::from(tr.to_string_repr().as_ref()));
461                            }
462                        }
463                    }
464                }
465                index.classes.push(trait_def);
466            }
467
468            // ── Enum ─────────────────────────────────────────────────────────
469            StmtKind::Enum(e) => {
470                let start_line = view.position_of(stmt.span.start).line;
471                let ns = cur_ns.as_deref();
472
473                let e_name = e.name.or_error();
474                let mut enum_def = ClassDef {
475                    name: Box::from(e_name),
476                    fqn: fqn(ns, e_name),
477                    kind: ClassKind::Enum,
478                    is_abstract: false,
479                    parent: None,
480                    implements: e
481                        .implements
482                        .iter()
483                        .map(|i| Arc::from(i.to_string_repr().as_ref()))
484                        .collect(),
485                    traits: Vec::new(),
486                    methods: Vec::new(),
487                    properties: Vec::new(),
488                    constants: Vec::new(),
489                    cases: Vec::new(),
490                    start_line,
491                    name_char: name_char(e_name),
492                    doc_methods: Vec::new(),
493                    mixins: Vec::new(),
494                };
495
496                for member in e.body.members.iter() {
497                    match &member.kind {
498                        EnumMemberKind::Case(c) => {
499                            enum_def.cases.push(Box::from(c.name.or_error()));
500                        }
501                        EnumMemberKind::Method(m) => {
502                            let mdoc = m.doc_comment.as_ref().map(|c| c.text.into());
503                            let mstart = view.position_of(member.span.start).line;
504                            let vis = method_visibility(m.visibility);
505                            let m_name = m.name.or_error();
506                            enum_def.methods.push(MethodDef {
507                                name: Box::from(m_name),
508                                is_static: m.is_static,
509                                is_abstract: m.is_abstract,
510                                visibility: vis,
511                                params: extract_params(&m.params),
512                                return_type: m
513                                    .return_type
514                                    .as_ref()
515                                    .map(|t| format_type_hint(t).into()),
516                                doc: mdoc,
517                                start_line: mstart,
518                                name_char: name_char(m_name),
519                            });
520                        }
521                        EnumMemberKind::ClassConst(cc) => {
522                            enum_def.constants.push(Box::from(cc.name.or_error()));
523                        }
524                        _ => {}
525                    }
526                }
527                index.classes.push(enum_def);
528            }
529
530            // ── Top-level const ──────────────────────────────────────────────
531            StmtKind::Const(consts) => {
532                for c in consts.iter() {
533                    index.constants.push(Box::from(c.name.or_error()));
534                }
535            }
536
537            _ => {}
538        }
539    }
540}
541
542fn extract_params<'a, 'b>(params: &[php_ast::Param<'a, 'b>]) -> Vec<ParamDef> {
543    params
544        .iter()
545        .map(|p| ParamDef {
546            name: Box::from(p.name.or_error()),
547            type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
548            has_default: p.default.is_some(),
549            variadic: p.variadic,
550        })
551        .collect()
552}
553
554fn method_visibility(vis: Option<php_ast::Visibility>) -> Visibility {
555    match vis {
556        Some(php_ast::Visibility::Protected) => Visibility::Protected,
557        Some(php_ast::Visibility::Private) => Visibility::Private,
558        _ => Visibility::Public,
559    }
560}
561
562/// Return the source line (0-based) of the `@method method_name` tag within
563/// `doc_comment`. Falls back to the docblock's own start line if not found.
564fn doc_method_tag_line(
565    view: &crate::ast::SourceView<'_>,
566    doc_comment: &php_ast::Comment<'_>,
567    method_name: &str,
568) -> u32 {
569    let text = doc_comment.text;
570    let base = doc_comment.span.start as usize;
571    let mut offset = 0usize;
572    while let Some(tag_pos) = text[offset..].find("@method") {
573        let segment_start = offset + tag_pos;
574        let segment = &text[segment_start..];
575        let line_len = segment.find('\n').unwrap_or(segment.len());
576        // Require `method_name(` to avoid matching the name as a substring
577        // inside a parameter name (e.g. `@method void log(string $find)` must
578        // not match when looking for `find`).
579        let needle = format!("{}(", method_name);
580        if segment[..line_len].contains(needle.as_str()) {
581            return view.position_of((base + segment_start) as u32).line;
582        }
583        offset = segment_start + "@method".len();
584    }
585    view.position_of(doc_comment.span.start).line
586}
587
588// ── Tests ─────────────────────────────────────────────────────────────────────
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn extracts_class_and_method() {
596        let src = "<?php\nclass Greeter {\n    public function greet(string $name): string {}\n}";
597        let doc = ParsedDoc::parse(src.to_string());
598        let idx = FileIndex::extract(&doc);
599        assert_eq!(idx.classes.len(), 1);
600        let cls = &idx.classes[0];
601        assert_eq!(cls.name, "Greeter".into());
602        assert_eq!(cls.kind, ClassKind::Class);
603        assert_eq!(cls.start_line, 1);
604        assert_eq!(cls.methods.len(), 1);
605        let method = &cls.methods[0];
606        assert_eq!(method.name, "greet".into());
607        assert_eq!(method.return_type.as_deref(), Some("string"));
608        assert_eq!(method.params.len(), 1);
609        assert_eq!(method.params[0].name, "name".into());
610        assert_eq!(method.params[0].type_hint.as_deref(), Some("string"));
611    }
612
613    #[test]
614    fn extracts_function() {
615        let src = "<?php\nfunction add(int $a, int $b): int {}";
616        let doc = ParsedDoc::parse(src.to_string());
617        let idx = FileIndex::extract(&doc);
618        assert_eq!(idx.functions.len(), 1);
619        let f = &idx.functions[0];
620        assert_eq!(f.name, "add".into());
621        assert_eq!(f.return_type.as_deref(), Some("int"));
622        assert_eq!(f.params.len(), 2);
623    }
624
625    #[test]
626    fn extracts_namespace() {
627        let src = "<?php\nnamespace App\\Services;\nclass Mailer {}";
628        let doc = ParsedDoc::parse(src.to_string());
629        let idx = FileIndex::extract(&doc);
630        assert_eq!(idx.namespace.as_deref(), Some("App\\Services"));
631        assert_eq!(idx.classes[0].fqn, "App\\Services\\Mailer".into());
632    }
633
634    #[test]
635    fn extracts_braced_namespace() {
636        let src = "<?php\nnamespace App\\Models {\n    class User {}\n}";
637        let doc = ParsedDoc::parse(src.to_string());
638        let idx = FileIndex::extract(&doc);
639        assert_eq!(idx.namespace.as_deref(), Some("App\\Models"));
640        assert_eq!(idx.classes[0].fqn, "App\\Models\\User".into());
641    }
642
643    #[test]
644    fn extracts_interface() {
645        let src = "<?php\ninterface Countable {\n    public function count(): int;\n}";
646        let doc = ParsedDoc::parse(src.to_string());
647        let idx = FileIndex::extract(&doc);
648        assert_eq!(idx.classes.len(), 1);
649        assert_eq!(idx.classes[0].kind, ClassKind::Interface);
650        assert_eq!(idx.classes[0].methods[0].name, "count".into());
651        assert!(idx.classes[0].methods[0].is_abstract);
652    }
653
654    #[test]
655    fn extracts_trait() {
656        let src = "<?php\ntrait Loggable {\n    public function log(): void {}\n}";
657        let doc = ParsedDoc::parse(src.to_string());
658        let idx = FileIndex::extract(&doc);
659        assert_eq!(idx.classes[0].kind, ClassKind::Trait);
660        assert_eq!(idx.classes[0].methods[0].name, "log".into());
661    }
662
663    #[test]
664    fn extracts_enum_cases() {
665        let src = "<?php\nenum Status { case Active; case Inactive; }";
666        let doc = ParsedDoc::parse(src.to_string());
667        let idx = FileIndex::extract(&doc);
668        assert_eq!(idx.classes[0].kind, ClassKind::Enum);
669        assert!(idx.classes[0].cases.iter().any(|c| c.as_ref() == "Active"));
670        assert!(
671            idx.classes[0]
672                .cases
673                .iter()
674                .any(|c| c.as_ref() == "Inactive")
675        );
676    }
677
678    #[test]
679    fn extracts_class_properties_and_constants() {
680        let src = "<?php\nclass Config {\n    public string $host;\n    const VERSION = '1.0';\n}";
681        let doc = ParsedDoc::parse(src.to_string());
682        let idx = FileIndex::extract(&doc);
683        let cls = &idx.classes[0];
684        assert_eq!(cls.properties.len(), 1);
685        assert_eq!(cls.properties[0].name, "host".into());
686        assert!(cls.constants.iter().any(|c| c.as_ref() == "VERSION"));
687    }
688
689    #[test]
690    fn extracts_trait_use() {
691        let src = "<?php\ntrait T {}\nclass MyClass { use T; }";
692        let doc = ParsedDoc::parse(src.to_string());
693        let idx = FileIndex::extract(&doc);
694        let cls = idx
695            .classes
696            .iter()
697            .find(|c| c.name.as_ref() == "MyClass")
698            .unwrap();
699        assert!(cls.traits.iter().any(|t| t.as_ref() == "T"));
700    }
701
702    #[test]
703    fn extracts_class_implements_and_extends() {
704        let src = "<?php\nclass Dog extends Animal implements Pet, Movable {}";
705        let doc = ParsedDoc::parse(src.to_string());
706        let idx = FileIndex::extract(&doc);
707        let cls = &idx.classes[0];
708        assert_eq!(cls.parent.as_deref(), Some("Animal"));
709        assert!(cls.implements.iter().any(|i| i.as_ref() == "Pet"));
710        assert!(cls.implements.iter().any(|i| i.as_ref() == "Movable"));
711    }
712
713    #[test]
714    fn constructor_promoted_params_become_properties() {
715        let src = "<?php\nclass User {\n    public function __construct(public string $name) {}\n}";
716        let doc = ParsedDoc::parse(src.to_string());
717        let idx = FileIndex::extract(&doc);
718        let cls = &idx.classes[0];
719        // Should have a property from the promoted param.
720        assert!(
721            cls.properties.iter().any(|p| p.name.as_ref() == "name"),
722            "expected promoted property 'name', got: {:?}",
723            cls.properties.iter().map(|p| &p.name).collect::<Vec<_>>()
724        );
725    }
726
727    #[test]
728    fn extracts_doc_methods_from_class_docblock() {
729        let src = "<?php\n/**\n * @method User find(int $id)\n * @method static Builder where(string $col, mixed $val)\n */\nclass Model {}";
730        let doc = ParsedDoc::parse(src.to_string());
731        let idx = FileIndex::extract(&doc);
732        let cls = &idx.classes[0];
733        assert_eq!(cls.doc_methods.len(), 2, "expected 2 @method entries");
734
735        let find = cls.doc_methods.iter().find(|m| m.name.as_ref() == "find");
736        assert!(find.is_some(), "expected @method find");
737        let find = find.unwrap();
738        assert!(!find.is_static);
739        assert_eq!(find.return_type.as_deref(), Some("User"));
740        assert_eq!(find.start_line, 2); // 0-based: line 0=<?php, 1=/**, 2=@method find
741
742        let where_m = cls.doc_methods.iter().find(|m| m.name.as_ref() == "where");
743        assert!(where_m.is_some(), "expected @method where");
744        let where_m = where_m.unwrap();
745        assert!(where_m.is_static);
746        assert_eq!(where_m.return_type.as_deref(), Some("Builder"));
747        assert_eq!(where_m.start_line, 3); // 0-based: line 3=@method static where
748    }
749
750    #[test]
751    fn doc_method_tag_line_no_substring_collision() {
752        // `log` has a param named `$find`; `find` must resolve to its own line, not `log`'s.
753        let src = "<?php\n/**\n * @method void log(string $find)\n * @method Model find()\n */\nclass Builder {}";
754        let doc = ParsedDoc::parse(src.to_string());
755        let idx = FileIndex::extract(&doc);
756        let cls = &idx.classes[0];
757        let find = cls.doc_methods.iter().find(|m| m.name.as_ref() == "find");
758        assert!(find.is_some(), "expected @method find");
759        assert_eq!(find.unwrap().start_line, 3); // line 3 = `@method Model find()`, not line 2
760    }
761
762    #[test]
763    fn class_without_docblock_has_no_doc_methods() {
764        let src = "<?php\nclass Plain {\n    public function foo(): void {}\n}";
765        let doc = ParsedDoc::parse(src.to_string());
766        let idx = FileIndex::extract(&doc);
767        assert!(idx.classes[0].doc_methods.is_empty());
768    }
769
770    #[test]
771    fn extracts_mixins_from_class_docblock() {
772        let src = "<?php\n/**\n * @mixin Macroable\n * @mixin HasEvents\n */\nclass Builder {}";
773        let doc = ParsedDoc::parse(src.to_string());
774        let idx = FileIndex::extract(&doc);
775        let cls = &idx.classes[0];
776        assert_eq!(cls.mixins.len(), 2, "expected 2 @mixin entries");
777        assert!(cls.mixins.iter().any(|m| m.as_ref() == "Macroable"));
778        assert!(cls.mixins.iter().any(|m| m.as_ref() == "HasEvents"));
779    }
780
781    #[test]
782    fn class_without_docblock_has_no_mixins() {
783        let src = "<?php\nclass Plain {}";
784        let doc = ParsedDoc::parse(src.to_string());
785        let idx = FileIndex::extract(&doc);
786        assert!(idx.classes[0].mixins.is_empty());
787    }
788}