1use 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#[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#[derive(Debug, Clone)]
44pub struct SymbolEntry {
45 pub name_range: Range,
47 pub kind: SymbolEntryKind,
48 pub is_abstract: bool,
51 pub signature: Option<String>,
54 pub doc_markdown: Option<String>,
57}
58
59#[derive(Clone, Default)]
65pub struct SymbolMap {
66 entries: HashMap<String, Vec<SymbolEntry>>,
67}
68
69impl SymbolMap {
70 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 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 #[cfg(test)]
90 pub fn len(&self) -> usize {
91 self.entries.len()
92 }
93}
94
95fn 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
104fn 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 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::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 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 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
418pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
422 !matches!(
423 kind,
424 SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
425 )
426}
427
428pub fn is_abstract_entry(e: &SymbolEntry) -> bool {
430 match e.kind {
431 SymbolEntryKind::Interface => true,
432 SymbolEntryKind::Method {
433 container: Container::Interface,
434 } => true,
435 SymbolEntryKind::Method {
436 container: Container::Class | Container::Trait,
437 } => e.is_abstract,
438 _ => false,
439 }
440}
441
442pub fn is_any_entry(e: &SymbolEntry) -> bool {
444 !matches!(e.kind, SymbolEntryKind::PromotedParam)
445}
446
447pub fn is_definition_entry(e: &SymbolEntry) -> bool {
449 !matches!(
450 e.kind,
451 SymbolEntryKind::ClassConst {
452 container: Container::Enum
453 }
454 )
455}
456
457#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn build(src: &str) -> SymbolMap {
464 let doc = ParsedDoc::parse(src.to_owned());
465 SymbolMap::build(&doc)
466 }
467
468 #[test]
469 fn top_level_function() {
470 let m = build("<?php\nfunction greet(string $name): string { return $name; }");
471 let e = m.lookup("greet", |_| true).unwrap();
472 assert_eq!(e.kind, SymbolEntryKind::Function);
473 assert!(!e.is_abstract);
474 assert_eq!(
475 e.signature.as_deref(),
476 Some("function greet(string $name): string")
477 );
478 }
479
480 #[test]
481 fn class_with_abstract_method() {
482 let m = build("<?php\nabstract class Foo {\n abstract public function bar(): void;\n}");
483 let cls = m.lookup("Foo", |_| true).unwrap();
484 assert_eq!(cls.kind, SymbolEntryKind::Class);
485 assert!(cls.is_abstract);
486
487 let method = m
488 .lookup("bar", |e| {
489 matches!(
490 e.kind,
491 SymbolEntryKind::Method {
492 container: Container::Class
493 }
494 )
495 })
496 .unwrap();
497 assert!(method.is_abstract);
498 }
499
500 #[test]
501 fn interface_member_is_abstract() {
502 let m = build("<?php\ninterface Shape {\n public function area(): float;\n}");
503 let method = m.lookup("area", |_| true).unwrap();
504 assert!(method.is_abstract);
505 assert_eq!(
506 method.kind,
507 SymbolEntryKind::Method {
508 container: Container::Interface
509 }
510 );
511 }
512
513 #[test]
514 fn enum_entries() {
515 let m = build("<?php\nenum Color {\n case Red;\n case Blue;\n}");
516 assert!(m.lookup("Color", |_| true).is_some());
517 assert!(m.lookup("Red", |_| true).is_some());
518 assert!(m.lookup("Blue", |_| true).is_some());
519 }
520
521 #[test]
522 fn promoted_param_keyed_without_dollar() {
523 let m = build(
524 "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}",
525 );
526 assert!(
527 m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
528 .is_some()
529 );
530 assert!(
531 m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
532 .is_some()
533 );
534 }
535
536 #[test]
537 fn source_order_preserved() {
538 let m = build(
541 "<?php\ninterface I {\n public function render(): void;\n}\ntrait T {\n abstract public function render(): void;\n}",
542 );
543 let entries = m.entries.get("render").unwrap();
544 assert_eq!(
545 entries[0].kind,
546 SymbolEntryKind::Method {
547 container: Container::Interface
548 }
549 );
550 assert_eq!(
551 entries[1].kind,
552 SymbolEntryKind::Method {
553 container: Container::Trait
554 }
555 );
556 }
557
558 #[test]
559 fn docblock_extracted() {
560 let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
561 let e = m.lookup("greet", |_| true).unwrap();
562 assert!(
563 e.doc_markdown.is_some(),
564 "expected docblock to be extracted"
565 );
566 }
567
568 #[test]
569 fn no_docblock_when_absent() {
570 let m = build("<?php\nfunction greet(): void {}");
571 let e = m.lookup("greet", |_| true).unwrap();
572 assert!(e.doc_markdown.is_none());
573 }
574}