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).unwrap_or(class_name)
276 } else {
277 class_name
278 };
279 let fast_doc = find_class_doc.and_then(|f| f(&effective_class));
283 let docs_to_search: Vec<&ParsedDoc> = if let Some(fd) = &fast_doc {
284 vec![fd.as_ref()]
285 } else {
286 std::iter::once(doc)
287 .chain(other_docs.iter().map(|(_, d)| d.as_ref()))
288 .collect()
289 };
290 for d in docs_to_search {
291 if let Some((modifiers, type_str, db)) =
292 find_property_info(d, &effective_class, prop_name)
293 {
294 let sig = format!(
295 "(property) {}{}::${}{}",
296 modifiers,
297 effective_class,
298 prop_name,
299 if type_str.is_empty() {
300 String::new()
301 } else {
302 format!(": {}", type_str)
303 }
304 );
305 let mut value = wrap_php(&sig);
306 if let Some(doc) = db {
307 let md = doc.to_markdown();
308 if !md.is_empty() {
309 value.push_str("\n\n---\n\n");
310 value.push_str(&md);
311 }
312 }
313 return Some(Hover {
314 contents: HoverContents::Markup(MarkupContent {
315 kind: MarkupKind::Markdown,
316 value,
317 }),
318 range: hover_range,
319 });
320 }
321 }
322 }
323
324 if word.starts_with('$') {
325 let mir_ty = analysis.and_then(|a| {
328 let off =
329 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
330 crate::types::type_query::type_at_offset(a, off)
331 });
332
333 if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
335 {
336 return Some(Hover {
337 contents: HoverContents::Markup(MarkupContent {
338 kind: MarkupKind::Markdown,
339 value: format!("`{word}` `{ty}`"),
340 }),
341 range: hover_range,
342 });
343 }
344 if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
348 return Some(Hover {
349 contents: HoverContents::Markup(MarkupContent {
350 kind: MarkupKind::Markdown,
351 value: format!("`{word}` `{ty}`"),
352 }),
353 range: hover_range,
354 });
355 }
356 }
357
358 if word.starts_with('$')
362 && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
363 && let prop_name = word.trim_start_matches('$')
364 && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
365 {
366 let sig = format!(
367 "(property) {}{}::${}{}",
368 modifiers,
369 class_name,
370 prop_name,
371 if type_str.is_empty() {
372 String::new()
373 } else {
374 format!(": {type_str}")
375 }
376 );
377 let mut value = wrap_php(&sig);
378 if let Some(doc_block) = db {
379 let md = doc_block.to_markdown();
380 if !md.is_empty() {
381 value.push_str("\n\n---\n\n");
382 value.push_str(&md);
383 }
384 }
385 return Some(Hover {
386 contents: HoverContents::Markup(MarkupContent {
387 kind: MarkupKind::Markdown,
388 value,
389 }),
390 range: hover_range,
391 });
392 }
393
394 if !word.starts_with('$')
395 && let Some(sym) = analysis.and_then(|a| {
396 let off =
397 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
398 a.symbol_at(off)
399 })
400 {
401 let mir_hover = mir_member_hover(sym, &word, doc, other_docs, find_class_doc);
402 if mir_hover.is_some() {
403 return mir_hover.map(|value| Hover {
404 contents: HoverContents::Markup(MarkupContent {
405 kind: MarkupKind::Markdown,
406 value,
407 }),
408 range: hover_range,
409 });
410 }
411 }
412
413 if (word == "function" || word == "fn")
414 && let Some(sig) = closure_hover(source, doc, position, &word)
415 {
416 return Some(Hover {
417 contents: HoverContents::Markup(MarkupContent {
418 kind: MarkupKind::Markdown,
419 value: wrap_php(&sig),
420 }),
421 range: hover_range,
422 });
423 }
424
425 let all_stmts = &*doc.program().stmts as &[_];
426 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
427
428 let current_doc_found =
430 resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
431 .and_then(|d| declaration_signature(&d, &resolved_word))
432 .map(|sig| {
433 let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
434 .map(|db| db.to_markdown())
435 .filter(|md| !md.is_empty());
436 (sig, doc_md)
437 });
438
439 let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
441
442 if let Some((sig, doc_md)) = found {
443 let mut value = wrap_php(&sig);
444 if let Some(md) = doc_md
445 && !md.is_empty()
446 {
447 value.push_str("\n\n---\n\n");
448 value.push_str(&md);
449 }
450 if is_php_builtin(&resolved_word) {
451 value.push_str(&format!(
452 "\n\n[php.net documentation]({})",
453 php_doc_url(&resolved_word)
454 ));
455 }
456 return Some(Hover {
457 contents: HoverContents::Markup(MarkupContent {
458 kind: MarkupKind::Markdown,
459 value,
460 }),
461 range: hover_range,
462 });
463 }
464
465 if is_php_builtin(&resolved_word) {
466 let value = format!(
467 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
468 resolved_word,
469 php_doc_url(&resolved_word)
470 );
471 return Some(Hover {
472 contents: HoverContents::Markup(MarkupContent {
473 kind: MarkupKind::Markdown,
474 value,
475 }),
476 range: hover_range,
477 });
478 }
479
480 if let Some(stub) =
481 session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
482 {
483 return Some(builtin_class_hover(stub, &resolved_word, hover_range));
484 }
485
486 None
487}
488
489fn candidates_for<'a>(
494 fast: &'a Option<Arc<ParsedDoc>>,
495 doc: &'a ParsedDoc,
496 other_docs: &'a [(Uri, Arc<ParsedDoc>)],
497) -> Vec<&'a ParsedDoc> {
498 if let Some(fd) = fast {
499 vec![fd.as_ref()]
500 } else {
501 std::iter::once(doc)
502 .chain(other_docs.iter().map(|(_, d)| d.as_ref()))
503 .collect()
504 }
505}
506
507fn mir_member_hover(
511 sym: &mir_analyzer::ResolvedSymbol,
512 word: &str,
513 doc: &ParsedDoc,
514 other_docs: &[(tower_lsp_server::ls_types::Uri, std::sync::Arc<ParsedDoc>)],
515 find_class_doc: Option<ClassDocLookup<'_>>,
516) -> Option<String> {
517 match &sym.kind {
518 mir_analyzer::ReferenceKind::MethodCall { class, .. }
519 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
520 let class_short = fqn_short_name(class);
521 let fast = find_class_doc.and_then(|f| f(class));
522 let candidates = candidates_for(&fast, doc, other_docs);
523 for d in &candidates {
524 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
525 let sig = augment_return_type(sig, &sym.resolved_type);
527 let mut value = wrap_php(&sig);
528 let docblock_docs = fast
532 .iter()
533 .map(|d| d.as_ref())
534 .chain(std::iter::once(doc))
535 .chain(other_docs.iter().map(|(_, d)| d.as_ref()));
536 if let Some(db) = resolve_method_docblock(docblock_docs, class_short, word) {
537 let md = db.to_markdown();
538 if !md.is_empty() {
539 value.push_str("\n\n---\n\n");
540 value.push_str(&md);
541 }
542 }
543 return Some(value);
544 }
545 }
546 None
547 }
548 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
549 let class_short = fqn_short_name(class);
550 let fast = find_class_doc.and_then(|f| f(class));
551 let candidates = candidates_for(&fast, doc, other_docs);
552 for d in candidates {
553 if let Some((modifiers, declared_type, db)) =
554 find_property_info(d, class_short, property)
555 {
556 let type_str = augment_property_type(declared_type, &sym.resolved_type);
558 let sig = format!(
559 "(property) {}{}::${}{}",
560 modifiers,
561 class_short,
562 property,
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_block) = db {
571 let md = doc_block.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(value);
578 }
579 }
580 let ty_str = format!("{}", sym.resolved_type);
583 if !matches!(ty_str.as_str(), "" | "void" | "never") {
584 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
585 return Some(wrap_php(&sig));
586 }
587 None
588 }
589 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
590 let class_short = fqn_short_name(class);
591 let fast = find_class_doc.and_then(|f| f(class));
592 let candidates = candidates_for(&fast, doc, other_docs);
593 for d in candidates {
594 if let Some(sig) =
595 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
596 {
597 return Some(wrap_php(&sig));
598 }
599 if let Some(sig) =
600 scan_class_const_of_class(&d.program().stmts, class_short, constant)
601 {
602 return Some(wrap_php(&sig));
603 }
604 }
605 None
606 }
607 _ => None,
608 }
609}
610
611fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
615 let ty_str = format!("{resolved}");
616 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
617 return sig;
618 }
619 let Some(paren) = sig.rfind(')') else {
620 return sig;
621 };
622 let rest = &sig[paren + 1..];
623 if let Some(colon_pos) = rest.find(": ") {
624 let declared = rest[colon_pos + 2..].trim();
625 if matches!(declared, "static" | "self" | "parent") {
628 return sig;
629 }
630 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
631 } else {
632 format!("{}: {}", sig, ty_str)
633 }
634}
635
636fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
639 let ty_str = format!("{resolved}");
640 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
641 return declared;
642 }
643 ty_str
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::test_utils::cursor;
650
651 fn pos(line: u32, character: u32) -> Position {
652 Position { line, character }
653 }
654
655 #[test]
656 fn word_at_extracts_from_middle_of_identifier() {
657 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
658 let word = word_at_position(&src, p);
659 assert_eq!(word.as_deref(), Some("greetUser"));
660 }
661
662 #[test]
663 fn hover_on_builtin_class_requires_session() {
664 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
668 let doc = ParsedDoc::parse(src.to_string());
669 let h = hover_info_with_maps(src, &doc, None, pos(1, 12), &[], &[], None, None);
670 assert!(h.is_none(), "built-in class hover requires a session");
671 }
672}