Skip to main content

php_lsp/
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::ast::ParsedDoc;
20use crate::hover::formatting::declaration_signature;
21use crate::resolve::{Container, Declaration};
22
23// ── Public types ──────────────────────────────────────────────────────────────
24
25/// Which kind of PHP declaration this entry represents. Mirrors the variants of
26/// [`Declaration`] so callers can reconstruct any accept predicate without an
27/// AST walk.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SymbolEntryKind {
30    Function,
31    Class,
32    Interface,
33    Trait,
34    Enum,
35    Method { container: Container },
36    ClassConst { container: Container },
37    Property { container: Container },
38    PromotedParam,
39    EnumCase,
40}
41
42/// A single resolved declaration stored in the pre-computed symbol map.
43#[derive(Debug, Clone)]
44pub struct SymbolEntry {
45    /// Precise LSP range of the identifier (not the full declaration span).
46    pub name_range: Range,
47    pub kind: SymbolEntryKind,
48    /// Whether the declaration is abstract (interface members, abstract methods).
49    /// Used to reconstruct `goto_declaration`'s two-pass abstract-first logic.
50    pub is_abstract: bool,
51    /// Pre-rendered hover signature (e.g. `function foo(int $x): void`).
52    /// `None` for properties and promoted parameters, which use the mir path.
53    pub signature: Option<String>,
54    /// Pre-extracted docblock rendered as markdown. `None` when no docblock
55    /// precedes the declaration.
56    pub doc_markdown: Option<String>,
57}
58
59/// Pre-computed symbol table for a single PHP file.
60///
61/// Built by [`SymbolMap::build`] in one AST pass; looked up in O(1) via
62/// [`SymbolMap::lookup`]. The `Vec` per key preserves source order so that
63/// predicates applied by [`lookup`] (e.g. "abstract first") stay correct.
64#[derive(Clone, Default)]
65pub struct SymbolMap {
66    entries: HashMap<String, Vec<SymbolEntry>>,
67}
68
69impl SymbolMap {
70    /// Walk `doc`'s AST once and build the complete symbol map.
71    pub fn build(doc: &ParsedDoc) -> Self {
72        let sv = doc.view();
73        let mut entries: HashMap<String, Vec<SymbolEntry>> = HashMap::new();
74        collect_stmts(&doc.program().stmts, sv, &mut entries);
75        SymbolMap { entries }
76    }
77
78    /// Find the first entry with key `name` that `accept` approves, in source
79    /// order — matching [`resolve_declaration`]'s first-match semantics.
80    pub fn lookup(
81        &self,
82        name: &str,
83        accept: impl Fn(&SymbolEntry) -> bool,
84    ) -> Option<&SymbolEntry> {
85        self.entries.get(name)?.iter().find(|e| accept(e))
86    }
87
88    /// Number of distinct symbol names (for size estimation / tests).
89    #[cfg(test)]
90    pub fn len(&self) -> usize {
91        self.entries.len()
92    }
93}
94
95// ── Helpers ───────────────────────────────────────────────────────────────────
96
97/// Parse a doc-comment already attached by the parser and render it as markdown.
98/// Returns `None` when the docblock has no visible content.
99fn doc_to_markdown(c: &php_ast::Comment<'_>) -> Option<String> {
100    let md = crate::docblock::parse_docblock(c.text).to_markdown();
101    if md.is_empty() { None } else { Some(md) }
102}
103
104// ── AST walker ────────────────────────────────────────────────────────────────
105
106fn collect_stmts<'a>(
107    stmts: &'a [Stmt<'a, 'a>],
108    sv: crate::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(case_name),
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(mname),
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(cc_name),
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::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 = if container == Container::Class {
329                    sv.name_range_in_span(mname, member.span)
330                } else {
331                    sv.name_range(mname)
332                };
333                let is_abstract = match container {
334                    Container::Interface => true,
335                    Container::Class | Container::Trait => m.is_abstract,
336                    Container::Enum => false,
337                };
338                push(
339                    out,
340                    mname.to_owned(),
341                    SymbolEntry {
342                        name_range,
343                        kind: SymbolEntryKind::Method { container },
344                        is_abstract,
345                        signature: sig,
346                        doc_markdown,
347                    },
348                );
349
350                // Constructor-promoted parameters (only for Container::Class).
351                if container == Container::Class && m.name == "__construct" {
352                    for p in m.params.iter() {
353                        if p.visibility.is_some() {
354                            let pname = p.name.or_error();
355                            let bare = pname.trim_start_matches('$');
356                            push(
357                                out,
358                                bare.to_owned(),
359                                SymbolEntry {
360                                    name_range: sv.name_range_in_span(pname, p.span),
361                                    kind: SymbolEntryKind::PromotedParam,
362                                    is_abstract: false,
363                                    signature: None,
364                                    doc_markdown: None,
365                                },
366                            );
367                        }
368                    }
369                }
370            }
371
372            ClassMemberKind::ClassConst(cc) => {
373                let cc_name = cc.name.or_error();
374                let cc_decl = Declaration::ClassConst {
375                    konst: cc,
376                    container,
377                    member_span: member.span,
378                };
379                let sig = declaration_signature(&cc_decl, cc_name);
380                let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
381                let name_range = if container == Container::Class {
382                    sv.name_range_in_span(cc_name, member.span)
383                } else {
384                    sv.name_range(cc_name)
385                };
386                push(
387                    out,
388                    cc_name.to_owned(),
389                    SymbolEntry {
390                        name_range,
391                        kind: SymbolEntryKind::ClassConst { container },
392                        is_abstract: false,
393                        signature: sig,
394                        doc_markdown,
395                    },
396                );
397            }
398
399            ClassMemberKind::Property(p) => {
400                let pname = p.name.or_error();
401                let bare = pname.trim_start_matches('$');
402                // Properties: signature rendered via mir, not here.
403                let name_range = if container == Container::Class {
404                    sv.name_range_in_span(pname, member.span)
405                } else {
406                    sv.name_range(pname)
407                };
408                push(
409                    out,
410                    bare.to_owned(),
411                    SymbolEntry {
412                        name_range,
413                        kind: SymbolEntryKind::Property { container },
414                        is_abstract: false,
415                        signature: None,
416                        doc_markdown: None,
417                    },
418                );
419            }
420
421            _ => {}
422        }
423    }
424}
425
426fn push(out: &mut HashMap<String, Vec<SymbolEntry>>, key: String, entry: SymbolEntry) {
427    out.entry(key).or_default().push(entry);
428}
429
430// ── Predicate helpers (mirrors resolve.rs / declaration.rs predicates) ────────
431
432/// Reconstruct the `is_hoverable` predicate from a stored [`SymbolEntryKind`].
433pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
434    !matches!(
435        kind,
436        SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
437    )
438}
439
440/// `goto_declaration` pass 1: abstract/interface declarations.
441pub fn is_abstract_entry(e: &SymbolEntry) -> bool {
442    match e.kind {
443        SymbolEntryKind::Interface => true,
444        SymbolEntryKind::Method {
445            container: Container::Interface,
446        } => true,
447        SymbolEntryKind::Method {
448            container: Container::Class | Container::Trait,
449        } => e.is_abstract,
450        _ => false,
451    }
452}
453
454/// `goto_declaration` pass 2: any declaration except promoted params.
455pub fn is_any_entry(e: &SymbolEntry) -> bool {
456    !matches!(e.kind, SymbolEntryKind::PromotedParam)
457}
458
459/// `goto_definition`: skip enum constants (matching original walker).
460pub fn is_definition_entry(e: &SymbolEntry) -> bool {
461    !matches!(
462        e.kind,
463        SymbolEntryKind::ClassConst {
464            container: Container::Enum
465        }
466    )
467}
468
469// ── Tests ─────────────────────────────────────────────────────────────────────
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn build(src: &str) -> SymbolMap {
476        let doc = ParsedDoc::parse(src.to_owned());
477        SymbolMap::build(&doc)
478    }
479
480    #[test]
481    fn top_level_function() {
482        let m = build("<?php\nfunction greet(string $name): string { return $name; }");
483        let e = m.lookup("greet", |_| true).unwrap();
484        assert_eq!(e.kind, SymbolEntryKind::Function);
485        assert!(!e.is_abstract);
486        assert_eq!(
487            e.signature.as_deref(),
488            Some("function greet(string $name): string")
489        );
490    }
491
492    #[test]
493    fn class_with_abstract_method() {
494        let m = build("<?php\nabstract class Foo {\n    abstract public function bar(): void;\n}");
495        let cls = m.lookup("Foo", |_| true).unwrap();
496        assert_eq!(cls.kind, SymbolEntryKind::Class);
497        assert!(cls.is_abstract);
498
499        let method = m
500            .lookup("bar", |e| {
501                matches!(
502                    e.kind,
503                    SymbolEntryKind::Method {
504                        container: Container::Class
505                    }
506                )
507            })
508            .unwrap();
509        assert!(method.is_abstract);
510    }
511
512    #[test]
513    fn interface_member_is_abstract() {
514        let m = build("<?php\ninterface Shape {\n    public function area(): float;\n}");
515        let method = m.lookup("area", |_| true).unwrap();
516        assert!(method.is_abstract);
517        assert_eq!(
518            method.kind,
519            SymbolEntryKind::Method {
520                container: Container::Interface
521            }
522        );
523    }
524
525    #[test]
526    fn enum_entries() {
527        let m = build("<?php\nenum Color {\n    case Red;\n    case Blue;\n}");
528        assert!(m.lookup("Color", |_| true).is_some());
529        assert!(m.lookup("Red", |_| true).is_some());
530        assert!(m.lookup("Blue", |_| true).is_some());
531    }
532
533    #[test]
534    fn promoted_param_keyed_without_dollar() {
535        let m = build(
536            "<?php\nclass Point {\n    public function __construct(\n        public float $x,\n        public float $y,\n    ) {}\n}",
537        );
538        assert!(
539            m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
540                .is_some()
541        );
542        assert!(
543            m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
544                .is_some()
545        );
546    }
547
548    #[test]
549    fn source_order_preserved() {
550        // Both `render` in Interface and Trait: interface entry must come before
551        // trait entry so the abstract-first lookup finds the right one.
552        let m = build(
553            "<?php\ninterface I {\n    public function render(): void;\n}\ntrait T {\n    abstract public function render(): void;\n}",
554        );
555        let entries = m.entries.get("render").unwrap();
556        assert_eq!(
557            entries[0].kind,
558            SymbolEntryKind::Method {
559                container: Container::Interface
560            }
561        );
562        assert_eq!(
563            entries[1].kind,
564            SymbolEntryKind::Method {
565                container: Container::Trait
566            }
567        );
568    }
569
570    #[test]
571    fn docblock_extracted() {
572        let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
573        let e = m.lookup("greet", |_| true).unwrap();
574        assert!(
575            e.doc_markdown.is_some(),
576            "expected docblock to be extracted"
577        );
578    }
579
580    #[test]
581    fn no_docblock_when_absent() {
582        let m = build("<?php\nfunction greet(): void {}");
583        let e = m.lookup("greet", |_| true).unwrap();
584        assert!(e.doc_markdown.is_none());
585    }
586}