Skip to main content

php_lsp/types/
symbol_map.rs

1//! Per-file memoized symbol table.
2//!
3//! [`SymbolMap`] is a pre-computed `HashMap<name, Vec<SymbolEntry>>` built from
4//! a parsed PHP file in one AST pass. Each entry stores the precise LSP `Range`
5//! of the identifier, the declaration kind, whether it is abstract, a
6//! pre-rendered hover signature, and a pre-extracted docblock (as markdown).
7//!
8//! Because building the map is O(AST_size) but lookup is O(1), the payoff is
9//! on the cross-file / `other_docs` path: a stable file (one that hasn't changed
10//! since the last keystroke) has its map served from the salsa cache rather than
11//! re-walking its AST on every request. See [`crate::db::symbol_map`] for the
12//! salsa query that drives this.
13
14use std::collections::HashMap;
15
16use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
17use tower_lsp::lsp_types::Range;
18
19use crate::document::ast::ParsedDoc;
20use crate::hover::formatting::declaration_signature;
21use crate::types::resolve::{Container, Declaration};
22
23/// Which kind of PHP declaration this entry represents. Mirrors the variants of
24/// [`Declaration`] so callers can reconstruct any accept predicate without an
25/// AST walk.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SymbolEntryKind {
28    Function,
29    Class,
30    Interface,
31    Trait,
32    Enum,
33    Method { container: Container },
34    ClassConst { container: Container },
35    Property { container: Container },
36    PromotedParam,
37    EnumCase,
38}
39
40/// A single resolved declaration stored in the pre-computed symbol map.
41#[derive(Debug, Clone)]
42pub struct SymbolEntry {
43    /// Precise LSP range of the identifier (not the full declaration span).
44    pub name_range: Range,
45    pub kind: SymbolEntryKind,
46    /// Whether the declaration is abstract (interface members, abstract methods).
47    /// Used to reconstruct `goto_declaration`'s two-pass abstract-first logic.
48    pub is_abstract: bool,
49    /// Pre-rendered hover signature (e.g. `function foo(int $x): void`).
50    /// `None` for properties and promoted parameters, which use the mir path.
51    pub signature: Option<String>,
52    /// Pre-extracted docblock rendered as markdown. `None` when no docblock
53    /// precedes the declaration.
54    pub doc_markdown: Option<String>,
55}
56
57/// Pre-computed symbol table for a single PHP file.
58///
59/// Built by [`SymbolMap::build`] in one AST pass; looked up in O(1) via
60/// [`SymbolMap::lookup`]. The `Vec` per key preserves source order so that
61/// predicates applied by [`lookup`] (e.g. "abstract first") stay correct.
62#[derive(Clone, Default)]
63pub struct SymbolMap {
64    entries: HashMap<String, Vec<SymbolEntry>>,
65}
66
67impl SymbolMap {
68    /// Walk `doc`'s AST once and build the complete symbol map.
69    pub fn build(doc: &ParsedDoc) -> Self {
70        let sv = doc.view();
71        let mut entries: HashMap<String, Vec<SymbolEntry>> = HashMap::new();
72        collect_stmts(&doc.program().stmts, sv, &mut entries);
73        SymbolMap { entries }
74    }
75
76    /// Find the first entry with key `name` that `accept` approves, in source
77    /// order — matching [`resolve_declaration`]'s first-match semantics.
78    pub fn lookup(
79        &self,
80        name: &str,
81        accept: impl Fn(&SymbolEntry) -> bool,
82    ) -> Option<&SymbolEntry> {
83        self.entries.get(name)?.iter().find(|e| accept(e))
84    }
85
86    /// Number of distinct symbol names (for size estimation / tests).
87    #[cfg(test)]
88    pub fn len(&self) -> usize {
89        self.entries.len()
90    }
91
92    /// Whether the map holds no symbols.
93    #[cfg(test)]
94    pub fn is_empty(&self) -> bool {
95        self.entries.is_empty()
96    }
97}
98
99/// Parse a doc-comment already attached by the parser and render it as markdown.
100/// Returns `None` when the docblock has no visible content.
101fn doc_to_markdown(c: &php_ast::Comment<'_>) -> Option<String> {
102    let md = crate::lang::docblock::parse_docblock(c.text).to_markdown();
103    if md.is_empty() { None } else { Some(md) }
104}
105
106fn collect_stmts<'a>(
107    stmts: &'a [Stmt<'a, 'a>],
108    sv: crate::document::ast::SourceView<'_>,
109    out: &mut HashMap<String, Vec<SymbolEntry>>,
110) {
111    for stmt in stmts {
112        match &stmt.kind {
113            StmtKind::Function(f) => {
114                let Some(name) = f.name.as_str() else {
115                    continue;
116                };
117                let decl = Declaration::Function {
118                    decl: f,
119                    stmt_span: stmt.span,
120                };
121                let sig = declaration_signature(&decl, name);
122                let doc_markdown = f.doc_comment.as_ref().and_then(doc_to_markdown);
123                push(
124                    out,
125                    name.to_owned(),
126                    SymbolEntry {
127                        name_range: sv.name_range_in_span(name, stmt.span),
128                        kind: SymbolEntryKind::Function,
129                        is_abstract: false,
130                        signature: sig,
131                        doc_markdown,
132                    },
133                );
134            }
135
136            StmtKind::Class(c) => {
137                // Class name entry.
138                if let Some(name_ident) = c.name {
139                    let name = name_ident.or_error();
140                    let decl = Declaration::Class {
141                        decl: c,
142                        name: name_ident,
143                        stmt_span: stmt.span,
144                    };
145                    let sig = declaration_signature(&decl, name);
146                    let doc_markdown = c.doc_comment.as_ref().and_then(doc_to_markdown);
147                    push(
148                        out,
149                        name.to_owned(),
150                        SymbolEntry {
151                            name_range: sv.name_range_in_span(name, stmt.span),
152                            kind: SymbolEntryKind::Class,
153                            is_abstract: c.modifiers.is_abstract,
154                            signature: sig,
155                            doc_markdown,
156                        },
157                    );
158                }
159                collect_members(c.body.members.iter(), sv, Container::Class, out);
160            }
161
162            StmtKind::Interface(i) => {
163                let name = i.name.or_error();
164                let decl = Declaration::Interface {
165                    decl: i,
166                    stmt_span: stmt.span,
167                };
168                let sig = declaration_signature(&decl, name);
169                let doc_markdown = i.doc_comment.as_ref().and_then(doc_to_markdown);
170                push(
171                    out,
172                    name.to_owned(),
173                    SymbolEntry {
174                        name_range: sv.name_range_in_span(name, stmt.span),
175                        kind: SymbolEntryKind::Interface,
176                        is_abstract: true,
177                        signature: sig,
178                        doc_markdown,
179                    },
180                );
181                collect_members(i.body.members.iter(), sv, Container::Interface, out);
182            }
183
184            StmtKind::Trait(t) => {
185                let name = t.name.or_error();
186                let decl = Declaration::Trait {
187                    decl: t,
188                    stmt_span: stmt.span,
189                };
190                let sig = declaration_signature(&decl, name);
191                let doc_markdown = t.doc_comment.as_ref().and_then(doc_to_markdown);
192                push(
193                    out,
194                    name.to_owned(),
195                    SymbolEntry {
196                        name_range: sv.name_range_in_span(name, stmt.span),
197                        kind: SymbolEntryKind::Trait,
198                        is_abstract: false,
199                        signature: sig,
200                        doc_markdown,
201                    },
202                );
203                collect_members(t.body.members.iter(), sv, Container::Trait, out);
204            }
205
206            StmtKind::Enum(e) => {
207                let name = e.name.or_error();
208                let decl = Declaration::Enum {
209                    decl: e,
210                    stmt_span: stmt.span,
211                };
212                let sig = declaration_signature(&decl, name);
213                let doc_markdown = e.doc_comment.as_ref().and_then(doc_to_markdown);
214                push(
215                    out,
216                    name.to_owned(),
217                    SymbolEntry {
218                        name_range: sv.name_range_in_span(name, stmt.span),
219                        kind: SymbolEntryKind::Enum,
220                        is_abstract: false,
221                        signature: sig,
222                        doc_markdown,
223                    },
224                );
225
226                for member in e.body.members.iter() {
227                    match &member.kind {
228                        EnumMemberKind::Case(c) => {
229                            let case_name = c.name.or_error();
230                            let case_decl = Declaration::EnumCase {
231                                case: c,
232                                enum_name: e.name,
233                                member_span: member.span,
234                            };
235                            let sig = declaration_signature(&case_decl, case_name);
236                            let doc_markdown = c.doc_comment.as_ref().and_then(doc_to_markdown);
237                            push(
238                                out,
239                                case_name.to_owned(),
240                                SymbolEntry {
241                                    name_range: sv.name_range_in_span(case_name, member.span),
242                                    kind: SymbolEntryKind::EnumCase,
243                                    is_abstract: false,
244                                    signature: sig,
245                                    doc_markdown,
246                                },
247                            );
248                        }
249                        EnumMemberKind::Method(m) => {
250                            let mname = m.name.or_error();
251                            let m_decl = Declaration::Method {
252                                method: m,
253                                container: Container::Enum,
254                                member_span: member.span,
255                            };
256                            let sig = declaration_signature(&m_decl, mname);
257                            let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
258                            push(
259                                out,
260                                mname.to_owned(),
261                                SymbolEntry {
262                                    name_range: sv.name_range_in_span(mname, member.span),
263                                    kind: SymbolEntryKind::Method {
264                                        container: Container::Enum,
265                                    },
266                                    is_abstract: false,
267                                    signature: sig,
268                                    doc_markdown,
269                                },
270                            );
271                        }
272                        EnumMemberKind::ClassConst(cc) => {
273                            let cc_name = cc.name.or_error();
274                            let cc_decl = Declaration::ClassConst {
275                                konst: cc,
276                                container: Container::Enum,
277                                member_span: member.span,
278                            };
279                            let sig = declaration_signature(&cc_decl, cc_name);
280                            let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
281                            push(
282                                out,
283                                cc_name.to_owned(),
284                                SymbolEntry {
285                                    name_range: sv.name_range_in_span(cc_name, member.span),
286                                    kind: SymbolEntryKind::ClassConst {
287                                        container: Container::Enum,
288                                    },
289                                    is_abstract: false,
290                                    signature: sig,
291                                    doc_markdown,
292                                },
293                            );
294                        }
295                        _ => {}
296                    }
297                }
298            }
299
300            StmtKind::Namespace(ns) => {
301                if let NamespaceBody::Braced(inner) = &ns.body {
302                    collect_stmts(&inner.stmts, sv, out);
303                }
304            }
305
306            _ => {}
307        }
308    }
309}
310
311fn collect_members<'a>(
312    members: impl Iterator<Item = &'a php_ast::ClassMember<'a, 'a>>,
313    sv: crate::document::ast::SourceView<'_>,
314    container: Container,
315    out: &mut HashMap<String, Vec<SymbolEntry>>,
316) {
317    for member in members {
318        match &member.kind {
319            ClassMemberKind::Method(m) => {
320                let mname = m.name.or_error();
321                let m_decl = Declaration::Method {
322                    method: m,
323                    container,
324                    member_span: member.span,
325                };
326                let sig = declaration_signature(&m_decl, mname);
327                let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
328                let name_range = sv.name_range_in_span(mname, member.span);
329                let is_abstract = match container {
330                    Container::Interface => true,
331                    Container::Class | Container::Trait => m.is_abstract,
332                    Container::Enum => false,
333                };
334                push(
335                    out,
336                    mname.to_owned(),
337                    SymbolEntry {
338                        name_range,
339                        kind: SymbolEntryKind::Method { container },
340                        is_abstract,
341                        signature: sig,
342                        doc_markdown,
343                    },
344                );
345
346                // Constructor-promoted parameters (only for Container::Class).
347                if container == Container::Class && m.name == "__construct" {
348                    for p in m.params.iter() {
349                        if p.visibility.is_some() {
350                            let pname = p.name.or_error();
351                            let bare = pname.trim_start_matches('$');
352                            push(
353                                out,
354                                bare.to_owned(),
355                                SymbolEntry {
356                                    name_range: sv.name_range_in_span(pname, p.span),
357                                    kind: SymbolEntryKind::PromotedParam,
358                                    is_abstract: false,
359                                    signature: None,
360                                    doc_markdown: None,
361                                },
362                            );
363                        }
364                    }
365                }
366            }
367
368            ClassMemberKind::ClassConst(cc) => {
369                let cc_name = cc.name.or_error();
370                let cc_decl = Declaration::ClassConst {
371                    konst: cc,
372                    container,
373                    member_span: member.span,
374                };
375                let sig = declaration_signature(&cc_decl, cc_name);
376                let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
377                let name_range = sv.name_range_in_span(cc_name, member.span);
378                push(
379                    out,
380                    cc_name.to_owned(),
381                    SymbolEntry {
382                        name_range,
383                        kind: SymbolEntryKind::ClassConst { container },
384                        is_abstract: false,
385                        signature: sig,
386                        doc_markdown,
387                    },
388                );
389            }
390
391            ClassMemberKind::Property(p) => {
392                let pname = p.name.or_error();
393                let bare = pname.trim_start_matches('$');
394                // Properties: signature rendered via mir, not here.
395                let name_range = sv.name_range_in_span(pname, member.span);
396                push(
397                    out,
398                    bare.to_owned(),
399                    SymbolEntry {
400                        name_range,
401                        kind: SymbolEntryKind::Property { container },
402                        is_abstract: false,
403                        signature: None,
404                        doc_markdown: None,
405                    },
406                );
407            }
408
409            _ => {}
410        }
411    }
412}
413
414fn push(out: &mut HashMap<String, Vec<SymbolEntry>>, key: String, entry: SymbolEntry) {
415    out.entry(key).or_default().push(entry);
416}
417
418/// Reconstruct the `is_hoverable` predicate from a stored [`SymbolEntryKind`].
419pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
420    !matches!(
421        kind,
422        SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
423    )
424}
425
426/// `goto_declaration` pass 1: abstract/interface declarations.
427pub fn is_abstract_entry(e: &SymbolEntry) -> bool {
428    match e.kind {
429        SymbolEntryKind::Interface => true,
430        SymbolEntryKind::Method {
431            container: Container::Interface,
432        } => true,
433        SymbolEntryKind::Method {
434            container: Container::Class | Container::Trait,
435        } => e.is_abstract,
436        _ => false,
437    }
438}
439
440/// `goto_declaration` pass 2: any declaration except promoted params.
441pub fn is_any_entry(e: &SymbolEntry) -> bool {
442    !matches!(e.kind, SymbolEntryKind::PromotedParam)
443}
444
445/// `goto_definition`: skip enum constants (matching original walker).
446pub fn is_definition_entry(e: &SymbolEntry) -> bool {
447    !matches!(
448        e.kind,
449        SymbolEntryKind::ClassConst {
450            container: Container::Enum
451        }
452    )
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    fn build(src: &str) -> SymbolMap {
460        let doc = ParsedDoc::parse(src.to_owned());
461        SymbolMap::build(&doc)
462    }
463
464    #[test]
465    fn top_level_function() {
466        let m = build("<?php\nfunction greet(string $name): string { return $name; }");
467        let e = m.lookup("greet", |_| true).unwrap();
468        assert_eq!(e.kind, SymbolEntryKind::Function);
469        assert!(!e.is_abstract);
470        assert_eq!(
471            e.signature.as_deref(),
472            Some("function greet(string $name): string")
473        );
474    }
475
476    #[test]
477    fn class_with_abstract_method() {
478        let m = build("<?php\nabstract class Foo {\n    abstract public function bar(): void;\n}");
479        let cls = m.lookup("Foo", |_| true).unwrap();
480        assert_eq!(cls.kind, SymbolEntryKind::Class);
481        assert!(cls.is_abstract);
482
483        let method = m
484            .lookup("bar", |e| {
485                matches!(
486                    e.kind,
487                    SymbolEntryKind::Method {
488                        container: Container::Class
489                    }
490                )
491            })
492            .unwrap();
493        assert!(method.is_abstract);
494    }
495
496    #[test]
497    fn interface_member_is_abstract() {
498        let m = build("<?php\ninterface Shape {\n    public function area(): float;\n}");
499        let method = m.lookup("area", |_| true).unwrap();
500        assert!(method.is_abstract);
501        assert_eq!(
502            method.kind,
503            SymbolEntryKind::Method {
504                container: Container::Interface
505            }
506        );
507    }
508
509    #[test]
510    fn enum_entries() {
511        let m = build("<?php\nenum Color {\n    case Red;\n    case Blue;\n}");
512        assert!(m.lookup("Color", |_| true).is_some());
513        assert!(m.lookup("Red", |_| true).is_some());
514        assert!(m.lookup("Blue", |_| true).is_some());
515    }
516
517    #[test]
518    fn promoted_param_keyed_without_dollar() {
519        let m = build(
520            "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}",
521        );
522        assert!(
523            m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
524                .is_some()
525        );
526        assert!(
527            m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
528                .is_some()
529        );
530    }
531
532    #[test]
533    fn source_order_preserved() {
534        // Both `render` in Interface and Trait: interface entry must come before
535        // trait entry so the abstract-first lookup finds the right one.
536        let m = build(
537            "<?php\ninterface I {\n    public function render(): void;\n}\ntrait T {\n    abstract public function render(): void;\n}",
538        );
539        let entries = m.entries.get("render").unwrap();
540        assert_eq!(
541            entries[0].kind,
542            SymbolEntryKind::Method {
543                container: Container::Interface
544            }
545        );
546        assert_eq!(
547            entries[1].kind,
548            SymbolEntryKind::Method {
549                container: Container::Trait
550            }
551        );
552    }
553
554    #[test]
555    fn docblock_extracted() {
556        let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
557        let e = m.lookup("greet", |_| true).unwrap();
558        assert!(
559            e.doc_markdown.is_some(),
560            "expected docblock to be extracted"
561        );
562    }
563
564    #[test]
565    fn no_docblock_when_absent() {
566        let m = build("<?php\nfunction greet(): void {}");
567        let e = m.lookup("greet", |_| true).unwrap();
568        assert!(e.doc_markdown.is_none());
569    }
570}