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