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 member_span: member.span,
245 };
246 let sig = declaration_signature(&m_decl, mname);
247 let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
248 push(
249 out,
250 mname.to_owned(),
251 SymbolEntry {
252 name_range: sv.name_range_in_span(mname, member.span),
253 kind: SymbolEntryKind::Method {
254 container: Container::Enum,
255 },
256 signature: sig,
257 doc_markdown,
258 },
259 );
260 }
261 EnumMemberKind::ClassConst(cc) => {
262 let cc_name = cc.name.or_error();
263 let cc_decl = Declaration::ClassConst {
264 konst: cc,
265 container: Container::Enum,
266 member_span: member.span,
267 };
268 let sig = declaration_signature(&cc_decl, cc_name);
269 let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
270 push(
271 out,
272 cc_name.to_owned(),
273 SymbolEntry {
274 name_range: sv.name_range_in_span(cc_name, member.span),
275 kind: SymbolEntryKind::ClassConst {
276 container: Container::Enum,
277 },
278 signature: sig,
279 doc_markdown,
280 },
281 );
282 }
283 _ => {}
284 }
285 }
286 }
287
288 StmtKind::Namespace(ns) => {
289 if let NamespaceBody::Braced(inner) = &ns.body {
290 collect_stmts(&inner.stmts, sv, out);
291 }
292 }
293
294 _ => {}
295 }
296 }
297}
298
299fn collect_members<'a>(
300 members: impl Iterator<Item = &'a php_ast::ClassMember<'a, 'a>>,
301 sv: crate::document::ast::SourceView<'_>,
302 container: Container,
303 out: &mut HashMap<String, Vec<SymbolEntry>>,
304) {
305 for member in members {
306 match &member.kind {
307 ClassMemberKind::Method(m) => {
308 let mname = m.name.or_error();
309 let m_decl = Declaration::Method {
310 method: m,
311 member_span: member.span,
312 };
313 let sig = declaration_signature(&m_decl, mname);
314 let doc_markdown = m.doc_comment.as_ref().and_then(doc_to_markdown);
315 let name_range = sv.name_range_in_span(mname, member.span);
316 push(
317 out,
318 mname.to_owned(),
319 SymbolEntry {
320 name_range,
321 kind: SymbolEntryKind::Method { container },
322 signature: sig,
323 doc_markdown,
324 },
325 );
326
327 if container == Container::Class && m.name == "__construct" {
329 for p in m.params.iter() {
330 if p.visibility.is_some() {
331 let pname = p.name.or_error();
332 let bare = pname.trim_start_matches('$');
333 push(
334 out,
335 bare.to_owned(),
336 SymbolEntry {
337 name_range: sv.name_range_in_span(pname, p.span),
338 kind: SymbolEntryKind::PromotedParam,
339 signature: None,
340 doc_markdown: None,
341 },
342 );
343 }
344 }
345 }
346 }
347
348 ClassMemberKind::ClassConst(cc) => {
349 let cc_name = cc.name.or_error();
350 let cc_decl = Declaration::ClassConst {
351 konst: cc,
352 container,
353 member_span: member.span,
354 };
355 let sig = declaration_signature(&cc_decl, cc_name);
356 let doc_markdown = cc.doc_comment.as_ref().and_then(doc_to_markdown);
357 let name_range = sv.name_range_in_span(cc_name, member.span);
358 push(
359 out,
360 cc_name.to_owned(),
361 SymbolEntry {
362 name_range,
363 kind: SymbolEntryKind::ClassConst { container },
364 signature: sig,
365 doc_markdown,
366 },
367 );
368 }
369
370 ClassMemberKind::Property(p) => {
371 let pname = p.name.or_error();
372 let bare = pname.trim_start_matches('$');
373 let name_range = sv.name_range_in_span(pname, member.span);
375 push(
376 out,
377 bare.to_owned(),
378 SymbolEntry {
379 name_range,
380 kind: SymbolEntryKind::Property { container },
381 signature: None,
382 doc_markdown: None,
383 },
384 );
385 }
386
387 _ => {}
388 }
389 }
390}
391
392fn push(out: &mut HashMap<String, Vec<SymbolEntry>>, key: String, entry: SymbolEntry) {
393 out.entry(key).or_default().push(entry);
394}
395
396pub fn is_hoverable_kind(kind: SymbolEntryKind) -> bool {
398 !matches!(
399 kind,
400 SymbolEntryKind::Property { .. } | SymbolEntryKind::PromotedParam
401 )
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn build(src: &str) -> SymbolMap {
409 let doc = ParsedDoc::parse(src.to_owned());
410 SymbolMap::build(&doc)
411 }
412
413 #[test]
414 fn top_level_function() {
415 let m = build("<?php\nfunction greet(string $name): string { return $name; }");
416 let e = m.lookup("greet", |_| true).unwrap();
417 assert_eq!(e.kind, SymbolEntryKind::Function);
418 assert_eq!(
419 e.signature.as_deref(),
420 Some("function greet(string $name): string")
421 );
422 }
423
424 #[test]
425 fn class_with_abstract_method() {
426 let m = build("<?php\nabstract class Foo {\n abstract public function bar(): void;\n}");
427 let cls = m.lookup("Foo", |_| true).unwrap();
428 assert_eq!(cls.kind, SymbolEntryKind::Class);
429
430 assert!(
431 m.lookup("bar", |e| {
432 matches!(
433 e.kind,
434 SymbolEntryKind::Method {
435 container: Container::Class
436 }
437 )
438 })
439 .is_some()
440 );
441 }
442
443 #[test]
444 fn interface_member_is_abstract() {
445 let m = build("<?php\ninterface Shape {\n public function area(): float;\n}");
446 let method = m.lookup("area", |_| true).unwrap();
447 assert_eq!(
448 method.kind,
449 SymbolEntryKind::Method {
450 container: Container::Interface
451 }
452 );
453 }
454
455 #[test]
456 fn enum_entries() {
457 let m = build("<?php\nenum Color {\n case Red;\n case Blue;\n}");
458 assert_eq!(
459 m.lookup("Color", |_| true).unwrap().kind,
460 SymbolEntryKind::Enum
461 );
462 assert_eq!(
463 m.lookup("Red", |_| true).unwrap().kind,
464 SymbolEntryKind::EnumCase
465 );
466 assert_eq!(
467 m.lookup("Blue", |_| true).unwrap().kind,
468 SymbolEntryKind::EnumCase
469 );
470 }
471
472 #[test]
473 fn promoted_param_keyed_without_dollar() {
474 let m = build(
475 "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}",
476 );
477 assert!(
478 m.lookup("x", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
479 .is_some()
480 );
481 assert!(
482 m.lookup("y", |e| matches!(e.kind, SymbolEntryKind::PromotedParam))
483 .is_some()
484 );
485 }
486
487 #[test]
488 fn source_order_preserved() {
489 let m = build(
492 "<?php\ninterface I {\n public function render(): void;\n}\ntrait T {\n abstract public function render(): void;\n}",
493 );
494 let entries = m.entries.get("render").unwrap();
495 assert_eq!(
496 entries[0].kind,
497 SymbolEntryKind::Method {
498 container: Container::Interface
499 }
500 );
501 assert_eq!(
502 entries[1].kind,
503 SymbolEntryKind::Method {
504 container: Container::Trait
505 }
506 );
507 }
508
509 #[test]
510 fn docblock_extracted() {
511 let m = build("<?php\n/** Greets the user. */\nfunction greet(): void {}");
512 let e = m.lookup("greet", |_| true).unwrap();
513 assert_eq!(e.doc_markdown.as_deref(), Some("Greets the user."));
514 }
515
516 #[test]
517 fn no_docblock_when_absent() {
518 let m = build("<?php\nfunction greet(): void {}");
519 let e = m.lookup("greet", |_| true).unwrap();
520 assert!(e.doc_markdown.is_none());
521 }
522}