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