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 let ty_str = format!("{}", sym.resolved_type);
800 if !matches!(ty_str.as_str(), "" | "void" | "never") {
801 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
802 return Some(wrap_php(&sig));
803 }
804 None
805 }
806 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
807 let class_short = fqn_short_name(class);
808 for d in docs() {
809 if let Some(sig) =
810 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
811 {
812 return Some(wrap_php(&sig));
813 }
814 if let Some(sig) =
815 scan_class_const_of_class(&d.program().stmts, class_short, constant)
816 {
817 return Some(wrap_php(&sig));
818 }
819 }
820 None
821 }
822 _ => None,
823 }
824}
825
826fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
830 let ty_str = format!("{resolved}");
831 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
832 return sig;
833 }
834 let Some(paren) = sig.rfind(')') else {
835 return sig;
836 };
837 let rest = &sig[paren + 1..];
838 if let Some(colon_pos) = rest.find(": ") {
839 let declared = rest[colon_pos + 2..].trim();
840 if matches!(declared, "static" | "self" | "parent") {
843 return sig;
844 }
845 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
846 } else {
847 format!("{}: {}", sig, ty_str)
848 }
849}
850
851fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
854 let ty_str = format!("{resolved}");
855 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
856 return declared;
857 }
858 ty_str
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use crate::test_utils::cursor;
865
866 fn pos(line: u32, character: u32) -> Position {
867 Position { line, character }
868 }
869
870 #[test]
871 fn hover_on_function_name_returns_signature() {
872 let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
873 let doc = ParsedDoc::parse(src.clone());
874 let result = hover_info(&src, &doc, None, p, &[]);
875 assert!(result.is_some(), "expected hover result");
876 if let Some(Hover {
877 contents: HoverContents::Markup(mc),
878 ..
879 }) = result
880 {
881 assert!(
882 mc.value.contains("function greet("),
883 "expected function signature, got: {}",
884 mc.value
885 );
886 }
887 }
888
889 #[test]
890 fn hover_on_class_name_returns_class_sig() {
891 let (src, p) = cursor("<?php\nclass My$0Service {}");
892 let doc = ParsedDoc::parse(src.clone());
893 let result = hover_info(&src, &doc, None, p, &[]);
894 assert!(result.is_some(), "expected hover result");
895 if let Some(Hover {
896 contents: HoverContents::Markup(mc),
897 ..
898 }) = result
899 {
900 assert!(
901 mc.value.contains("class MyService"),
902 "expected class sig, got: {}",
903 mc.value
904 );
905 }
906 }
907
908 #[test]
909 fn hover_on_unknown_word_returns_none() {
910 let src = "<?php\n$unknown = 42;";
911 let doc = ParsedDoc::parse(src.to_string());
912 let result = hover_info(src, &doc, None, pos(1, 2), &[]);
913 assert!(result.is_none(), "expected None for unknown word");
914 }
915
916 #[test]
917 fn hover_at_column_beyond_line_length_returns_none() {
918 let src = "<?php\nfunction hi() {}";
919 let doc = ParsedDoc::parse(src.to_string());
920 let result = hover_info(src, &doc, None, pos(1, 999), &[]);
921 assert!(result.is_none());
922 }
923
924 #[test]
925 fn word_at_extracts_from_middle_of_identifier() {
926 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
927 let word = word_at_position(&src, p);
928 assert_eq!(word.as_deref(), Some("greetUser"));
929 }
930
931 #[test]
932 fn hover_on_class_with_extends_shows_parent() {
933 let src = "<?php\nclass Dog extends Animal {}";
934 let doc = ParsedDoc::parse(src.to_string());
935 let result = hover_info(src, &doc, None, pos(1, 8), &[]);
936 assert!(result.is_some());
937 if let Some(Hover {
938 contents: HoverContents::Markup(mc),
939 ..
940 }) = result
941 {
942 assert!(
943 mc.value.contains("extends Animal"),
944 "expected 'extends Animal', got: {}",
945 mc.value
946 );
947 }
948 }
949
950 #[test]
951 fn hover_on_class_with_implements_shows_interfaces() {
952 let src = "<?php\nclass Repo implements Countable, Serializable {}";
953 let doc = ParsedDoc::parse(src.to_string());
954 let result = hover_info(src, &doc, None, pos(1, 8), &[]);
955 assert!(result.is_some());
956 if let Some(Hover {
957 contents: HoverContents::Markup(mc),
958 ..
959 }) = result
960 {
961 assert!(
962 mc.value.contains("implements Countable, Serializable"),
963 "expected implements list, got: {}",
964 mc.value
965 );
966 }
967 }
968
969 #[test]
970 fn hover_on_trait_returns_trait_sig() {
971 let src = "<?php\ntrait Loggable {}";
972 let doc = ParsedDoc::parse(src.to_string());
973 let result = hover_info(src, &doc, None, pos(1, 8), &[]);
974 assert!(result.is_some());
975 if let Some(Hover {
976 contents: HoverContents::Markup(mc),
977 ..
978 }) = result
979 {
980 assert!(
981 mc.value.contains("trait Loggable"),
982 "expected 'trait Loggable', got: {}",
983 mc.value
984 );
985 }
986 }
987
988 #[test]
989 fn hover_on_interface_returns_interface_sig() {
990 let src = "<?php\ninterface Serializable {}";
991 let doc = ParsedDoc::parse(src.to_string());
992 let result = hover_info(src, &doc, None, pos(1, 12), &[]);
993 assert!(result.is_some(), "expected hover result");
994 if let Some(Hover {
995 contents: HoverContents::Markup(mc),
996 ..
997 }) = result
998 {
999 assert!(
1000 mc.value.contains("interface Serializable"),
1001 "expected interface sig, got: {}",
1002 mc.value
1003 );
1004 }
1005 }
1006
1007 #[test]
1008 fn function_with_no_params_no_return_shows_no_colon() {
1009 let src = "<?php\nfunction init() {}";
1010 let doc = ParsedDoc::parse(src.to_string());
1011 let result = hover_info(src, &doc, None, pos(1, 10), &[]);
1012 assert!(result.is_some());
1013 if let Some(Hover {
1014 contents: HoverContents::Markup(mc),
1015 ..
1016 }) = result
1017 {
1018 assert!(
1019 mc.value.contains("function init()"),
1020 "expected 'function init()', got: {}",
1021 mc.value
1022 );
1023 assert!(
1024 !mc.value.contains(':'),
1025 "should not contain ':' when no return type, got: {}",
1026 mc.value
1027 );
1028 }
1029 }
1030
1031 #[test]
1032 fn hover_on_enum_returns_enum_sig() {
1033 let src = "<?php\nenum Suit {}";
1034 let doc = ParsedDoc::parse(src.to_string());
1035 let result = hover_info(src, &doc, None, pos(1, 6), &[]);
1036 assert!(result.is_some());
1037 if let Some(Hover {
1038 contents: HoverContents::Markup(mc),
1039 ..
1040 }) = result
1041 {
1042 assert!(
1043 mc.value.contains("enum Suit"),
1044 "expected 'enum Suit', got: {}",
1045 mc.value
1046 );
1047 }
1048 }
1049
1050 #[test]
1051 fn hover_on_enum_with_implements_shows_interface() {
1052 let src = "<?php\nenum Status: string implements Stringable {}";
1053 let doc = ParsedDoc::parse(src.to_string());
1054 let result = hover_info(src, &doc, None, pos(1, 6), &[]);
1055 assert!(result.is_some());
1056 if let Some(Hover {
1057 contents: HoverContents::Markup(mc),
1058 ..
1059 }) = result
1060 {
1061 assert!(
1062 mc.value.contains("implements Stringable"),
1063 "expected implements clause, got: {}",
1064 mc.value
1065 );
1066 }
1067 }
1068
1069 #[test]
1070 fn hover_on_enum_case_shows_case_sig() {
1071 let src = "<?php\nenum Status { case Active; case Inactive; }";
1072 let doc = ParsedDoc::parse(src.to_string());
1073 let result = hover_info(src, &doc, None, pos(1, 21), &[]);
1075 assert!(result.is_some(), "expected hover on enum case");
1076 if let Some(Hover {
1077 contents: HoverContents::Markup(mc),
1078 ..
1079 }) = result
1080 {
1081 assert!(
1082 mc.value.contains("Status::Active"),
1083 "expected 'Status::Active', got: {}",
1084 mc.value
1085 );
1086 }
1087 }
1088
1089 #[test]
1090 fn snapshot_hover_backed_enum_case_shows_value() {
1091 check_hover(
1092 "<?php\nenum Color: string { case Red = 'red'; }",
1093 pos(1, 27),
1094 expect![[r#"
1095 ```php
1096 case Color::Red = 'red'
1097 ```"#]],
1098 );
1099 }
1100
1101 #[test]
1102 fn snapshot_hover_enum_class_const() {
1103 check_hover(
1104 "<?php\nenum Suit { const int MAX = 4; }",
1105 pos(1, 22),
1106 expect![[r#"
1107 ```php
1108 const int MAX = 4
1109 ```"#]],
1110 );
1111 }
1112
1113 #[test]
1114 fn hover_on_trait_method_returns_signature() {
1115 let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
1116 let doc = ParsedDoc::parse(src.to_string());
1117 let result = hover_info(src, &doc, None, pos(1, 34), &[]);
1119 assert!(result.is_some(), "expected hover on trait method");
1120 if let Some(Hover {
1121 contents: HoverContents::Markup(mc),
1122 ..
1123 }) = result
1124 {
1125 assert!(
1126 mc.value.contains("function log("),
1127 "expected function sig, got: {}",
1128 mc.value
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn cross_file_hover_finds_class_in_other_doc() {
1135 use std::sync::Arc;
1136 let src = "<?php\n$x = new PaymentService();";
1137 let other_src = "<?php\nclass PaymentService { public function charge() {} }";
1138 let doc = ParsedDoc::parse(src.to_string());
1139 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
1140 let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
1141 let other_docs = vec![(uri, other_doc)];
1142 let result = hover_info(src, &doc, None, pos(1, 12), &other_docs);
1144 assert!(result.is_some(), "expected cross-file hover result");
1145 if let Some(Hover {
1146 contents: HoverContents::Markup(mc),
1147 ..
1148 }) = result
1149 {
1150 assert!(
1151 mc.value.contains("PaymentService"),
1152 "expected 'PaymentService', got: {}",
1153 mc.value
1154 );
1155 }
1156 }
1157
1158 #[test]
1159 fn hover_on_variable_shows_type() {
1160 let src = "<?php\n$obj = new Mailer();\n$obj";
1161 let doc = ParsedDoc::parse(src.to_string());
1162 let h = hover_at(src, &doc, None, &[], pos(2, 2));
1163 assert!(h.is_some());
1164 let text = match h.unwrap().contents {
1165 HoverContents::Markup(m) => m.value,
1166 _ => String::new(),
1167 };
1168 assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
1169 }
1170
1171 #[test]
1172 fn hover_on_builtin_class_shows_stub_info() {
1173 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
1174 let doc = ParsedDoc::parse(src.to_string());
1175 let h = hover_at(src, &doc, None, &[], pos(1, 12));
1176 assert!(h.is_some(), "should hover on PDO");
1177 let text = match h.unwrap().contents {
1178 HoverContents::Markup(m) => m.value,
1179 _ => String::new(),
1180 };
1181 assert!(text.contains("PDO"), "hover should mention PDO");
1182 }
1183
1184 #[test]
1185 fn hover_on_use_alias_shows_fqn() {
1186 let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
1187 let doc = ParsedDoc::parse(src.to_string());
1188 let h = hover_at(
1189 src,
1190 &doc,
1191 None,
1192 &[],
1193 Position {
1194 line: 1,
1195 character: 20,
1196 },
1197 );
1198 assert!(h.is_some());
1199 let text = match h.unwrap().contents {
1200 HoverContents::Markup(m) => m.value,
1201 _ => String::new(),
1202 };
1203 assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
1204 }
1205
1206 #[test]
1207 fn hover_unknown_symbol_returns_none() {
1208 let src = "<?php\nunknownFunc();";
1210 let doc = ParsedDoc::parse(src.to_string());
1211 let result = hover_info(src, &doc, None, pos(1, 3), &[]);
1212 assert!(
1213 result.is_none(),
1214 "hover on undefined symbol should return None"
1215 );
1216 }
1217
1218 #[test]
1219 fn hover_on_builtin_function_returns_signature() {
1220 let src = "<?php\nstrlen('hello');";
1223 let doc = ParsedDoc::parse(src.to_string());
1224 let result = hover_info(src, &doc, None, pos(1, 3), &[]);
1225 let h = result.expect("expected hover result for built-in 'strlen'");
1226 let text = match h.contents {
1227 HoverContents::Markup(mc) => mc.value,
1228 _ => String::new(),
1229 };
1230 assert!(
1231 !text.is_empty(),
1232 "hover on strlen should return non-empty content"
1233 );
1234 assert!(
1235 text.contains("strlen"),
1236 "hover content should contain 'strlen', got: {text}"
1237 );
1238 }
1239
1240 use expect_test::{Expect, expect};
1243
1244 fn check_hover(src: &str, position: Position, expect: Expect) {
1245 let doc = ParsedDoc::parse(src.to_string());
1246 let result = hover_info(src, &doc, None, position, &[]);
1247 let actual = match result {
1248 Some(Hover {
1249 contents: HoverContents::Markup(mc),
1250 ..
1251 }) => mc.value,
1252 Some(_) => "(non-markup hover)".to_string(),
1253 None => "(no hover)".to_string(),
1254 };
1255 expect.assert_eq(&actual);
1256 }
1257
1258 #[test]
1259 fn snapshot_hover_simple_function() {
1260 check_hover(
1261 "<?php\nfunction init() {}",
1262 pos(1, 10),
1263 expect![[r#"
1264 ```php
1265 function init()
1266 ```"#]],
1267 );
1268 }
1269
1270 #[test]
1271 fn snapshot_hover_function_with_return_type() {
1272 check_hover(
1273 "<?php\nfunction greet(string $name): string {}",
1274 pos(1, 10),
1275 expect![[r#"
1276 ```php
1277 function greet(string $name): string
1278 ```"#]],
1279 );
1280 }
1281
1282 #[test]
1283 fn snapshot_hover_class() {
1284 check_hover(
1285 "<?php\nclass MyService {}",
1286 pos(1, 8),
1287 expect![[r#"
1288 ```php
1289 class MyService
1290 ```"#]],
1291 );
1292 }
1293
1294 #[test]
1295 fn snapshot_hover_class_with_extends() {
1296 check_hover(
1297 "<?php\nclass Dog extends Animal {}",
1298 pos(1, 8),
1299 expect![[r#"
1300 ```php
1301 class Dog extends Animal
1302 ```"#]],
1303 );
1304 }
1305
1306 #[test]
1307 fn snapshot_hover_method() {
1308 check_hover(
1309 "<?php\nclass Calc { public function add(int $a, int $b): int {} }",
1310 pos(1, 32),
1311 expect![[r#"
1312 ```php
1313 public function add(int $a, int $b): int
1314 ```"#]],
1315 );
1316 }
1317
1318 #[test]
1319 fn snapshot_hover_trait() {
1320 check_hover(
1321 "<?php\ntrait Loggable {}",
1322 pos(1, 8),
1323 expect![[r#"
1324 ```php
1325 trait Loggable
1326 ```"#]],
1327 );
1328 }
1329
1330 #[test]
1331 fn snapshot_hover_interface() {
1332 check_hover(
1333 "<?php\ninterface Serializable {}",
1334 pos(1, 12),
1335 expect![[r#"
1336 ```php
1337 interface Serializable
1338 ```"#]],
1339 );
1340 }
1341
1342 #[test]
1343 fn snapshot_hover_class_const_with_type_hint() {
1344 check_hover(
1345 "<?php\nclass Config { const string VERSION = '1.0.0'; }",
1346 pos(1, 28),
1347 expect![[r#"
1348 ```php
1349 const string VERSION = '1.0.0'
1350 ```"#]],
1351 );
1352 }
1353
1354 #[test]
1355 fn snapshot_hover_class_const_float_value() {
1356 check_hover(
1357 "<?php\nclass Math { const float PI = 3.14; }",
1358 pos(1, 27),
1359 expect![[r#"
1360 ```php
1361 const float PI = 3.14
1362 ```"#]],
1363 );
1364 }
1365
1366 #[test]
1367 fn snapshot_hover_class_const_infers_type_from_value() {
1368 let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
1369 check_hover(
1370 &src,
1371 p,
1372 expect![[r#"
1373 ```php
1374 const string VERSION = '1.0.0'
1375 ```"#]],
1376 );
1377 }
1378
1379 #[test]
1380 fn snapshot_hover_interface_const_shows_type_and_value() {
1381 let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
1382 check_hover(
1383 &src,
1384 p,
1385 expect![[r#"
1386 ```php
1387 const int MAX = 100
1388 ```"#]],
1389 );
1390 }
1391
1392 #[test]
1393 fn snapshot_hover_trait_const_shows_type_and_value() {
1394 let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
1395 check_hover(
1396 &src,
1397 p,
1398 expect![[r#"
1399 ```php
1400 const string TAG = 'v1'
1401 ```"#]],
1402 );
1403 }
1404
1405 #[test]
1406 fn hover_on_static_var_with_array_default_shows_array() {
1407 let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
1408 let doc = ParsedDoc::parse(src.clone());
1409 let result = hover_info(&src, &doc, None, p, &[]);
1410 assert!(
1411 result.is_some(),
1412 "expected hover result for static variable"
1413 );
1414 if let Some(Hover {
1415 contents: HoverContents::Markup(mc),
1416 ..
1417 }) = result
1418 {
1419 assert!(
1420 mc.value.contains("array"),
1421 "expected array type in hover, got: {}",
1422 mc.value
1423 );
1424 }
1425 }
1426
1427 #[test]
1428 fn hover_on_static_var_with_new_shows_class() {
1429 let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
1430 let doc = ParsedDoc::parse(src.clone());
1431 let result = hover_info(&src, &doc, None, p, &[]);
1432 assert!(
1433 result.is_some(),
1434 "expected hover result for static variable"
1435 );
1436 if let Some(Hover {
1437 contents: HoverContents::Markup(mc),
1438 ..
1439 }) = result
1440 {
1441 assert!(
1442 mc.value.contains("MyService"),
1443 "expected MyService in hover, got: {}",
1444 mc.value
1445 );
1446 }
1447 }
1448
1449 #[test]
1451 fn hover_variable_in_method_does_not_leak_across_methods() {
1452 let (src, p) = cursor(concat!(
1455 "<?php\n",
1456 "class Service {\n",
1457 " public function methodA(): void { $result = new Widget(); }\n",
1458 " public function methodB(): void { $res$0ult = new Invoice(); }\n",
1459 "}\n",
1460 ));
1461 let doc = ParsedDoc::parse(src.clone());
1462 let result = hover_info(&src, &doc, None, p, &[]);
1463 if let Some(Hover {
1464 contents: HoverContents::Markup(mc),
1465 ..
1466 }) = result
1467 {
1468 assert!(
1469 !mc.value.contains("Widget"),
1470 "Widget from methodA must not appear in methodB hover, got: {}",
1471 mc.value
1472 );
1473 assert!(
1474 mc.value.contains("Invoice"),
1475 "Invoice from methodB should appear in hover, got: {}",
1476 mc.value
1477 );
1478 }
1479 }
1480}