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