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 fn hover_info_with_maps(
37 source: &str,
38 doc: &ParsedDoc,
39 analysis: Option<&mir_analyzer::FileAnalysis>,
40 position: Position,
41 other_docs: &[(Url, Arc<ParsedDoc>)],
42 other_maps: &[(Url, Arc<SymbolMap>)],
43 session: Option<&mir_analyzer::AnalysisSession>,
44) -> Option<Hover> {
45 hover_at_core(
46 source,
47 doc,
48 analysis,
49 other_docs,
50 position,
51 session,
52 |resolved_word| {
53 for (_, sym_map) in other_maps {
54 if let Some(entry) = sym_map.lookup(resolved_word, |e| is_hoverable_kind(e.kind))
55 && let Some(sig) = &entry.signature
56 {
57 return Some((sig.clone(), entry.doc_markdown.clone()));
58 }
59 }
60 None
61 },
62 )
63}
64
65fn builtin_class_hover(
66 stub: crate::types::type_map::ClassMembers,
67 name: &str,
68 range: Option<tower_lsp::lsp_types::Range>,
69) -> Hover {
70 let method_names: Vec<&str> = stub
71 .methods
72 .iter()
73 .filter(|(_, is_static)| !is_static)
74 .map(|(n, _)| n.as_str())
75 .take(8)
76 .collect();
77 let static_names: Vec<&str> = stub
78 .methods
79 .iter()
80 .filter(|(_, is_static)| *is_static)
81 .map(|(n, _)| n.as_str())
82 .take(4)
83 .collect();
84 let mut lines = vec![format!("**{}** — built-in class", name)];
85 if !method_names.is_empty() {
86 lines.push(format!(
87 "Methods: {}",
88 method_names
89 .iter()
90 .map(|n| format!("`{n}`"))
91 .collect::<Vec<_>>()
92 .join(", ")
93 ));
94 }
95 if !static_names.is_empty() {
96 lines.push(format!(
97 "Static: {}",
98 static_names
99 .iter()
100 .map(|n| format!("`{n}`"))
101 .collect::<Vec<_>>()
102 .join(", ")
103 ));
104 }
105 if let Some(parent) = &stub.parent {
106 lines.push(format!("Extends: `{parent}`"));
107 }
108 Hover {
109 contents: HoverContents::Markup(MarkupContent {
110 kind: MarkupKind::Markdown,
111 value: lines.join("\n\n"),
112 }),
113 range,
114 }
115}
116
117fn hover_at_core(
123 source: &str,
124 doc: &ParsedDoc,
125 analysis: Option<&mir_analyzer::FileAnalysis>,
126 other_docs: &[(Url, Arc<ParsedDoc>)],
127 position: Position,
128 session: Option<&mir_analyzer::AnalysisSession>,
129 resolve_cross_file: impl Fn(&str) -> Option<(String, Option<String>)>,
130) -> Option<Hover> {
131 let hover_range = word_range_at(source, position);
132
133 if let Some(line_text) = source.lines().nth(position.line as usize) {
134 let trimmed = line_text.trim();
135 if trimmed.starts_with("use ") {
136 let (prefix, content) = if trimmed.starts_with("use function ") {
137 (
138 "use function ",
139 trimmed.strip_prefix("use function ").unwrap_or(""),
140 )
141 } else if trimmed.starts_with("use const ") {
142 (
143 "use const ",
144 trimmed.strip_prefix("use const ").unwrap_or(""),
145 )
146 } else {
147 ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
148 };
149 let fqn = content.trim_end_matches(';').trim();
150 if !fqn.is_empty() {
151 let maybe_word = word_at_position(source, position);
152 let alias = fqn_short_name(fqn);
153 let matches = match &maybe_word {
154 Some(w) => w == alias || fqn.contains(w.as_str()),
155 None => true,
156 };
157 if matches {
158 return Some(Hover {
159 contents: HoverContents::Markup(MarkupContent {
160 kind: MarkupKind::Markdown,
161 value: format!("`{}{};`", prefix, fqn),
162 }),
163 range: hover_range,
164 });
165 }
166 }
167 }
168 }
169
170 let word = word_at_position(source, position)?;
171
172 if let Some(line_text) = source.lines().nth(position.line as usize)
173 && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
174 {
175 let keyword_doc: Option<&str> = match word.as_str() {
176 "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
177 "null" => Some("`null` — the null value; a variable has no value"),
178 "true" => Some("`true` — boolean true"),
179 "false" => Some("`false` — boolean false"),
180 "abstract" => Some(
181 "`abstract` — declares an abstract class or method that must be implemented by a subclass",
182 ),
183 "readonly" => {
184 Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
185 }
186 "yield" => Some("`yield` — produces a value from a generator function"),
187 "never" => Some(
188 "`never` — return type indicating the function always throws or exits (PHP 8.1)",
189 ),
190 "throw" => {
191 Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
192 }
193 _ => None,
194 };
195 if let Some(doc_str) = keyword_doc {
196 return Some(Hover {
197 contents: HoverContents::Markup(MarkupContent {
198 kind: MarkupKind::Markdown,
199 value: doc_str.to_string(),
200 }),
201 range: hover_range,
202 });
203 }
204 }
205
206 let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
207 let type_map =
208 || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
209
210 if let Some(line_text) = source.lines().nth(position.line as usize)
211 && !word.starts_with('$')
212 && is_named_arg_at(line_text, position.character as usize, &word)
213 && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
214 && let Some(value) = named_arg_hover_value(
215 source,
216 doc,
217 other_docs,
218 position,
219 &callee,
220 &word,
221 analysis,
222 &type_map_cell,
223 )
224 {
225 return Some(Hover {
226 contents: HoverContents::Markup(MarkupContent {
227 kind: MarkupKind::Markdown,
228 value,
229 }),
230 range: hover_range,
231 });
232 }
233
234 if word.starts_with('$') {
235 let mir_ty = analysis.and_then(|a| {
238 let off =
239 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
240 crate::types::type_query::type_at_offset(a, off)
241 });
242
243 if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
245 {
246 return Some(Hover {
247 contents: HoverContents::Markup(MarkupContent {
248 kind: MarkupKind::Markdown,
249 value: format!("`{word}` `{ty}`"),
250 }),
251 range: hover_range,
252 });
253 }
254 if let Some(class_name) = type_map().get(&word) {
257 return Some(Hover {
258 contents: HoverContents::Markup(MarkupContent {
259 kind: MarkupKind::Markdown,
260 value: format!("`{word}` `{class_name}`"),
261 }),
262 range: hover_range,
263 });
264 }
265 if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
269 return Some(Hover {
270 contents: HoverContents::Markup(MarkupContent {
271 kind: MarkupKind::Markdown,
272 value: format!("`{word}` `{ty}`"),
273 }),
274 range: hover_range,
275 });
276 }
277 }
278
279 if word.starts_with('$')
280 && let Some(line_text) = source.lines().nth(position.line as usize)
281 && let Some(class_name) =
282 extract_static_class_before_cursor(line_text, position.character as usize)
283 {
284 let prop_name = word.trim_start_matches('$');
285 let effective_class = if class_name == "self" || class_name == "static" {
286 crate::types::type_map::enclosing_class_at(source, doc, position)
287 .unwrap_or(class_name.clone())
288 } else {
289 class_name.clone()
290 };
291 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
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('$')
329 && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
330 && let prop_name = word.trim_start_matches('$')
331 && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
332 {
333 let sig = format!(
334 "(property) {}{}::${}{}",
335 modifiers,
336 class_name,
337 prop_name,
338 if type_str.is_empty() {
339 String::new()
340 } else {
341 format!(": {type_str}")
342 }
343 );
344 let mut value = wrap_php(&sig);
345 if let Some(doc_block) = db {
346 let md = doc_block.to_markdown();
347 if !md.is_empty() {
348 value.push_str("\n\n---\n\n");
349 value.push_str(&md);
350 }
351 }
352 return Some(Hover {
353 contents: HoverContents::Markup(MarkupContent {
354 kind: MarkupKind::Markdown,
355 value,
356 }),
357 range: hover_range,
358 });
359 }
360
361 if !word.starts_with('$')
362 && let Some(sym) = analysis.and_then(|a| {
363 let off =
364 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
365 a.symbol_at(off)
366 })
367 {
368 let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
369 if mir_hover.is_some() {
370 return mir_hover.map(|value| Hover {
371 contents: HoverContents::Markup(MarkupContent {
372 kind: MarkupKind::Markdown,
373 value,
374 }),
375 range: hover_range,
376 });
377 }
378 }
379
380 if (word == "function" || word == "fn")
381 && let Some(sig) = closure_hover(source, doc, position, &word)
382 {
383 return Some(Hover {
384 contents: HoverContents::Markup(MarkupContent {
385 kind: MarkupKind::Markdown,
386 value: wrap_php(&sig),
387 }),
388 range: hover_range,
389 });
390 }
391
392 let all_stmts = &*doc.program().stmts as &[_];
393 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
394
395 let current_doc_found =
397 resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
398 .and_then(|d| declaration_signature(&d, &resolved_word))
399 .map(|sig| {
400 let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
401 .map(|db| db.to_markdown())
402 .filter(|md| !md.is_empty());
403 (sig, doc_md)
404 });
405
406 let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
408
409 if let Some((sig, doc_md)) = found {
410 let mut value = wrap_php(&sig);
411 if let Some(md) = doc_md
412 && !md.is_empty()
413 {
414 value.push_str("\n\n---\n\n");
415 value.push_str(&md);
416 }
417 if is_php_builtin(&resolved_word) {
418 value.push_str(&format!(
419 "\n\n[php.net documentation]({})",
420 php_doc_url(&resolved_word)
421 ));
422 }
423 return Some(Hover {
424 contents: HoverContents::Markup(MarkupContent {
425 kind: MarkupKind::Markdown,
426 value,
427 }),
428 range: hover_range,
429 });
430 }
431
432 if is_php_builtin(&resolved_word) {
433 let value = format!(
434 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
435 resolved_word,
436 php_doc_url(&resolved_word)
437 );
438 return Some(Hover {
439 contents: HoverContents::Markup(MarkupContent {
440 kind: MarkupKind::Markdown,
441 value,
442 }),
443 range: hover_range,
444 });
445 }
446
447 if let Some(stub) =
448 session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
449 {
450 return Some(builtin_class_hover(stub, &resolved_word, hover_range));
451 }
452
453 None
454}
455
456fn mir_member_hover(
460 sym: &mir_analyzer::ResolvedSymbol,
461 word: &str,
462 doc: &ParsedDoc,
463 other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
464) -> Option<String> {
465 let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
466 match &sym.kind {
467 mir_analyzer::ReferenceKind::MethodCall { class, .. }
468 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
469 let class_short = fqn_short_name(class);
470 for d in docs() {
471 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
472 let sig = augment_return_type(sig, &sym.resolved_type);
474 let mut value = wrap_php(&sig);
475 let all =
476 std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
477 if let Some(db) = resolve_method_docblock(all, class_short, word) {
478 let md = db.to_markdown();
479 if !md.is_empty() {
480 value.push_str("\n\n---\n\n");
481 value.push_str(&md);
482 }
483 }
484 return Some(value);
485 }
486 }
487 None
488 }
489 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
490 let class_short = fqn_short_name(class);
491 for d in docs() {
492 if let Some((modifiers, declared_type, db)) =
493 find_property_info(d, class_short, property)
494 {
495 let type_str = augment_property_type(declared_type, &sym.resolved_type);
497 let sig = format!(
498 "(property) {}{}::${}{}",
499 modifiers,
500 class_short,
501 property,
502 if type_str.is_empty() {
503 String::new()
504 } else {
505 format!(": {}", type_str)
506 }
507 );
508 let mut value = wrap_php(&sig);
509 if let Some(doc_block) = db {
510 let md = doc_block.to_markdown();
511 if !md.is_empty() {
512 value.push_str("\n\n---\n\n");
513 value.push_str(&md);
514 }
515 }
516 return Some(value);
517 }
518 }
519 let ty_str = format!("{}", sym.resolved_type);
522 if !matches!(ty_str.as_str(), "" | "void" | "never") {
523 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
524 return Some(wrap_php(&sig));
525 }
526 None
527 }
528 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
529 let class_short = fqn_short_name(class);
530 for d in docs() {
531 if let Some(sig) =
532 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
533 {
534 return Some(wrap_php(&sig));
535 }
536 if let Some(sig) =
537 scan_class_const_of_class(&d.program().stmts, class_short, constant)
538 {
539 return Some(wrap_php(&sig));
540 }
541 }
542 None
543 }
544 _ => None,
545 }
546}
547
548fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
552 let ty_str = format!("{resolved}");
553 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
554 return sig;
555 }
556 let Some(paren) = sig.rfind(')') else {
557 return sig;
558 };
559 let rest = &sig[paren + 1..];
560 if let Some(colon_pos) = rest.find(": ") {
561 let declared = rest[colon_pos + 2..].trim();
562 if matches!(declared, "static" | "self" | "parent") {
565 return sig;
566 }
567 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
568 } else {
569 format!("{}: {}", sig, ty_str)
570 }
571}
572
573fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
576 let ty_str = format!("{resolved}");
577 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
578 return declared;
579 }
580 ty_str
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::test_utils::cursor;
587
588 fn pos(line: u32, character: u32) -> Position {
589 Position { line, character }
590 }
591
592 #[test]
593 fn word_at_extracts_from_middle_of_identifier() {
594 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
595 let word = word_at_position(&src, p);
596 assert_eq!(word.as_deref(), Some("greetUser"));
597 }
598
599 #[test]
600 fn hover_on_builtin_class_requires_session() {
601 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
605 let doc = ParsedDoc::parse(src.to_string());
606 let h = hover_info_with_maps(src, &doc, None, pos(1, 12), &[], &[], None);
607 assert!(h.is_none(), "built-in class hover requires a session");
608 }
609}