Skip to main content

php_lsp/index/
file_index.rs

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