Skip to main content

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