1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position, Uri};
5
6use crate::completion::ClassDocLookup;
7use crate::document::ast::ParsedDoc;
8use crate::document::document_store::DocumentStore;
9use crate::lang::docblock::find_docblock;
10use crate::lang::file_header::header_line_count;
11use crate::lang::is_unresolvable_bareword_at;
12use crate::lang::php_names::{is_php_builtin, php_doc_url};
13use crate::text::{fqn_short_name, word_at_position, word_range_at};
14use crate::types::resolve::{Declaration, resolve_declaration};
15use crate::types::symbol_map::{SymbolMap, is_hoverable_kind};
16
17use super::closures::closure_hover;
18use super::formatting::{declaration_signature, wrap_php};
19use super::members::{
20 find_property_info, resolve_method_docblock, scan_class_const_of_class,
21 scan_enum_case_of_class, scan_method_of_class,
22};
23use super::named_args::{find_named_arg_at, named_arg_hover_value};
24use super::parsing::{extract_static_class_before_cursor, resolve_use_alias_fqn};
25
26fn is_hoverable(decl: &Declaration<'_>) -> bool {
29 !matches!(
30 decl,
31 Declaration::Property { .. } | Declaration::PromotedParam { .. }
32 )
33}
34
35#[allow(clippy::too_many_arguments)]
43pub fn hover_info_with_maps(
44 source: &str,
45 doc: &ParsedDoc,
46 analysis: Option<&mir_analyzer::FileAnalysis>,
47 position: Position,
48 other_docs: &[(Uri, Arc<ParsedDoc>)],
49 other_maps: &[(Uri, Arc<SymbolMap>)],
50 session: Option<&DocumentStore>,
51 find_class_doc: Option<ClassDocLookup<'_>>,
52) -> Option<Hover> {
53 hover_at_core(
54 source,
55 doc,
56 analysis,
57 other_docs,
58 position,
59 session,
60 find_class_doc,
61 |resolved_word| {
62 for (_, sym_map) in other_maps {
63 if let Some(entry) = sym_map.lookup(resolved_word, |e| is_hoverable_kind(e.kind))
64 && let Some(sig) = &entry.signature
65 {
66 return Some((sig.clone(), entry.doc_markdown.clone()));
67 }
68 }
69 None
70 },
71 )
72}
73
74fn builtin_class_hover(
75 stub: crate::types::type_map::ClassMembers,
76 name: &str,
77 range: Option<tower_lsp_server::ls_types::Range>,
78) -> Hover {
79 let method_names: Vec<&str> = stub
80 .methods
81 .iter()
82 .filter(|(_, is_static, _)| !is_static)
83 .map(|(n, _, _)| n.as_str())
84 .take(8)
85 .collect();
86 let static_names: Vec<&str> = stub
87 .methods
88 .iter()
89 .filter(|(_, is_static, _)| *is_static)
90 .map(|(n, _, _)| n.as_str())
91 .take(4)
92 .collect();
93 let mut lines = vec![format!("**{}** — built-in class", name)];
94 if !method_names.is_empty() {
95 lines.push(format!(
96 "Methods: {}",
97 method_names
98 .iter()
99 .map(|n| format!("`{n}`"))
100 .collect::<Vec<_>>()
101 .join(", ")
102 ));
103 }
104 if !static_names.is_empty() {
105 lines.push(format!(
106 "Static: {}",
107 static_names
108 .iter()
109 .map(|n| format!("`{n}`"))
110 .collect::<Vec<_>>()
111 .join(", ")
112 ));
113 }
114 if let Some(parent) = &stub.parent {
115 lines.push(format!("Extends: `{parent}`"));
116 }
117 Hover {
118 contents: HoverContents::Markup(MarkupContent {
119 kind: MarkupKind::Markdown,
120 value: lines.join("\n\n"),
121 }),
122 range,
123 }
124}
125
126#[allow(clippy::too_many_arguments)]
131fn hover_at_core(
132 source: &str,
133 doc: &ParsedDoc,
134 analysis: Option<&mir_analyzer::FileAnalysis>,
135 other_docs: &[(Uri, Arc<ParsedDoc>)],
136 position: Position,
137 session: Option<&DocumentStore>,
138 find_class_doc: Option<ClassDocLookup<'_>>,
139 resolve_cross_file: impl Fn(&str) -> Option<(String, Option<String>)>,
140) -> Option<Hover> {
141 let hover_range = word_range_at(source, position);
142
143 if let Some(line_text) = source.lines().nth(position.line as usize)
144 && (position.line as usize) < header_line_count(source)
145 {
146 let trimmed = line_text.trim();
147 if trimmed.starts_with("use ") {
148 let (prefix, content) = if trimmed.starts_with("use function ") {
149 (
150 "use function ",
151 trimmed.strip_prefix("use function ").unwrap_or(""),
152 )
153 } else if trimmed.starts_with("use const ") {
154 (
155 "use const ",
156 trimmed.strip_prefix("use const ").unwrap_or(""),
157 )
158 } else {
159 ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
160 };
161 let fqn = content.trim_end_matches(';').trim();
162 if !fqn.is_empty() {
163 let maybe_word = word_at_position(source, position);
164 let alias = fqn_short_name(fqn);
165 let matches = match &maybe_word {
166 Some(w) => {
167 let w = w.trim_start_matches('\\');
168 w == alias
169 || fqn.split([' ', ',', '{', '}']).any(|name| {
170 name.trim_start_matches('\\') == w
171 || name.split('\\').any(|seg| seg == w)
172 })
173 }
174 None => true,
175 };
176 if matches {
177 return Some(Hover {
178 contents: HoverContents::Markup(MarkupContent {
179 kind: MarkupKind::Markdown,
180 value: format!("`{}{};`", prefix, fqn),
181 }),
182 range: hover_range,
183 });
184 }
185 }
186 }
187 }
188
189 let word = word_at_position(source, position)?;
190
191 if let Some(line_text) = source.lines().nth(position.line as usize)
192 && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
193 {
194 let keyword_doc: Option<&str> = match word.as_str() {
195 "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
196 "null" => Some("`null` — the null value; a variable has no value"),
197 "true" => Some("`true` — boolean true"),
198 "false" => Some("`false` — boolean false"),
199 "abstract" => Some(
200 "`abstract` — declares an abstract class or method that must be implemented by a subclass",
201 ),
202 "readonly" => {
203 Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
204 }
205 "yield" => Some("`yield` — produces a value from a generator function"),
206 "never" => Some(
207 "`never` — return type indicating the function always throws or exits (PHP 8.1)",
208 ),
209 "throw" => {
210 Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
211 }
212 "void" => Some("`void` — return type indicating the function returns no value"),
213 "bool" => Some("`bool` — boolean type: `true` or `false`"),
214 "int" => Some("`int` — integer type"),
215 "float" => Some("`float` — floating-point number type"),
216 "string" => Some("`string` — string type"),
217 "mixed" => Some("`mixed` — any type (no type constraint)"),
218 "object" => Some("`object` — any class instance"),
219 "iterable" => Some("`iterable` — array or Traversable (PHP 7.1)"),
220 "array" => Some("`array` — ordered map type"),
221 "callable" => Some(
222 "`callable` — any callable: Closure, function-name string, or `[object, method]` array",
223 ),
224 "self" => Some("`self` — the class in which the method is defined"),
225 "static" => Some(
226 "`static` — the class on which the method was called (late static binding, PHP 5.3)",
227 ),
228 "parent" => Some("`parent` — the parent class of the current class"),
229 _ => None,
230 };
231 if let Some(doc_str) = keyword_doc {
232 return Some(Hover {
233 contents: HoverContents::Markup(MarkupContent {
234 kind: MarkupKind::Markdown,
235 value: doc_str.to_string(),
236 }),
237 range: hover_range,
238 });
239 }
240 let magic_doc: Option<&str> = match word.as_str() {
241 "__CLASS__" => Some("`__CLASS__` — name of the current class"),
242 "__DIR__" => Some("`__DIR__` — directory of the current file"),
243 "__FILE__" => Some("`__FILE__` — absolute path of the current file"),
244 "__FUNCTION__" => Some("`__FUNCTION__` — name of the current function or closure"),
245 "__LINE__" => Some("`__LINE__` — current line number in the file"),
246 "__METHOD__" => Some("`__METHOD__` — current method name (`ClassName::methodName`)"),
247 "__NAMESPACE__" => Some("`__NAMESPACE__` — name of the current namespace"),
248 "__TRAIT__" => Some("`__TRAIT__` — name of the current trait"),
249 _ => None,
250 };
251 if let Some(doc_str) = magic_doc {
252 return Some(Hover {
253 contents: HoverContents::Markup(MarkupContent {
254 kind: MarkupKind::Markdown,
255 value: doc_str.to_string(),
256 }),
257 range: hover_range,
258 });
259 }
260 }
261
262 if !word.starts_with('$')
263 && let Some(offset) =
264 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))
265 && let Some((callee, label)) = find_named_arg_at(doc, offset)
266 && let Some(value) =
267 named_arg_hover_value(source, doc, other_docs, position, &callee, &label, analysis)
268 {
269 return Some(Hover {
270 contents: HoverContents::Markup(MarkupContent {
271 kind: MarkupKind::Markdown,
272 value,
273 }),
274 range: hover_range,
275 });
276 }
277
278 if word.starts_with('$')
282 && let Some(line_text) = source.lines().nth(position.line as usize)
283 && let Some(class_name) =
284 extract_static_class_before_cursor(line_text, position.character as usize)
285 {
286 let prop_name = word.trim_start_matches('$');
287 let effective_class = if class_name == "self" || class_name == "static" {
288 crate::types::type_map::enclosing_class_at(source, doc, position).unwrap_or(class_name)
289 } else {
290 class_name
291 };
292 let fast_doc = find_class_doc.and_then(|f| f(&effective_class));
296 let docs_to_search: Vec<&ParsedDoc> = if let Some(fd) = &fast_doc {
297 vec![fd.as_ref()]
298 } else {
299 std::iter::once(doc)
300 .chain(other_docs.iter().map(|(_, d)| d.as_ref()))
301 .collect()
302 };
303 for d in docs_to_search {
304 if let Some((modifiers, type_str, db)) =
305 find_property_info(d, &effective_class, prop_name)
306 {
307 let sig = format!(
308 "(property) {}{}::${}{}",
309 modifiers,
310 effective_class,
311 prop_name,
312 if type_str.is_empty() {
313 String::new()
314 } else {
315 format!(": {}", type_str)
316 }
317 );
318 let mut value = wrap_php(&sig);
319 if let Some(doc) = db {
320 let md = doc.to_markdown();
321 if !md.is_empty() {
322 value.push_str("\n\n---\n\n");
323 value.push_str(&md);
324 }
325 }
326 return Some(Hover {
327 contents: HoverContents::Markup(MarkupContent {
328 kind: MarkupKind::Markdown,
329 value,
330 }),
331 range: hover_range,
332 });
333 }
334 }
335 }
336
337 if word.starts_with('$') {
338 let mir_ty = analysis.and_then(|a| {
341 let off =
342 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
343 crate::types::type_query::type_at_offset(a, off)
344 });
345
346 if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
348 {
349 return Some(Hover {
350 contents: HoverContents::Markup(MarkupContent {
351 kind: MarkupKind::Markdown,
352 value: format!("`{word}` `{ty}`"),
353 }),
354 range: hover_range,
355 });
356 }
357 if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
361 return Some(Hover {
362 contents: HoverContents::Markup(MarkupContent {
363 kind: MarkupKind::Markdown,
364 value: format!("`{word}` `{ty}`"),
365 }),
366 range: hover_range,
367 });
368 }
369 }
370
371 if word.starts_with('$')
375 && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
376 && let prop_name = word.trim_start_matches('$')
377 && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
378 {
379 let sig = format!(
380 "(property) {}{}::${}{}",
381 modifiers,
382 class_name,
383 prop_name,
384 if type_str.is_empty() {
385 String::new()
386 } else {
387 format!(": {type_str}")
388 }
389 );
390 let mut value = wrap_php(&sig);
391 if let Some(doc_block) = db {
392 let md = doc_block.to_markdown();
393 if !md.is_empty() {
394 value.push_str("\n\n---\n\n");
395 value.push_str(&md);
396 }
397 }
398 return Some(Hover {
399 contents: HoverContents::Markup(MarkupContent {
400 kind: MarkupKind::Markdown,
401 value,
402 }),
403 range: hover_range,
404 });
405 }
406
407 if !word.starts_with('$')
408 && let Some(sym) = analysis.and_then(|a| {
409 let off =
410 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
411 a.symbol_at(off)
412 })
413 {
414 let mir_hover = mir_member_hover(sym, &word, doc, other_docs, find_class_doc);
415 if mir_hover.is_some() {
416 return mir_hover.map(|value| Hover {
417 contents: HoverContents::Markup(MarkupContent {
418 kind: MarkupKind::Markdown,
419 value,
420 }),
421 range: hover_range,
422 });
423 }
424 }
425
426 if (word == "function" || word == "fn")
427 && let Some(sig) = closure_hover(source, doc, position, &word)
428 {
429 return Some(Hover {
430 contents: HoverContents::Markup(MarkupContent {
431 kind: MarkupKind::Markdown,
432 value: wrap_php(&sig),
433 }),
434 range: hover_range,
435 });
436 }
437
438 if is_unresolvable_bareword_at(source, position, &word) {
445 return None;
446 }
447
448 let all_stmts = &*doc.program().stmts as &[_];
449 let alias = resolve_use_alias_fqn(all_stmts, &word);
450 let resolved_word = alias
451 .as_ref()
452 .map_or_else(|| word.clone(), |(short, _)| short.clone());
453 let alias_targets_current_doc = alias.as_ref().is_none_or(|(short, fqn)| {
456 let local_fqn = crate::navigation::moniker::resolve_fqn(doc, short, &HashMap::new());
457 local_fqn
458 .trim_start_matches('\\')
459 .eq_ignore_ascii_case(fqn.trim_start_matches('\\'))
460 });
461
462 let current_doc_found = alias_targets_current_doc
464 .then(|| {
465 resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
466 .and_then(|d| declaration_signature(&d, &resolved_word))
467 .map(|sig| {
468 let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
469 .map(|db| db.to_markdown())
470 .filter(|md| !md.is_empty());
471 (sig, doc_md)
472 })
473 })
474 .flatten();
475
476 let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
478
479 if let Some((sig, doc_md)) = found {
480 let mut value = wrap_php(&sig);
481 if let Some(md) = doc_md
482 && !md.is_empty()
483 {
484 value.push_str("\n\n---\n\n");
485 value.push_str(&md);
486 }
487 if is_php_builtin(&resolved_word) {
488 value.push_str(&format!(
489 "\n\n[php.net documentation]({})",
490 php_doc_url(&resolved_word)
491 ));
492 }
493 return Some(Hover {
494 contents: HoverContents::Markup(MarkupContent {
495 kind: MarkupKind::Markdown,
496 value,
497 }),
498 range: hover_range,
499 });
500 }
501
502 if is_php_builtin(&resolved_word) {
503 let value = format!(
504 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
505 resolved_word,
506 php_doc_url(&resolved_word)
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 let Some(stub) = session.and_then(|docs| docs.stub_class_members(&resolved_word)) {
518 return Some(builtin_class_hover(stub, &resolved_word, hover_range));
519 }
520
521 None
522}
523
524fn candidates_for<'a>(
529 fast: &'a Option<Arc<ParsedDoc>>,
530 doc: &'a ParsedDoc,
531 other_docs: &'a [(Uri, Arc<ParsedDoc>)],
532) -> Vec<&'a ParsedDoc> {
533 if let Some(fd) = fast {
534 vec![fd.as_ref()]
535 } else {
536 std::iter::once(doc)
537 .chain(other_docs.iter().map(|(_, d)| d.as_ref()))
538 .collect()
539 }
540}
541
542fn mir_member_hover(
546 sym: &mir_analyzer::ResolvedSymbol,
547 word: &str,
548 doc: &ParsedDoc,
549 other_docs: &[(tower_lsp_server::ls_types::Uri, std::sync::Arc<ParsedDoc>)],
550 find_class_doc: Option<ClassDocLookup<'_>>,
551) -> Option<String> {
552 match &sym.kind {
553 mir_analyzer::ReferenceKind::MethodCall { class, .. }
554 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
555 let class_short = fqn_short_name(class);
556 let fast = find_class_doc.and_then(|f| f(class));
557 let candidates = candidates_for(&fast, doc, other_docs);
558 for d in &candidates {
559 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
560 let sig = augment_return_type(sig, &sym.resolved_type);
562 let mut value = wrap_php(&sig);
563 let docblock_docs = fast
567 .iter()
568 .map(|d| d.as_ref())
569 .chain(std::iter::once(doc))
570 .chain(other_docs.iter().map(|(_, d)| d.as_ref()));
571 if let Some(db) = resolve_method_docblock(docblock_docs, class_short, word) {
572 let md = db.to_markdown();
573 if !md.is_empty() {
574 value.push_str("\n\n---\n\n");
575 value.push_str(&md);
576 }
577 }
578 return Some(value);
579 }
580 }
581 None
582 }
583 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
584 let class_short = fqn_short_name(class);
585 let fast = find_class_doc.and_then(|f| f(class));
586 let candidates = candidates_for(&fast, doc, other_docs);
587 for d in candidates {
588 if let Some((modifiers, declared_type, db)) =
589 find_property_info(d, class_short, property)
590 {
591 let type_str = augment_property_type(declared_type, &sym.resolved_type);
593 let sig = format!(
594 "(property) {}{}::${}{}",
595 modifiers,
596 class_short,
597 property,
598 if type_str.is_empty() {
599 String::new()
600 } else {
601 format!(": {}", type_str)
602 }
603 );
604 let mut value = wrap_php(&sig);
605 if let Some(doc_block) = db {
606 let md = doc_block.to_markdown();
607 if !md.is_empty() {
608 value.push_str("\n\n---\n\n");
609 value.push_str(&md);
610 }
611 }
612 return Some(value);
613 }
614 }
615 let ty_str = format!("{}", sym.resolved_type);
618 if !matches!(ty_str.as_str(), "" | "void" | "never") {
619 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
620 return Some(wrap_php(&sig));
621 }
622 None
623 }
624 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
625 let class_short = fqn_short_name(class);
626 let fast = find_class_doc.and_then(|f| f(class));
627 let candidates = candidates_for(&fast, doc, other_docs);
628 for d in candidates {
629 if let Some(sig) =
630 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
631 {
632 return Some(wrap_php(&sig));
633 }
634 if let Some(sig) =
635 scan_class_const_of_class(&d.program().stmts, class_short, constant)
636 {
637 return Some(wrap_php(&sig));
638 }
639 }
640 None
641 }
642 _ => None,
643 }
644}
645
646fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
650 let ty_str = format!("{resolved}");
651 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
652 return sig;
653 }
654 let Some(paren) = sig.rfind(')') else {
655 return sig;
656 };
657 let rest = &sig[paren + 1..];
658 if let Some(colon_pos) = rest.find(": ") {
659 let declared = rest[colon_pos + 2..].trim();
660 if matches!(declared, "static" | "self" | "parent") {
663 return sig;
664 }
665 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
666 } else {
667 format!("{}: {}", sig, ty_str)
668 }
669}
670
671fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
674 let ty_str = format!("{resolved}");
675 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
676 return declared;
677 }
678 ty_str
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use crate::test_utils::cursor;
685
686 fn pos(line: u32, character: u32) -> Position {
687 Position { line, character }
688 }
689
690 #[test]
691 fn word_at_extracts_from_middle_of_identifier() {
692 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
693 let word = word_at_position(&src, p);
694 assert_eq!(word.as_deref(), Some("greetUser"));
695 }
696
697 #[test]
698 fn hover_on_builtin_class_requires_session() {
699 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
703 let doc = ParsedDoc::parse(src.to_string());
704 let h = hover_info_with_maps(src, &doc, None, pos(1, 12), &[], &[], None, None);
705 assert!(h.is_none(), "built-in class hover requires a session");
706 }
707}