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