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