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