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