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