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(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 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 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
430pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
434 !matches!(
435 kind,
436 SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
437 )
438}
439
440pub 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
454pub fn is_any_entry(e: &SymbolEntry) -> bool {
456 !matches!(e.kind, SymbolEntryKind::PromotedParam)
457}
458
459pub fn is_definition_entry(e: &SymbolEntry) -> bool {
461 !matches!(
462 e.kind,
463 SymbolEntryKind::ClassConst {
464 container: Container::Enum
465 }
466 )
467}
468
469#[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 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}