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