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