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::is_unresolvable_bareword_at;
9use crate::lang::php_names::{is_php_builtin, php_doc_url};
10use crate::text::{fqn_short_name, word_at_position, word_range_at};
11use crate::types::resolve::{Declaration, resolve_declaration};
12use crate::types::symbol_map::{SymbolMap, is_hoverable_kind};
13
14use super::closures::closure_hover;
15use super::formatting::{declaration_signature, wrap_php};
16use super::members::{
17 find_property_info, resolve_method_docblock, scan_class_const_of_class,
18 scan_enum_case_of_class, scan_method_of_class,
19};
20use super::named_args::{find_named_arg_at, named_arg_hover_value};
21use super::parsing::{extract_static_class_before_cursor, resolve_use_alias};
22
23fn is_hoverable(decl: &Declaration<'_>) -> bool {
26 !matches!(
27 decl,
28 Declaration::Property { .. } | Declaration::PromotedParam { .. }
29 )
30}
31
32#[allow(clippy::too_many_arguments)]
40pub fn hover_info_with_maps(
41 source: &str,
42 doc: &ParsedDoc,
43 analysis: Option<&mir_analyzer::FileAnalysis>,
44 position: Position,
45 other_docs: &[(Uri, Arc<ParsedDoc>)],
46 other_maps: &[(Uri, Arc<SymbolMap>)],
47 session: Option<&mir_analyzer::AnalysisSession>,
48 find_class_doc: Option<ClassDocLookup<'_>>,
49) -> Option<Hover> {
50 hover_at_core(
51 source,
52 doc,
53 analysis,
54 other_docs,
55 position,
56 session,
57 find_class_doc,
58 |resolved_word| {
59 for (_, sym_map) in other_maps {
60 if let Some(entry) = sym_map.lookup(resolved_word, |e| is_hoverable_kind(e.kind))
61 && let Some(sig) = &entry.signature
62 {
63 return Some((sig.clone(), entry.doc_markdown.clone()));
64 }
65 }
66 None
67 },
68 )
69}
70
71fn builtin_class_hover(
72 stub: crate::types::type_map::ClassMembers,
73 name: &str,
74 range: Option<tower_lsp_server::ls_types::Range>,
75) -> Hover {
76 let method_names: Vec<&str> = stub
77 .methods
78 .iter()
79 .filter(|(_, is_static, _)| !is_static)
80 .map(|(n, _, _)| n.as_str())
81 .take(8)
82 .collect();
83 let static_names: Vec<&str> = stub
84 .methods
85 .iter()
86 .filter(|(_, is_static, _)| *is_static)
87 .map(|(n, _, _)| n.as_str())
88 .take(4)
89 .collect();
90 let mut lines = vec![format!("**{}** — built-in class", name)];
91 if !method_names.is_empty() {
92 lines.push(format!(
93 "Methods: {}",
94 method_names
95 .iter()
96 .map(|n| format!("`{n}`"))
97 .collect::<Vec<_>>()
98 .join(", ")
99 ));
100 }
101 if !static_names.is_empty() {
102 lines.push(format!(
103 "Static: {}",
104 static_names
105 .iter()
106 .map(|n| format!("`{n}`"))
107 .collect::<Vec<_>>()
108 .join(", ")
109 ));
110 }
111 if let Some(parent) = &stub.parent {
112 lines.push(format!("Extends: `{parent}`"));
113 }
114 Hover {
115 contents: HoverContents::Markup(MarkupContent {
116 kind: MarkupKind::Markdown,
117 value: lines.join("\n\n"),
118 }),
119 range,
120 }
121}
122
123#[allow(clippy::too_many_arguments)]
128fn hover_at_core(
129 source: &str,
130 doc: &ParsedDoc,
131 analysis: Option<&mir_analyzer::FileAnalysis>,
132 other_docs: &[(Uri, Arc<ParsedDoc>)],
133 position: Position,
134 session: Option<&mir_analyzer::AnalysisSession>,
135 find_class_doc: Option<ClassDocLookup<'_>>,
136 resolve_cross_file: impl Fn(&str) -> Option<(String, Option<String>)>,
137) -> Option<Hover> {
138 let hover_range = word_range_at(source, position);
139
140 if let Some(line_text) = source.lines().nth(position.line as usize) {
141 let trimmed = line_text.trim();
142 if trimmed.starts_with("use ") {
143 let (prefix, content) = if trimmed.starts_with("use function ") {
144 (
145 "use function ",
146 trimmed.strip_prefix("use function ").unwrap_or(""),
147 )
148 } else if trimmed.starts_with("use const ") {
149 (
150 "use const ",
151 trimmed.strip_prefix("use const ").unwrap_or(""),
152 )
153 } else {
154 ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
155 };
156 let fqn = content.trim_end_matches(';').trim();
157 if !fqn.is_empty() {
158 let maybe_word = word_at_position(source, position);
159 let alias = fqn_short_name(fqn);
160 let matches = match &maybe_word {
161 Some(w) => w == alias || fqn.contains(w.as_str()),
162 None => true,
163 };
164 if matches {
165 return Some(Hover {
166 contents: HoverContents::Markup(MarkupContent {
167 kind: MarkupKind::Markdown,
168 value: format!("`{}{};`", prefix, fqn),
169 }),
170 range: hover_range,
171 });
172 }
173 }
174 }
175 }
176
177 let word = word_at_position(source, position)?;
178
179 if let Some(line_text) = source.lines().nth(position.line as usize)
180 && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
181 {
182 let keyword_doc: Option<&str> = match word.as_str() {
183 "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
184 "null" => Some("`null` — the null value; a variable has no value"),
185 "true" => Some("`true` — boolean true"),
186 "false" => Some("`false` — boolean false"),
187 "abstract" => Some(
188 "`abstract` — declares an abstract class or method that must be implemented by a subclass",
189 ),
190 "readonly" => {
191 Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
192 }
193 "yield" => Some("`yield` — produces a value from a generator function"),
194 "never" => Some(
195 "`never` — return type indicating the function always throws or exits (PHP 8.1)",
196 ),
197 "throw" => {
198 Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
199 }
200 "void" => Some("`void` — return type indicating the function returns no value"),
201 "bool" => Some("`bool` — boolean type: `true` or `false`"),
202 "int" => Some("`int` — integer type"),
203 "float" => Some("`float` — floating-point number type"),
204 "string" => Some("`string` — string type"),
205 "mixed" => Some("`mixed` — any type (no type constraint)"),
206 "object" => Some("`object` — any class instance"),
207 "iterable" => Some("`iterable` — array or Traversable (PHP 7.1)"),
208 "array" => Some("`array` — ordered map type"),
209 "callable" => Some(
210 "`callable` — any callable: Closure, function-name string, or `[object, method]` array",
211 ),
212 "self" => Some("`self` — the class in which the method is defined"),
213 "static" => Some(
214 "`static` — the class on which the method was called (late static binding, PHP 5.3)",
215 ),
216 "parent" => Some("`parent` — the parent class of the current class"),
217 _ => None,
218 };
219 if let Some(doc_str) = keyword_doc {
220 return Some(Hover {
221 contents: HoverContents::Markup(MarkupContent {
222 kind: MarkupKind::Markdown,
223 value: doc_str.to_string(),
224 }),
225 range: hover_range,
226 });
227 }
228 let magic_doc: Option<&str> = match word.as_str() {
229 "__CLASS__" => Some("`__CLASS__` — name of the current class"),
230 "__DIR__" => Some("`__DIR__` — directory of the current file"),
231 "__FILE__" => Some("`__FILE__` — absolute path of the current file"),
232 "__FUNCTION__" => Some("`__FUNCTION__` — name of the current function or closure"),
233 "__LINE__" => Some("`__LINE__` — current line number in the file"),
234 "__METHOD__" => Some("`__METHOD__` — current method name (`ClassName::methodName`)"),
235 "__NAMESPACE__" => Some("`__NAMESPACE__` — name of the current namespace"),
236 "__TRAIT__" => Some("`__TRAIT__` — name of the current trait"),
237 _ => None,
238 };
239 if let Some(doc_str) = magic_doc {
240 return Some(Hover {
241 contents: HoverContents::Markup(MarkupContent {
242 kind: MarkupKind::Markdown,
243 value: doc_str.to_string(),
244 }),
245 range: hover_range,
246 });
247 }
248 }
249
250 if !word.starts_with('$')
251 && let Some(offset) =
252 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))
253 && let Some((callee, label)) = find_named_arg_at(doc, offset)
254 && let Some(value) =
255 named_arg_hover_value(source, doc, other_docs, position, &callee, &label, analysis)
256 {
257 return Some(Hover {
258 contents: HoverContents::Markup(MarkupContent {
259 kind: MarkupKind::Markdown,
260 value,
261 }),
262 range: hover_range,
263 });
264 }
265
266 if word.starts_with('$')
270 && let Some(line_text) = source.lines().nth(position.line as usize)
271 && let Some(class_name) =
272 extract_static_class_before_cursor(line_text, position.character as usize)
273 {
274 let prop_name = word.trim_start_matches('$');
275 let effective_class = if class_name == "self" || class_name == "static" {
276 crate::types::type_map::enclosing_class_at(source, doc, position).unwrap_or(class_name)
277 } else {
278 class_name
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 if is_unresolvable_bareword_at(source, position, &word) {
433 return None;
434 }
435
436 let all_stmts = &*doc.program().stmts as &[_];
437 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
438
439 let current_doc_found =
441 resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
442 .and_then(|d| declaration_signature(&d, &resolved_word))
443 .map(|sig| {
444 let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
445 .map(|db| db.to_markdown())
446 .filter(|md| !md.is_empty());
447 (sig, doc_md)
448 });
449
450 let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
452
453 if let Some((sig, doc_md)) = found {
454 let mut value = wrap_php(&sig);
455 if let Some(md) = doc_md
456 && !md.is_empty()
457 {
458 value.push_str("\n\n---\n\n");
459 value.push_str(&md);
460 }
461 if is_php_builtin(&resolved_word) {
462 value.push_str(&format!(
463 "\n\n[php.net documentation]({})",
464 php_doc_url(&resolved_word)
465 ));
466 }
467 return Some(Hover {
468 contents: HoverContents::Markup(MarkupContent {
469 kind: MarkupKind::Markdown,
470 value,
471 }),
472 range: hover_range,
473 });
474 }
475
476 if is_php_builtin(&resolved_word) {
477 let value = format!(
478 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
479 resolved_word,
480 php_doc_url(&resolved_word)
481 );
482 return Some(Hover {
483 contents: HoverContents::Markup(MarkupContent {
484 kind: MarkupKind::Markdown,
485 value,
486 }),
487 range: hover_range,
488 });
489 }
490
491 if let Some(stub) =
492 session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
493 {
494 return Some(builtin_class_hover(stub, &resolved_word, hover_range));
495 }
496
497 None
498}
499
500fn candidates_for<'a>(
505 fast: &'a Option<Arc<ParsedDoc>>,
506 doc: &'a ParsedDoc,
507 other_docs: &'a [(Uri, Arc<ParsedDoc>)],
508) -> Vec<&'a ParsedDoc> {
509 if let Some(fd) = fast {
510 vec![fd.as_ref()]
511 } else {
512 std::iter::once(doc)
513 .chain(other_docs.iter().map(|(_, d)| d.as_ref()))
514 .collect()
515 }
516}
517
518fn mir_member_hover(
522 sym: &mir_analyzer::ResolvedSymbol,
523 word: &str,
524 doc: &ParsedDoc,
525 other_docs: &[(tower_lsp_server::ls_types::Uri, std::sync::Arc<ParsedDoc>)],
526 find_class_doc: Option<ClassDocLookup<'_>>,
527) -> Option<String> {
528 match &sym.kind {
529 mir_analyzer::ReferenceKind::MethodCall { class, .. }
530 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
531 let class_short = fqn_short_name(class);
532 let fast = find_class_doc.and_then(|f| f(class));
533 let candidates = candidates_for(&fast, doc, other_docs);
534 for d in &candidates {
535 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
536 let sig = augment_return_type(sig, &sym.resolved_type);
538 let mut value = wrap_php(&sig);
539 let docblock_docs = fast
543 .iter()
544 .map(|d| d.as_ref())
545 .chain(std::iter::once(doc))
546 .chain(other_docs.iter().map(|(_, d)| d.as_ref()));
547 if let Some(db) = resolve_method_docblock(docblock_docs, class_short, word) {
548 let md = db.to_markdown();
549 if !md.is_empty() {
550 value.push_str("\n\n---\n\n");
551 value.push_str(&md);
552 }
553 }
554 return Some(value);
555 }
556 }
557 None
558 }
559 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
560 let class_short = fqn_short_name(class);
561 let fast = find_class_doc.and_then(|f| f(class));
562 let candidates = candidates_for(&fast, doc, other_docs);
563 for d in candidates {
564 if let Some((modifiers, declared_type, db)) =
565 find_property_info(d, class_short, property)
566 {
567 let type_str = augment_property_type(declared_type, &sym.resolved_type);
569 let sig = format!(
570 "(property) {}{}::${}{}",
571 modifiers,
572 class_short,
573 property,
574 if type_str.is_empty() {
575 String::new()
576 } else {
577 format!(": {}", type_str)
578 }
579 );
580 let mut value = wrap_php(&sig);
581 if let Some(doc_block) = db {
582 let md = doc_block.to_markdown();
583 if !md.is_empty() {
584 value.push_str("\n\n---\n\n");
585 value.push_str(&md);
586 }
587 }
588 return Some(value);
589 }
590 }
591 let ty_str = format!("{}", sym.resolved_type);
594 if !matches!(ty_str.as_str(), "" | "void" | "never") {
595 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
596 return Some(wrap_php(&sig));
597 }
598 None
599 }
600 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
601 let class_short = fqn_short_name(class);
602 let fast = find_class_doc.and_then(|f| f(class));
603 let candidates = candidates_for(&fast, doc, other_docs);
604 for d in candidates {
605 if let Some(sig) =
606 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
607 {
608 return Some(wrap_php(&sig));
609 }
610 if let Some(sig) =
611 scan_class_const_of_class(&d.program().stmts, class_short, constant)
612 {
613 return Some(wrap_php(&sig));
614 }
615 }
616 None
617 }
618 _ => None,
619 }
620}
621
622fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
626 let ty_str = format!("{resolved}");
627 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
628 return sig;
629 }
630 let Some(paren) = sig.rfind(')') else {
631 return sig;
632 };
633 let rest = &sig[paren + 1..];
634 if let Some(colon_pos) = rest.find(": ") {
635 let declared = rest[colon_pos + 2..].trim();
636 if matches!(declared, "static" | "self" | "parent") {
639 return sig;
640 }
641 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
642 } else {
643 format!("{}: {}", sig, ty_str)
644 }
645}
646
647fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
650 let ty_str = format!("{resolved}");
651 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
652 return declared;
653 }
654 ty_str
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::test_utils::cursor;
661
662 fn pos(line: u32, character: u32) -> Position {
663 Position { line, character }
664 }
665
666 #[test]
667 fn word_at_extracts_from_middle_of_identifier() {
668 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
669 let word = word_at_position(&src, p);
670 assert_eq!(word.as_deref(), Some("greetUser"));
671 }
672
673 #[test]
674 fn hover_on_builtin_class_requires_session() {
675 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
679 let doc = ParsedDoc::parse(src.to_string());
680 let h = hover_info_with_maps(src, &doc, None, pos(1, 12), &[], &[], None, None);
681 assert!(h.is_none(), "built-in class hover requires a session");
682 }
683}