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