1use std::cell::OnceCell;
2use std::sync::Arc;
3
4use php_ast::{ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Param, Stmt, StmtKind};
5use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
6
7use crate::ast::{MethodReturnsMap, ParsedDoc, format_type_hint};
8use crate::docblock::{Docblock, docblock_before, find_docblock, parse_docblock};
9use crate::type_map::TypeMap;
10use crate::util::{is_php_builtin, php_doc_url, word_at};
11
12pub fn hover_info(
13 source: &str,
14 doc: &ParsedDoc,
15 doc_returns: &MethodReturnsMap,
16 position: Position,
17 other_docs: &[(
18 tower_lsp::lsp_types::Url,
19 Arc<ParsedDoc>,
20 Arc<MethodReturnsMap>,
21 )],
22) -> Option<Hover> {
23 hover_at(source, doc, doc_returns, other_docs, position)
24}
25
26pub fn hover_at(
28 source: &str,
29 doc: &ParsedDoc,
30 doc_returns: &MethodReturnsMap,
31 other_docs: &[(
32 tower_lsp::lsp_types::Url,
33 Arc<ParsedDoc>,
34 Arc<MethodReturnsMap>,
35 )],
36 position: Position,
37) -> Option<Hover> {
38 if let Some(line_text) = source.lines().nth(position.line as usize) {
41 let trimmed = line_text.trim();
42 if trimmed.starts_with("use ") && !trimmed.starts_with("use function ") {
43 let fqn = trimmed
44 .strip_prefix("use ")
45 .unwrap_or("")
46 .trim_end_matches(';')
47 .trim();
48 if !fqn.is_empty() {
49 let maybe_word = word_at(source, position);
51 let alias = fqn.rsplit('\\').next().unwrap_or(fqn);
52 let matches = match &maybe_word {
53 Some(w) => w == alias || fqn.contains(w.as_str()),
54 None => true, };
56 if matches {
57 return Some(Hover {
58 contents: HoverContents::Markup(MarkupContent {
59 kind: MarkupKind::Markdown,
60 value: format!("`use {};`", fqn),
61 }),
62 range: None,
63 });
64 }
65 }
66 }
67 }
68
69 let word = word_at(source, position)?;
70
71 let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
73 let type_map = || {
74 type_map_cell.get_or_init(|| {
75 TypeMap::from_docs_at_position(
76 doc,
77 doc_returns,
78 other_docs.iter().map(|(_, d, r)| (d.as_ref(), r.as_ref())),
79 None,
80 position,
81 )
82 })
83 };
84
85 if word.starts_with('$')
87 && let Some(class_name) = type_map().get(&word)
88 {
89 return Some(Hover {
90 contents: HoverContents::Markup(MarkupContent {
91 kind: MarkupKind::Markdown,
92 value: format!("`{}` `{}`", word, class_name),
93 }),
94 range: None,
95 });
96 }
97
98 if !word.starts_with('$')
101 && let Some(line_text) = source.lines().nth(position.line as usize)
102 {
103 let arrow_word = format!("->{}", word);
104 let nullsafe_arrow_word = format!("?->{}", word);
105 if line_text.contains(&arrow_word) || line_text.contains(&nullsafe_arrow_word) {
106 let arrow_pos = line_text
107 .find(&nullsafe_arrow_word)
108 .or_else(|| line_text.find(&arrow_word));
109 if let Some(apos) = arrow_pos {
110 let before_arrow = &line_text[..apos];
111 if let Some(var_name) = extract_receiver_var_from_end(before_arrow) {
112 let tm = type_map();
113 let class_name = if var_name == "$this" {
114 crate::type_map::enclosing_class_at(source, doc, position)
115 .or_else(|| tm.get("$this").map(|s| s.to_string()))
116 } else {
117 tm.get(&var_name).map(|s| s.to_string())
118 };
119 if let Some(cls) = class_name {
120 let first_cls = cls.split('|').next().unwrap_or(&cls);
121 for d in std::iter::once(doc)
122 .chain(other_docs.iter().map(|(_, d, _)| d.as_ref()))
123 {
124 if let Some(sig) =
125 scan_method_of_class(&d.program().stmts, first_cls, &word)
126 {
127 let mut value = wrap_php(&sig);
128 if let Some(db) = find_method_docblock(d, first_cls, &word) {
129 let md = db.to_markdown();
130 if !md.is_empty() {
131 value.push_str("\n\n---\n\n");
132 value.push_str(&md);
133 }
134 }
135 return Some(Hover {
136 contents: HoverContents::Markup(MarkupContent {
137 kind: MarkupKind::Markdown,
138 value,
139 }),
140 range: None,
141 });
142 }
143 }
144 }
145 }
146 }
147 }
148 }
149
150 let found = scan_statements(&doc.program().stmts, &word).map(|sig| (sig, source, doc));
152 let found = found.or_else(|| {
153 for (_, other, _) in other_docs {
154 if let Some(sig) = scan_statements(&other.program().stmts, &word) {
155 return Some((sig, other.source(), other.as_ref()));
156 }
157 }
158 None
159 });
160
161 if let Some((sig, sig_source, sig_doc)) = found {
162 let mut value = wrap_php(&sig);
163 if let Some(db) = find_docblock(sig_source, &sig_doc.program().stmts, &word) {
164 let md = db.to_markdown();
165 if !md.is_empty() {
166 value.push_str("\n\n---\n\n");
167 value.push_str(&md);
168 }
169 }
170 if is_php_builtin(&word) {
171 value.push_str(&format!(
172 "\n\n[php.net documentation]({})",
173 php_doc_url(&word)
174 ));
175 }
176 return Some(Hover {
177 contents: HoverContents::Markup(MarkupContent {
178 kind: MarkupKind::Markdown,
179 value,
180 }),
181 range: None,
182 });
183 }
184
185 if is_php_builtin(&word) {
187 let value = format!(
188 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
189 word,
190 php_doc_url(&word)
191 );
192 return Some(Hover {
193 contents: HoverContents::Markup(MarkupContent {
194 kind: MarkupKind::Markdown,
195 value,
196 }),
197 range: None,
198 });
199 }
200
201 if !word.starts_with('$')
203 && let Some(line_text) = source.lines().nth(position.line as usize)
204 {
205 let arrow_word = format!("->{}", word);
207 let nullsafe_arrow_word = format!("?->{}", word);
208 if line_text.contains(&arrow_word) || line_text.contains(&nullsafe_arrow_word) {
209 let arrow_pos = line_text
212 .find(&nullsafe_arrow_word)
213 .or_else(|| line_text.find(&arrow_word));
214 if let Some(apos) = arrow_pos {
215 let before_arrow = &line_text[..apos];
216 let receiver_var = extract_receiver_var_from_end(before_arrow);
217 if let Some(var_name) = receiver_var {
218 let tm = type_map();
219 let class_name = if var_name == "$this" {
220 crate::type_map::enclosing_class_at(source, doc, position)
221 .or_else(|| tm.get("$this").map(|s| s.to_string()))
222 } else {
223 tm.get(&var_name).map(|s| s.to_string())
224 };
225 if let Some(cls) = class_name {
226 for d in std::iter::once(doc)
227 .chain(other_docs.iter().map(|(_, d, _)| d.as_ref()))
228 {
229 if let Some((type_str, db)) = find_property_info(d, &cls, &word) {
230 let sig = format!(
231 "(property) {}::${}{}",
232 cls,
233 word,
234 if type_str.is_empty() {
235 String::new()
236 } else {
237 format!(": {}", type_str)
238 }
239 );
240 let mut value = wrap_php(&sig);
241 if let Some(doc) = db {
242 let md = doc.to_markdown();
243 if !md.is_empty() {
244 value.push_str("\n\n---\n\n");
245 value.push_str(&md);
246 }
247 }
248 return Some(Hover {
249 contents: HoverContents::Markup(MarkupContent {
250 kind: MarkupKind::Markdown,
251 value,
252 }),
253 range: None,
254 });
255 }
256 }
257 }
258 }
259 }
260 }
261 }
262
263 if let Some(stub) = crate::stubs::builtin_class_members(&word) {
265 let method_names: Vec<&str> = stub
266 .methods
267 .iter()
268 .filter(|(_, is_static)| !is_static)
269 .map(|(n, _)| n.as_str())
270 .take(8)
271 .collect();
272 let static_names: Vec<&str> = stub
273 .methods
274 .iter()
275 .filter(|(_, is_static)| *is_static)
276 .map(|(n, _)| n.as_str())
277 .take(4)
278 .collect();
279 let mut lines = vec![format!("**{}** — built-in class", word)];
280 if !method_names.is_empty() {
281 lines.push(format!(
282 "Methods: {}",
283 method_names
284 .iter()
285 .map(|n| format!("`{n}`"))
286 .collect::<Vec<_>>()
287 .join(", ")
288 ));
289 }
290 if !static_names.is_empty() {
291 lines.push(format!(
292 "Static: {}",
293 static_names
294 .iter()
295 .map(|n| format!("`{n}`"))
296 .collect::<Vec<_>>()
297 .join(", ")
298 ));
299 }
300 if let Some(parent) = &stub.parent {
301 lines.push(format!("Extends: `{parent}`"));
302 }
303 return Some(Hover {
304 contents: HoverContents::Markup(MarkupContent {
305 kind: MarkupKind::Markdown,
306 value: lines.join("\n\n"),
307 }),
308 range: None,
309 });
310 }
311
312 None
313}
314
315fn scan_statements(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
316 for stmt in stmts {
317 match &stmt.kind {
318 StmtKind::Function(f) if f.name == word => {
319 let params = format_params(&f.params);
320 let ret = f
321 .return_type
322 .as_ref()
323 .map(|r| format!(": {}", format_type_hint(r)))
324 .unwrap_or_default();
325 return Some(format!("function {}({}){}", word, params, ret));
326 }
327 StmtKind::Class(c) if c.name == Some(word) => {
328 let mut sig = format!("class {}", word);
329 if let Some(ext) = &c.extends {
330 sig.push_str(&format!(" extends {}", ext.to_string_repr()));
331 }
332 if !c.implements.is_empty() {
333 let ifaces: Vec<String> = c
334 .implements
335 .iter()
336 .map(|i| i.to_string_repr().into_owned())
337 .collect();
338 sig.push_str(&format!(" implements {}", ifaces.join(", ")));
339 }
340 return Some(sig);
341 }
342 StmtKind::Interface(i) if i.name == word => {
343 return Some(format!("interface {}", word));
344 }
345 StmtKind::Interface(i) => {
346 for member in i.members.iter() {
347 match &member.kind {
348 ClassMemberKind::Method(m) if m.name == word => {
349 let params = format_params(&m.params);
350 let ret = m
351 .return_type
352 .as_ref()
353 .map(|r| format!(": {}", format_type_hint(r)))
354 .unwrap_or_default();
355 return Some(format!("function {}({}){}", word, params, ret));
356 }
357 ClassMemberKind::ClassConst(k) if k.name == word => {
358 return Some(format_class_const(k));
359 }
360 _ => {}
361 }
362 }
363 }
364 StmtKind::Trait(t) if t.name == word => {
365 return Some(format!("trait {}", word));
366 }
367 StmtKind::Enum(e) if e.name == word => {
368 let mut sig = format!("enum {}", word);
369 if !e.implements.is_empty() {
370 let ifaces: Vec<String> = e
371 .implements
372 .iter()
373 .map(|i| i.to_string_repr().into_owned())
374 .collect();
375 sig.push_str(&format!(" implements {}", ifaces.join(", ")));
376 }
377 return Some(sig);
378 }
379 StmtKind::Enum(e) => {
380 for member in e.members.iter() {
381 match &member.kind {
382 EnumMemberKind::Method(m) if m.name == word => {
383 let params = format_params(&m.params);
384 let ret = m
385 .return_type
386 .as_ref()
387 .map(|r| format!(": {}", format_type_hint(r)))
388 .unwrap_or_default();
389 return Some(format!("function {}({}){}", word, params, ret));
390 }
391 EnumMemberKind::Case(c) if c.name == word => {
392 let value_str = c
393 .value
394 .as_ref()
395 .and_then(format_expr_literal)
396 .map(|v| format!(" = {v}"))
397 .unwrap_or_default();
398 return Some(format!("case {}::{}{}", e.name, c.name, value_str));
399 }
400 EnumMemberKind::ClassConst(k) if k.name == word => {
401 return Some(format_class_const(k));
402 }
403 _ => {}
404 }
405 }
406 }
407 StmtKind::Class(c) => {
408 for member in c.members.iter() {
409 match &member.kind {
410 ClassMemberKind::Method(m) if m.name == word => {
411 let params = format_params(&m.params);
412 let ret = m
413 .return_type
414 .as_ref()
415 .map(|r| format!(": {}", format_type_hint(r)))
416 .unwrap_or_default();
417 return Some(format!("function {}({}){}", word, params, ret));
418 }
419 ClassMemberKind::ClassConst(k) if k.name == word => {
420 return Some(format_class_const(k));
421 }
422 _ => {}
423 }
424 }
425 }
426 StmtKind::Trait(t) => {
427 for member in t.members.iter() {
428 match &member.kind {
429 ClassMemberKind::Method(m) if m.name == word => {
430 let params = format_params(&m.params);
431 let ret = m
432 .return_type
433 .as_ref()
434 .map(|r| format!(": {}", format_type_hint(r)))
435 .unwrap_or_default();
436 return Some(format!("function {}({}){}", word, params, ret));
437 }
438 ClassMemberKind::ClassConst(k) if k.name == word => {
439 return Some(format_class_const(k));
440 }
441 _ => {}
442 }
443 }
444 }
445 StmtKind::Namespace(ns) => {
446 if let NamespaceBody::Braced(inner) = &ns.body
447 && let Some(sig) = scan_statements(inner, word)
448 {
449 return Some(sig);
450 }
451 }
452 _ => {}
453 }
454 }
455 None
456}
457
458fn format_expr_literal(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
460 match &expr.kind {
461 ExprKind::Int(n) => Some(n.to_string()),
462 ExprKind::Float(f) => Some(f.to_string()),
463 ExprKind::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
464 ExprKind::String(s) => Some(format!("'{}'", s)),
465 _ => None,
466 }
467}
468
469fn format_class_const(c: &php_ast::ClassConstDecl<'_, '_>) -> String {
471 let type_str = c
472 .type_hint
473 .as_ref()
474 .map(|t| format!("{} ", format_type_hint(t)))
475 .or_else(|| match &c.value.kind {
476 ExprKind::Int(_) => Some("int ".to_string()),
477 ExprKind::String(_) => Some("string ".to_string()),
478 ExprKind::Float(_) => Some("float ".to_string()),
479 ExprKind::Bool(_) => Some("bool ".to_string()),
480 _ => None,
481 })
482 .unwrap_or_default();
483 let value_str = format_expr_literal(&c.value)
484 .map(|v| format!(" = {v}"))
485 .unwrap_or_default();
486 format!("const {}{}{}", type_str, c.name, value_str)
487}
488
489pub(crate) fn format_params_str(params: &[Param<'_, '_>]) -> String {
490 format_params(params)
491}
492
493pub fn signature_for_symbol_from_index(
498 name: &str,
499 indexes: &[(
500 tower_lsp::lsp_types::Url,
501 std::sync::Arc<crate::file_index::FileIndex>,
502 )],
503) -> Option<String> {
504 for (_, idx) in indexes {
505 for f in &idx.functions {
506 if f.name == name {
507 let params_str = f
508 .params
509 .iter()
510 .map(|p| {
511 let mut s = String::new();
512 if let Some(t) = &p.type_hint {
513 s.push_str(&format!("{} ", t));
514 }
515 if p.variadic {
516 s.push_str("...");
517 }
518 s.push_str(&format!("${}", p.name));
519 s
520 })
521 .collect::<Vec<_>>()
522 .join(", ");
523 let ret = f
524 .return_type
525 .as_deref()
526 .map(|r| format!(": {}", r))
527 .unwrap_or_default();
528 return Some(format!("function {}({}){}", name, params_str, ret));
529 }
530 }
531 for cls in &idx.classes {
532 for m in &cls.methods {
533 if m.name == name {
534 let params_str = m
535 .params
536 .iter()
537 .map(|p| {
538 let mut s = String::new();
539 if let Some(t) = &p.type_hint {
540 s.push_str(&format!("{} ", t));
541 }
542 if p.variadic {
543 s.push_str("...");
544 }
545 s.push_str(&format!("${}", p.name));
546 s
547 })
548 .collect::<Vec<_>>()
549 .join(", ");
550 let ret = m
551 .return_type
552 .as_deref()
553 .map(|r| format!(": {}", r))
554 .unwrap_or_default();
555 return Some(format!("function {}({}){}", name, params_str, ret));
556 }
557 }
558 }
559 }
560 None
561}
562
563pub fn docs_for_symbol_from_index(
565 name: &str,
566 indexes: &[(
567 tower_lsp::lsp_types::Url,
568 std::sync::Arc<crate::file_index::FileIndex>,
569 )],
570) -> Option<String> {
571 if let Some(sig) = signature_for_symbol_from_index(name, indexes) {
572 let mut value = wrap_php(&sig);
573 for (_, idx) in indexes {
575 for f in &idx.functions {
576 if f.name == name {
577 if let Some(raw) = &f.doc {
578 let db = crate::docblock::parse_docblock(raw);
579 let md = db.to_markdown();
580 if !md.is_empty() {
581 value.push_str("\n\n---\n\n");
582 value.push_str(&md);
583 }
584 }
585 break;
586 }
587 }
588 for cls in &idx.classes {
589 for m in &cls.methods {
590 if m.name == name {
591 if let Some(raw) = &m.doc {
592 let db = crate::docblock::parse_docblock(raw);
593 let md = db.to_markdown();
594 if !md.is_empty() {
595 value.push_str("\n\n---\n\n");
596 value.push_str(&md);
597 }
598 }
599 break;
600 }
601 }
602 }
603 }
604 if is_php_builtin(name) {
605 value.push_str(&format!(
606 "\n\n[php.net documentation]({})",
607 php_doc_url(name)
608 ));
609 }
610 return Some(value);
611 }
612 if is_php_builtin(name) {
614 return Some(format!(
615 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
616 name,
617 php_doc_url(name)
618 ));
619 }
620 None
621}
622
623pub fn class_hover_from_index(
626 word: &str,
627 indexes: &[(
628 tower_lsp::lsp_types::Url,
629 std::sync::Arc<crate::file_index::FileIndex>,
630 )],
631) -> Option<Hover> {
632 use crate::file_index::ClassKind;
633
634 for (_, idx) in indexes {
635 for cls in &idx.classes {
636 if cls.name == word || cls.fqn.trim_start_matches('\\') == word {
637 let kw = match cls.kind {
638 ClassKind::Interface => "interface",
639 ClassKind::Trait => "trait",
640 ClassKind::Enum => "enum",
641 ClassKind::Class => {
642 if cls.is_abstract {
643 "abstract class"
644 } else {
645 "class"
646 }
647 }
648 };
649 let mut sig = format!("{} {}", kw, cls.name);
650 if let Some(parent) = &cls.parent {
651 sig.push_str(&format!(" extends {}", parent));
652 }
653 if !cls.implements.is_empty() {
654 let list: Vec<&str> = cls.implements.iter().map(|s| s.as_ref()).collect();
655 sig.push_str(&format!(" implements {}", list.join(", ")));
656 }
657 return Some(Hover {
658 contents: HoverContents::Markup(MarkupContent {
659 kind: MarkupKind::Markdown,
660 value: wrap_php(&sig),
661 }),
662 range: None,
663 });
664 }
665 }
666 }
667 None
668}
669
670fn format_params(params: &[Param<'_, '_>]) -> String {
671 params
672 .iter()
673 .map(|p| {
674 let mut s = String::new();
675 if p.by_ref {
676 s.push('&');
677 }
678 if let Some(t) = &p.type_hint {
679 s.push_str(&format!("{} ", format_type_hint(t)));
680 }
681 if p.variadic {
682 s.push_str("...");
683 }
684 s.push_str(&format!("${}", p.name));
685 if let Some(default) = &p.default {
686 s.push_str(&format!(" = {}", format_default_value(default)));
687 }
688 s
689 })
690 .collect::<Vec<_>>()
691 .join(", ")
692}
693
694fn format_default_value(expr: &php_ast::Expr<'_, '_>) -> String {
696 match &expr.kind {
697 ExprKind::Int(n) => n.to_string(),
698 ExprKind::Float(f) => f.to_string(),
699 ExprKind::String(s) => format!("'{}'", s),
700 ExprKind::Bool(b) => {
701 if *b {
702 "true".to_string()
703 } else {
704 "false".to_string()
705 }
706 }
707 ExprKind::Null => "null".to_string(),
708 ExprKind::Array(items) => {
709 if items.is_empty() {
710 "[]".to_string()
711 } else {
712 "[...]".to_string()
713 }
714 }
715 _ => "...".to_string(),
716 }
717}
718
719fn wrap_php(sig: &str) -> String {
720 format!("```php\n{}\n```", sig)
721}
722
723fn extract_receiver_var_from_end(before_arrow: &str) -> Option<String> {
726 let trimmed = before_arrow.trim_end();
728 let var_name: String = trimmed
729 .chars()
730 .rev()
731 .take_while(|&c| c.is_alphanumeric() || c == '_' || c == '$')
732 .collect::<String>()
733 .chars()
734 .rev()
735 .collect();
736 if var_name.starts_with('$') && var_name.len() > 1 {
737 Some(var_name)
738 } else if !var_name.is_empty() && !var_name.starts_with('$') {
739 Some(format!("${}", var_name))
740 } else {
741 None
742 }
743}
744
745fn find_property_info(
749 doc: &ParsedDoc,
750 class_name: &str,
751 prop_name: &str,
752) -> Option<(String, Option<Docblock>)> {
753 find_property_info_in_stmts(doc.source(), &doc.program().stmts, class_name, prop_name)
754}
755
756fn find_property_info_in_stmts<'a>(
757 source: &str,
758 stmts: &[Stmt<'a, 'a>],
759 class_name: &str,
760 prop_name: &str,
761) -> Option<(String, Option<Docblock>)> {
762 for stmt in stmts {
763 match &stmt.kind {
764 StmtKind::Class(c) if c.name == Some(class_name) => {
765 for member in c.members.iter() {
766 match &member.kind {
767 ClassMemberKind::Property(p) if p.name == prop_name => {
768 let type_str = p
769 .type_hint
770 .as_ref()
771 .map(|t| crate::ast::format_type_hint(t))
772 .unwrap_or_default();
773 let db = docblock_before(source, member.span.start)
774 .map(|raw| parse_docblock(&raw));
775 return Some((type_str, db));
776 }
777 ClassMemberKind::Method(m) if m.name == "__construct" => {
778 for p in m.params.iter() {
780 if p.name == prop_name && p.visibility.is_some() {
781 let type_str = p
782 .type_hint
783 .as_ref()
784 .map(|t| crate::ast::format_type_hint(t))
785 .unwrap_or_default();
786 let db = docblock_before(source, member.span.start).and_then(
792 |raw| {
793 let full = parse_docblock(&raw);
794 let matching: Vec<_> = full
795 .params
796 .into_iter()
797 .filter(|dp| {
798 dp.name.strip_prefix('$') == Some(prop_name)
799 })
800 .collect();
801 if matching.is_empty() {
802 None
803 } else {
804 Some(crate::docblock::Docblock {
805 params: matching,
806 ..Default::default()
807 })
808 }
809 },
810 );
811 return Some((type_str, db));
812 }
813 }
814 }
815 _ => {}
816 }
817 }
818 return None;
820 }
821 StmtKind::Namespace(ns) => {
822 if let NamespaceBody::Braced(inner) = &ns.body
823 && let Some(t) =
824 find_property_info_in_stmts(source, inner, class_name, prop_name)
825 {
826 return Some(t);
827 }
828 }
829 _ => {}
830 }
831 }
832 None
833}
834
835fn scan_method_of_class(
838 stmts: &[Stmt<'_, '_>],
839 class_name: &str,
840 method_name: &str,
841) -> Option<String> {
842 for stmt in stmts {
843 match &stmt.kind {
844 StmtKind::Class(c) if c.name == Some(class_name) => {
845 for member in c.members.iter() {
846 if let ClassMemberKind::Method(m) = &member.kind
847 && m.name == method_name
848 {
849 let params = format_params(&m.params);
850 let ret = m
851 .return_type
852 .as_ref()
853 .map(|r| format!(": {}", format_type_hint(r)))
854 .unwrap_or_default();
855 return Some(format!(
856 "{}::{}({}){}",
857 class_name, method_name, params, ret
858 ));
859 }
860 }
861 return None;
862 }
863 StmtKind::Trait(t) if t.name == class_name => {
864 for member in t.members.iter() {
865 if let ClassMemberKind::Method(m) = &member.kind
866 && m.name == method_name
867 {
868 let params = format_params(&m.params);
869 let ret = m
870 .return_type
871 .as_ref()
872 .map(|r| format!(": {}", format_type_hint(r)))
873 .unwrap_or_default();
874 return Some(format!(
875 "{}::{}({}){}",
876 class_name, method_name, params, ret
877 ));
878 }
879 }
880 return None;
881 }
882 StmtKind::Enum(e) if e.name == class_name => {
883 for member in e.members.iter() {
884 if let EnumMemberKind::Method(m) = &member.kind
885 && m.name == method_name
886 {
887 let params = format_params(&m.params);
888 let ret = m
889 .return_type
890 .as_ref()
891 .map(|r| format!(": {}", format_type_hint(r)))
892 .unwrap_or_default();
893 return Some(format!(
894 "{}::{}({}){}",
895 class_name, method_name, params, ret
896 ));
897 }
898 }
899 return None;
900 }
901 StmtKind::Namespace(ns) => {
902 if let NamespaceBody::Braced(inner) = &ns.body {
903 let result = scan_method_of_class(inner, class_name, method_name);
904 if result.is_some() {
905 return result;
906 }
907 }
908 }
909 _ => {}
910 }
911 }
912 None
913}
914
915fn find_method_docblock(
916 doc: &ParsedDoc,
917 class_name: &str,
918 method_name: &str,
919) -> Option<crate::docblock::Docblock> {
920 find_method_docblock_in_stmts(doc.source(), &doc.program().stmts, class_name, method_name)
921}
922
923fn find_method_docblock_in_stmts(
924 source: &str,
925 stmts: &[Stmt<'_, '_>],
926 class_name: &str,
927 method_name: &str,
928) -> Option<crate::docblock::Docblock> {
929 for stmt in stmts {
930 match &stmt.kind {
931 StmtKind::Class(c) if c.name == Some(class_name) => {
932 for member in c.members.iter() {
933 if let ClassMemberKind::Method(m) = &member.kind
934 && m.name == method_name
935 {
936 return docblock_before(source, member.span.start)
937 .map(|raw| parse_docblock(&raw));
938 }
939 }
940 return None;
941 }
942 StmtKind::Trait(t) if t.name == class_name => {
943 for member in t.members.iter() {
944 if let ClassMemberKind::Method(m) = &member.kind
945 && m.name == method_name
946 {
947 return docblock_before(source, member.span.start)
948 .map(|raw| parse_docblock(&raw));
949 }
950 }
951 return None;
952 }
953 StmtKind::Enum(e) if e.name == class_name => {
954 for member in e.members.iter() {
955 if let EnumMemberKind::Method(m) = &member.kind
956 && m.name == method_name
957 {
958 return docblock_before(source, member.span.start)
959 .map(|raw| parse_docblock(&raw));
960 }
961 }
962 return None;
963 }
964 StmtKind::Namespace(ns) => {
965 if let NamespaceBody::Braced(inner) = &ns.body {
966 let result =
967 find_method_docblock_in_stmts(source, inner, class_name, method_name);
968 if result.is_some() {
969 return result;
970 }
971 }
972 }
973 _ => {}
974 }
975 }
976 None
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982 use crate::test_utils::cursor;
983 use crate::type_map::build_method_returns;
984
985 fn pos(line: u32, character: u32) -> Position {
986 Position { line, character }
987 }
988
989 #[test]
990 fn hover_on_function_name_returns_signature() {
991 let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
992 let doc = ParsedDoc::parse(src.clone());
993 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
994 assert!(result.is_some(), "expected hover result");
995 if let Some(Hover {
996 contents: HoverContents::Markup(mc),
997 ..
998 }) = result
999 {
1000 assert!(
1001 mc.value.contains("function greet("),
1002 "expected function signature, got: {}",
1003 mc.value
1004 );
1005 }
1006 }
1007
1008 #[test]
1009 fn hover_on_class_name_returns_class_sig() {
1010 let (src, p) = cursor("<?php\nclass My$0Service {}");
1011 let doc = ParsedDoc::parse(src.clone());
1012 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1013 assert!(result.is_some(), "expected hover result");
1014 if let Some(Hover {
1015 contents: HoverContents::Markup(mc),
1016 ..
1017 }) = result
1018 {
1019 assert!(
1020 mc.value.contains("class MyService"),
1021 "expected class sig, got: {}",
1022 mc.value
1023 );
1024 }
1025 }
1026
1027 #[test]
1028 fn hover_on_unknown_word_returns_none() {
1029 let src = "<?php\n$unknown = 42;";
1030 let doc = ParsedDoc::parse(src.to_string());
1031 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 2), &[]);
1032 assert!(result.is_none(), "expected None for unknown word");
1033 }
1034
1035 #[test]
1036 fn hover_at_column_beyond_line_length_returns_none() {
1037 let src = "<?php\nfunction hi() {}";
1038 let doc = ParsedDoc::parse(src.to_string());
1039 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 999), &[]);
1040 assert!(result.is_none());
1041 }
1042
1043 #[test]
1044 fn word_at_extracts_from_middle_of_identifier() {
1045 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
1046 let word = word_at(&src, p);
1047 assert_eq!(word.as_deref(), Some("greetUser"));
1048 }
1049
1050 #[test]
1051 fn hover_on_class_with_extends_shows_parent() {
1052 let src = "<?php\nclass Dog extends Animal {}";
1053 let doc = ParsedDoc::parse(src.to_string());
1054 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
1055 assert!(result.is_some());
1056 if let Some(Hover {
1057 contents: HoverContents::Markup(mc),
1058 ..
1059 }) = result
1060 {
1061 assert!(
1062 mc.value.contains("extends Animal"),
1063 "expected 'extends Animal', got: {}",
1064 mc.value
1065 );
1066 }
1067 }
1068
1069 #[test]
1070 fn hover_on_class_with_implements_shows_interfaces() {
1071 let src = "<?php\nclass Repo implements Countable, Serializable {}";
1072 let doc = ParsedDoc::parse(src.to_string());
1073 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
1074 assert!(result.is_some());
1075 if let Some(Hover {
1076 contents: HoverContents::Markup(mc),
1077 ..
1078 }) = result
1079 {
1080 assert!(
1081 mc.value.contains("implements Countable, Serializable"),
1082 "expected implements list, got: {}",
1083 mc.value
1084 );
1085 }
1086 }
1087
1088 #[test]
1089 fn hover_on_trait_returns_trait_sig() {
1090 let src = "<?php\ntrait Loggable {}";
1091 let doc = ParsedDoc::parse(src.to_string());
1092 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
1093 assert!(result.is_some());
1094 if let Some(Hover {
1095 contents: HoverContents::Markup(mc),
1096 ..
1097 }) = result
1098 {
1099 assert!(
1100 mc.value.contains("trait Loggable"),
1101 "expected 'trait Loggable', got: {}",
1102 mc.value
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn hover_on_interface_returns_interface_sig() {
1109 let src = "<?php\ninterface Serializable {}";
1110 let doc = ParsedDoc::parse(src.to_string());
1111 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 12), &[]);
1112 assert!(result.is_some(), "expected hover result");
1113 if let Some(Hover {
1114 contents: HoverContents::Markup(mc),
1115 ..
1116 }) = result
1117 {
1118 assert!(
1119 mc.value.contains("interface Serializable"),
1120 "expected interface sig, got: {}",
1121 mc.value
1122 );
1123 }
1124 }
1125
1126 #[test]
1127 fn function_with_no_params_no_return_shows_no_colon() {
1128 let src = "<?php\nfunction init() {}";
1129 let doc = ParsedDoc::parse(src.to_string());
1130 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 10), &[]);
1131 assert!(result.is_some());
1132 if let Some(Hover {
1133 contents: HoverContents::Markup(mc),
1134 ..
1135 }) = result
1136 {
1137 assert!(
1138 mc.value.contains("function init()"),
1139 "expected 'function init()', got: {}",
1140 mc.value
1141 );
1142 assert!(
1143 !mc.value.contains(':'),
1144 "should not contain ':' when no return type, got: {}",
1145 mc.value
1146 );
1147 }
1148 }
1149
1150 #[test]
1151 fn hover_on_enum_returns_enum_sig() {
1152 let src = "<?php\nenum Suit {}";
1153 let doc = ParsedDoc::parse(src.to_string());
1154 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 6), &[]);
1155 assert!(result.is_some());
1156 if let Some(Hover {
1157 contents: HoverContents::Markup(mc),
1158 ..
1159 }) = result
1160 {
1161 assert!(
1162 mc.value.contains("enum Suit"),
1163 "expected 'enum Suit', got: {}",
1164 mc.value
1165 );
1166 }
1167 }
1168
1169 #[test]
1170 fn hover_on_enum_with_implements_shows_interface() {
1171 let src = "<?php\nenum Status: string implements Stringable {}";
1172 let doc = ParsedDoc::parse(src.to_string());
1173 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 6), &[]);
1174 assert!(result.is_some());
1175 if let Some(Hover {
1176 contents: HoverContents::Markup(mc),
1177 ..
1178 }) = result
1179 {
1180 assert!(
1181 mc.value.contains("implements Stringable"),
1182 "expected implements clause, got: {}",
1183 mc.value
1184 );
1185 }
1186 }
1187
1188 #[test]
1189 fn hover_on_enum_case_shows_case_sig() {
1190 let src = "<?php\nenum Status { case Active; case Inactive; }";
1191 let doc = ParsedDoc::parse(src.to_string());
1192 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 21), &[]);
1194 assert!(result.is_some(), "expected hover on enum case");
1195 if let Some(Hover {
1196 contents: HoverContents::Markup(mc),
1197 ..
1198 }) = result
1199 {
1200 assert!(
1201 mc.value.contains("Status::Active"),
1202 "expected 'Status::Active', got: {}",
1203 mc.value
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn snapshot_hover_backed_enum_case_shows_value() {
1210 check_hover(
1211 "<?php\nenum Color: string { case Red = 'red'; }",
1212 pos(1, 27),
1213 expect![[r#"
1214 ```php
1215 case Color::Red = 'red'
1216 ```"#]],
1217 );
1218 }
1219
1220 #[test]
1221 fn snapshot_hover_enum_class_const() {
1222 check_hover(
1223 "<?php\nenum Suit { const int MAX = 4; }",
1224 pos(1, 22),
1225 expect![[r#"
1226 ```php
1227 const int MAX = 4
1228 ```"#]],
1229 );
1230 }
1231
1232 #[test]
1233 fn hover_on_trait_method_returns_signature() {
1234 let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
1235 let doc = ParsedDoc::parse(src.to_string());
1236 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 34), &[]);
1238 assert!(result.is_some(), "expected hover on trait method");
1239 if let Some(Hover {
1240 contents: HoverContents::Markup(mc),
1241 ..
1242 }) = result
1243 {
1244 assert!(
1245 mc.value.contains("function log("),
1246 "expected function sig, got: {}",
1247 mc.value
1248 );
1249 }
1250 }
1251
1252 #[test]
1253 fn cross_file_hover_finds_class_in_other_doc() {
1254 use std::sync::Arc;
1255 let src = "<?php\n$x = new PaymentService();";
1256 let other_src = "<?php\nclass PaymentService { public function charge() {} }";
1257 let doc = ParsedDoc::parse(src.to_string());
1258 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
1259 let other_mr = Arc::new(build_method_returns(&other_doc));
1260 let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
1261 let other_docs = vec![(uri, other_doc, other_mr)];
1262 let result = hover_info(
1264 src,
1265 &doc,
1266 &build_method_returns(&doc),
1267 pos(1, 12),
1268 &other_docs,
1269 );
1270 assert!(result.is_some(), "expected cross-file hover result");
1271 if let Some(Hover {
1272 contents: HoverContents::Markup(mc),
1273 ..
1274 }) = result
1275 {
1276 assert!(
1277 mc.value.contains("PaymentService"),
1278 "expected 'PaymentService', got: {}",
1279 mc.value
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn hover_on_variable_shows_type() {
1286 let src = "<?php\n$obj = new Mailer();\n$obj";
1287 let doc = ParsedDoc::parse(src.to_string());
1288 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(2, 2));
1289 assert!(h.is_some());
1290 let text = match h.unwrap().contents {
1291 HoverContents::Markup(m) => m.value,
1292 _ => String::new(),
1293 };
1294 assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
1295 }
1296
1297 #[test]
1298 fn hover_on_builtin_class_shows_stub_info() {
1299 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
1300 let doc = ParsedDoc::parse(src.to_string());
1301 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(1, 12));
1302 assert!(h.is_some(), "should hover on PDO");
1303 let text = match h.unwrap().contents {
1304 HoverContents::Markup(m) => m.value,
1305 _ => String::new(),
1306 };
1307 assert!(text.contains("PDO"), "hover should mention PDO");
1308 }
1309
1310 #[test]
1311 fn hover_on_property_shows_type() {
1312 let src = "<?php\nclass User { public string $name; public int $age; }\n$u = new User();\n$u->name";
1313 let doc = ParsedDoc::parse(src.to_string());
1314 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(3, 5));
1316 assert!(h.is_some(), "expected hover on property");
1317 let text = match h.unwrap().contents {
1318 HoverContents::Markup(m) => m.value,
1319 _ => String::new(),
1320 };
1321 assert!(text.contains("User"), "should mention class name");
1322 assert!(text.contains("name"), "should mention property name");
1323 assert!(text.contains("string"), "should show type hint");
1324 }
1325
1326 #[test]
1327 fn hover_on_promoted_property_shows_type() {
1328 let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}\n$p = new Point(1.0, 2.0);\n$p->x";
1329 let doc = ParsedDoc::parse(src.to_string());
1330 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(8, 4));
1332 assert!(h.is_some(), "expected hover on promoted property");
1333 let text = match h.unwrap().contents {
1334 HoverContents::Markup(m) => m.value,
1335 _ => String::new(),
1336 };
1337 assert!(text.contains("Point"), "should mention class name");
1338 assert!(text.contains("x"), "should mention property name");
1339 assert!(
1340 text.contains("float"),
1341 "should show type hint for promoted property"
1342 );
1343 }
1344
1345 #[test]
1346 fn hover_on_promoted_property_shows_only_its_param_docblock() {
1347 let src = "<?php\nclass User {\n /**\n * Create a user.\n * @param string $name The user's display name\n * @param int $age The user's age\n * @return void\n * @throws \\InvalidArgumentException\n */\n public function __construct(\n public string $name,\n public int $age,\n ) {}\n}\n$u = new User('Alice', 30);\n$u->name";
1351 let doc = ParsedDoc::parse(src.to_string());
1352 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(15, 4));
1354 assert!(h.is_some(), "expected hover on promoted property");
1355 let text = match h.unwrap().contents {
1356 HoverContents::Markup(m) => m.value,
1357 _ => String::new(),
1358 };
1359 assert!(
1360 text.contains("@param") && text.contains("$name"),
1361 "should show @param for $name"
1362 );
1363 assert!(
1364 !text.contains("$age"),
1365 "should NOT show @param for other parameters"
1366 );
1367 assert!(
1368 !text.contains("@return"),
1369 "should NOT show @return from constructor docblock"
1370 );
1371 assert!(
1372 !text.contains("@throws"),
1373 "should NOT show @throws from constructor docblock"
1374 );
1375 assert!(
1376 !text.contains("Create a user"),
1377 "should NOT show constructor description"
1378 );
1379 }
1380
1381 #[test]
1382 fn hover_on_promoted_property_with_no_param_docblock_shows_type_only() {
1383 let src = "<?php\nclass User {\n /**\n * Create a user.\n * @return void\n */\n public function __construct(\n public string $name,\n ) {}\n}\n$u = new User('Alice');\n$u->name";
1386 let doc = ParsedDoc::parse(src.to_string());
1387 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(11, 4));
1388 assert!(h.is_some(), "expected hover on promoted property");
1389 let text = match h.unwrap().contents {
1390 HoverContents::Markup(m) => m.value,
1391 _ => String::new(),
1392 };
1393 assert!(text.contains("string"), "should show type hint");
1394 assert!(
1395 !text.contains("---"),
1396 "should not append a docblock section"
1397 );
1398 }
1399
1400 #[test]
1401 fn hover_on_use_alias_shows_fqn() {
1402 let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
1403 let doc = ParsedDoc::parse(src.to_string());
1404 let h = hover_at(
1405 src,
1406 &doc,
1407 &build_method_returns(&doc),
1408 &[],
1409 Position {
1410 line: 1,
1411 character: 20,
1412 },
1413 );
1414 assert!(h.is_some());
1415 let text = match h.unwrap().contents {
1416 HoverContents::Markup(m) => m.value,
1417 _ => String::new(),
1418 };
1419 assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
1420 }
1421
1422 #[test]
1423 fn hover_unknown_symbol_returns_none() {
1424 let src = "<?php\nunknownFunc();";
1426 let doc = ParsedDoc::parse(src.to_string());
1427 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 3), &[]);
1428 assert!(
1429 result.is_none(),
1430 "hover on undefined symbol should return None"
1431 );
1432 }
1433
1434 #[test]
1435 fn hover_on_builtin_function_returns_signature() {
1436 let src = "<?php\nstrlen('hello');";
1439 let doc = ParsedDoc::parse(src.to_string());
1440 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 3), &[]);
1441 let h = result.expect("expected hover result for built-in 'strlen'");
1442 let text = match h.contents {
1443 HoverContents::Markup(mc) => mc.value,
1444 _ => String::new(),
1445 };
1446 assert!(
1447 !text.is_empty(),
1448 "hover on strlen should return non-empty content"
1449 );
1450 assert!(
1451 text.contains("strlen"),
1452 "hover content should contain 'strlen', got: {text}"
1453 );
1454 }
1455
1456 #[test]
1457 fn hover_on_property_shows_docblock() {
1458 let src = "<?php\nclass User {\n /** The user's display name. */\n public string $name;\n}\n$u = new User();\n$u->name";
1459 let doc = ParsedDoc::parse(src.to_string());
1460 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
1462 assert!(h.is_some(), "expected hover on property with docblock");
1463 let text = match h.unwrap().contents {
1464 HoverContents::Markup(m) => m.value,
1465 _ => String::new(),
1466 };
1467 assert!(text.contains("User"), "should mention class name");
1468 assert!(text.contains("name"), "should mention property name");
1469 assert!(text.contains("string"), "should show type hint");
1470 assert!(
1471 text.contains("display name"),
1472 "should include docblock description, got: {}",
1473 text
1474 );
1475 }
1476
1477 #[test]
1478 fn hover_on_property_with_var_tag_shows_type_annotation() {
1479 let src = "<?php\nclass User {\n /** @var string */\n public $name;\n}\n$u = new User();\n$u->name";
1483 let doc = ParsedDoc::parse(src.to_string());
1484 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
1485 assert!(h.is_some(), "expected hover on @var-only property");
1486 let text = match h.unwrap().contents {
1487 HoverContents::Markup(m) => m.value,
1488 _ => String::new(),
1489 };
1490 assert!(
1491 text.contains("@var"),
1492 "should show @var annotation, got: {}",
1493 text
1494 );
1495 assert!(
1496 text.contains("string"),
1497 "should show var type, got: {}",
1498 text
1499 );
1500 }
1501
1502 #[test]
1503 fn hover_on_property_with_var_tag_and_description() {
1504 let src = "<?php\nclass User {\n /** @var string The display name. */\n public $name;\n}\n$u = new User();\n$u->name";
1505 let doc = ParsedDoc::parse(src.to_string());
1506 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
1507 assert!(
1508 h.is_some(),
1509 "expected hover on property with @var description"
1510 );
1511 let text = match h.unwrap().contents {
1512 HoverContents::Markup(m) => m.value,
1513 _ => String::new(),
1514 };
1515 assert!(
1516 text.contains("@var"),
1517 "should show @var annotation, got: {}",
1518 text
1519 );
1520 assert!(
1521 text.contains("The display name"),
1522 "should show @var description, got: {}",
1523 text
1524 );
1525 }
1526
1527 #[test]
1528 fn hover_on_this_property_shows_type() {
1529 let src = "<?php\nclass Counter {\n public int $count = 0;\n public function increment(): void {\n $this->count;\n }\n}";
1530 let doc = ParsedDoc::parse(src.to_string());
1531 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(4, 16));
1533 assert!(h.is_some(), "expected hover on $this->property");
1534 let text = match h.unwrap().contents {
1535 HoverContents::Markup(m) => m.value,
1536 _ => String::new(),
1537 };
1538 assert!(text.contains("Counter"), "should mention enclosing class");
1539 assert!(text.contains("count"), "should mention property name");
1540 assert!(text.contains("int"), "should show type hint");
1541 }
1542
1543 #[test]
1544 fn hover_on_nullsafe_property_shows_type() {
1545 let src = "<?php\nclass Profile { public string $bio; }\n$p = new Profile();\n$p?->bio";
1546 let doc = ParsedDoc::parse(src.to_string());
1547 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(3, 5));
1549 assert!(h.is_some(), "expected hover on nullsafe property access");
1550 let text = match h.unwrap().contents {
1551 HoverContents::Markup(m) => m.value,
1552 _ => String::new(),
1553 };
1554 assert!(text.contains("Profile"), "should mention class name");
1555 assert!(text.contains("bio"), "should mention property name");
1556 assert!(text.contains("string"), "should show type hint");
1557 }
1558
1559 use expect_test::{Expect, expect};
1562
1563 fn check_hover(src: &str, position: Position, expect: Expect) {
1564 let doc = ParsedDoc::parse(src.to_string());
1565 let result = hover_info(src, &doc, &build_method_returns(&doc), position, &[]);
1566 let actual = match result {
1567 Some(Hover {
1568 contents: HoverContents::Markup(mc),
1569 ..
1570 }) => mc.value,
1571 Some(_) => "(non-markup hover)".to_string(),
1572 None => "(no hover)".to_string(),
1573 };
1574 expect.assert_eq(&actual);
1575 }
1576
1577 #[test]
1578 fn snapshot_hover_simple_function() {
1579 check_hover(
1580 "<?php\nfunction init() {}",
1581 pos(1, 10),
1582 expect![[r#"
1583 ```php
1584 function init()
1585 ```"#]],
1586 );
1587 }
1588
1589 #[test]
1590 fn snapshot_hover_function_with_return_type() {
1591 check_hover(
1592 "<?php\nfunction greet(string $name): string {}",
1593 pos(1, 10),
1594 expect![[r#"
1595 ```php
1596 function greet(string $name): string
1597 ```"#]],
1598 );
1599 }
1600
1601 #[test]
1602 fn snapshot_hover_class() {
1603 check_hover(
1604 "<?php\nclass MyService {}",
1605 pos(1, 8),
1606 expect![[r#"
1607 ```php
1608 class MyService
1609 ```"#]],
1610 );
1611 }
1612
1613 #[test]
1614 fn snapshot_hover_class_with_extends() {
1615 check_hover(
1616 "<?php\nclass Dog extends Animal {}",
1617 pos(1, 8),
1618 expect![[r#"
1619 ```php
1620 class Dog extends Animal
1621 ```"#]],
1622 );
1623 }
1624
1625 #[test]
1626 fn snapshot_hover_method() {
1627 check_hover(
1628 "<?php\nclass Calc { public function add(int $a, int $b): int {} }",
1629 pos(1, 32),
1630 expect![[r#"
1631 ```php
1632 function add(int $a, int $b): int
1633 ```"#]],
1634 );
1635 }
1636
1637 #[test]
1638 fn snapshot_hover_trait() {
1639 check_hover(
1640 "<?php\ntrait Loggable {}",
1641 pos(1, 8),
1642 expect![[r#"
1643 ```php
1644 trait Loggable
1645 ```"#]],
1646 );
1647 }
1648
1649 #[test]
1650 fn snapshot_hover_interface() {
1651 check_hover(
1652 "<?php\ninterface Serializable {}",
1653 pos(1, 12),
1654 expect![[r#"
1655 ```php
1656 interface Serializable
1657 ```"#]],
1658 );
1659 }
1660
1661 #[test]
1662 fn snapshot_hover_class_const_with_type_hint() {
1663 check_hover(
1664 "<?php\nclass Config { const string VERSION = '1.0.0'; }",
1665 pos(1, 28),
1666 expect![[r#"
1667 ```php
1668 const string VERSION = '1.0.0'
1669 ```"#]],
1670 );
1671 }
1672
1673 #[test]
1674 fn snapshot_hover_class_const_float_value() {
1675 check_hover(
1676 "<?php\nclass Math { const float PI = 3.14; }",
1677 pos(1, 27),
1678 expect![[r#"
1679 ```php
1680 const float PI = 3.14
1681 ```"#]],
1682 );
1683 }
1684
1685 #[test]
1686 fn snapshot_hover_class_const_infers_type_from_value() {
1687 let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
1688 check_hover(
1689 &src,
1690 p,
1691 expect![[r#"
1692 ```php
1693 const string VERSION = '1.0.0'
1694 ```"#]],
1695 );
1696 }
1697
1698 #[test]
1699 fn snapshot_hover_interface_const_shows_type_and_value() {
1700 let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
1701 check_hover(
1702 &src,
1703 p,
1704 expect![[r#"
1705 ```php
1706 const int MAX = 100
1707 ```"#]],
1708 );
1709 }
1710
1711 #[test]
1712 fn snapshot_hover_trait_const_shows_type_and_value() {
1713 let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
1714 check_hover(
1715 &src,
1716 p,
1717 expect![[r#"
1718 ```php
1719 const string TAG = 'v1'
1720 ```"#]],
1721 );
1722 }
1723
1724 #[test]
1725 fn hover_on_catch_variable_shows_exception_class() {
1726 let (src, p) = cursor("<?php\ntry { } catch (RuntimeException $e$0) { }");
1727 let doc = ParsedDoc::parse(src.clone());
1728 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1729 assert!(result.is_some(), "expected hover result for catch variable");
1730 if let Some(Hover {
1731 contents: HoverContents::Markup(mc),
1732 ..
1733 }) = result
1734 {
1735 assert!(
1736 mc.value.contains("RuntimeException"),
1737 "expected RuntimeException in hover, got: {}",
1738 mc.value
1739 );
1740 }
1741 }
1742
1743 #[test]
1744 fn hover_on_static_var_with_array_default_shows_array() {
1745 let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
1746 let doc = ParsedDoc::parse(src.clone());
1747 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1748 assert!(
1749 result.is_some(),
1750 "expected hover result for static variable"
1751 );
1752 if let Some(Hover {
1753 contents: HoverContents::Markup(mc),
1754 ..
1755 }) = result
1756 {
1757 assert!(
1758 mc.value.contains("array"),
1759 "expected array type in hover, got: {}",
1760 mc.value
1761 );
1762 }
1763 }
1764
1765 #[test]
1766 fn hover_on_static_var_with_new_shows_class() {
1767 let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
1768 let doc = ParsedDoc::parse(src.clone());
1769 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1770 assert!(
1771 result.is_some(),
1772 "expected hover result for static variable"
1773 );
1774 if let Some(Hover {
1775 contents: HoverContents::Markup(mc),
1776 ..
1777 }) = result
1778 {
1779 assert!(
1780 mc.value.contains("MyService"),
1781 "expected MyService in hover, got: {}",
1782 mc.value
1783 );
1784 }
1785 }
1786
1787 #[test]
1789 fn hover_variable_in_method_does_not_leak_across_methods() {
1790 let (src, p) = cursor(concat!(
1793 "<?php\n",
1794 "class Service {\n",
1795 " public function methodA(): void { $result = new Widget(); }\n",
1796 " public function methodB(): void { $res$0ult = new Invoice(); }\n",
1797 "}\n",
1798 ));
1799 let doc = ParsedDoc::parse(src.clone());
1800 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1801 if let Some(Hover {
1802 contents: HoverContents::Markup(mc),
1803 ..
1804 }) = result
1805 {
1806 assert!(
1807 !mc.value.contains("Widget"),
1808 "Widget from methodA must not appear in methodB hover, got: {}",
1809 mc.value
1810 );
1811 assert!(
1812 mc.value.contains("Invoice"),
1813 "Invoice from methodB should appear in hover, got: {}",
1814 mc.value
1815 );
1816 }
1817 }
1818
1819 #[test]
1821 fn hover_method_call_shows_correct_class_signature() {
1822 let (src, p) = cursor(concat!(
1825 "<?php\n",
1826 "class Mailer { public function process(string $to): bool {} }\n",
1827 "class Queue { public function process(int $id): void {} }\n",
1828 "$mailer = new Mailer();\n",
1829 "$mailer->proc$0ess();\n",
1830 ));
1831 let doc = ParsedDoc::parse(src.clone());
1832 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1833 assert!(result.is_some(), "expected hover on method call");
1834 if let Some(Hover {
1835 contents: HoverContents::Markup(mc),
1836 ..
1837 }) = result
1838 {
1839 assert!(
1840 mc.value.contains("Mailer::process"),
1841 "should show Mailer::process, got: {}",
1842 mc.value
1843 );
1844 assert!(
1845 mc.value.contains("string $to"),
1846 "should show Mailer's params, got: {}",
1847 mc.value
1848 );
1849 assert!(
1850 !mc.value.contains("int $id"),
1851 "must NOT show Queue::process params, got: {}",
1852 mc.value
1853 );
1854 }
1855 }
1856}