1use std::collections::HashMap;
15
16use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
17use tower_lsp::lsp_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 is_abstract: bool,
49 pub signature: Option<String>,
52 pub doc_markdown: Option<String>,
55}
56
57#[derive(Clone, Default)]
63pub struct SymbolMap {
64 entries: HashMap<String, Vec<SymbolEntry>>,
65}
66
67impl SymbolMap {
68 pub fn build(doc: &ParsedDoc) -> Self {
70 let sv = doc.view();
71 let mut entries: HashMap<String, Vec<SymbolEntry>> = HashMap::new();
72 collect_stmts(&doc.program().stmts, sv, &mut entries);
73 SymbolMap { entries }
74 }
75
76 pub fn lookup(
79 &self,
80 name: &str,
81 accept: impl Fn(&SymbolEntry) -> bool,
82 ) -> Option<&SymbolEntry> {
83 self.entries.get(name)?.iter().find(|e| accept(e))
84 }
85
86 #[cfg(test)]
88 pub fn len(&self) -> usize {
89 self.entries.len()
90 }
91
92 #[cfg(test)]
94 pub fn is_empty(&self) -> bool {
95 self.entries.is_empty()
96 }
97}
98
99fn doc_to_markdown(c: &php_ast::Comment<'_>) -> Option<String> {
102 let md = crate::lang::docblock::parse_docblock(c.text).to_markdown();
103 if md.is_empty() { None } else { Some(md) }
104}
105
106fn collect_stmts<'a>(
107 stmts: &'a [Stmt<'a, 'a>],
108 sv: crate::document::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::document::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 {
420 !matches!(
421 kind,
422 SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
423 )
424}
425
426pub fn is_abstract_entry(e: &SymbolEntry) -> bool {
428 match e.kind {
429 SymbolEntryKind::Interface => true,
430 SymbolEntryKind::Method {
431 container: Container::Interface,
432 } => true,
433 SymbolEntryKind::Method {
434 container: Container::Class | Container::Trait,
435 } => e.is_abstract,
436 _ => false,
437 }
438}
439
440pub fn is_any_entry(e: &SymbolEntry) -> bool {
442 !matches!(e.kind, SymbolEntryKind::PromotedParam)
443}
444
445pub fn is_definition_entry(e: &SymbolEntry) -> bool {
447 !matches!(
448 e.kind,
449 SymbolEntryKind::ClassConst {
450 container: Container::Enum
451 }
452 )
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 fn build(src: &str) -> SymbolMap {
460 let doc = ParsedDoc::parse(src.to_owned());
461 SymbolMap::build(&doc)
462 }
463
464 #[test]
465 fn top_level_function() {
466 let m = build("<?php\nfunction greet(string $name): string { return $name; }");
467 let e = m.lookup("greet", |_| true).unwrap();
468 assert_eq!(e.kind, SymbolEntryKind::Function);
469 assert!(!e.is_abstract);
470 assert_eq!(
471 e.signature.as_deref(),
472 Some("function greet(string $name): string")
473 );
474 }
475
476 #[test]
477 fn class_with_abstract_method() {
478 let m = build("<?php\nabstract class Foo {\n abstract public function bar(): void;\n}");
479 let cls = m.lookup("Foo", |_| true).unwrap();
480 assert_eq!(cls.kind, SymbolEntryKind::Class);
481 assert!(cls.is_abstract);
482
483 let method = m
484 .lookup("bar", |e| {
485 matches!(
486 e.kind,
487 SymbolEntryKind::Method {
488 container: Container::Class
489 }
490 )
491 })
492 .unwrap();
493 assert!(method.is_abstract);
494 }
495
496 #[test]
497 fn interface_member_is_abstract() {
498 let m = build("<?php\ninterface Shape {\n public function area(): float;\n}");
499 let method = m.lookup("area", |_| true).unwrap();
500 assert!(method.is_abstract);
501 assert_eq!(
502 method.kind,
503 SymbolEntryKind::Method {
504 container: Container::Interface
505 }
506 );
507 }
508
509 #[test]
510 fn enum_entries() {
511 let m = build("<?php\nenum Color {\n case Red;\n case Blue;\n}");
512 assert!(m.lookup("Color", |_| true).is_some());
513 assert!(m.lookup("Red", |_| true).is_some());
514 assert!(m.lookup("Blue", |_| true).is_some());
515 }
516
517 #[test]
518 fn promoted_param_keyed_without_dollar() {
519 let m = build(
520 "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}",
521 );
522 assert!(
523 m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
524 .is_some()
525 );
526 assert!(
527 m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
528 .is_some()
529 );
530 }
531
532 #[test]
533 fn source_order_preserved() {
534 let m = build(
537 "<?php\ninterface I {\n public function render(): void;\n}\ntrait T {\n abstract public function render(): void;\n}",
538 );
539 let entries = m.entries.get("render").unwrap();
540 assert_eq!(
541 entries[0].kind,
542 SymbolEntryKind::Method {
543 container: Container::Interface
544 }
545 );
546 assert_eq!(
547 entries[1].kind,
548 SymbolEntryKind::Method {
549 container: Container::Trait
550 }
551 );
552 }
553
554 #[test]
555 fn docblock_extracted() {
556 let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
557 let e = m.lookup("greet", |_| true).unwrap();
558 assert!(
559 e.doc_markdown.is_some(),
560 "expected docblock to be extracted"
561 );
562 }
563
564 #[test]
565 fn no_docblock_when_absent() {
566 let m = build("<?php\nfunction greet(): void {}");
567 let e = m.lookup("greet", |_| true).unwrap();
568 assert!(e.doc_markdown.is_none());
569 }
570}