1use std::cell::OnceCell;
2use std::sync::Arc;
3
4use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position, Url};
5
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};
12use crate::types::type_map::TypeMap;
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::{extract_named_arg_callee, is_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
32pub(crate) fn hover_info(
33 source: &str,
34 doc: &ParsedDoc,
35 analysis: Option<&mir_analyzer::FileAnalysis>,
36 position: Position,
37 other_docs: &[(Url, Arc<ParsedDoc>)],
38) -> Option<Hover> {
39 hover_at(source, doc, analysis, other_docs, position, None)
40}
41
42pub fn hover_info_with_maps(
47 source: &str,
48 doc: &ParsedDoc,
49 analysis: Option<&mir_analyzer::FileAnalysis>,
50 position: Position,
51 other_docs: &[(Url, Arc<ParsedDoc>)],
52 other_maps: &[(Url, Arc<SymbolMap>)],
53 session: Option<&mir_analyzer::AnalysisSession>,
54) -> Option<Hover> {
55 hover_at_core(
56 source,
57 doc,
58 analysis,
59 other_docs,
60 position,
61 session,
62 |resolved_word| {
63 for (_, sym_map) in other_maps {
64 if let Some(entry) = sym_map.lookup(resolved_word, |e| is_hoverable_kind(e.kind))
65 && let Some(sig) = &entry.signature
66 {
67 return Some((sig.clone(), entry.doc_markdown.clone()));
68 }
69 }
70 None
71 },
72 )
73}
74
75pub fn hover_at(
78 source: &str,
79 doc: &ParsedDoc,
80 analysis: Option<&mir_analyzer::FileAnalysis>,
81 other_docs: &[(Url, Arc<ParsedDoc>)],
82 position: Position,
83 session: Option<&mir_analyzer::AnalysisSession>,
84) -> Option<Hover> {
85 hover_at_core(
86 source,
87 doc,
88 analysis,
89 other_docs,
90 position,
91 session,
92 |resolved_word| {
93 for (_, other) in other_docs {
94 if let Some(sig) =
95 resolve_declaration(&other.program().stmts, resolved_word, &is_hoverable)
96 .and_then(|d| declaration_signature(&d, resolved_word))
97 {
98 let md = find_docblock(&other.program().stmts, resolved_word)
99 .map(|db| db.to_markdown())
100 .filter(|md| !md.is_empty());
101 return Some((sig, md));
102 }
103 }
104 None
105 },
106 )
107}
108
109fn builtin_class_hover(
110 stub: crate::types::type_map::ClassMembers,
111 name: &str,
112 range: Option<tower_lsp::lsp_types::Range>,
113) -> Hover {
114 let method_names: Vec<&str> = stub
115 .methods
116 .iter()
117 .filter(|(_, is_static)| !is_static)
118 .map(|(n, _)| n.as_str())
119 .take(8)
120 .collect();
121 let static_names: Vec<&str> = stub
122 .methods
123 .iter()
124 .filter(|(_, is_static)| *is_static)
125 .map(|(n, _)| n.as_str())
126 .take(4)
127 .collect();
128 let mut lines = vec![format!("**{}** — built-in class", name)];
129 if !method_names.is_empty() {
130 lines.push(format!(
131 "Methods: {}",
132 method_names
133 .iter()
134 .map(|n| format!("`{n}`"))
135 .collect::<Vec<_>>()
136 .join(", ")
137 ));
138 }
139 if !static_names.is_empty() {
140 lines.push(format!(
141 "Static: {}",
142 static_names
143 .iter()
144 .map(|n| format!("`{n}`"))
145 .collect::<Vec<_>>()
146 .join(", ")
147 ));
148 }
149 if let Some(parent) = &stub.parent {
150 lines.push(format!("Extends: `{parent}`"));
151 }
152 Hover {
153 contents: HoverContents::Markup(MarkupContent {
154 kind: MarkupKind::Markdown,
155 value: lines.join("\n\n"),
156 }),
157 range,
158 }
159}
160
161fn hover_at_core(
167 source: &str,
168 doc: &ParsedDoc,
169 analysis: Option<&mir_analyzer::FileAnalysis>,
170 other_docs: &[(Url, Arc<ParsedDoc>)],
171 position: Position,
172 session: Option<&mir_analyzer::AnalysisSession>,
173 resolve_cross_file: impl Fn(&str) -> Option<(String, Option<String>)>,
174) -> Option<Hover> {
175 let hover_range = word_range_at(source, position);
176
177 if let Some(line_text) = source.lines().nth(position.line as usize) {
178 let trimmed = line_text.trim();
179 if trimmed.starts_with("use ") {
180 let (prefix, content) = if trimmed.starts_with("use function ") {
181 (
182 "use function ",
183 trimmed.strip_prefix("use function ").unwrap_or(""),
184 )
185 } else if trimmed.starts_with("use const ") {
186 (
187 "use const ",
188 trimmed.strip_prefix("use const ").unwrap_or(""),
189 )
190 } else {
191 ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
192 };
193 let fqn = content.trim_end_matches(';').trim();
194 if !fqn.is_empty() {
195 let maybe_word = word_at_position(source, position);
196 let alias = fqn_short_name(fqn);
197 let matches = match &maybe_word {
198 Some(w) => w == alias || fqn.contains(w.as_str()),
199 None => true,
200 };
201 if matches {
202 return Some(Hover {
203 contents: HoverContents::Markup(MarkupContent {
204 kind: MarkupKind::Markdown,
205 value: format!("`{}{};`", prefix, fqn),
206 }),
207 range: hover_range,
208 });
209 }
210 }
211 }
212 }
213
214 let word = word_at_position(source, position)?;
215
216 if let Some(line_text) = source.lines().nth(position.line as usize)
217 && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
218 {
219 let keyword_doc: Option<&str> = match word.as_str() {
220 "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
221 "null" => Some("`null` — the null value; a variable has no value"),
222 "true" => Some("`true` — boolean true"),
223 "false" => Some("`false` — boolean false"),
224 "abstract" => Some(
225 "`abstract` — declares an abstract class or method that must be implemented by a subclass",
226 ),
227 "readonly" => {
228 Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
229 }
230 "yield" => Some("`yield` — produces a value from a generator function"),
231 "never" => Some(
232 "`never` — return type indicating the function always throws or exits (PHP 8.1)",
233 ),
234 "throw" => {
235 Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
236 }
237 _ => None,
238 };
239 if let Some(doc_str) = keyword_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 let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
251 let type_map =
252 || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
253
254 if let Some(line_text) = source.lines().nth(position.line as usize)
255 && !word.starts_with('$')
256 && is_named_arg_at(line_text, position.character as usize, &word)
257 && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
258 && let Some(value) = named_arg_hover_value(
259 source,
260 doc,
261 other_docs,
262 position,
263 &callee,
264 &word,
265 analysis,
266 &type_map_cell,
267 )
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('$') {
279 let mir_ty = analysis.and_then(|a| {
282 let off =
283 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
284 crate::types::type_query::type_at_offset(a, off)
285 });
286
287 if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
289 {
290 return Some(Hover {
291 contents: HoverContents::Markup(MarkupContent {
292 kind: MarkupKind::Markdown,
293 value: format!("`{word}` `{ty}`"),
294 }),
295 range: hover_range,
296 });
297 }
298 if let Some(class_name) = type_map().get(&word) {
301 return Some(Hover {
302 contents: HoverContents::Markup(MarkupContent {
303 kind: MarkupKind::Markdown,
304 value: format!("`{word}` `{class_name}`"),
305 }),
306 range: hover_range,
307 });
308 }
309 if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
313 return Some(Hover {
314 contents: HoverContents::Markup(MarkupContent {
315 kind: MarkupKind::Markdown,
316 value: format!("`{word}` `{ty}`"),
317 }),
318 range: hover_range,
319 });
320 }
321 }
322
323 if word.starts_with('$')
324 && let Some(line_text) = source.lines().nth(position.line as usize)
325 && let Some(class_name) =
326 extract_static_class_before_cursor(line_text, position.character as usize)
327 {
328 let prop_name = word.trim_start_matches('$');
329 let effective_class = if class_name == "self" || class_name == "static" {
330 crate::types::type_map::enclosing_class_at(source, doc, position)
331 .unwrap_or(class_name.clone())
332 } else {
333 class_name.clone()
334 };
335 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
336 if let Some((modifiers, type_str, db)) =
337 find_property_info(d, &effective_class, prop_name)
338 {
339 let sig = format!(
340 "(property) {}{}::${}{}",
341 modifiers,
342 effective_class,
343 prop_name,
344 if type_str.is_empty() {
345 String::new()
346 } else {
347 format!(": {}", type_str)
348 }
349 );
350 let mut value = wrap_php(&sig);
351 if let Some(doc) = db {
352 let md = doc.to_markdown();
353 if !md.is_empty() {
354 value.push_str("\n\n---\n\n");
355 value.push_str(&md);
356 }
357 }
358 return Some(Hover {
359 contents: HoverContents::Markup(MarkupContent {
360 kind: MarkupKind::Markdown,
361 value,
362 }),
363 range: hover_range,
364 });
365 }
366 }
367 }
368
369 if word.starts_with('$')
373 && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
374 && let prop_name = word.trim_start_matches('$')
375 && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
376 {
377 let sig = format!(
378 "(property) {}{}::${}{}",
379 modifiers,
380 class_name,
381 prop_name,
382 if type_str.is_empty() {
383 String::new()
384 } else {
385 format!(": {type_str}")
386 }
387 );
388 let mut value = wrap_php(&sig);
389 if let Some(doc_block) = db {
390 let md = doc_block.to_markdown();
391 if !md.is_empty() {
392 value.push_str("\n\n---\n\n");
393 value.push_str(&md);
394 }
395 }
396 return Some(Hover {
397 contents: HoverContents::Markup(MarkupContent {
398 kind: MarkupKind::Markdown,
399 value,
400 }),
401 range: hover_range,
402 });
403 }
404
405 if !word.starts_with('$')
406 && let Some(sym) = analysis.and_then(|a| {
407 let off =
408 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
409 a.symbol_at(off)
410 })
411 {
412 let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
413 if mir_hover.is_some() {
414 return mir_hover.map(|value| Hover {
415 contents: HoverContents::Markup(MarkupContent {
416 kind: MarkupKind::Markdown,
417 value,
418 }),
419 range: hover_range,
420 });
421 }
422 }
423
424 if (word == "function" || word == "fn")
425 && let Some(sig) = closure_hover(source, doc, position, &word)
426 {
427 return Some(Hover {
428 contents: HoverContents::Markup(MarkupContent {
429 kind: MarkupKind::Markdown,
430 value: wrap_php(&sig),
431 }),
432 range: hover_range,
433 });
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 mir_member_hover(
504 sym: &mir_analyzer::ResolvedSymbol,
505 word: &str,
506 doc: &ParsedDoc,
507 other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
508) -> Option<String> {
509 let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
510 match &sym.kind {
511 mir_analyzer::ReferenceKind::MethodCall { class, .. }
512 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
513 let class_short = fqn_short_name(class);
514 for d in docs() {
515 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
516 let sig = augment_return_type(sig, &sym.resolved_type);
518 let mut value = wrap_php(&sig);
519 let all =
520 std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
521 if let Some(db) = resolve_method_docblock(all, class_short, word) {
522 let md = db.to_markdown();
523 if !md.is_empty() {
524 value.push_str("\n\n---\n\n");
525 value.push_str(&md);
526 }
527 }
528 return Some(value);
529 }
530 }
531 None
532 }
533 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
534 let class_short = fqn_short_name(class);
535 for d in docs() {
536 if let Some((modifiers, declared_type, db)) =
537 find_property_info(d, class_short, property)
538 {
539 let type_str = augment_property_type(declared_type, &sym.resolved_type);
541 let sig = format!(
542 "(property) {}{}::${}{}",
543 modifiers,
544 class_short,
545 property,
546 if type_str.is_empty() {
547 String::new()
548 } else {
549 format!(": {}", type_str)
550 }
551 );
552 let mut value = wrap_php(&sig);
553 if let Some(doc_block) = db {
554 let md = doc_block.to_markdown();
555 if !md.is_empty() {
556 value.push_str("\n\n---\n\n");
557 value.push_str(&md);
558 }
559 }
560 return Some(value);
561 }
562 }
563 let ty_str = format!("{}", sym.resolved_type);
566 if !matches!(ty_str.as_str(), "" | "void" | "never") {
567 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
568 return Some(wrap_php(&sig));
569 }
570 None
571 }
572 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
573 let class_short = fqn_short_name(class);
574 for d in docs() {
575 if let Some(sig) =
576 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
577 {
578 return Some(wrap_php(&sig));
579 }
580 if let Some(sig) =
581 scan_class_const_of_class(&d.program().stmts, class_short, constant)
582 {
583 return Some(wrap_php(&sig));
584 }
585 }
586 None
587 }
588 _ => None,
589 }
590}
591
592fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
596 let ty_str = format!("{resolved}");
597 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
598 return sig;
599 }
600 let Some(paren) = sig.rfind(')') else {
601 return sig;
602 };
603 let rest = &sig[paren + 1..];
604 if let Some(colon_pos) = rest.find(": ") {
605 let declared = rest[colon_pos + 2..].trim();
606 if matches!(declared, "static" | "self" | "parent") {
609 return sig;
610 }
611 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
612 } else {
613 format!("{}: {}", sig, ty_str)
614 }
615}
616
617fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
620 let ty_str = format!("{resolved}");
621 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
622 return declared;
623 }
624 ty_str
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use crate::test_utils::cursor;
631
632 fn pos(line: u32, character: u32) -> Position {
633 Position { line, character }
634 }
635
636 #[test]
637 fn word_at_extracts_from_middle_of_identifier() {
638 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
639 let word = word_at_position(&src, p);
640 assert_eq!(word.as_deref(), Some("greetUser"));
641 }
642
643 #[test]
644 fn hover_on_builtin_class_requires_session() {
645 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
649 let doc = ParsedDoc::parse(src.to_string());
650 let h = hover_at(src, &doc, None, &[], pos(1, 12), None);
651 assert!(h.is_none(), "built-in class hover requires a session");
652 }
653}