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