Skip to main content

php_lsp/index/
file_index.rs

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