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 if let Some(ty) = analysis.and_then(|a| {
280 let off =
281 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
282 crate::types::type_query::type_at_offset(a, off)
283 }) && !crate::types::type_query::class_names(ty).is_empty()
284 {
285 return Some(Hover {
286 contents: HoverContents::Markup(MarkupContent {
287 kind: MarkupKind::Markdown,
288 value: format!("`{word}` `{ty}`"),
289 }),
290 range: hover_range,
291 });
292 }
293 if let Some(class_name) = type_map().get(&word) {
294 return Some(Hover {
295 contents: HoverContents::Markup(MarkupContent {
296 kind: MarkupKind::Markdown,
297 value: format!("`{}` `{}`", word, class_name),
298 }),
299 range: hover_range,
300 });
301 }
302 }
303
304 if word.starts_with('$')
305 && let Some(line_text) = source.lines().nth(position.line as usize)
306 && let Some(class_name) =
307 extract_static_class_before_cursor(line_text, position.character as usize)
308 {
309 let prop_name = word.trim_start_matches('$');
310 let effective_class = if class_name == "self" || class_name == "static" {
311 crate::types::type_map::enclosing_class_at(source, doc, position)
312 .unwrap_or(class_name.clone())
313 } else {
314 class_name.clone()
315 };
316 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
317 if let Some((modifiers, type_str, db)) =
318 find_property_info(d, &effective_class, prop_name)
319 {
320 let sig = format!(
321 "(property) {}{}::${}{}",
322 modifiers,
323 effective_class,
324 prop_name,
325 if type_str.is_empty() {
326 String::new()
327 } else {
328 format!(": {}", type_str)
329 }
330 );
331 let mut value = wrap_php(&sig);
332 if let Some(doc) = db {
333 let md = doc.to_markdown();
334 if !md.is_empty() {
335 value.push_str("\n\n---\n\n");
336 value.push_str(&md);
337 }
338 }
339 return Some(Hover {
340 contents: HoverContents::Markup(MarkupContent {
341 kind: MarkupKind::Markdown,
342 value,
343 }),
344 range: hover_range,
345 });
346 }
347 }
348 }
349
350 if word.starts_with('$')
354 && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
355 && let prop_name = word.trim_start_matches('$')
356 && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
357 {
358 let sig = format!(
359 "(property) {}{}::${}{}",
360 modifiers,
361 class_name,
362 prop_name,
363 if type_str.is_empty() {
364 String::new()
365 } else {
366 format!(": {type_str}")
367 }
368 );
369 let mut value = wrap_php(&sig);
370 if let Some(doc_block) = db {
371 let md = doc_block.to_markdown();
372 if !md.is_empty() {
373 value.push_str("\n\n---\n\n");
374 value.push_str(&md);
375 }
376 }
377 return Some(Hover {
378 contents: HoverContents::Markup(MarkupContent {
379 kind: MarkupKind::Markdown,
380 value,
381 }),
382 range: hover_range,
383 });
384 }
385
386 if !word.starts_with('$')
387 && let Some(sym) = analysis.and_then(|a| {
388 let off =
389 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
390 a.symbol_at(off)
391 })
392 {
393 let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
394 if mir_hover.is_some() {
395 return mir_hover.map(|value| Hover {
396 contents: HoverContents::Markup(MarkupContent {
397 kind: MarkupKind::Markdown,
398 value,
399 }),
400 range: hover_range,
401 });
402 }
403 }
404
405 if (word == "function" || word == "fn")
406 && let Some(sig) = closure_hover(source, doc, position, &word)
407 {
408 return Some(Hover {
409 contents: HoverContents::Markup(MarkupContent {
410 kind: MarkupKind::Markdown,
411 value: wrap_php(&sig),
412 }),
413 range: hover_range,
414 });
415 }
416
417 let all_stmts = &*doc.program().stmts as &[_];
418 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
419
420 let current_doc_found =
422 resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
423 .and_then(|d| declaration_signature(&d, &resolved_word))
424 .map(|sig| {
425 let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
426 .map(|db| db.to_markdown())
427 .filter(|md| !md.is_empty());
428 (sig, doc_md)
429 });
430
431 let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
433
434 if let Some((sig, doc_md)) = found {
435 let mut value = wrap_php(&sig);
436 if let Some(md) = doc_md
437 && !md.is_empty()
438 {
439 value.push_str("\n\n---\n\n");
440 value.push_str(&md);
441 }
442 if is_php_builtin(&resolved_word) {
443 value.push_str(&format!(
444 "\n\n[php.net documentation]({})",
445 php_doc_url(&resolved_word)
446 ));
447 }
448 return Some(Hover {
449 contents: HoverContents::Markup(MarkupContent {
450 kind: MarkupKind::Markdown,
451 value,
452 }),
453 range: hover_range,
454 });
455 }
456
457 if is_php_builtin(&resolved_word) {
458 let value = format!(
459 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
460 resolved_word,
461 php_doc_url(&resolved_word)
462 );
463 return Some(Hover {
464 contents: HoverContents::Markup(MarkupContent {
465 kind: MarkupKind::Markdown,
466 value,
467 }),
468 range: hover_range,
469 });
470 }
471
472 if let Some(stub) =
473 session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
474 {
475 return Some(builtin_class_hover(stub, &resolved_word, hover_range));
476 }
477
478 None
479}
480
481fn mir_member_hover(
485 sym: &mir_analyzer::ResolvedSymbol,
486 word: &str,
487 doc: &ParsedDoc,
488 other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
489) -> Option<String> {
490 let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
491 match &sym.kind {
492 mir_analyzer::ReferenceKind::MethodCall { class, .. }
493 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
494 let class_short = fqn_short_name(class);
495 for d in docs() {
496 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
497 let sig = augment_return_type(sig, &sym.resolved_type);
499 let mut value = wrap_php(&sig);
500 let all =
501 std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
502 if let Some(db) = resolve_method_docblock(all, class_short, word) {
503 let md = db.to_markdown();
504 if !md.is_empty() {
505 value.push_str("\n\n---\n\n");
506 value.push_str(&md);
507 }
508 }
509 return Some(value);
510 }
511 }
512 None
513 }
514 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
515 let class_short = fqn_short_name(class);
516 for d in docs() {
517 if let Some((modifiers, declared_type, db)) =
518 find_property_info(d, class_short, property)
519 {
520 let type_str = augment_property_type(declared_type, &sym.resolved_type);
522 let sig = format!(
523 "(property) {}{}::${}{}",
524 modifiers,
525 class_short,
526 property,
527 if type_str.is_empty() {
528 String::new()
529 } else {
530 format!(": {}", type_str)
531 }
532 );
533 let mut value = wrap_php(&sig);
534 if let Some(doc_block) = db {
535 let md = doc_block.to_markdown();
536 if !md.is_empty() {
537 value.push_str("\n\n---\n\n");
538 value.push_str(&md);
539 }
540 }
541 return Some(value);
542 }
543 }
544 let ty_str = format!("{}", sym.resolved_type);
547 if !matches!(ty_str.as_str(), "" | "void" | "never") {
548 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
549 return Some(wrap_php(&sig));
550 }
551 None
552 }
553 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
554 let class_short = fqn_short_name(class);
555 for d in docs() {
556 if let Some(sig) =
557 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
558 {
559 return Some(wrap_php(&sig));
560 }
561 if let Some(sig) =
562 scan_class_const_of_class(&d.program().stmts, class_short, constant)
563 {
564 return Some(wrap_php(&sig));
565 }
566 }
567 None
568 }
569 _ => None,
570 }
571}
572
573fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
577 let ty_str = format!("{resolved}");
578 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
579 return sig;
580 }
581 let Some(paren) = sig.rfind(')') else {
582 return sig;
583 };
584 let rest = &sig[paren + 1..];
585 if let Some(colon_pos) = rest.find(": ") {
586 let declared = rest[colon_pos + 2..].trim();
587 if matches!(declared, "static" | "self" | "parent") {
590 return sig;
591 }
592 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
593 } else {
594 format!("{}: {}", sig, ty_str)
595 }
596}
597
598fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
601 let ty_str = format!("{resolved}");
602 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
603 return declared;
604 }
605 ty_str
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::test_utils::cursor;
612
613 fn pos(line: u32, character: u32) -> Position {
614 Position { line, character }
615 }
616
617 #[test]
618 fn word_at_extracts_from_middle_of_identifier() {
619 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
620 let word = word_at_position(&src, p);
621 assert_eq!(word.as_deref(), Some("greetUser"));
622 }
623
624 #[test]
625 fn hover_on_builtin_class_requires_session() {
626 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
630 let doc = ParsedDoc::parse(src.to_string());
631 let h = hover_at(src, &doc, None, &[], pos(1, 12), None);
632 assert!(h.is_none(), "built-in class hover requires a session");
633 }
634}