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