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