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                                member_span: member.span,
245                            };
246                            let sig = declaration_signature(&m_decl, mname);
247                            let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
248                            push(
249                                out,
250                                mname.to_owned(),
251                                SymbolEntry {
252                                    name_range: sv.name_range_in_span(mname, member.span),
253                                    kind: SymbolEntryKind::Method {
254                                        container: Container::Enum,
255                                    },
256                                    signature: sig,
257                                    doc_markdown,
258                                },
259                            );
260                        }
261                        EnumMemberKind::ClassConst(cc) => {
262                            let cc_name = cc.name.or_error();
263                            let cc_decl = Declaration::ClassConst {
264                                konst: cc,
265                                container: Container::Enum,
266                                member_span: member.span,
267                            };
268                            let sig = declaration_signature(&cc_decl, cc_name);
269                            let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
270                            push(
271                                out,
272                                cc_name.to_owned(),
273                                SymbolEntry {
274                                    name_range: sv.name_range_in_span(cc_name, member.span),
275                                    kind: SymbolEntryKind::ClassConst {
276                                        container: Container::Enum,
277                                    },
278                                    signature: sig,
279                                    doc_markdown,
280                                },
281                            );
282                        }
283                        _ => {}
284                    }
285                }
286            }
287
288            StmtKind::Namespace(ns) => {
289                if let NamespaceBody::Braced(inner) = &ns.body {
290                    collect_stmts(&inner.stmts, sv, out);
291                }
292            }
293
294            _ => {}
295        }
296    }
297}
298
299fn collect_members<'a>(
300    members: impl Iterator<Item = &'a php_ast::ClassMember<'a, 'a>>,
301    sv: crate::document::ast::SourceView<'_>,
302    container: Container,
303    out: &mut HashMap<String, Vec<SymbolEntry>>,
304) {
305    for member in members {
306        match &member.kind {
307            ClassMemberKind::Method(m) => {
308                let mname = m.name.or_error();
309                let m_decl = Declaration::Method {
310                    method: m,
311                    member_span: member.span,
312                };
313                let sig = declaration_signature(&m_decl, mname);
314                let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
315                let name_range = sv.name_range_in_span(mname, member.span);
316                push(
317                    out,
318                    mname.to_owned(),
319                    SymbolEntry {
320                        name_range,
321                        kind: SymbolEntryKind::Method { container },
322                        signature: sig,
323                        doc_markdown,
324                    },
325                );
326
327                // Constructor-promoted parameters (only for Container::Class).
328                if container == Container::Class && m.name == "__construct" {
329                    for p in m.params.iter() {
330                        if p.visibility.is_some() {
331                            let pname = p.name.or_error();
332                            let bare = pname.trim_start_matches('$');
333                            push(
334                                out,
335                                bare.to_owned(),
336                                SymbolEntry {
337                                    name_range: sv.name_range_in_span(pname, p.span),
338                                    kind: SymbolEntryKind::PromotedParam,
339                                    signature: None,
340                                    doc_markdown: None,
341                                },
342                            );
343                        }
344                    }
345                }
346            }
347
348            ClassMemberKind::ClassConst(cc) => {
349                let cc_name = cc.name.or_error();
350                let cc_decl = Declaration::ClassConst {
351                    konst: cc,
352                    container,
353                    member_span: member.span,
354                };
355                let sig = declaration_signature(&cc_decl, cc_name);
356                let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
357                let name_range = sv.name_range_in_span(cc_name, member.span);
358                push(
359                    out,
360                    cc_name.to_owned(),
361                    SymbolEntry {
362                        name_range,
363                        kind: SymbolEntryKind::ClassConst { container },
364                        signature: sig,
365                        doc_markdown,
366                    },
367                );
368            }
369
370            ClassMemberKind::Property(p) => {
371                let pname = p.name.or_error();
372                let bare = pname.trim_start_matches('$');
373                // Properties: signature rendered via mir, not here.
374                let name_range = sv.name_range_in_span(pname, member.span);
375                push(
376                    out,
377                    bare.to_owned(),
378                    SymbolEntry {
379                        name_range,
380                        kind: SymbolEntryKind::Property { container },
381                        signature: None,
382                        doc_markdown: None,
383                    },
384                );
385            }
386
387            _ => {}
388        }
389    }
390}
391
392fn push(out: &mut HashMap<String, Vec<SymbolEntry>>, key: String, entry: SymbolEntry) {
393    out.entry(key).or_default().push(entry);
394}
395
396/// Reconstruct the `is_hoverable` predicate from a stored [`SymbolEntryKind`].
397pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
398    !matches!(
399        kind,
400        SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
401    )
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn build(src: &str) -> SymbolMap {
409        let doc = ParsedDoc::parse(src.to_owned());
410        SymbolMap::build(&doc)
411    }
412
413    #[test]
414    fn top_level_function() {
415        let m = build("<?php\nfunction greet(string $name): string { return $name; }");
416        let e = m.lookup("greet", |_| true).unwrap();
417        assert_eq!(e.kind, SymbolEntryKind::Function);
418        assert_eq!(
419            e.signature.as_deref(),
420            Some("function greet(string $name): string")
421        );
422    }
423
424    #[test]
425    fn class_with_abstract_method() {
426        let m = build("<?php\nabstract class Foo {\n    abstract public function bar(): void;\n}");
427        let cls = m.lookup("Foo", |_| true).unwrap();
428        assert_eq!(cls.kind, SymbolEntryKind::Class);
429
430        assert!(
431            m.lookup("bar", |e| {
432                matches!(
433                    e.kind,
434                    SymbolEntryKind::Method {
435                        container: Container::Class
436                    }
437                )
438            })
439            .is_some()
440        );
441    }
442
443    #[test]
444    fn interface_member_is_abstract() {
445        let m = build("<?php\ninterface Shape {\n    public function area(): float;\n}");
446        let method = m.lookup("area", |_| true).unwrap();
447        assert_eq!(
448            method.kind,
449            SymbolEntryKind::Method {
450                container: Container::Interface
451            }
452        );
453    }
454
455    #[test]
456    fn enum_entries() {
457        let m = build("<?php\nenum Color {\n    case Red;\n    case Blue;\n}");
458        assert_eq!(
459            m.lookup("Color", |_| true).unwrap().kind,
460            SymbolEntryKind::Enum
461        );
462        assert_eq!(
463            m.lookup("Red", |_| true).unwrap().kind,
464            SymbolEntryKind::EnumCase
465        );
466        assert_eq!(
467            m.lookup("Blue", |_| true).unwrap().kind,
468            SymbolEntryKind::EnumCase
469        );
470    }
471
472    #[test]
473    fn promoted_param_keyed_without_dollar() {
474        let m = build(
475            "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}",
476        );
477        assert!(
478            m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
479                .is_some()
480        );
481        assert!(
482            m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
483                .is_some()
484        );
485    }
486
487    #[test]
488    fn source_order_preserved() {
489        // Both `render` in Interface and Trait: interface entry must come before
490        // trait entry so the abstract-first lookup finds the right one.
491        let m = build(
492            "<?php\ninterface I {\n    public function render(): void;\n}\ntrait T {\n    abstract public function render(): void;\n}",
493        );
494        let entries = m.entries.get("render").unwrap();
495        assert_eq!(
496            entries[0].kind,
497            SymbolEntryKind::Method {
498                container: Container::Interface
499            }
500        );
501        assert_eq!(
502            entries[1].kind,
503            SymbolEntryKind::Method {
504                container: Container::Trait
505            }
506        );
507    }
508
509    #[test]
510    fn docblock_extracted() {
511        let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
512        let e = m.lookup("greet", |_| true).unwrap();
513        assert_eq!(e.doc_markdown.as_deref(), Some("Greets the user."));
514    }
515
516    #[test]
517    fn no_docblock_when_absent() {
518        let m = build("<?php\nfunction greet(): void {}");
519        let e = m.lookup("greet", |_| true).unwrap();
520        assert!(e.doc_markdown.is_none());
521    }
522}