1use std::cell::OnceCell;
2use std::sync::Arc;
3
4use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Stmt, StmtKind};
5use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
6
7use crate::ast::{MethodReturnsMap, ParsedDoc, format_type_hint};
8use crate::docblock::find_docblock;
9use crate::type_map::TypeMap;
10use crate::util::{is_php_builtin, php_doc_url, word_at_position, word_range_at};
11
12use super::closures::closure_hover;
13use super::formatting::{
14 format_class_const, format_expr_literal, format_method_prefix, format_params, wrap_php,
15};
16use super::members::{
17 find_parent_class_name, find_property_info, resolve_method_docblock, scan_class_const_of_class,
18 scan_enum_case_of_class, scan_method_of_class,
19};
20use super::named_args::{extract_named_arg_callee, is_named_arg_at, named_arg_hover_value};
21use super::parsing::{
22 extract_receiver_var_before_cursor, extract_static_class_before_cursor, resolve_use_alias,
23};
24
25fn scan_statements(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
26 for stmt in stmts {
27 match &stmt.kind {
28 StmtKind::Function(f) if f.name == word => {
29 let params = format_params(&f.params);
30 let ret = f
31 .return_type
32 .as_ref()
33 .map(|r| format!(": {}", format_type_hint(r)))
34 .unwrap_or_default();
35 return Some(format!("function {}({}){}", word, params, ret));
36 }
37 StmtKind::Class(c)
38 if c.name.as_ref().map(|n| n.to_string()) == Some(word.to_string()) =>
39 {
40 let kw = if c.modifiers.is_abstract {
41 "abstract class"
42 } else if c.modifiers.is_final {
43 "final class"
44 } else if c.modifiers.is_readonly {
45 "readonly class"
46 } else {
47 "class"
48 };
49 let mut sig = format!("{} {}", kw, word);
50 if let Some(ext) = &c.extends {
51 sig.push_str(&format!(" extends {}", ext.to_string_repr()));
52 }
53 if !c.implements.is_empty() {
54 let ifaces: Vec<String> = c
55 .implements
56 .iter()
57 .map(|i| i.to_string_repr().into_owned())
58 .collect();
59 sig.push_str(&format!(" implements {}", ifaces.join(", ")));
60 }
61 return Some(sig);
62 }
63 StmtKind::Class(c) => {
64 for member in c.body.members.iter() {
65 match &member.kind {
66 ClassMemberKind::Method(m) if m.name == word => {
67 let prefix = format_method_prefix(
68 m.visibility.as_ref(),
69 m.is_static,
70 m.is_abstract,
71 m.is_final,
72 );
73 let params = format_params(&m.params);
74 let ret = m
75 .return_type
76 .as_ref()
77 .map(|r| format!(": {}", format_type_hint(r)))
78 .unwrap_or_default();
79 return Some(format!(
80 "{}function {}({}){}",
81 prefix, m.name, params, ret
82 ));
83 }
84 ClassMemberKind::ClassConst(const_decl) if const_decl.name == word => {
85 return Some(format_class_const(const_decl));
86 }
87 _ => {}
88 }
89 }
90 }
91 StmtKind::Interface(i) if i.name == word => {
92 return Some(format!("interface {}", word));
93 }
94 StmtKind::Interface(i) => {
95 for member in i.body.members.iter() {
96 match &member.kind {
97 ClassMemberKind::Method(m) if m.name == word => {
98 let prefix = format_method_prefix(
99 m.visibility.as_ref(),
100 m.is_static,
101 m.is_abstract,
102 m.is_final,
103 );
104 let params = format_params(&m.params);
105 let ret = m
106 .return_type
107 .as_ref()
108 .map(|r| format!(": {}", format_type_hint(r)))
109 .unwrap_or_default();
110 return Some(format!(
111 "{}function {}({}){}",
112 prefix, m.name, params, ret
113 ));
114 }
115 ClassMemberKind::ClassConst(const_decl) if const_decl.name == word => {
116 return Some(format_class_const(const_decl));
117 }
118 _ => {}
119 }
120 }
121 }
122 StmtKind::Trait(t) if t.name == word => {
123 return Some(format!("trait {}", word));
124 }
125 StmtKind::Trait(t) => {
126 for member in t.body.members.iter() {
127 match &member.kind {
128 ClassMemberKind::Method(m) if m.name == word => {
129 let prefix = format_method_prefix(
130 m.visibility.as_ref(),
131 m.is_static,
132 m.is_abstract,
133 m.is_final,
134 );
135 let params = format_params(&m.params);
136 let ret = m
137 .return_type
138 .as_ref()
139 .map(|r| format!(": {}", format_type_hint(r)))
140 .unwrap_or_default();
141 return Some(format!(
142 "{}function {}({}){}",
143 prefix, m.name, params, ret
144 ));
145 }
146 ClassMemberKind::ClassConst(const_decl) if const_decl.name == word => {
147 return Some(format_class_const(const_decl));
148 }
149 _ => {}
150 }
151 }
152 }
153 StmtKind::Enum(e) if e.name == word => {
154 let mut sig = if let Some(scalar) = &e.scalar_type {
155 format!("enum {}: {}", word, scalar.to_string_repr())
156 } else {
157 format!("enum {}", word)
158 };
159 if !e.implements.is_empty() {
160 let ifaces: Vec<String> = e
161 .implements
162 .iter()
163 .map(|i| i.to_string_repr().into_owned())
164 .collect();
165 sig.push_str(&format!(" implements {}", ifaces.join(", ")));
166 }
167 return Some(sig);
168 }
169 StmtKind::Enum(e) => {
170 for member in e.body.members.iter() {
171 match &member.kind {
172 EnumMemberKind::Case(c) if c.name == word => {
173 let value_str = c
174 .value
175 .as_ref()
176 .and_then(format_expr_literal)
177 .map(|v| format!(" = {v}"))
178 .unwrap_or_default();
179 return Some(format!("case {}::{}{}", e.name, c.name, value_str));
180 }
181 EnumMemberKind::Method(m) if m.name == word => {
182 let prefix = format_method_prefix(
183 m.visibility.as_ref(),
184 m.is_static,
185 m.is_abstract,
186 m.is_final,
187 );
188 let params = format_params(&m.params);
189 let ret = m
190 .return_type
191 .as_ref()
192 .map(|r| format!(": {}", format_type_hint(r)))
193 .unwrap_or_default();
194 return Some(format!(
195 "{}function {}({}){}",
196 prefix, m.name, params, ret
197 ));
198 }
199 EnumMemberKind::ClassConst(k) if k.name == word => {
200 return Some(format_class_const(k));
201 }
202 _ => {}
203 }
204 }
205 }
206 StmtKind::Namespace(ns) => {
207 if let NamespaceBody::Braced(inner) = &ns.body
208 && let Some(s) = scan_statements(&inner.stmts, word)
209 {
210 return Some(s);
211 }
212 }
213 _ => {}
214 }
215 }
216 None
217}
218
219pub fn hover_info(
220 source: &str,
221 doc: &ParsedDoc,
222 doc_returns: &MethodReturnsMap,
223 position: Position,
224 other_docs: &[(
225 tower_lsp::lsp_types::Url,
226 Arc<ParsedDoc>,
227 Arc<MethodReturnsMap>,
228 )],
229) -> Option<Hover> {
230 hover_at(source, doc, doc_returns, other_docs, position)
231}
232
233pub fn hover_at(
235 source: &str,
236 doc: &ParsedDoc,
237 doc_returns: &MethodReturnsMap,
238 other_docs: &[(
239 tower_lsp::lsp_types::Url,
240 Arc<ParsedDoc>,
241 Arc<MethodReturnsMap>,
242 )],
243 position: Position,
244) -> Option<Hover> {
245 let hover_range = word_range_at(source, position);
246
247 if let Some(line_text) = source.lines().nth(position.line as usize) {
250 let trimmed = line_text.trim();
251 if trimmed.starts_with("use ") {
252 let (prefix, content) = if trimmed.starts_with("use function ") {
253 (
254 "use function ",
255 trimmed.strip_prefix("use function ").unwrap_or(""),
256 )
257 } else if trimmed.starts_with("use const ") {
258 (
259 "use const ",
260 trimmed.strip_prefix("use const ").unwrap_or(""),
261 )
262 } else {
263 ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
264 };
265 let fqn = content.trim_end_matches(';').trim();
266 if !fqn.is_empty() {
267 let maybe_word = word_at_position(source, position);
268 let alias = fqn.rsplit('\\').next().unwrap_or(fqn);
269 let matches = match &maybe_word {
270 Some(w) => w == alias || fqn.contains(w.as_str()),
271 None => true,
272 };
273 if matches {
274 return Some(Hover {
275 contents: HoverContents::Markup(MarkupContent {
276 kind: MarkupKind::Markdown,
277 value: format!("`{}{};`", prefix, fqn),
278 }),
279 range: hover_range,
280 });
281 }
282 }
283 }
284 }
285
286 let word = word_at_position(source, position)?;
287
288 if let Some(line_text) = source.lines().nth(position.line as usize)
292 && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
293 {
294 let keyword_doc: Option<&str> = match word.as_str() {
295 "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
296 "null" => Some("`null` — the null value; a variable has no value"),
297 "true" => Some("`true` — boolean true"),
298 "false" => Some("`false` — boolean false"),
299 "abstract" => Some(
300 "`abstract` — declares an abstract class or method that must be implemented by a subclass",
301 ),
302 "readonly" => {
303 Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
304 }
305 "yield" => Some("`yield` — produces a value from a generator function"),
306 "never" => Some(
307 "`never` — return type indicating the function always throws or exits (PHP 8.1)",
308 ),
309 "throw" => {
310 Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
311 }
312 _ => None,
313 };
314 if let Some(doc_str) = keyword_doc {
315 return Some(Hover {
316 contents: HoverContents::Markup(MarkupContent {
317 kind: MarkupKind::Markdown,
318 value: doc_str.to_string(),
319 }),
320 range: hover_range,
321 });
322 }
323 }
324
325 if let Some(line_text) = source.lines().nth(position.line as usize)
328 && !word.starts_with('$')
329 && is_named_arg_at(line_text, position.character as usize, &word)
330 && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
331 && let Some(value) = named_arg_hover_value(
332 source,
333 doc,
334 doc_returns,
335 other_docs,
336 position,
337 &callee,
338 &word,
339 )
340 {
341 return Some(Hover {
342 contents: HoverContents::Markup(MarkupContent {
343 kind: MarkupKind::Markdown,
344 value,
345 }),
346 range: hover_range,
347 });
348 }
349
350 let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
352 let type_map = || {
353 type_map_cell.get_or_init(|| {
354 TypeMap::from_docs_at_position(
355 doc,
356 doc_returns,
357 other_docs.iter().map(|(_, d, r)| (d.as_ref(), r.as_ref())),
358 None,
359 position,
360 )
361 })
362 };
363
364 if word.starts_with('$')
366 && let Some(class_name) = type_map().get(&word)
367 {
368 return Some(Hover {
369 contents: HoverContents::Markup(MarkupContent {
370 kind: MarkupKind::Markdown,
371 value: format!("`{}` `{}`", word, class_name),
372 }),
373 range: hover_range,
374 });
375 }
376
377 if word.starts_with('$')
379 && let Some(line_text) = source.lines().nth(position.line as usize)
380 && let Some(class_name) =
381 extract_static_class_before_cursor(line_text, position.character as usize)
382 {
383 let prop_name = word.trim_start_matches('$');
384 let effective_class = if class_name == "self" || class_name == "static" {
385 crate::type_map::enclosing_class_at(source, doc, position).unwrap_or(class_name.clone())
386 } else {
387 class_name.clone()
388 };
389 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref())) {
390 if let Some((modifiers, type_str, db)) =
391 find_property_info(d, &effective_class, prop_name)
392 {
393 let sig = format!(
394 "(property) {}{}::${}{}",
395 modifiers,
396 effective_class,
397 prop_name,
398 if type_str.is_empty() {
399 String::new()
400 } else {
401 format!(": {}", type_str)
402 }
403 );
404 let mut value = wrap_php(&sig);
405 if let Some(doc) = db {
406 let md = doc.to_markdown();
407 if !md.is_empty() {
408 value.push_str("\n\n---\n\n");
409 value.push_str(&md);
410 }
411 }
412 return Some(Hover {
413 contents: HoverContents::Markup(MarkupContent {
414 kind: MarkupKind::Markdown,
415 value,
416 }),
417 range: hover_range,
418 });
419 }
420 }
421 }
422
423 if !word.starts_with('$')
427 && let Some(line_text) = source.lines().nth(position.line as usize)
428 {
429 if let Some(var_name) =
430 extract_receiver_var_before_cursor(line_text, position.character as usize)
431 {
432 let tm = type_map();
433 let class_name = if var_name == "$this" {
434 crate::type_map::enclosing_class_at(source, doc, position)
435 .or_else(|| tm.get("$this").map(|s| s.to_string()))
436 } else {
437 tm.get(&var_name).map(|s| s.to_string())
438 };
439 if let Some(cls) = class_name {
440 let first_cls = cls.split('|').next().unwrap_or(&cls);
441 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref())) {
443 if let Some(sig) = scan_method_of_class(&d.program().stmts, first_cls, &word) {
444 let mut value = wrap_php(&sig);
445 let all_docs = std::iter::once(doc)
446 .chain(other_docs.iter().map(|(_, d, _)| d.as_ref()));
447 if let Some(db) = resolve_method_docblock(all_docs, first_cls, &word) {
448 let md = db.to_markdown();
449 if !md.is_empty() {
450 value.push_str("\n\n---\n\n");
451 value.push_str(&md);
452 }
453 }
454 return Some(Hover {
455 contents: HoverContents::Markup(MarkupContent {
456 kind: MarkupKind::Markdown,
457 value,
458 }),
459 range: hover_range,
460 });
461 }
462 if let Some((modifiers, type_str, db)) = find_property_info(d, first_cls, &word)
463 {
464 let sig = format!(
465 "(property) {}{}::${}{}",
466 modifiers,
467 first_cls,
468 word,
469 if type_str.is_empty() {
470 String::new()
471 } else {
472 format!(": {}", type_str)
473 }
474 );
475 let mut value = wrap_php(&sig);
476 if let Some(doc) = db {
477 let md = doc.to_markdown();
478 if !md.is_empty() {
479 value.push_str("\n\n---\n\n");
480 value.push_str(&md);
481 }
482 }
483 return Some(Hover {
484 contents: HoverContents::Markup(MarkupContent {
485 kind: MarkupKind::Markdown,
486 value,
487 }),
488 range: hover_range,
489 });
490 }
491 }
492 }
493 }
494
495 if let Some(class_name) =
497 extract_static_class_before_cursor(line_text, position.character as usize)
498 {
499 let effective_class = if class_name == "self" || class_name == "static" {
500 crate::type_map::enclosing_class_at(source, doc, position)
501 .unwrap_or(class_name.clone())
502 } else if class_name == "parent" {
503 crate::type_map::enclosing_class_at(source, doc, position)
505 .and_then(|enc| {
506 find_parent_class_name(&doc.program().stmts, &enc).or_else(|| {
507 for (_, other_doc, _) in other_docs.iter() {
509 if let Some(parent) =
510 find_parent_class_name(&other_doc.program().stmts, &enc)
511 {
512 return Some(parent);
513 }
514 }
515 None
516 })
517 })
518 .unwrap_or(class_name.clone())
519 } else {
520 class_name.clone()
521 };
522 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref())) {
523 if let Some(sig) = scan_method_of_class(&d.program().stmts, &effective_class, &word)
524 {
525 let mut value = wrap_php(&sig);
526 let all_docs =
527 std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref()));
528 if let Some(db) = resolve_method_docblock(all_docs, &effective_class, &word) {
529 let md = db.to_markdown();
530 if !md.is_empty() {
531 value.push_str("\n\n---\n\n");
532 value.push_str(&md);
533 }
534 }
535 return Some(Hover {
536 contents: HoverContents::Markup(MarkupContent {
537 kind: MarkupKind::Markdown,
538 value,
539 }),
540 range: hover_range,
541 });
542 }
543 if let Some(sig) =
545 scan_enum_case_of_class(&d.program().stmts, &effective_class, &word)
546 {
547 return Some(Hover {
548 contents: HoverContents::Markup(MarkupContent {
549 kind: MarkupKind::Markdown,
550 value: wrap_php(&sig),
551 }),
552 range: hover_range,
553 });
554 }
555 if let Some(sig) =
557 scan_class_const_of_class(&d.program().stmts, &effective_class, &word)
558 {
559 return Some(Hover {
560 contents: HoverContents::Markup(MarkupContent {
561 kind: MarkupKind::Markdown,
562 value: wrap_php(&sig),
563 }),
564 range: hover_range,
565 });
566 }
567 }
568 }
569 }
570
571 if (word == "function" || word == "fn")
575 && let Some(sig) = closure_hover(source, doc, position, &word)
576 {
577 return Some(Hover {
578 contents: HoverContents::Markup(MarkupContent {
579 kind: MarkupKind::Markdown,
580 value: wrap_php(&sig),
581 }),
582 range: hover_range,
583 });
584 }
585
586 let all_stmts = &*doc.program().stmts as &[_];
589 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
590
591 let found = scan_statements(&doc.program().stmts, &resolved_word).map(|sig| (sig, source, doc));
593 let found = found.or_else(|| {
594 for (_, other, _) in other_docs {
595 if let Some(sig) = scan_statements(&other.program().stmts, &resolved_word) {
596 return Some((sig, other.source(), other.as_ref()));
597 }
598 }
599 None
600 });
601
602 if let Some((sig, sig_source, sig_doc)) = found {
603 let mut value = wrap_php(&sig);
604 if let Some(db) = find_docblock(sig_source, &sig_doc.program().stmts, &resolved_word) {
605 let md = db.to_markdown();
606 if !md.is_empty() {
607 value.push_str("\n\n---\n\n");
608 value.push_str(&md);
609 }
610 }
611 if is_php_builtin(&resolved_word) {
612 value.push_str(&format!(
613 "\n\n[php.net documentation]({})",
614 php_doc_url(&resolved_word)
615 ));
616 }
617 return Some(Hover {
618 contents: HoverContents::Markup(MarkupContent {
619 kind: MarkupKind::Markdown,
620 value,
621 }),
622 range: hover_range,
623 });
624 }
625
626 if is_php_builtin(&resolved_word) {
628 let value = format!(
629 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
630 resolved_word,
631 php_doc_url(&resolved_word)
632 );
633 return Some(Hover {
634 contents: HoverContents::Markup(MarkupContent {
635 kind: MarkupKind::Markdown,
636 value,
637 }),
638 range: hover_range,
639 });
640 }
641
642 if let Some(stub) = crate::stubs::builtin_class_members(&resolved_word) {
644 let method_names: Vec<&str> = stub
645 .methods
646 .iter()
647 .filter(|(_, is_static)| !is_static)
648 .map(|(n, _)| n.as_str())
649 .take(8)
650 .collect();
651 let static_names: Vec<&str> = stub
652 .methods
653 .iter()
654 .filter(|(_, is_static)| *is_static)
655 .map(|(n, _)| n.as_str())
656 .take(4)
657 .collect();
658 let mut lines = vec![format!("**{}** — built-in class", resolved_word)];
659 if !method_names.is_empty() {
660 lines.push(format!(
661 "Methods: {}",
662 method_names
663 .iter()
664 .map(|n| format!("`{n}`"))
665 .collect::<Vec<_>>()
666 .join(", ")
667 ));
668 }
669 if !static_names.is_empty() {
670 lines.push(format!(
671 "Static: {}",
672 static_names
673 .iter()
674 .map(|n| format!("`{n}`"))
675 .collect::<Vec<_>>()
676 .join(", ")
677 ));
678 }
679 if let Some(parent) = &stub.parent {
680 lines.push(format!("Extends: `{parent}`"));
681 }
682 return Some(Hover {
683 contents: HoverContents::Markup(MarkupContent {
684 kind: MarkupKind::Markdown,
685 value: lines.join("\n\n"),
686 }),
687 range: hover_range,
688 });
689 }
690
691 None
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use crate::test_utils::cursor;
698 use crate::type_map::build_method_returns;
699
700 fn pos(line: u32, character: u32) -> Position {
701 Position { line, character }
702 }
703
704 #[test]
705 fn hover_on_function_name_returns_signature() {
706 let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
707 let doc = ParsedDoc::parse(src.clone());
708 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
709 assert!(result.is_some(), "expected hover result");
710 if let Some(Hover {
711 contents: HoverContents::Markup(mc),
712 ..
713 }) = result
714 {
715 assert!(
716 mc.value.contains("function greet("),
717 "expected function signature, got: {}",
718 mc.value
719 );
720 }
721 }
722
723 #[test]
724 fn hover_on_class_name_returns_class_sig() {
725 let (src, p) = cursor("<?php\nclass My$0Service {}");
726 let doc = ParsedDoc::parse(src.clone());
727 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
728 assert!(result.is_some(), "expected hover result");
729 if let Some(Hover {
730 contents: HoverContents::Markup(mc),
731 ..
732 }) = result
733 {
734 assert!(
735 mc.value.contains("class MyService"),
736 "expected class sig, got: {}",
737 mc.value
738 );
739 }
740 }
741
742 #[test]
743 fn hover_on_unknown_word_returns_none() {
744 let src = "<?php\n$unknown = 42;";
745 let doc = ParsedDoc::parse(src.to_string());
746 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 2), &[]);
747 assert!(result.is_none(), "expected None for unknown word");
748 }
749
750 #[test]
751 fn hover_at_column_beyond_line_length_returns_none() {
752 let src = "<?php\nfunction hi() {}";
753 let doc = ParsedDoc::parse(src.to_string());
754 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 999), &[]);
755 assert!(result.is_none());
756 }
757
758 #[test]
759 fn word_at_extracts_from_middle_of_identifier() {
760 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
761 let word = word_at_position(&src, p);
762 assert_eq!(word.as_deref(), Some("greetUser"));
763 }
764
765 #[test]
766 fn hover_on_class_with_extends_shows_parent() {
767 let src = "<?php\nclass Dog extends Animal {}";
768 let doc = ParsedDoc::parse(src.to_string());
769 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
770 assert!(result.is_some());
771 if let Some(Hover {
772 contents: HoverContents::Markup(mc),
773 ..
774 }) = result
775 {
776 assert!(
777 mc.value.contains("extends Animal"),
778 "expected 'extends Animal', got: {}",
779 mc.value
780 );
781 }
782 }
783
784 #[test]
785 fn hover_on_class_with_implements_shows_interfaces() {
786 let src = "<?php\nclass Repo implements Countable, Serializable {}";
787 let doc = ParsedDoc::parse(src.to_string());
788 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
789 assert!(result.is_some());
790 if let Some(Hover {
791 contents: HoverContents::Markup(mc),
792 ..
793 }) = result
794 {
795 assert!(
796 mc.value.contains("implements Countable, Serializable"),
797 "expected implements list, got: {}",
798 mc.value
799 );
800 }
801 }
802
803 #[test]
804 fn hover_on_trait_returns_trait_sig() {
805 let src = "<?php\ntrait Loggable {}";
806 let doc = ParsedDoc::parse(src.to_string());
807 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
808 assert!(result.is_some());
809 if let Some(Hover {
810 contents: HoverContents::Markup(mc),
811 ..
812 }) = result
813 {
814 assert!(
815 mc.value.contains("trait Loggable"),
816 "expected 'trait Loggable', got: {}",
817 mc.value
818 );
819 }
820 }
821
822 #[test]
823 fn hover_on_interface_returns_interface_sig() {
824 let src = "<?php\ninterface Serializable {}";
825 let doc = ParsedDoc::parse(src.to_string());
826 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 12), &[]);
827 assert!(result.is_some(), "expected hover result");
828 if let Some(Hover {
829 contents: HoverContents::Markup(mc),
830 ..
831 }) = result
832 {
833 assert!(
834 mc.value.contains("interface Serializable"),
835 "expected interface sig, got: {}",
836 mc.value
837 );
838 }
839 }
840
841 #[test]
842 fn function_with_no_params_no_return_shows_no_colon() {
843 let src = "<?php\nfunction init() {}";
844 let doc = ParsedDoc::parse(src.to_string());
845 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 10), &[]);
846 assert!(result.is_some());
847 if let Some(Hover {
848 contents: HoverContents::Markup(mc),
849 ..
850 }) = result
851 {
852 assert!(
853 mc.value.contains("function init()"),
854 "expected 'function init()', got: {}",
855 mc.value
856 );
857 assert!(
858 !mc.value.contains(':'),
859 "should not contain ':' when no return type, got: {}",
860 mc.value
861 );
862 }
863 }
864
865 #[test]
866 fn hover_on_enum_returns_enum_sig() {
867 let src = "<?php\nenum Suit {}";
868 let doc = ParsedDoc::parse(src.to_string());
869 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 6), &[]);
870 assert!(result.is_some());
871 if let Some(Hover {
872 contents: HoverContents::Markup(mc),
873 ..
874 }) = result
875 {
876 assert!(
877 mc.value.contains("enum Suit"),
878 "expected 'enum Suit', got: {}",
879 mc.value
880 );
881 }
882 }
883
884 #[test]
885 fn hover_on_enum_with_implements_shows_interface() {
886 let src = "<?php\nenum Status: string implements Stringable {}";
887 let doc = ParsedDoc::parse(src.to_string());
888 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 6), &[]);
889 assert!(result.is_some());
890 if let Some(Hover {
891 contents: HoverContents::Markup(mc),
892 ..
893 }) = result
894 {
895 assert!(
896 mc.value.contains("implements Stringable"),
897 "expected implements clause, got: {}",
898 mc.value
899 );
900 }
901 }
902
903 #[test]
904 fn hover_on_enum_case_shows_case_sig() {
905 let src = "<?php\nenum Status { case Active; case Inactive; }";
906 let doc = ParsedDoc::parse(src.to_string());
907 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 21), &[]);
909 assert!(result.is_some(), "expected hover on enum case");
910 if let Some(Hover {
911 contents: HoverContents::Markup(mc),
912 ..
913 }) = result
914 {
915 assert!(
916 mc.value.contains("Status::Active"),
917 "expected 'Status::Active', got: {}",
918 mc.value
919 );
920 }
921 }
922
923 #[test]
924 fn snapshot_hover_backed_enum_case_shows_value() {
925 check_hover(
926 "<?php\nenum Color: string { case Red = 'red'; }",
927 pos(1, 27),
928 expect![[r#"
929 ```php
930 case Color::Red = 'red'
931 ```"#]],
932 );
933 }
934
935 #[test]
936 fn snapshot_hover_enum_class_const() {
937 check_hover(
938 "<?php\nenum Suit { const int MAX = 4; }",
939 pos(1, 22),
940 expect![[r#"
941 ```php
942 const int MAX = 4
943 ```"#]],
944 );
945 }
946
947 #[test]
948 fn hover_on_trait_method_returns_signature() {
949 let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
950 let doc = ParsedDoc::parse(src.to_string());
951 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 34), &[]);
953 assert!(result.is_some(), "expected hover on trait method");
954 if let Some(Hover {
955 contents: HoverContents::Markup(mc),
956 ..
957 }) = result
958 {
959 assert!(
960 mc.value.contains("function log("),
961 "expected function sig, got: {}",
962 mc.value
963 );
964 }
965 }
966
967 #[test]
968 fn cross_file_hover_finds_class_in_other_doc() {
969 use std::sync::Arc;
970 let src = "<?php\n$x = new PaymentService();";
971 let other_src = "<?php\nclass PaymentService { public function charge() {} }";
972 let doc = ParsedDoc::parse(src.to_string());
973 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
974 let other_mr = Arc::new(build_method_returns(&other_doc));
975 let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
976 let other_docs = vec![(uri, other_doc, other_mr)];
977 let result = hover_info(
979 src,
980 &doc,
981 &build_method_returns(&doc),
982 pos(1, 12),
983 &other_docs,
984 );
985 assert!(result.is_some(), "expected cross-file hover result");
986 if let Some(Hover {
987 contents: HoverContents::Markup(mc),
988 ..
989 }) = result
990 {
991 assert!(
992 mc.value.contains("PaymentService"),
993 "expected 'PaymentService', got: {}",
994 mc.value
995 );
996 }
997 }
998
999 #[test]
1000 fn hover_on_variable_shows_type() {
1001 let src = "<?php\n$obj = new Mailer();\n$obj";
1002 let doc = ParsedDoc::parse(src.to_string());
1003 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(2, 2));
1004 assert!(h.is_some());
1005 let text = match h.unwrap().contents {
1006 HoverContents::Markup(m) => m.value,
1007 _ => String::new(),
1008 };
1009 assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
1010 }
1011
1012 #[test]
1013 fn hover_on_builtin_class_shows_stub_info() {
1014 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
1015 let doc = ParsedDoc::parse(src.to_string());
1016 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(1, 12));
1017 assert!(h.is_some(), "should hover on PDO");
1018 let text = match h.unwrap().contents {
1019 HoverContents::Markup(m) => m.value,
1020 _ => String::new(),
1021 };
1022 assert!(text.contains("PDO"), "hover should mention PDO");
1023 }
1024
1025 #[test]
1026 fn hover_on_property_shows_type() {
1027 let src = "<?php\nclass User { public string $name; public int $age; }\n$u = new User();\n$u->name";
1028 let doc = ParsedDoc::parse(src.to_string());
1029 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(3, 5));
1031 assert!(h.is_some(), "expected hover on property");
1032 let text = match h.unwrap().contents {
1033 HoverContents::Markup(m) => m.value,
1034 _ => String::new(),
1035 };
1036 assert!(text.contains("User"), "should mention class name");
1037 assert!(text.contains("name"), "should mention property name");
1038 assert!(text.contains("string"), "should show type hint");
1039 }
1040
1041 #[test]
1042 fn hover_on_promoted_property_shows_type() {
1043 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";
1044 let doc = ParsedDoc::parse(src.to_string());
1045 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(8, 4));
1047 assert!(h.is_some(), "expected hover on promoted property");
1048 let text = match h.unwrap().contents {
1049 HoverContents::Markup(m) => m.value,
1050 _ => String::new(),
1051 };
1052 assert!(text.contains("Point"), "should mention class name");
1053 assert!(text.contains("x"), "should mention property name");
1054 assert!(
1055 text.contains("float"),
1056 "should show type hint for promoted property"
1057 );
1058 }
1059
1060 #[test]
1061 fn hover_on_promoted_property_shows_only_its_param_docblock() {
1062 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";
1066 let doc = ParsedDoc::parse(src.to_string());
1067 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(15, 4));
1069 assert!(h.is_some(), "expected hover on promoted property");
1070 let text = match h.unwrap().contents {
1071 HoverContents::Markup(m) => m.value,
1072 _ => String::new(),
1073 };
1074 assert!(
1075 text.contains("@param") && text.contains("$name"),
1076 "should show @param for $name"
1077 );
1078 assert!(
1079 !text.contains("$age"),
1080 "should NOT show @param for other parameters"
1081 );
1082 assert!(
1083 !text.contains("@return"),
1084 "should NOT show @return from constructor docblock"
1085 );
1086 assert!(
1087 !text.contains("@throws"),
1088 "should NOT show @throws from constructor docblock"
1089 );
1090 assert!(
1091 !text.contains("Create a user"),
1092 "should NOT show constructor description"
1093 );
1094 }
1095
1096 #[test]
1097 fn hover_on_promoted_property_with_no_param_docblock_shows_type_only() {
1098 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";
1101 let doc = ParsedDoc::parse(src.to_string());
1102 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(11, 4));
1103 assert!(h.is_some(), "expected hover on promoted property");
1104 let text = match h.unwrap().contents {
1105 HoverContents::Markup(m) => m.value,
1106 _ => String::new(),
1107 };
1108 assert!(text.contains("string"), "should show type hint");
1109 assert!(
1110 !text.contains("---"),
1111 "should not append a docblock section"
1112 );
1113 }
1114
1115 #[test]
1116 fn hover_on_use_alias_shows_fqn() {
1117 let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
1118 let doc = ParsedDoc::parse(src.to_string());
1119 let h = hover_at(
1120 src,
1121 &doc,
1122 &build_method_returns(&doc),
1123 &[],
1124 Position {
1125 line: 1,
1126 character: 20,
1127 },
1128 );
1129 assert!(h.is_some());
1130 let text = match h.unwrap().contents {
1131 HoverContents::Markup(m) => m.value,
1132 _ => String::new(),
1133 };
1134 assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
1135 }
1136
1137 #[test]
1138 fn hover_unknown_symbol_returns_none() {
1139 let src = "<?php\nunknownFunc();";
1141 let doc = ParsedDoc::parse(src.to_string());
1142 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 3), &[]);
1143 assert!(
1144 result.is_none(),
1145 "hover on undefined symbol should return None"
1146 );
1147 }
1148
1149 #[test]
1150 fn hover_on_builtin_function_returns_signature() {
1151 let src = "<?php\nstrlen('hello');";
1154 let doc = ParsedDoc::parse(src.to_string());
1155 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 3), &[]);
1156 let h = result.expect("expected hover result for built-in 'strlen'");
1157 let text = match h.contents {
1158 HoverContents::Markup(mc) => mc.value,
1159 _ => String::new(),
1160 };
1161 assert!(
1162 !text.is_empty(),
1163 "hover on strlen should return non-empty content"
1164 );
1165 assert!(
1166 text.contains("strlen"),
1167 "hover content should contain 'strlen', got: {text}"
1168 );
1169 }
1170
1171 #[test]
1172 fn hover_on_property_shows_docblock() {
1173 let src = "<?php\nclass User {\n /** The user's display name. */\n public string $name;\n}\n$u = new User();\n$u->name";
1174 let doc = ParsedDoc::parse(src.to_string());
1175 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
1177 assert!(h.is_some(), "expected hover on property with docblock");
1178 let text = match h.unwrap().contents {
1179 HoverContents::Markup(m) => m.value,
1180 _ => String::new(),
1181 };
1182 assert!(text.contains("User"), "should mention class name");
1183 assert!(text.contains("name"), "should mention property name");
1184 assert!(text.contains("string"), "should show type hint");
1185 assert!(
1186 text.contains("display name"),
1187 "should include docblock description, got: {}",
1188 text
1189 );
1190 }
1191
1192 #[test]
1193 fn hover_on_property_with_var_tag_shows_type_annotation() {
1194 let src = "<?php\nclass User {\n /** @var string */\n public $name;\n}\n$u = new User();\n$u->name";
1198 let doc = ParsedDoc::parse(src.to_string());
1199 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
1200 assert!(h.is_some(), "expected hover on @var-only property");
1201 let text = match h.unwrap().contents {
1202 HoverContents::Markup(m) => m.value,
1203 _ => String::new(),
1204 };
1205 assert!(
1206 text.contains("@var"),
1207 "should show @var annotation, got: {}",
1208 text
1209 );
1210 assert!(
1211 text.contains("string"),
1212 "should show var type, got: {}",
1213 text
1214 );
1215 }
1216
1217 #[test]
1218 fn hover_on_property_with_var_tag_and_description() {
1219 let src = "<?php\nclass User {\n /** @var string The display name. */\n public $name;\n}\n$u = new User();\n$u->name";
1220 let doc = ParsedDoc::parse(src.to_string());
1221 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
1222 assert!(
1223 h.is_some(),
1224 "expected hover on property with @var description"
1225 );
1226 let text = match h.unwrap().contents {
1227 HoverContents::Markup(m) => m.value,
1228 _ => String::new(),
1229 };
1230 assert!(
1231 text.contains("@var"),
1232 "should show @var annotation, got: {}",
1233 text
1234 );
1235 assert!(
1236 text.contains("The display name"),
1237 "should show @var description, got: {}",
1238 text
1239 );
1240 }
1241
1242 #[test]
1243 fn hover_on_this_property_shows_type() {
1244 let src = "<?php\nclass Counter {\n public int $count = 0;\n public function increment(): void {\n $this->count;\n }\n}";
1245 let doc = ParsedDoc::parse(src.to_string());
1246 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(4, 16));
1248 assert!(h.is_some(), "expected hover on $this->property");
1249 let text = match h.unwrap().contents {
1250 HoverContents::Markup(m) => m.value,
1251 _ => String::new(),
1252 };
1253 assert!(text.contains("Counter"), "should mention enclosing class");
1254 assert!(text.contains("count"), "should mention property name");
1255 assert!(text.contains("int"), "should show type hint");
1256 }
1257
1258 #[test]
1259 fn hover_on_nullsafe_property_shows_type() {
1260 let src = "<?php\nclass Profile { public string $bio; }\n$p = new Profile();\n$p?->bio";
1261 let doc = ParsedDoc::parse(src.to_string());
1262 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(3, 5));
1264 assert!(h.is_some(), "expected hover on nullsafe property access");
1265 let text = match h.unwrap().contents {
1266 HoverContents::Markup(m) => m.value,
1267 _ => String::new(),
1268 };
1269 assert!(text.contains("Profile"), "should mention class name");
1270 assert!(text.contains("bio"), "should mention property name");
1271 assert!(text.contains("string"), "should show type hint");
1272 }
1273
1274 use expect_test::{Expect, expect};
1277
1278 fn check_hover(src: &str, position: Position, expect: Expect) {
1279 let doc = ParsedDoc::parse(src.to_string());
1280 let result = hover_info(src, &doc, &build_method_returns(&doc), position, &[]);
1281 let actual = match result {
1282 Some(Hover {
1283 contents: HoverContents::Markup(mc),
1284 ..
1285 }) => mc.value,
1286 Some(_) => "(non-markup hover)".to_string(),
1287 None => "(no hover)".to_string(),
1288 };
1289 expect.assert_eq(&actual);
1290 }
1291
1292 #[test]
1293 fn snapshot_hover_simple_function() {
1294 check_hover(
1295 "<?php\nfunction init() {}",
1296 pos(1, 10),
1297 expect![[r#"
1298 ```php
1299 function init()
1300 ```"#]],
1301 );
1302 }
1303
1304 #[test]
1305 fn snapshot_hover_function_with_return_type() {
1306 check_hover(
1307 "<?php\nfunction greet(string $name): string {}",
1308 pos(1, 10),
1309 expect![[r#"
1310 ```php
1311 function greet(string $name): string
1312 ```"#]],
1313 );
1314 }
1315
1316 #[test]
1317 fn snapshot_hover_class() {
1318 check_hover(
1319 "<?php\nclass MyService {}",
1320 pos(1, 8),
1321 expect![[r#"
1322 ```php
1323 class MyService
1324 ```"#]],
1325 );
1326 }
1327
1328 #[test]
1329 fn snapshot_hover_class_with_extends() {
1330 check_hover(
1331 "<?php\nclass Dog extends Animal {}",
1332 pos(1, 8),
1333 expect![[r#"
1334 ```php
1335 class Dog extends Animal
1336 ```"#]],
1337 );
1338 }
1339
1340 #[test]
1341 fn snapshot_hover_method() {
1342 check_hover(
1343 "<?php\nclass Calc { public function add(int $a, int $b): int {} }",
1344 pos(1, 32),
1345 expect![[r#"
1346 ```php
1347 public function add(int $a, int $b): int
1348 ```"#]],
1349 );
1350 }
1351
1352 #[test]
1353 fn snapshot_hover_trait() {
1354 check_hover(
1355 "<?php\ntrait Loggable {}",
1356 pos(1, 8),
1357 expect![[r#"
1358 ```php
1359 trait Loggable
1360 ```"#]],
1361 );
1362 }
1363
1364 #[test]
1365 fn snapshot_hover_interface() {
1366 check_hover(
1367 "<?php\ninterface Serializable {}",
1368 pos(1, 12),
1369 expect![[r#"
1370 ```php
1371 interface Serializable
1372 ```"#]],
1373 );
1374 }
1375
1376 #[test]
1377 fn snapshot_hover_class_const_with_type_hint() {
1378 check_hover(
1379 "<?php\nclass Config { const string VERSION = '1.0.0'; }",
1380 pos(1, 28),
1381 expect![[r#"
1382 ```php
1383 const string VERSION = '1.0.0'
1384 ```"#]],
1385 );
1386 }
1387
1388 #[test]
1389 fn snapshot_hover_class_const_float_value() {
1390 check_hover(
1391 "<?php\nclass Math { const float PI = 3.14; }",
1392 pos(1, 27),
1393 expect![[r#"
1394 ```php
1395 const float PI = 3.14
1396 ```"#]],
1397 );
1398 }
1399
1400 #[test]
1401 fn snapshot_hover_class_const_infers_type_from_value() {
1402 let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
1403 check_hover(
1404 &src,
1405 p,
1406 expect![[r#"
1407 ```php
1408 const string VERSION = '1.0.0'
1409 ```"#]],
1410 );
1411 }
1412
1413 #[test]
1414 fn snapshot_hover_interface_const_shows_type_and_value() {
1415 let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
1416 check_hover(
1417 &src,
1418 p,
1419 expect![[r#"
1420 ```php
1421 const int MAX = 100
1422 ```"#]],
1423 );
1424 }
1425
1426 #[test]
1427 fn snapshot_hover_trait_const_shows_type_and_value() {
1428 let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
1429 check_hover(
1430 &src,
1431 p,
1432 expect![[r#"
1433 ```php
1434 const string TAG = 'v1'
1435 ```"#]],
1436 );
1437 }
1438
1439 #[test]
1440 fn hover_on_catch_variable_shows_exception_class() {
1441 let (src, p) = cursor("<?php\ntry { } catch (RuntimeException $e$0) { }");
1442 let doc = ParsedDoc::parse(src.clone());
1443 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1444 assert!(result.is_some(), "expected hover result for catch variable");
1445 if let Some(Hover {
1446 contents: HoverContents::Markup(mc),
1447 ..
1448 }) = result
1449 {
1450 assert!(
1451 mc.value.contains("RuntimeException"),
1452 "expected RuntimeException in hover, got: {}",
1453 mc.value
1454 );
1455 }
1456 }
1457
1458 #[test]
1459 fn hover_on_static_var_with_array_default_shows_array() {
1460 let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
1461 let doc = ParsedDoc::parse(src.clone());
1462 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1463 assert!(
1464 result.is_some(),
1465 "expected hover result for static variable"
1466 );
1467 if let Some(Hover {
1468 contents: HoverContents::Markup(mc),
1469 ..
1470 }) = result
1471 {
1472 assert!(
1473 mc.value.contains("array"),
1474 "expected array type in hover, got: {}",
1475 mc.value
1476 );
1477 }
1478 }
1479
1480 #[test]
1481 fn hover_on_static_var_with_new_shows_class() {
1482 let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
1483 let doc = ParsedDoc::parse(src.clone());
1484 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1485 assert!(
1486 result.is_some(),
1487 "expected hover result for static variable"
1488 );
1489 if let Some(Hover {
1490 contents: HoverContents::Markup(mc),
1491 ..
1492 }) = result
1493 {
1494 assert!(
1495 mc.value.contains("MyService"),
1496 "expected MyService in hover, got: {}",
1497 mc.value
1498 );
1499 }
1500 }
1501
1502 #[test]
1504 fn hover_variable_in_method_does_not_leak_across_methods() {
1505 let (src, p) = cursor(concat!(
1508 "<?php\n",
1509 "class Service {\n",
1510 " public function methodA(): void { $result = new Widget(); }\n",
1511 " public function methodB(): void { $res$0ult = new Invoice(); }\n",
1512 "}\n",
1513 ));
1514 let doc = ParsedDoc::parse(src.clone());
1515 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1516 if let Some(Hover {
1517 contents: HoverContents::Markup(mc),
1518 ..
1519 }) = result
1520 {
1521 assert!(
1522 !mc.value.contains("Widget"),
1523 "Widget from methodA must not appear in methodB hover, got: {}",
1524 mc.value
1525 );
1526 assert!(
1527 mc.value.contains("Invoice"),
1528 "Invoice from methodB should appear in hover, got: {}",
1529 mc.value
1530 );
1531 }
1532 }
1533
1534 #[test]
1536 fn hover_method_call_shows_correct_class_signature() {
1537 let (src, p) = cursor(concat!(
1540 "<?php\n",
1541 "class Mailer { public function process(string $to): bool {} }\n",
1542 "class Queue { public function process(int $id): void {} }\n",
1543 "$mailer = new Mailer();\n",
1544 "$mailer->proc$0ess();\n",
1545 ));
1546 let doc = ParsedDoc::parse(src.clone());
1547 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
1548 assert!(result.is_some(), "expected hover on method call");
1549 if let Some(Hover {
1550 contents: HoverContents::Markup(mc),
1551 ..
1552 }) = result
1553 {
1554 assert!(
1555 mc.value.contains("Mailer::process"),
1556 "should show Mailer::process, got: {}",
1557 mc.value
1558 );
1559 assert!(
1560 mc.value.contains("string $to"),
1561 "should show Mailer's params, got: {}",
1562 mc.value
1563 );
1564 assert!(
1565 !mc.value.contains("int $id"),
1566 "must NOT show Queue::process params, got: {}",
1567 mc.value
1568 );
1569 }
1570 }
1571}