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 "void" => Some("`void` — return type indicating the function returns no value"),
194 "bool" => Some("`bool` — boolean type: `true` or `false`"),
195 "int" => Some("`int` — integer type"),
196 "float" => Some("`float` — floating-point number type"),
197 "string" => Some("`string` — string type"),
198 "mixed" => Some("`mixed` — any type (no type constraint)"),
199 "object" => Some("`object` — any class instance"),
200 "iterable" => Some("`iterable` — array or Traversable (PHP 7.1)"),
201 "array" => Some("`array` — ordered map type"),
202 "callable" => Some(
203 "`callable` — any callable: Closure, function-name string, or `[object, method]` array",
204 ),
205 "self" => Some("`self` — the class in which the method is defined"),
206 "static" => Some(
207 "`static` — the class on which the method was called (late static binding, PHP 5.3)",
208 ),
209 "parent" => Some("`parent` — the parent class of the current class"),
210 _ => None,
211 };
212 if let Some(doc_str) = keyword_doc {
213 return Some(Hover {
214 contents: HoverContents::Markup(MarkupContent {
215 kind: MarkupKind::Markdown,
216 value: doc_str.to_string(),
217 }),
218 range: hover_range,
219 });
220 }
221 let magic_doc: Option<&str> = match word.as_str() {
222 "__CLASS__" => Some("`__CLASS__` — name of the current class"),
223 "__DIR__" => Some("`__DIR__` — directory of the current file"),
224 "__FILE__" => Some("`__FILE__` — absolute path of the current file"),
225 "__FUNCTION__" => Some("`__FUNCTION__` — name of the current function or closure"),
226 "__LINE__" => Some("`__LINE__` — current line number in the file"),
227 "__METHOD__" => Some("`__METHOD__` — current method name (`ClassName::methodName`)"),
228 "__NAMESPACE__" => Some("`__NAMESPACE__` — name of the current namespace"),
229 "__TRAIT__" => Some("`__TRAIT__` — name of the current trait"),
230 _ => None,
231 };
232 if let Some(doc_str) = magic_doc {
233 return Some(Hover {
234 contents: HoverContents::Markup(MarkupContent {
235 kind: MarkupKind::Markdown,
236 value: doc_str.to_string(),
237 }),
238 range: hover_range,
239 });
240 }
241 }
242
243 let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
244 let type_map =
245 || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
246
247 if let Some(line_text) = source.lines().nth(position.line as usize)
248 && !word.starts_with('$')
249 && is_named_arg_at(line_text, position.character as usize, &word)
250 && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
251 && let Some(value) = named_arg_hover_value(
252 source,
253 doc,
254 other_docs,
255 position,
256 &callee,
257 &word,
258 analysis,
259 &type_map_cell,
260 )
261 {
262 return Some(Hover {
263 contents: HoverContents::Markup(MarkupContent {
264 kind: MarkupKind::Markdown,
265 value,
266 }),
267 range: hover_range,
268 });
269 }
270
271 if word.starts_with('$')
275 && let Some(line_text) = source.lines().nth(position.line as usize)
276 && let Some(class_name) =
277 extract_static_class_before_cursor(line_text, position.character as usize)
278 {
279 let prop_name = word.trim_start_matches('$');
280 let effective_class = if class_name == "self" || class_name == "static" {
281 crate::types::type_map::enclosing_class_at(source, doc, position)
282 .unwrap_or(class_name.clone())
283 } else {
284 class_name.clone()
285 };
286 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
287 if let Some((modifiers, type_str, db)) =
288 find_property_info(d, &effective_class, prop_name)
289 {
290 let sig = format!(
291 "(property) {}{}::${}{}",
292 modifiers,
293 effective_class,
294 prop_name,
295 if type_str.is_empty() {
296 String::new()
297 } else {
298 format!(": {}", type_str)
299 }
300 );
301 let mut value = wrap_php(&sig);
302 if let Some(doc) = db {
303 let md = doc.to_markdown();
304 if !md.is_empty() {
305 value.push_str("\n\n---\n\n");
306 value.push_str(&md);
307 }
308 }
309 return Some(Hover {
310 contents: HoverContents::Markup(MarkupContent {
311 kind: MarkupKind::Markdown,
312 value,
313 }),
314 range: hover_range,
315 });
316 }
317 }
318 }
319
320 if word.starts_with('$') {
321 let mir_ty = analysis.and_then(|a| {
324 let off =
325 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
326 crate::types::type_query::type_at_offset(a, off)
327 });
328
329 if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
331 {
332 return Some(Hover {
333 contents: HoverContents::Markup(MarkupContent {
334 kind: MarkupKind::Markdown,
335 value: format!("`{word}` `{ty}`"),
336 }),
337 range: hover_range,
338 });
339 }
340 if let Some(class_name) = type_map().get(&word) {
343 return Some(Hover {
344 contents: HoverContents::Markup(MarkupContent {
345 kind: MarkupKind::Markdown,
346 value: format!("`{word}` `{class_name}`"),
347 }),
348 range: hover_range,
349 });
350 }
351 if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
355 return Some(Hover {
356 contents: HoverContents::Markup(MarkupContent {
357 kind: MarkupKind::Markdown,
358 value: format!("`{word}` `{ty}`"),
359 }),
360 range: hover_range,
361 });
362 }
363 }
364
365 if word.starts_with('$')
369 && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
370 && let prop_name = word.trim_start_matches('$')
371 && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
372 {
373 let sig = format!(
374 "(property) {}{}::${}{}",
375 modifiers,
376 class_name,
377 prop_name,
378 if type_str.is_empty() {
379 String::new()
380 } else {
381 format!(": {type_str}")
382 }
383 );
384 let mut value = wrap_php(&sig);
385 if let Some(doc_block) = db {
386 let md = doc_block.to_markdown();
387 if !md.is_empty() {
388 value.push_str("\n\n---\n\n");
389 value.push_str(&md);
390 }
391 }
392 return Some(Hover {
393 contents: HoverContents::Markup(MarkupContent {
394 kind: MarkupKind::Markdown,
395 value,
396 }),
397 range: hover_range,
398 });
399 }
400
401 if !word.starts_with('$')
402 && let Some(sym) = analysis.and_then(|a| {
403 let off =
404 word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
405 a.symbol_at(off)
406 })
407 {
408 let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
409 if mir_hover.is_some() {
410 return mir_hover.map(|value| Hover {
411 contents: HoverContents::Markup(MarkupContent {
412 kind: MarkupKind::Markdown,
413 value,
414 }),
415 range: hover_range,
416 });
417 }
418 }
419
420 if (word == "function" || word == "fn")
421 && let Some(sig) = closure_hover(source, doc, position, &word)
422 {
423 return Some(Hover {
424 contents: HoverContents::Markup(MarkupContent {
425 kind: MarkupKind::Markdown,
426 value: wrap_php(&sig),
427 }),
428 range: hover_range,
429 });
430 }
431
432 let all_stmts = &*doc.program().stmts as &[_];
433 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
434
435 let current_doc_found =
437 resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
438 .and_then(|d| declaration_signature(&d, &resolved_word))
439 .map(|sig| {
440 let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
441 .map(|db| db.to_markdown())
442 .filter(|md| !md.is_empty());
443 (sig, doc_md)
444 });
445
446 let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
448
449 if let Some((sig, doc_md)) = found {
450 let mut value = wrap_php(&sig);
451 if let Some(md) = doc_md
452 && !md.is_empty()
453 {
454 value.push_str("\n\n---\n\n");
455 value.push_str(&md);
456 }
457 if is_php_builtin(&resolved_word) {
458 value.push_str(&format!(
459 "\n\n[php.net documentation]({})",
460 php_doc_url(&resolved_word)
461 ));
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 is_php_builtin(&resolved_word) {
473 let value = format!(
474 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
475 resolved_word,
476 php_doc_url(&resolved_word)
477 );
478 return Some(Hover {
479 contents: HoverContents::Markup(MarkupContent {
480 kind: MarkupKind::Markdown,
481 value,
482 }),
483 range: hover_range,
484 });
485 }
486
487 if let Some(stub) =
488 session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
489 {
490 return Some(builtin_class_hover(stub, &resolved_word, hover_range));
491 }
492
493 None
494}
495
496fn mir_member_hover(
500 sym: &mir_analyzer::ResolvedSymbol,
501 word: &str,
502 doc: &ParsedDoc,
503 other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
504) -> Option<String> {
505 let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
506 match &sym.kind {
507 mir_analyzer::ReferenceKind::MethodCall { class, .. }
508 | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
509 let class_short = fqn_short_name(class);
510 for d in docs() {
511 if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
512 let sig = augment_return_type(sig, &sym.resolved_type);
514 let mut value = wrap_php(&sig);
515 let all =
516 std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
517 if let Some(db) = resolve_method_docblock(all, class_short, word) {
518 let md = db.to_markdown();
519 if !md.is_empty() {
520 value.push_str("\n\n---\n\n");
521 value.push_str(&md);
522 }
523 }
524 return Some(value);
525 }
526 }
527 None
528 }
529 mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
530 let class_short = fqn_short_name(class);
531 for d in docs() {
532 if let Some((modifiers, declared_type, db)) =
533 find_property_info(d, class_short, property)
534 {
535 let type_str = augment_property_type(declared_type, &sym.resolved_type);
537 let sig = format!(
538 "(property) {}{}::${}{}",
539 modifiers,
540 class_short,
541 property,
542 if type_str.is_empty() {
543 String::new()
544 } else {
545 format!(": {}", type_str)
546 }
547 );
548 let mut value = wrap_php(&sig);
549 if let Some(doc_block) = db {
550 let md = doc_block.to_markdown();
551 if !md.is_empty() {
552 value.push_str("\n\n---\n\n");
553 value.push_str(&md);
554 }
555 }
556 return Some(value);
557 }
558 }
559 let ty_str = format!("{}", sym.resolved_type);
562 if !matches!(ty_str.as_str(), "" | "void" | "never") {
563 let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
564 return Some(wrap_php(&sig));
565 }
566 None
567 }
568 mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
569 let class_short = fqn_short_name(class);
570 for d in docs() {
571 if let Some(sig) =
572 scan_enum_case_of_class(&d.program().stmts, class_short, constant)
573 {
574 return Some(wrap_php(&sig));
575 }
576 if let Some(sig) =
577 scan_class_const_of_class(&d.program().stmts, class_short, constant)
578 {
579 return Some(wrap_php(&sig));
580 }
581 }
582 None
583 }
584 _ => None,
585 }
586}
587
588fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
592 let ty_str = format!("{resolved}");
593 if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
594 return sig;
595 }
596 let Some(paren) = sig.rfind(')') else {
597 return sig;
598 };
599 let rest = &sig[paren + 1..];
600 if let Some(colon_pos) = rest.find(": ") {
601 let declared = rest[colon_pos + 2..].trim();
602 if matches!(declared, "static" | "self" | "parent") {
605 return sig;
606 }
607 format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
608 } else {
609 format!("{}: {}", sig, ty_str)
610 }
611}
612
613fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
616 let ty_str = format!("{resolved}");
617 if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
618 return declared;
619 }
620 ty_str
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::test_utils::cursor;
627
628 fn pos(line: u32, character: u32) -> Position {
629 Position { line, character }
630 }
631
632 #[test]
633 fn word_at_extracts_from_middle_of_identifier() {
634 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
635 let word = word_at_position(&src, p);
636 assert_eq!(word.as_deref(), Some("greetUser"));
637 }
638
639 #[test]
640 fn hover_on_builtin_class_requires_session() {
641 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
645 let doc = ParsedDoc::parse(src.to_string());
646 let h = hover_info_with_maps(src, &doc, None, pos(1, 12), &[], &[], None);
647 assert!(h.is_none(), "built-in class hover requires a session");
648 }
649}