Skip to main content

php_lsp/
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.
11use std::sync::Arc;
12
13use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
14
15use crate::ast::{ParsedDoc, format_type_hint};
16use crate::docblock::docblock_before;
17
18// ── Public types ──────────────────────────────────────────────────────────────
19
20#[derive(Debug, Clone, Default)]
21pub struct FileIndex {
22    pub namespace: Option<Box<str>>,
23    pub functions: Vec<FunctionDef>,
24    pub classes: Vec<ClassDef>,
25    pub constants: Vec<Box<str>>,
26}
27
28#[derive(Debug, Clone)]
29pub struct FunctionDef {
30    pub name: Box<str>,
31    /// Fully-qualified name: `\Namespace\function_name` or just `function_name`.
32    pub fqn: Box<str>,
33    pub params: Vec<ParamDef>,
34    pub return_type: Option<Box<str>>,
35    /// Raw docblock text (the `/** … */` comment before the declaration).
36    pub doc: Option<Box<str>>,
37    pub start_line: u32,
38    /// Character position of the function name on its line (UTF-16 code units).
39    pub name_char: u32,
40}
41
42#[derive(Debug, Clone)]
43pub struct ParamDef {
44    pub name: Box<str>,
45    pub type_hint: Option<Box<str>>,
46    pub has_default: bool,
47    pub variadic: bool,
48}
49
50#[derive(Debug, Clone)]
51pub struct ClassDef {
52    pub name: Box<str>,
53    /// Fully-qualified name.
54    pub fqn: Box<str>,
55    pub kind: ClassKind,
56    pub is_abstract: bool,
57    /// `extends` clause as written in source (may be short name or FQN).
58    pub parent: Option<Arc<str>>,
59    pub implements: Vec<Arc<str>>,
60    pub traits: Vec<Arc<str>>,
61    pub methods: Vec<MethodDef>,
62    pub properties: Vec<PropertyDef>,
63    pub constants: Vec<Box<str>>,
64    /// Enum case names (only populated for `ClassKind::Enum`).
65    pub cases: Vec<Box<str>>,
66    pub start_line: u32,
67    /// Character position of the class/interface/trait/enum name on its line (UTF-16 code units).
68    pub name_char: u32,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ClassKind {
73    Class,
74    Interface,
75    Trait,
76    Enum,
77}
78
79#[derive(Debug, Clone)]
80pub struct MethodDef {
81    pub name: Box<str>,
82    pub is_static: bool,
83    pub is_abstract: bool,
84    pub visibility: Visibility,
85    pub params: Vec<ParamDef>,
86    pub return_type: Option<Box<str>>,
87    pub doc: Option<Box<str>>,
88    pub start_line: u32,
89    /// Character position of the method name on its line (UTF-16 code units).
90    pub name_char: u32,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Visibility {
95    Public,
96    Protected,
97    Private,
98}
99
100#[derive(Debug, Clone)]
101pub struct PropertyDef {
102    pub name: Box<str>,
103    pub is_static: bool,
104    pub type_hint: Option<Box<str>>,
105    pub visibility: Visibility,
106    pub start_line: u32,
107    /// Character position of the property name on its line (UTF-16 code units).
108    pub name_char: u32,
109}
110
111// ── Extract ───────────────────────────────────────────────────────────────────
112
113impl FileIndex {
114    /// Walk `doc.program().stmts` once and build a compact symbol index.
115    pub fn extract(doc: &ParsedDoc) -> Self {
116        let source = doc.source();
117        let view = doc.view();
118        let mut index = FileIndex::default();
119        collect_stmts(source, &view, &doc.program().stmts, None, &mut index);
120        index
121    }
122}
123
124// ── Internal helpers ─────────────────────────────────────────────────────────
125
126fn fqn(namespace: Option<&str>, name: &str) -> Box<str> {
127    match namespace {
128        Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, name).into(),
129        _ => name.into(),
130    }
131}
132
133fn collect_stmts(
134    source: &str,
135    view: &crate::ast::SourceView<'_>,
136    stmts: &[Stmt<'_, '_>],
137    namespace: Option<&str>,
138    index: &mut FileIndex,
139) {
140    use crate::ast::str_offset;
141
142    let name_char = |name: &str| -> u32 {
143        str_offset(source, name)
144            .map(|off| view.position_of(off).character)
145            .unwrap_or(0)
146    };
147
148    // Track the current namespace for unbraced `namespace Foo;` statements.
149    let mut cur_ns: Option<Box<str>> = namespace.map(|s| s.into());
150
151    for stmt in stmts {
152        match &stmt.kind {
153            // ── Namespace ────────────────────────────────────────────────────
154            StmtKind::Namespace(ns) => {
155                let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into());
156
157                match &ns.body {
158                    NamespaceBody::Braced(inner) => {
159                        // Braced namespace: recurse with its name as context.
160                        let ns_str = ns_name.as_deref();
161                        // Update the top-level namespace if not already set.
162                        if index.namespace.is_none() {
163                            index.namespace = ns_name.clone();
164                        }
165                        collect_stmts(source, view, inner, ns_str, index);
166                    }
167                    NamespaceBody::Simple => {
168                        // Unbraced namespace: all following stmts belong to it.
169                        if index.namespace.is_none() {
170                            index.namespace = ns_name.clone();
171                        }
172                        cur_ns = ns_name;
173                    }
174                }
175            }
176
177            // ── Top-level function ───────────────────────────────────────────
178            StmtKind::Function(f) => {
179                let doc_text = docblock_before(source, stmt.span.start).map(|s| s.into());
180                let start_line = view.position_of(stmt.span.start).line;
181                let ns = cur_ns.as_deref();
182                let f_name = f.name.to_string();
183                index.functions.push(FunctionDef {
184                    name: f_name.clone().into(),
185                    fqn: fqn(ns, &f_name),
186                    params: extract_params(&f.params),
187                    return_type: f.return_type.as_ref().map(|t| format_type_hint(t).into()),
188                    doc: doc_text,
189                    start_line,
190                    name_char: name_char(&f_name),
191                });
192            }
193
194            // ── Class ────────────────────────────────────────────────────────
195            StmtKind::Class(c) => {
196                let Some(class_name) = c.name else { continue };
197                let class_name_str = class_name.to_string();
198                let start_line = view.position_of(stmt.span.start).line;
199                let ns = cur_ns.as_deref();
200
201                let mut class_def = ClassDef {
202                    name: class_name_str.clone().into(),
203                    fqn: fqn(ns, &class_name_str),
204                    kind: ClassKind::Class,
205                    is_abstract: c.modifiers.is_abstract,
206                    parent: c
207                        .extends
208                        .as_ref()
209                        .map(|e| Arc::from(e.to_string_repr().as_ref())),
210                    implements: c
211                        .implements
212                        .iter()
213                        .map(|i| Arc::from(i.to_string_repr().as_ref()))
214                        .collect(),
215                    traits: Vec::new(),
216                    methods: Vec::new(),
217                    properties: Vec::new(),
218                    constants: Vec::new(),
219                    cases: Vec::new(),
220                    start_line,
221                    name_char: name_char(&class_name_str),
222                };
223
224                for member in c.members.iter() {
225                    match &member.kind {
226                        ClassMemberKind::Method(m) => {
227                            let mdoc = docblock_before(source, member.span.start).map(|s| s.into());
228                            let mstart = view.position_of(member.span.start).line;
229                            let vis = method_visibility(m.visibility);
230                            let method_params = extract_params(&m.params);
231                            // Constructor-promoted params → also add as PropertyDef.
232                            for ast_param in m.params.iter() {
233                                if ast_param.visibility.is_some() {
234                                    let pvis = method_visibility(ast_param.visibility);
235                                    let pstart = view.position_of(ast_param.span.start).line;
236                                    class_def.properties.push(PropertyDef {
237                                        name: ast_param.name.to_string().into(),
238                                        is_static: false,
239                                        type_hint: ast_param
240                                            .type_hint
241                                            .as_ref()
242                                            .map(|t| format_type_hint(t).into()),
243                                        visibility: pvis,
244                                        start_line: pstart,
245                                        name_char: name_char(&ast_param.name.to_string()),
246                                    });
247                                }
248                            }
249                            class_def.methods.push(MethodDef {
250                                name: m.name.to_string().into(),
251                                is_static: m.is_static,
252                                is_abstract: m.is_abstract,
253                                visibility: vis,
254                                params: method_params,
255                                return_type: m
256                                    .return_type
257                                    .as_ref()
258                                    .map(|t| format_type_hint(t).into()),
259                                doc: mdoc,
260                                start_line: mstart,
261                                name_char: name_char(&m.name.to_string()),
262                            });
263                        }
264                        ClassMemberKind::Property(p) => {
265                            let vis = method_visibility(p.visibility);
266                            let pstart = view.position_of(member.span.start).line;
267                            class_def.properties.push(PropertyDef {
268                                name: p.name.to_string().into(),
269                                is_static: p.is_static,
270                                type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
271                                visibility: vis,
272                                start_line: pstart,
273                                name_char: name_char(&p.name.to_string()),
274                            });
275                        }
276                        ClassMemberKind::ClassConst(cc) => {
277                            class_def.constants.push(cc.name.to_string().into());
278                        }
279                        ClassMemberKind::TraitUse(tu) => {
280                            for t in tu.traits.iter() {
281                                class_def
282                                    .traits
283                                    .push(Arc::from(t.to_string_repr().as_ref()));
284                            }
285                        }
286                    }
287                }
288                index.classes.push(class_def);
289            }
290
291            // ── Interface ────────────────────────────────────────────────────
292            StmtKind::Interface(i) => {
293                let start_line = view.position_of(stmt.span.start).line;
294                let ns = cur_ns.as_deref();
295
296                let mut iface_def = ClassDef {
297                    name: i.name.to_string().into(),
298                    fqn: fqn(ns, &i.name.to_string()),
299                    kind: ClassKind::Interface,
300                    is_abstract: true,
301                    parent: None,
302                    implements: i
303                        .extends
304                        .iter()
305                        .map(|e| Arc::from(e.to_string_repr().as_ref()))
306                        .collect(),
307                    traits: Vec::new(),
308                    methods: Vec::new(),
309                    properties: Vec::new(),
310                    constants: Vec::new(),
311                    cases: Vec::new(),
312                    start_line,
313                    name_char: name_char(&i.name.to_string()),
314                };
315
316                for member in i.members.iter() {
317                    match &member.kind {
318                        ClassMemberKind::Method(m) => {
319                            let mdoc = docblock_before(source, member.span.start).map(|s| s.into());
320                            let mstart = view.position_of(member.span.start).line;
321                            iface_def.methods.push(MethodDef {
322                                name: m.name.to_string().into(),
323                                is_static: m.is_static,
324                                is_abstract: true,
325                                visibility: Visibility::Public,
326                                params: extract_params(&m.params),
327                                return_type: m
328                                    .return_type
329                                    .as_ref()
330                                    .map(|t| format_type_hint(t).into()),
331                                doc: mdoc,
332                                start_line: mstart,
333                                name_char: name_char(&m.name.to_string()),
334                            });
335                        }
336                        ClassMemberKind::ClassConst(cc) => {
337                            iface_def.constants.push(cc.name.to_string().into());
338                        }
339                        _ => {}
340                    }
341                }
342                index.classes.push(iface_def);
343            }
344
345            // ── Trait ────────────────────────────────────────────────────────
346            StmtKind::Trait(t) => {
347                let start_line = view.position_of(stmt.span.start).line;
348                let ns = cur_ns.as_deref();
349
350                let mut trait_def = ClassDef {
351                    name: t.name.to_string().into(),
352                    fqn: fqn(ns, &t.name.to_string()),
353                    kind: ClassKind::Trait,
354                    is_abstract: false,
355                    parent: None,
356                    implements: Vec::new(),
357                    traits: Vec::new(),
358                    methods: Vec::new(),
359                    properties: Vec::new(),
360                    constants: Vec::new(),
361                    cases: Vec::new(),
362                    start_line,
363                    name_char: name_char(&t.name.to_string()),
364                };
365
366                for member in t.members.iter() {
367                    match &member.kind {
368                        ClassMemberKind::Method(m) => {
369                            let mdoc = docblock_before(source, member.span.start).map(|s| s.into());
370                            let mstart = view.position_of(member.span.start).line;
371                            let vis = method_visibility(m.visibility);
372                            trait_def.methods.push(MethodDef {
373                                name: m.name.to_string().into(),
374                                is_static: m.is_static,
375                                is_abstract: m.is_abstract,
376                                visibility: vis,
377                                params: extract_params(&m.params),
378                                return_type: m
379                                    .return_type
380                                    .as_ref()
381                                    .map(|t| format_type_hint(t).into()),
382                                doc: mdoc,
383                                start_line: mstart,
384                                name_char: name_char(&m.name.to_string()),
385                            });
386                        }
387                        ClassMemberKind::Property(p) => {
388                            let vis = method_visibility(p.visibility);
389                            let pstart = view.position_of(member.span.start).line;
390                            trait_def.properties.push(PropertyDef {
391                                name: p.name.to_string().into(),
392                                is_static: p.is_static,
393                                type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
394                                visibility: vis,
395                                start_line: pstart,
396                                name_char: name_char(&p.name.to_string()),
397                            });
398                        }
399                        ClassMemberKind::ClassConst(cc) => {
400                            trait_def.constants.push(cc.name.to_string().into());
401                        }
402                        ClassMemberKind::TraitUse(tu) => {
403                            for tr in tu.traits.iter() {
404                                trait_def
405                                    .traits
406                                    .push(Arc::from(tr.to_string_repr().as_ref()));
407                            }
408                        }
409                    }
410                }
411                index.classes.push(trait_def);
412            }
413
414            // ── Enum ─────────────────────────────────────────────────────────
415            StmtKind::Enum(e) => {
416                let start_line = view.position_of(stmt.span.start).line;
417                let ns = cur_ns.as_deref();
418
419                let mut enum_def = ClassDef {
420                    name: e.name.to_string().into(),
421                    fqn: fqn(ns, &e.name.to_string()),
422                    kind: ClassKind::Enum,
423                    is_abstract: false,
424                    parent: None,
425                    implements: e
426                        .implements
427                        .iter()
428                        .map(|i| Arc::from(i.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(&e.name.to_string()),
437                };
438
439                for member in e.members.iter() {
440                    match &member.kind {
441                        EnumMemberKind::Case(c) => {
442                            enum_def.cases.push(c.name.to_string().into());
443                        }
444                        EnumMemberKind::Method(m) => {
445                            let mdoc = docblock_before(source, member.span.start).map(|s| s.into());
446                            let mstart = view.position_of(member.span.start).line;
447                            let vis = method_visibility(m.visibility);
448                            enum_def.methods.push(MethodDef {
449                                name: m.name.to_string().into(),
450                                is_static: m.is_static,
451                                is_abstract: m.is_abstract,
452                                visibility: vis,
453                                params: extract_params(&m.params),
454                                return_type: m
455                                    .return_type
456                                    .as_ref()
457                                    .map(|t| format_type_hint(t).into()),
458                                doc: mdoc,
459                                start_line: mstart,
460                                name_char: name_char(&m.name.to_string()),
461                            });
462                        }
463                        EnumMemberKind::ClassConst(cc) => {
464                            enum_def.constants.push(cc.name.to_string().into());
465                        }
466                        _ => {}
467                    }
468                }
469                index.classes.push(enum_def);
470            }
471
472            // ── Top-level const ──────────────────────────────────────────────
473            StmtKind::Const(consts) => {
474                for c in consts.iter() {
475                    index.constants.push(c.name.to_string().into());
476                }
477            }
478
479            _ => {}
480        }
481    }
482}
483
484fn extract_params<'a, 'b>(params: &[php_ast::Param<'a, 'b>]) -> Vec<ParamDef> {
485    params
486        .iter()
487        .map(|p| ParamDef {
488            name: p.name.to_string().into(),
489            type_hint: p.type_hint.as_ref().map(|t| format_type_hint(t).into()),
490            has_default: p.default.is_some(),
491            variadic: p.variadic,
492        })
493        .collect()
494}
495
496fn method_visibility(vis: Option<php_ast::Visibility>) -> Visibility {
497    match vis {
498        Some(php_ast::Visibility::Protected) => Visibility::Protected,
499        Some(php_ast::Visibility::Private) => Visibility::Private,
500        _ => Visibility::Public,
501    }
502}
503
504// ── Tests ─────────────────────────────────────────────────────────────────────
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn extracts_class_and_method() {
512        let src = "<?php\nclass Greeter {\n    public function greet(string $name): string {}\n}";
513        let doc = ParsedDoc::parse(src.to_string());
514        let idx = FileIndex::extract(&doc);
515        assert_eq!(idx.classes.len(), 1);
516        let cls = &idx.classes[0];
517        assert_eq!(cls.name, "Greeter".into());
518        assert_eq!(cls.kind, ClassKind::Class);
519        assert_eq!(cls.start_line, 1);
520        assert_eq!(cls.methods.len(), 1);
521        let method = &cls.methods[0];
522        assert_eq!(method.name, "greet".into());
523        assert_eq!(method.return_type.as_deref(), Some("string"));
524        assert_eq!(method.params.len(), 1);
525        assert_eq!(method.params[0].name, "name".into());
526        assert_eq!(method.params[0].type_hint.as_deref(), Some("string"));
527    }
528
529    #[test]
530    fn extracts_function() {
531        let src = "<?php\nfunction add(int $a, int $b): int {}";
532        let doc = ParsedDoc::parse(src.to_string());
533        let idx = FileIndex::extract(&doc);
534        assert_eq!(idx.functions.len(), 1);
535        let f = &idx.functions[0];
536        assert_eq!(f.name, "add".into());
537        assert_eq!(f.return_type.as_deref(), Some("int"));
538        assert_eq!(f.params.len(), 2);
539    }
540
541    #[test]
542    fn extracts_namespace() {
543        let src = "<?php\nnamespace App\\Services;\nclass Mailer {}";
544        let doc = ParsedDoc::parse(src.to_string());
545        let idx = FileIndex::extract(&doc);
546        assert_eq!(idx.namespace.as_deref(), Some("App\\Services"));
547        assert_eq!(idx.classes[0].fqn, "App\\Services\\Mailer".into());
548    }
549
550    #[test]
551    fn extracts_braced_namespace() {
552        let src = "<?php\nnamespace App\\Models {\n    class User {}\n}";
553        let doc = ParsedDoc::parse(src.to_string());
554        let idx = FileIndex::extract(&doc);
555        assert_eq!(idx.namespace.as_deref(), Some("App\\Models"));
556        assert_eq!(idx.classes[0].fqn, "App\\Models\\User".into());
557    }
558
559    #[test]
560    fn extracts_interface() {
561        let src = "<?php\ninterface Countable {\n    public function count(): int;\n}";
562        let doc = ParsedDoc::parse(src.to_string());
563        let idx = FileIndex::extract(&doc);
564        assert_eq!(idx.classes.len(), 1);
565        assert_eq!(idx.classes[0].kind, ClassKind::Interface);
566        assert_eq!(idx.classes[0].methods[0].name, "count".into());
567        assert!(idx.classes[0].methods[0].is_abstract);
568    }
569
570    #[test]
571    fn extracts_trait() {
572        let src = "<?php\ntrait Loggable {\n    public function log(): void {}\n}";
573        let doc = ParsedDoc::parse(src.to_string());
574        let idx = FileIndex::extract(&doc);
575        assert_eq!(idx.classes[0].kind, ClassKind::Trait);
576        assert_eq!(idx.classes[0].methods[0].name, "log".into());
577    }
578
579    #[test]
580    fn extracts_enum_cases() {
581        let src = "<?php\nenum Status { case Active; case Inactive; }";
582        let doc = ParsedDoc::parse(src.to_string());
583        let idx = FileIndex::extract(&doc);
584        assert_eq!(idx.classes[0].kind, ClassKind::Enum);
585        assert!(idx.classes[0].cases.iter().any(|c| c.as_ref() == "Active"));
586        assert!(
587            idx.classes[0]
588                .cases
589                .iter()
590                .any(|c| c.as_ref() == "Inactive")
591        );
592    }
593
594    #[test]
595    fn extracts_class_properties_and_constants() {
596        let src = "<?php\nclass Config {\n    public string $host;\n    const VERSION = '1.0';\n}";
597        let doc = ParsedDoc::parse(src.to_string());
598        let idx = FileIndex::extract(&doc);
599        let cls = &idx.classes[0];
600        assert_eq!(cls.properties.len(), 1);
601        assert_eq!(cls.properties[0].name, "host".into());
602        assert!(cls.constants.iter().any(|c| c.as_ref() == "VERSION"));
603    }
604
605    #[test]
606    fn extracts_trait_use() {
607        let src = "<?php\ntrait T {}\nclass MyClass { use T; }";
608        let doc = ParsedDoc::parse(src.to_string());
609        let idx = FileIndex::extract(&doc);
610        let cls = idx
611            .classes
612            .iter()
613            .find(|c| c.name.as_ref() == "MyClass")
614            .unwrap();
615        assert!(cls.traits.iter().any(|t| t.as_ref() == "T"));
616    }
617
618    #[test]
619    fn extracts_class_implements_and_extends() {
620        let src = "<?php\nclass Dog extends Animal implements Pet, Movable {}";
621        let doc = ParsedDoc::parse(src.to_string());
622        let idx = FileIndex::extract(&doc);
623        let cls = &idx.classes[0];
624        assert_eq!(cls.parent.as_deref(), Some("Animal"));
625        assert!(cls.implements.iter().any(|i| i.as_ref() == "Pet"));
626        assert!(cls.implements.iter().any(|i| i.as_ref() == "Movable"));
627    }
628
629    #[test]
630    fn constructor_promoted_params_become_properties() {
631        let src = "<?php\nclass User {\n    public function __construct(public string $name) {}\n}";
632        let doc = ParsedDoc::parse(src.to_string());
633        let idx = FileIndex::extract(&doc);
634        let cls = &idx.classes[0];
635        // Should have a property from the promoted param.
636        assert!(
637            cls.properties.iter().any(|p| p.name.as_ref() == "name"),
638            "expected promoted property 'name', got: {:?}",
639            cls.properties.iter().map(|p| &p.name).collect::<Vec<_>>()
640        );
641    }
642}