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