1use 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#[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#[derive(Debug, Clone)]
42pub struct SymbolEntry {
43 pub name_range: Range,
45 pub kind: SymbolEntryKind,
46 pub signature: Option<String>,
49 pub doc_markdown: Option<String>,
52}
53
54#[derive(Clone, Default)]
60pub struct SymbolMap {
61 entries: HashMap<String, Vec<SymbolEntry>>,
62}
63
64impl SymbolMap {
65 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 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 #[cfg(test)]
85 pub fn len(&self) -> usize {
86 self.entries.len()
87 }
88
89 #[cfg(test)]
91 pub fn is_empty(&self) -> bool {
92 self.entries.is_empty()
93 }
94}
95
96fn 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 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 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 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
398pub 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_eq!(
461 m.lookup("Color", |_| true).unwrap().kind,
462 SymbolEntryKind::Enum
463 );
464 assert_eq!(
465 m.lookup("Red", |_| true).unwrap().kind,
466 SymbolEntryKind::EnumCase
467 );
468 assert_eq!(
469 m.lookup("Blue", |_| true).unwrap().kind,
470 SymbolEntryKind::EnumCase
471 );
472 }
473
474 #[test]
475 fn promoted_param_keyed_without_dollar() {
476 let m = build(
477 "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}",
478 );
479 assert!(
480 m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
481 .is_some()
482 );
483 assert!(
484 m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
485 .is_some()
486 );
487 }
488
489 #[test]
490 fn source_order_preserved() {
491 let m = build(
494 "<?php\ninterface I {\n public function render(): void;\n}\ntrait T {\n abstract public function render(): void;\n}",
495 );
496 let entries = m.entries.get("render").unwrap();
497 assert_eq!(
498 entries[0].kind,
499 SymbolEntryKind::Method {
500 container: Container::Interface
501 }
502 );
503 assert_eq!(
504 entries[1].kind,
505 SymbolEntryKind::Method {
506 container: Container::Trait
507 }
508 );
509 }
510
511 #[test]
512 fn docblock_extracted() {
513 let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
514 let e = m.lookup("greet", |_| true).unwrap();
515 assert_eq!(e.doc_markdown.as_deref(), Some("Greets the user."));
516 }
517
518 #[test]
519 fn no_docblock_when_absent() {
520 let m = build("<?php\nfunction greet(): void {}");
521 let e = m.lookup("greet", |_| true).unwrap();
522 assert!(e.doc_markdown.is_none());
523 }
524}