1use std::cell::OnceCell;
2use std::sync::Arc;
3
4use php_ast::{
5 ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Param, Stmt, StmtKind, UseKind,
6 Visibility,
7};
8use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
9
10use crate::ast::{MethodReturnsMap, ParsedDoc, format_type_hint};
11use crate::docblock::{Docblock, docblock_before, find_docblock, parse_docblock};
12use crate::type_map::TypeMap;
13use crate::util::{is_php_builtin, php_doc_url, word_at, word_range_at};
14
15pub fn hover_info(
16 source: &str,
17 doc: &ParsedDoc,
18 doc_returns: &MethodReturnsMap,
19 position: Position,
20 other_docs: &[(
21 tower_lsp::lsp_types::Url,
22 Arc<ParsedDoc>,
23 Arc<MethodReturnsMap>,
24 )],
25) -> Option<Hover> {
26 hover_at(source, doc, doc_returns, other_docs, position)
27}
28
29pub fn hover_at(
31 source: &str,
32 doc: &ParsedDoc,
33 doc_returns: &MethodReturnsMap,
34 other_docs: &[(
35 tower_lsp::lsp_types::Url,
36 Arc<ParsedDoc>,
37 Arc<MethodReturnsMap>,
38 )],
39 position: Position,
40) -> Option<Hover> {
41 let hover_range = word_range_at(source, position);
42
43 if let Some(line_text) = source.lines().nth(position.line as usize) {
46 let trimmed = line_text.trim();
47 if trimmed.starts_with("use ") && !trimmed.starts_with("use function ") {
48 let fqn = trimmed
49 .strip_prefix("use ")
50 .unwrap_or("")
51 .trim_end_matches(';')
52 .trim();
53 if !fqn.is_empty() {
54 let maybe_word = word_at(source, position);
55 let alias = fqn.rsplit('\\').next().unwrap_or(fqn);
56 let matches = match &maybe_word {
57 Some(w) => w == alias || fqn.contains(w.as_str()),
58 None => true,
59 };
60 if matches {
61 return Some(Hover {
62 contents: HoverContents::Markup(MarkupContent {
63 kind: MarkupKind::Markdown,
64 value: format!("`use {};`", fqn),
65 }),
66 range: hover_range,
67 });
68 }
69 }
70 }
71 }
72
73 let word = word_at(source, position)?;
74
75 if let Some(line_text) = source.lines().nth(position.line as usize)
79 && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
80 {
81 let keyword_doc: Option<&str> = match word.as_str() {
82 "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
83 "null" => Some("`null` — the null value; a variable has no value"),
84 "true" => Some("`true` — boolean true"),
85 "false" => Some("`false` — boolean false"),
86 "abstract" => Some(
87 "`abstract` — declares an abstract class or method that must be implemented by a subclass",
88 ),
89 "readonly" => {
90 Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
91 }
92 "yield" => Some("`yield` — produces a value from a generator function"),
93 "never" => Some(
94 "`never` — return type indicating the function always throws or exits (PHP 8.1)",
95 ),
96 "throw" => {
97 Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
98 }
99 _ => None,
100 };
101 if let Some(doc_str) = keyword_doc {
102 return Some(Hover {
103 contents: HoverContents::Markup(MarkupContent {
104 kind: MarkupKind::Markdown,
105 value: doc_str.to_string(),
106 }),
107 range: hover_range,
108 });
109 }
110 }
111
112 if let Some(line_text) = source.lines().nth(position.line as usize)
115 && !word.starts_with('$')
116 && is_named_arg_at(line_text, position.character as usize, &word)
117 && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
118 && let Some(value) = named_arg_hover_value(
119 source,
120 doc,
121 doc_returns,
122 other_docs,
123 position,
124 &callee,
125 &word,
126 )
127 {
128 return Some(Hover {
129 contents: HoverContents::Markup(MarkupContent {
130 kind: MarkupKind::Markdown,
131 value,
132 }),
133 range: hover_range,
134 });
135 }
136
137 let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
139 let type_map = || {
140 type_map_cell.get_or_init(|| {
141 TypeMap::from_docs_at_position(
142 doc,
143 doc_returns,
144 other_docs.iter().map(|(_, d, r)| (d.as_ref(), r.as_ref())),
145 None,
146 position,
147 )
148 })
149 };
150
151 if word.starts_with('$')
153 && let Some(class_name) = type_map().get(&word)
154 {
155 return Some(Hover {
156 contents: HoverContents::Markup(MarkupContent {
157 kind: MarkupKind::Markdown,
158 value: format!("`{}` `{}`", word, class_name),
159 }),
160 range: hover_range,
161 });
162 }
163
164 if word.starts_with('$')
166 && let Some(line_text) = source.lines().nth(position.line as usize)
167 && let Some(class_name) =
168 extract_static_class_before_cursor(line_text, position.character as usize)
169 {
170 let prop_name = word.trim_start_matches('$');
171 let effective_class = if class_name == "self" || class_name == "static" {
172 crate::type_map::enclosing_class_at(source, doc, position).unwrap_or(class_name.clone())
173 } else {
174 class_name.clone()
175 };
176 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref())) {
177 if let Some((modifiers, type_str, db)) =
178 find_property_info(d, &effective_class, prop_name)
179 {
180 let sig = format!(
181 "(property) {}{}::${}{}",
182 modifiers,
183 effective_class,
184 prop_name,
185 if type_str.is_empty() {
186 String::new()
187 } else {
188 format!(": {}", type_str)
189 }
190 );
191 let mut value = wrap_php(&sig);
192 if let Some(doc) = db {
193 let md = doc.to_markdown();
194 if !md.is_empty() {
195 value.push_str("\n\n---\n\n");
196 value.push_str(&md);
197 }
198 }
199 return Some(Hover {
200 contents: HoverContents::Markup(MarkupContent {
201 kind: MarkupKind::Markdown,
202 value,
203 }),
204 range: hover_range,
205 });
206 }
207 }
208 }
209
210 if !word.starts_with('$')
214 && let Some(line_text) = source.lines().nth(position.line as usize)
215 {
216 if let Some(var_name) =
217 extract_receiver_var_before_cursor(line_text, position.character as usize)
218 {
219 let tm = type_map();
220 let class_name = if var_name == "$this" {
221 crate::type_map::enclosing_class_at(source, doc, position)
222 .or_else(|| tm.get("$this").map(|s| s.to_string()))
223 } else {
224 tm.get(&var_name).map(|s| s.to_string())
225 };
226 if let Some(cls) = class_name {
227 let first_cls = cls.split('|').next().unwrap_or(&cls);
228 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref())) {
230 if let Some(sig) = scan_method_of_class(&d.program().stmts, first_cls, &word) {
231 let mut value = wrap_php(&sig);
232 let all_docs = std::iter::once(doc)
233 .chain(other_docs.iter().map(|(_, d, _)| d.as_ref()));
234 if let Some(db) = resolve_method_docblock(all_docs, first_cls, &word) {
235 let md = db.to_markdown();
236 if !md.is_empty() {
237 value.push_str("\n\n---\n\n");
238 value.push_str(&md);
239 }
240 }
241 return Some(Hover {
242 contents: HoverContents::Markup(MarkupContent {
243 kind: MarkupKind::Markdown,
244 value,
245 }),
246 range: hover_range,
247 });
248 }
249 if let Some((modifiers, type_str, db)) = find_property_info(d, first_cls, &word)
250 {
251 let sig = format!(
252 "(property) {}{}::${}{}",
253 modifiers,
254 first_cls,
255 word,
256 if type_str.is_empty() {
257 String::new()
258 } else {
259 format!(": {}", type_str)
260 }
261 );
262 let mut value = wrap_php(&sig);
263 if let Some(doc) = db {
264 let md = doc.to_markdown();
265 if !md.is_empty() {
266 value.push_str("\n\n---\n\n");
267 value.push_str(&md);
268 }
269 }
270 return Some(Hover {
271 contents: HoverContents::Markup(MarkupContent {
272 kind: MarkupKind::Markdown,
273 value,
274 }),
275 range: hover_range,
276 });
277 }
278 }
279 }
280 }
281
282 if let Some(class_name) =
284 extract_static_class_before_cursor(line_text, position.character as usize)
285 {
286 let effective_class = if class_name == "self" || class_name == "static" {
287 crate::type_map::enclosing_class_at(source, doc, position)
288 .unwrap_or(class_name.clone())
289 } else if class_name == "parent" {
290 crate::type_map::enclosing_class_at(source, doc, position)
292 .and_then(|enc| find_parent_class_name(&doc.program().stmts, &enc))
293 .unwrap_or(class_name.clone())
294 } else {
295 class_name.clone()
296 };
297 for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref())) {
298 if let Some(sig) = scan_method_of_class(&d.program().stmts, &effective_class, &word)
299 {
300 let mut value = wrap_php(&sig);
301 let all_docs =
302 std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref()));
303 if let Some(db) = resolve_method_docblock(all_docs, &effective_class, &word) {
304 let md = db.to_markdown();
305 if !md.is_empty() {
306 value.push_str("\n\n---\n\n");
307 value.push_str(&md);
308 }
309 }
310 return Some(Hover {
311 contents: HoverContents::Markup(MarkupContent {
312 kind: MarkupKind::Markdown,
313 value,
314 }),
315 range: hover_range,
316 });
317 }
318 }
319 }
320 }
321
322 if (word == "function" || word == "fn")
326 && let Some(sig) = closure_hover(source, doc, position, &word)
327 {
328 return Some(Hover {
329 contents: HoverContents::Markup(MarkupContent {
330 kind: MarkupKind::Markdown,
331 value: wrap_php(&sig),
332 }),
333 range: hover_range,
334 });
335 }
336
337 let all_stmts = &*doc.program().stmts as &[_];
340 let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
341
342 let found = scan_statements(&doc.program().stmts, &resolved_word).map(|sig| (sig, source, doc));
344 let found = found.or_else(|| {
345 for (_, other, _) in other_docs {
346 if let Some(sig) = scan_statements(&other.program().stmts, &resolved_word) {
347 return Some((sig, other.source(), other.as_ref()));
348 }
349 }
350 None
351 });
352
353 if let Some((sig, sig_source, sig_doc)) = found {
354 let mut value = wrap_php(&sig);
355 if let Some(db) = find_docblock(sig_source, &sig_doc.program().stmts, &resolved_word) {
356 let md = db.to_markdown();
357 if !md.is_empty() {
358 value.push_str("\n\n---\n\n");
359 value.push_str(&md);
360 }
361 }
362 if is_php_builtin(&resolved_word) {
363 value.push_str(&format!(
364 "\n\n[php.net documentation]({})",
365 php_doc_url(&resolved_word)
366 ));
367 }
368 return Some(Hover {
369 contents: HoverContents::Markup(MarkupContent {
370 kind: MarkupKind::Markdown,
371 value,
372 }),
373 range: hover_range,
374 });
375 }
376
377 if is_php_builtin(&resolved_word) {
379 let value = format!(
380 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
381 resolved_word,
382 php_doc_url(&resolved_word)
383 );
384 return Some(Hover {
385 contents: HoverContents::Markup(MarkupContent {
386 kind: MarkupKind::Markdown,
387 value,
388 }),
389 range: hover_range,
390 });
391 }
392
393 if let Some(stub) = crate::stubs::builtin_class_members(&resolved_word) {
395 let method_names: Vec<&str> = stub
396 .methods
397 .iter()
398 .filter(|(_, is_static)| !is_static)
399 .map(|(n, _)| n.as_str())
400 .take(8)
401 .collect();
402 let static_names: Vec<&str> = stub
403 .methods
404 .iter()
405 .filter(|(_, is_static)| *is_static)
406 .map(|(n, _)| n.as_str())
407 .take(4)
408 .collect();
409 let mut lines = vec![format!("**{}** — built-in class", resolved_word)];
410 if !method_names.is_empty() {
411 lines.push(format!(
412 "Methods: {}",
413 method_names
414 .iter()
415 .map(|n| format!("`{n}`"))
416 .collect::<Vec<_>>()
417 .join(", ")
418 ));
419 }
420 if !static_names.is_empty() {
421 lines.push(format!(
422 "Static: {}",
423 static_names
424 .iter()
425 .map(|n| format!("`{n}`"))
426 .collect::<Vec<_>>()
427 .join(", ")
428 ));
429 }
430 if let Some(parent) = &stub.parent {
431 lines.push(format!("Extends: `{parent}`"));
432 }
433 return Some(Hover {
434 contents: HoverContents::Markup(MarkupContent {
435 kind: MarkupKind::Markdown,
436 value: lines.join("\n\n"),
437 }),
438 range: hover_range,
439 });
440 }
441
442 None
443}
444
445fn scan_statements(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
446 for stmt in stmts {
447 match &stmt.kind {
448 StmtKind::Function(f) if f.name == word => {
449 let params = format_params(&f.params);
450 let ret = f
451 .return_type
452 .as_ref()
453 .map(|r| format!(": {}", format_type_hint(r)))
454 .unwrap_or_default();
455 return Some(format!("function {}({}){}", word, params, ret));
456 }
457 StmtKind::Class(c) if c.name == Some(word) => {
458 let kw = if c.modifiers.is_abstract {
459 "abstract class"
460 } else if c.modifiers.is_final {
461 "final class"
462 } else if c.modifiers.is_readonly {
463 "readonly class"
464 } else {
465 "class"
466 };
467 let mut sig = format!("{} {}", kw, word);
468 if let Some(ext) = &c.extends {
469 sig.push_str(&format!(" extends {}", ext.to_string_repr()));
470 }
471 if !c.implements.is_empty() {
472 let ifaces: Vec<String> = c
473 .implements
474 .iter()
475 .map(|i| i.to_string_repr().into_owned())
476 .collect();
477 sig.push_str(&format!(" implements {}", ifaces.join(", ")));
478 }
479 return Some(sig);
480 }
481 StmtKind::Interface(i) if i.name == word => {
482 return Some(format!("interface {}", word));
483 }
484 StmtKind::Interface(i) => {
485 for member in i.members.iter() {
486 match &member.kind {
487 ClassMemberKind::Method(m) if m.name == word => {
488 let prefix = format_method_prefix(
489 m.visibility.as_ref(),
490 m.is_static,
491 m.is_abstract,
492 m.is_final,
493 );
494 let params = format_params(&m.params);
495 let ret = m
496 .return_type
497 .as_ref()
498 .map(|r| format!(": {}", format_type_hint(r)))
499 .unwrap_or_default();
500 return Some(format!("{}function {}({}){}", prefix, word, params, ret));
501 }
502 ClassMemberKind::ClassConst(k) if k.name == word => {
503 return Some(format_class_const(k));
504 }
505 _ => {}
506 }
507 }
508 }
509 StmtKind::Trait(t) if t.name == word => {
510 return Some(format!("trait {}", word));
511 }
512 StmtKind::Enum(e) if e.name == word => {
513 let mut sig = if let Some(scalar) = &e.scalar_type {
514 format!("enum {}: {}", word, scalar.to_string_repr())
515 } else {
516 format!("enum {}", word)
517 };
518 if !e.implements.is_empty() {
519 let ifaces: Vec<String> = e
520 .implements
521 .iter()
522 .map(|i| i.to_string_repr().into_owned())
523 .collect();
524 sig.push_str(&format!(" implements {}", ifaces.join(", ")));
525 }
526 return Some(sig);
527 }
528 StmtKind::Enum(e) => {
529 for member in e.members.iter() {
530 match &member.kind {
531 EnumMemberKind::Method(m) if m.name == word => {
532 let prefix = format_method_prefix(
533 m.visibility.as_ref(),
534 m.is_static,
535 m.is_abstract,
536 m.is_final,
537 );
538 let params = format_params(&m.params);
539 let ret = m
540 .return_type
541 .as_ref()
542 .map(|r| format!(": {}", format_type_hint(r)))
543 .unwrap_or_default();
544 return Some(format!("{}function {}({}){}", prefix, word, params, ret));
545 }
546 EnumMemberKind::Case(c) if c.name == word => {
547 let value_str = c
548 .value
549 .as_ref()
550 .and_then(format_expr_literal)
551 .map(|v| format!(" = {v}"))
552 .unwrap_or_default();
553 return Some(format!("case {}::{}{}", e.name, c.name, value_str));
554 }
555 EnumMemberKind::ClassConst(k) if k.name == word => {
556 return Some(format_class_const(k));
557 }
558 _ => {}
559 }
560 }
561 }
562 StmtKind::Class(c) => {
563 for member in c.members.iter() {
564 match &member.kind {
565 ClassMemberKind::Method(m) if m.name == word => {
566 let prefix = format_method_prefix(
567 m.visibility.as_ref(),
568 m.is_static,
569 m.is_abstract,
570 m.is_final,
571 );
572 let params = format_params(&m.params);
573 let ret = m
574 .return_type
575 .as_ref()
576 .map(|r| format!(": {}", format_type_hint(r)))
577 .unwrap_or_default();
578 return Some(format!("{}function {}({}){}", prefix, word, params, ret));
579 }
580 ClassMemberKind::ClassConst(k) if k.name == word => {
581 return Some(format_class_const(k));
582 }
583 _ => {}
584 }
585 }
586 }
587 StmtKind::Trait(t) => {
588 for member in t.members.iter() {
589 match &member.kind {
590 ClassMemberKind::Method(m) if m.name == word => {
591 let prefix = format_method_prefix(
592 m.visibility.as_ref(),
593 m.is_static,
594 m.is_abstract,
595 m.is_final,
596 );
597 let params = format_params(&m.params);
598 let ret = m
599 .return_type
600 .as_ref()
601 .map(|r| format!(": {}", format_type_hint(r)))
602 .unwrap_or_default();
603 return Some(format!("{}function {}({}){}", prefix, word, params, ret));
604 }
605 ClassMemberKind::ClassConst(k) if k.name == word => {
606 return Some(format_class_const(k));
607 }
608 _ => {}
609 }
610 }
611 }
612 StmtKind::Namespace(ns) => {
613 if let NamespaceBody::Braced(inner) = &ns.body
614 && let Some(sig) = scan_statements(inner, word)
615 {
616 return Some(sig);
617 }
618 }
619 _ => {}
620 }
621 }
622 None
623}
624
625fn format_expr_literal(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
627 match &expr.kind {
628 ExprKind::Int(n) => Some(n.to_string()),
629 ExprKind::Float(f) => Some(f.to_string()),
630 ExprKind::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
631 ExprKind::String(s) => Some(format!("'{}'", s)),
632 _ => None,
633 }
634}
635
636fn format_class_const(c: &php_ast::ClassConstDecl<'_, '_>) -> String {
638 let type_str = c
639 .type_hint
640 .as_ref()
641 .map(|t| format!("{} ", format_type_hint(t)))
642 .or_else(|| match &c.value.kind {
643 ExprKind::Int(_) => Some("int ".to_string()),
644 ExprKind::String(_) => Some("string ".to_string()),
645 ExprKind::Float(_) => Some("float ".to_string()),
646 ExprKind::Bool(_) => Some("bool ".to_string()),
647 _ => None,
648 })
649 .unwrap_or_default();
650 let value_str = format_expr_literal(&c.value)
651 .map(|v| format!(" = {v}"))
652 .unwrap_or_default();
653 format!("const {}{}{}", type_str, c.name, value_str)
654}
655
656pub(crate) fn format_params_str(params: &[Param<'_, '_>]) -> String {
657 format_params(params)
658}
659
660pub fn signature_for_symbol_from_index(
665 name: &str,
666 indexes: &[(
667 tower_lsp::lsp_types::Url,
668 std::sync::Arc<crate::file_index::FileIndex>,
669 )],
670) -> Option<String> {
671 for (_, idx) in indexes {
672 for f in &idx.functions {
673 if f.name == name {
674 let params_str = f
675 .params
676 .iter()
677 .map(|p| {
678 let mut s = String::new();
679 if let Some(t) = &p.type_hint {
680 s.push_str(&format!("{} ", t));
681 }
682 if p.variadic {
683 s.push_str("...");
684 }
685 s.push_str(&format!("${}", p.name));
686 s
687 })
688 .collect::<Vec<_>>()
689 .join(", ");
690 let ret = f
691 .return_type
692 .as_deref()
693 .map(|r| format!(": {}", r))
694 .unwrap_or_default();
695 return Some(format!("function {}({}){}", name, params_str, ret));
696 }
697 }
698 for cls in &idx.classes {
699 for m in &cls.methods {
700 if m.name == name {
701 let params_str = m
702 .params
703 .iter()
704 .map(|p| {
705 let mut s = String::new();
706 if let Some(t) = &p.type_hint {
707 s.push_str(&format!("{} ", t));
708 }
709 if p.variadic {
710 s.push_str("...");
711 }
712 s.push_str(&format!("${}", p.name));
713 s
714 })
715 .collect::<Vec<_>>()
716 .join(", ");
717 let ret = m
718 .return_type
719 .as_deref()
720 .map(|r| format!(": {}", r))
721 .unwrap_or_default();
722 return Some(format!("function {}({}){}", name, params_str, ret));
723 }
724 }
725 }
726 }
727 None
728}
729
730pub fn docs_for_symbol_from_index(
732 name: &str,
733 indexes: &[(
734 tower_lsp::lsp_types::Url,
735 std::sync::Arc<crate::file_index::FileIndex>,
736 )],
737) -> Option<String> {
738 if let Some(sig) = signature_for_symbol_from_index(name, indexes) {
739 let mut value = wrap_php(&sig);
740 for (_, idx) in indexes {
742 for f in &idx.functions {
743 if f.name == name {
744 if let Some(raw) = &f.doc {
745 let db = crate::docblock::parse_docblock(raw);
746 let md = db.to_markdown();
747 if !md.is_empty() {
748 value.push_str("\n\n---\n\n");
749 value.push_str(&md);
750 }
751 }
752 break;
753 }
754 }
755 for cls in &idx.classes {
756 for m in &cls.methods {
757 if m.name == name {
758 if let Some(raw) = &m.doc {
759 let db = crate::docblock::parse_docblock(raw);
760 let md = db.to_markdown();
761 if !md.is_empty() {
762 value.push_str("\n\n---\n\n");
763 value.push_str(&md);
764 }
765 }
766 break;
767 }
768 }
769 }
770 }
771 if is_php_builtin(name) {
772 value.push_str(&format!(
773 "\n\n[php.net documentation]({})",
774 php_doc_url(name)
775 ));
776 }
777 return Some(value);
778 }
779 if is_php_builtin(name) {
781 return Some(format!(
782 "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
783 name,
784 php_doc_url(name)
785 ));
786 }
787 None
788}
789
790pub fn class_hover_from_index(
793 word: &str,
794 indexes: &[(
795 tower_lsp::lsp_types::Url,
796 std::sync::Arc<crate::file_index::FileIndex>,
797 )],
798) -> Option<Hover> {
799 use crate::file_index::ClassKind;
800
801 for (_, idx) in indexes {
802 for cls in &idx.classes {
803 if cls.name == word || cls.fqn.trim_start_matches('\\') == word {
804 let kw = match cls.kind {
805 ClassKind::Interface => "interface",
806 ClassKind::Trait => "trait",
807 ClassKind::Enum => "enum",
808 ClassKind::Class => {
809 if cls.is_abstract {
810 "abstract class"
811 } else {
812 "class"
813 }
814 }
815 };
816 let mut sig = format!("{} {}", kw, cls.name);
817 if let Some(parent) = &cls.parent {
818 sig.push_str(&format!(" extends {}", parent));
819 }
820 if !cls.implements.is_empty() {
821 let list: Vec<&str> = cls.implements.iter().map(|s| s.as_ref()).collect();
822 sig.push_str(&format!(" implements {}", list.join(", ")));
823 }
824 return Some(Hover {
825 contents: HoverContents::Markup(MarkupContent {
826 kind: MarkupKind::Markdown,
827 value: wrap_php(&sig),
828 }),
829 range: None,
830 });
831 }
832 }
833 }
834 None
835}
836
837fn visibility_str(v: &Visibility) -> &'static str {
838 match v {
839 Visibility::Public => "public",
840 Visibility::Protected => "protected",
841 Visibility::Private => "private",
842 }
843}
844
845fn format_method_prefix(
846 visibility: Option<&Visibility>,
847 is_static: bool,
848 is_abstract: bool,
849 is_final: bool,
850) -> String {
851 let mut parts: Vec<&str> = Vec::new();
852 if let Some(v) = visibility {
853 parts.push(visibility_str(v));
854 }
855 if is_abstract {
856 parts.push("abstract");
857 }
858 if is_final {
859 parts.push("final");
860 }
861 if is_static {
862 parts.push("static");
863 }
864 if parts.is_empty() {
865 String::new()
866 } else {
867 parts.join(" ") + " "
868 }
869}
870
871fn format_prop_prefix(
872 visibility: Option<&Visibility>,
873 is_static: bool,
874 is_readonly: bool,
875) -> String {
876 let mut parts: Vec<&str> = Vec::new();
877 if let Some(v) = visibility {
878 parts.push(visibility_str(v));
879 }
880 if is_static {
881 parts.push("static");
882 }
883 if is_readonly {
884 parts.push("readonly");
885 }
886 if parts.is_empty() {
887 String::new()
888 } else {
889 parts.join(" ") + " "
890 }
891}
892
893fn format_params(params: &[Param<'_, '_>]) -> String {
894 params
895 .iter()
896 .map(|p| {
897 let mut s = String::new();
898 if p.by_ref {
899 s.push('&');
900 }
901 if let Some(t) = &p.type_hint {
902 s.push_str(&format!("{} ", format_type_hint(t)));
903 }
904 if p.variadic {
905 s.push_str("...");
906 }
907 s.push_str(&format!("${}", p.name));
908 if let Some(default) = &p.default {
909 s.push_str(&format!(" = {}", format_default_value(default)));
910 }
911 s
912 })
913 .collect::<Vec<_>>()
914 .join(", ")
915}
916
917fn format_default_value(expr: &php_ast::Expr<'_, '_>) -> String {
919 match &expr.kind {
920 ExprKind::Int(n) => n.to_string(),
921 ExprKind::Float(f) => f.to_string(),
922 ExprKind::String(s) => format!("'{}'", s),
923 ExprKind::Bool(b) => {
924 if *b {
925 "true".to_string()
926 } else {
927 "false".to_string()
928 }
929 }
930 ExprKind::Null => "null".to_string(),
931 ExprKind::Array(items) => {
932 if items.is_empty() {
933 "[]".to_string()
934 } else {
935 "[...]".to_string()
936 }
937 }
938 _ => "...".to_string(),
939 }
940}
941
942fn wrap_php(sig: &str) -> String {
943 format!("```php\n{}\n```", sig)
944}
945
946fn extract_receiver_var_before_cursor(line: &str, cursor_col_utf16: usize) -> Option<String> {
951 let chars: Vec<char> = line.chars().collect();
952
953 let mut utf16 = 0usize;
955 let mut char_idx = 0usize;
956 for ch in &chars {
957 if utf16 >= cursor_col_utf16 {
958 break;
959 }
960 utf16 += ch.len_utf16();
961 char_idx += 1;
962 }
963
964 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
966 let mut word_start = char_idx;
967 while word_start > 0 && is_word_char(chars[word_start - 1]) {
968 word_start -= 1;
969 }
970
971 let (is_arrow, arrow_end) = if word_start >= 3
973 && chars[word_start - 3] == '?'
974 && chars[word_start - 2] == '-'
975 && chars[word_start - 1] == '>'
976 {
977 (true, word_start - 3)
978 } else if word_start >= 2 && chars[word_start - 2] == '-' && chars[word_start - 1] == '>' {
979 (true, word_start - 2)
980 } else {
981 (false, 0)
982 };
983
984 if !is_arrow {
985 return None;
986 }
987
988 extract_name_from_chars_end(&chars[..arrow_end])
989}
990
991fn extract_static_class_before_cursor(line: &str, cursor_col_utf16: usize) -> Option<String> {
993 let chars: Vec<char> = line.chars().collect();
994
995 let mut utf16 = 0usize;
996 let mut char_idx = 0usize;
997 for ch in &chars {
998 if utf16 >= cursor_col_utf16 {
999 break;
1000 }
1001 utf16 += ch.len_utf16();
1002 char_idx += 1;
1003 }
1004
1005 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
1006 let mut word_start = char_idx;
1007 while word_start > 0 && is_word_char(chars[word_start - 1]) {
1008 word_start -= 1;
1009 }
1010
1011 if word_start > 0 && chars[word_start - 1] == '$' {
1013 word_start -= 1;
1014 }
1015
1016 if word_start < 2 || chars[word_start - 2] != ':' || chars[word_start - 1] != ':' {
1017 return None;
1018 }
1019
1020 let before_colons = &chars[..word_start - 2];
1021 let is_name_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
1023 let end = before_colons.len().saturating_sub(
1024 before_colons
1025 .iter()
1026 .rev()
1027 .take_while(|&&c| c == ' ' || c == '\t')
1028 .count(),
1029 );
1030 let mut start = end;
1031 while start > 0 && is_name_char(before_colons[start - 1]) {
1032 start -= 1;
1033 }
1034 if start == end {
1035 return None;
1036 }
1037 let full: String = before_colons[start..end].iter().collect();
1038 Some(full.rsplit('\\').next().unwrap_or(&full).to_owned())
1040}
1041
1042fn extract_name_from_chars_end(chars: &[char]) -> Option<String> {
1045 let is_var_char = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
1046 let end = chars.len()
1047 - chars
1048 .iter()
1049 .rev()
1050 .take_while(|&&c| c == ' ' || c == '\t')
1051 .count();
1052 if end == 0 {
1053 return None;
1054 }
1055 let mut start = end;
1056 while start > 0 && is_var_char(chars[start - 1]) {
1057 start -= 1;
1058 }
1059 if start == end {
1060 return None;
1061 }
1062 let name: String = chars[start..end].iter().collect();
1063 if name.starts_with('$') && name.len() > 1 {
1064 Some(name)
1065 } else if !name.is_empty() && !name.starts_with('$') {
1066 Some(format!("${}", name))
1069 } else {
1070 None
1071 }
1072}
1073
1074pub(crate) fn resolve_use_alias(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
1079 for stmt in stmts {
1080 match &stmt.kind {
1081 StmtKind::Use(u) if u.kind == UseKind::Normal => {
1082 for item in u.uses.iter() {
1083 if let Some(alias) = item.alias
1084 && alias == word
1085 {
1086 let fqn = item.name.to_string_repr();
1087 let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref()).to_owned();
1088 return Some(short);
1089 }
1090 }
1091 }
1092 StmtKind::Namespace(ns) => {
1093 if let NamespaceBody::Braced(inner) = &ns.body
1094 && let Some(s) = resolve_use_alias(inner, word)
1095 {
1096 return Some(s);
1097 }
1098 }
1099 _ => {}
1100 }
1101 }
1102 None
1103}
1104
1105fn find_property_info(
1109 doc: &ParsedDoc,
1110 class_name: &str,
1111 prop_name: &str,
1112) -> Option<(String, String, Option<Docblock>)> {
1113 find_property_info_in_stmts(doc.source(), &doc.program().stmts, class_name, prop_name)
1114}
1115
1116fn find_property_info_in_stmts<'a>(
1117 source: &str,
1118 stmts: &[Stmt<'a, 'a>],
1119 class_name: &str,
1120 prop_name: &str,
1121) -> Option<(String, String, Option<Docblock>)> {
1122 for stmt in stmts {
1123 match &stmt.kind {
1124 StmtKind::Class(c) if c.name == Some(class_name) => {
1125 for member in c.members.iter() {
1126 match &member.kind {
1127 ClassMemberKind::Property(p) if p.name == prop_name => {
1128 let modifiers = format_prop_prefix(
1129 p.visibility.as_ref(),
1130 p.is_static,
1131 p.is_readonly,
1132 );
1133 let type_str = p
1134 .type_hint
1135 .as_ref()
1136 .map(|t| crate::ast::format_type_hint(t))
1137 .unwrap_or_default();
1138 let db = docblock_before(source, member.span.start)
1139 .map(|raw| parse_docblock(&raw));
1140 return Some((modifiers, type_str, db));
1141 }
1142 ClassMemberKind::Method(m) if m.name == "__construct" => {
1143 for p in m.params.iter() {
1145 if p.name == prop_name && p.visibility.is_some() {
1146 let modifiers =
1147 format_prop_prefix(p.visibility.as_ref(), false, false);
1148 let type_str = p
1149 .type_hint
1150 .as_ref()
1151 .map(|t| crate::ast::format_type_hint(t))
1152 .unwrap_or_default();
1153 let db = docblock_before(source, member.span.start).and_then(
1159 |raw| {
1160 let full = parse_docblock(&raw);
1161 let matching: Vec<_> = full
1162 .params
1163 .into_iter()
1164 .filter(|dp| {
1165 dp.name.strip_prefix('$') == Some(prop_name)
1166 })
1167 .collect();
1168 if matching.is_empty() {
1169 None
1170 } else {
1171 Some(crate::docblock::Docblock {
1172 params: matching,
1173 ..Default::default()
1174 })
1175 }
1176 },
1177 );
1178 return Some((modifiers, type_str, db));
1179 }
1180 }
1181 }
1182 _ => {}
1183 }
1184 }
1185 return None;
1187 }
1188 StmtKind::Namespace(ns) => {
1189 if let NamespaceBody::Braced(inner) = &ns.body
1190 && let Some(t) =
1191 find_property_info_in_stmts(source, inner, class_name, prop_name)
1192 {
1193 return Some(t);
1194 }
1195 }
1196 _ => {}
1197 }
1198 }
1199 None
1200}
1201
1202fn scan_method_of_class(
1205 stmts: &[Stmt<'_, '_>],
1206 class_name: &str,
1207 method_name: &str,
1208) -> Option<String> {
1209 scan_method_of_class_impl(stmts, stmts, class_name, method_name)
1210}
1211
1212fn scan_method_of_class_impl<'a>(
1213 root: &[Stmt<'a, 'a>],
1214 stmts: &[Stmt<'a, 'a>],
1215 class_name: &str,
1216 method_name: &str,
1217) -> Option<String> {
1218 for stmt in stmts {
1219 match &stmt.kind {
1220 StmtKind::Class(c) if c.name == Some(class_name) => {
1221 for member in c.members.iter() {
1223 if let ClassMemberKind::Method(m) = &member.kind
1224 && m.name == method_name
1225 {
1226 let params = format_params(&m.params);
1227 let ret = m
1228 .return_type
1229 .as_ref()
1230 .map(|r| format!(": {}", format_type_hint(r)))
1231 .unwrap_or_default();
1232 return Some(format!(
1233 "{}::{}({}){}",
1234 class_name, method_name, params, ret
1235 ));
1236 }
1237 }
1238 let mut trait_names: Vec<String> = Vec::new();
1240 for member in c.members.iter() {
1241 if let ClassMemberKind::TraitUse(tu) = &member.kind {
1242 for tn in tu.traits.iter() {
1243 let s = tn.to_string_repr();
1244 let short = s.rsplit('\\').next().unwrap_or(s.as_ref()).to_owned();
1245 trait_names.push(short);
1246 }
1247 }
1248 }
1249 for tname in &trait_names {
1250 if let Some(partial) = find_method_sig_in_trait(root, tname, method_name) {
1251 return Some(format!("{}::{}", class_name, partial));
1252 }
1253 }
1254 if let Some(parent) = &c.extends {
1256 let pn = parent.to_string_repr();
1257 let short = pn.rsplit('\\').next().unwrap_or(pn.as_ref()).to_owned();
1258 if let Some(sig) = scan_method_of_class_impl(root, root, &short, method_name) {
1259 return Some(sig.replacen(
1262 &format!("{}::", short),
1263 &format!("{}::", class_name),
1264 1,
1265 ));
1266 }
1267 }
1268 return None;
1269 }
1270 StmtKind::Trait(t) if t.name == class_name => {
1271 for member in t.members.iter() {
1272 if let ClassMemberKind::Method(m) = &member.kind
1273 && m.name == method_name
1274 {
1275 let params = format_params(&m.params);
1276 let ret = m
1277 .return_type
1278 .as_ref()
1279 .map(|r| format!(": {}", format_type_hint(r)))
1280 .unwrap_or_default();
1281 return Some(format!(
1282 "{}::{}({}){}",
1283 class_name, method_name, params, ret
1284 ));
1285 }
1286 }
1287 return None;
1288 }
1289 StmtKind::Enum(e) if e.name == class_name => {
1290 for member in e.members.iter() {
1291 if let EnumMemberKind::Method(m) = &member.kind
1292 && m.name == method_name
1293 {
1294 let params = format_params(&m.params);
1295 let ret = m
1296 .return_type
1297 .as_ref()
1298 .map(|r| format!(": {}", format_type_hint(r)))
1299 .unwrap_or_default();
1300 return Some(format!(
1301 "{}::{}({}){}",
1302 class_name, method_name, params, ret
1303 ));
1304 }
1305 }
1306 return None;
1307 }
1308 StmtKind::Namespace(ns) => {
1309 if let NamespaceBody::Braced(inner) = &ns.body {
1310 let result = scan_method_of_class_impl(root, inner, class_name, method_name);
1311 if result.is_some() {
1312 return result;
1313 }
1314 }
1315 }
1316 _ => {}
1317 }
1318 }
1319 None
1320}
1321
1322fn find_method_sig_in_trait(
1324 stmts: &[Stmt<'_, '_>],
1325 trait_name: &str,
1326 method_name: &str,
1327) -> Option<String> {
1328 for stmt in stmts {
1329 match &stmt.kind {
1330 StmtKind::Trait(t) if t.name == trait_name => {
1331 for member in t.members.iter() {
1332 if let ClassMemberKind::Method(m) = &member.kind
1333 && m.name == method_name
1334 {
1335 let params = format_params(&m.params);
1336 let ret = m
1337 .return_type
1338 .as_ref()
1339 .map(|r| format!(": {}", format_type_hint(r)))
1340 .unwrap_or_default();
1341 return Some(format!("{}({}){}", method_name, params, ret));
1342 }
1343 }
1344 return None;
1345 }
1346 StmtKind::Namespace(ns) => {
1347 if let NamespaceBody::Braced(inner) = &ns.body
1348 && let Some(s) = find_method_sig_in_trait(inner, trait_name, method_name)
1349 {
1350 return Some(s);
1351 }
1352 }
1353 _ => {}
1354 }
1355 }
1356 None
1357}
1358
1359fn find_parent_class_name(stmts: &[Stmt<'_, '_>], class_name: &str) -> Option<String> {
1362 for stmt in stmts {
1363 match &stmt.kind {
1364 StmtKind::Class(c) if c.name == Some(class_name) => {
1365 return c.extends.as_ref().map(|p| {
1366 let pn = p.to_string_repr();
1367 pn.rsplit('\\').next().unwrap_or(pn.as_ref()).to_owned()
1368 });
1369 }
1370 StmtKind::Namespace(ns) => {
1371 if let NamespaceBody::Braced(inner) = &ns.body
1372 && let Some(s) = find_parent_class_name(inner, class_name)
1373 {
1374 return Some(s);
1375 }
1376 }
1377 _ => {}
1378 }
1379 }
1380 None
1381}
1382
1383fn find_method_docblock(
1384 doc: &ParsedDoc,
1385 class_name: &str,
1386 method_name: &str,
1387) -> Option<crate::docblock::Docblock> {
1388 find_method_docblock_in_stmts(doc.source(), &doc.program().stmts, class_name, method_name)
1389}
1390
1391fn resolve_method_docblock<'a>(
1394 docs: impl Iterator<Item = &'a ParsedDoc> + Clone,
1395 class_name: &str,
1396 method_name: &str,
1397) -> Option<crate::docblock::Docblock> {
1398 let docs: Vec<&'a ParsedDoc> = docs.collect();
1399 let mut current_class = class_name.to_owned();
1400 for _ in 0..16 {
1401 let db = docs
1402 .iter()
1403 .find_map(|d| find_method_docblock(d, ¤t_class, method_name));
1404 match db {
1405 Some(d) if d.is_inherit_doc => {
1406 let parent = docs
1408 .iter()
1409 .find_map(|d| find_parent_class_name(&d.program().stmts, ¤t_class));
1410 match parent {
1411 Some(p) => current_class = p,
1412 None => return None,
1413 }
1414 }
1415 other => return other,
1416 }
1417 }
1418 None
1419}
1420
1421fn find_method_docblock_in_stmts(
1422 source: &str,
1423 stmts: &[Stmt<'_, '_>],
1424 class_name: &str,
1425 method_name: &str,
1426) -> Option<crate::docblock::Docblock> {
1427 find_method_docblock_impl(source, stmts, stmts, class_name, method_name)
1428}
1429
1430fn find_method_docblock_impl<'a>(
1431 source: &str,
1432 root: &[Stmt<'a, 'a>],
1433 stmts: &[Stmt<'a, 'a>],
1434 class_name: &str,
1435 method_name: &str,
1436) -> Option<crate::docblock::Docblock> {
1437 for stmt in stmts {
1438 match &stmt.kind {
1439 StmtKind::Class(c) if c.name == Some(class_name) => {
1440 for member in c.members.iter() {
1442 if let ClassMemberKind::Method(m) = &member.kind
1443 && m.name == method_name
1444 {
1445 return docblock_before(source, member.span.start)
1446 .map(|raw| parse_docblock(&raw));
1447 }
1448 }
1449 for member in c.members.iter() {
1451 if let ClassMemberKind::TraitUse(tu) = &member.kind {
1452 for tn in tu.traits.iter() {
1453 let s = tn.to_string_repr();
1454 let short = s.rsplit('\\').next().unwrap_or(s.as_ref()).to_owned();
1455 if let Some(db) =
1456 find_method_docblock_impl(source, root, root, &short, method_name)
1457 {
1458 return Some(db);
1459 }
1460 }
1461 }
1462 }
1463 if let Some(parent) = &c.extends {
1465 let pn = parent.to_string_repr();
1466 let short = pn.rsplit('\\').next().unwrap_or(pn.as_ref()).to_owned();
1467 if let Some(db) =
1468 find_method_docblock_impl(source, root, root, &short, method_name)
1469 {
1470 return Some(db);
1471 }
1472 }
1473 return None;
1474 }
1475 StmtKind::Trait(t) if t.name == class_name => {
1476 for member in t.members.iter() {
1477 if let ClassMemberKind::Method(m) = &member.kind
1478 && m.name == method_name
1479 {
1480 return docblock_before(source, member.span.start)
1481 .map(|raw| parse_docblock(&raw));
1482 }
1483 }
1484 return None;
1485 }
1486 StmtKind::Enum(e) if e.name == class_name => {
1487 for member in e.members.iter() {
1488 if let EnumMemberKind::Method(m) = &member.kind
1489 && m.name == method_name
1490 {
1491 return docblock_before(source, member.span.start)
1492 .map(|raw| parse_docblock(&raw));
1493 }
1494 }
1495 return None;
1496 }
1497 StmtKind::Namespace(ns) => {
1498 if let NamespaceBody::Braced(inner) = &ns.body {
1499 let result =
1500 find_method_docblock_impl(source, root, inner, class_name, method_name);
1501 if result.is_some() {
1502 return result;
1503 }
1504 }
1505 }
1506 _ => {}
1507 }
1508 }
1509 None
1510}
1511
1512enum NamedArgCallee {
1516 Function(String),
1517 Method(
1518 String, String, ),
1521 StaticMethod(
1522 String, String, ),
1525}
1526
1527fn is_named_arg_at(line: &str, cursor_col_utf16: usize, _word: &str) -> bool {
1532 let trimmed = line.trim_start();
1533 if trimmed.starts_with("case ") || trimmed.starts_with("case\t") {
1534 return false;
1535 }
1536
1537 let chars: Vec<char> = line.chars().collect();
1538 let mut utf16 = 0usize;
1539 let mut char_idx = 0usize;
1540 for ch in &chars {
1541 if utf16 >= cursor_col_utf16 {
1542 break;
1543 }
1544 utf16 += ch.len_utf16();
1545 char_idx += 1;
1546 }
1547 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
1549 while char_idx < chars.len() && is_word_char(chars[char_idx]) {
1550 char_idx += 1;
1551 }
1552 char_idx < chars.len()
1554 && chars[char_idx] == ':'
1555 && !(char_idx + 1 < chars.len() && chars[char_idx + 1] == ':')
1556}
1557
1558fn extract_named_arg_callee(line: &str, cursor_col_utf16: usize) -> Option<NamedArgCallee> {
1562 let chars: Vec<char> = line.chars().collect();
1563
1564 let mut utf16 = 0usize;
1566 let mut char_idx = 0usize;
1567 for ch in &chars {
1568 if utf16 >= cursor_col_utf16 {
1569 break;
1570 }
1571 utf16 += ch.len_utf16();
1572 char_idx += 1;
1573 }
1574 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
1576 while char_idx > 0 && is_word_char(chars[char_idx - 1]) {
1577 char_idx -= 1;
1578 }
1579
1580 let mut depth = 0i32;
1582 let mut i = char_idx;
1583 while i > 0 {
1584 i -= 1;
1585 match chars[i] {
1586 ')' | ']' => depth += 1,
1587 '(' => {
1588 if depth == 0 {
1589 return callee_from_chars_before(&chars[..i]);
1590 }
1591 depth -= 1;
1592 }
1593 '[' => {
1594 if depth == 0 {
1595 return None; }
1597 depth -= 1;
1598 }
1599 _ => {}
1600 }
1601 }
1602 None
1603}
1604
1605fn callee_from_chars_before(chars: &[char]) -> Option<NamedArgCallee> {
1607 let is_name_char = |c: char| c.is_alphanumeric() || c == '_';
1608 let end = chars.len()
1609 - chars
1610 .iter()
1611 .rev()
1612 .take_while(|&&c| c == ' ' || c == '\t')
1613 .count();
1614 if end == 0 {
1615 return None;
1616 }
1617 let mut start = end;
1618 while start > 0 && is_name_char(chars[start - 1]) {
1619 start -= 1;
1620 }
1621 if start == end {
1622 return None;
1623 }
1624 let name: String = chars[start..end].iter().collect();
1625
1626 if start >= 2 && chars[start - 2] == '-' && chars[start - 1] == '>' {
1627 let receiver = extract_name_from_chars_end(&chars[..start - 2])?;
1629 Some(NamedArgCallee::Method(receiver, name))
1630 } else if start >= 3
1631 && chars[start - 3] == '?'
1632 && chars[start - 2] == '-'
1633 && chars[start - 1] == '>'
1634 {
1635 let receiver = extract_name_from_chars_end(&chars[..start - 3])?;
1637 Some(NamedArgCallee::Method(receiver, name))
1638 } else if start >= 2 && chars[start - 2] == ':' && chars[start - 1] == ':' {
1639 let is_class_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
1641 let cls_end = start - 2;
1642 let cls_end_trimmed = cls_end
1643 - chars[..cls_end]
1644 .iter()
1645 .rev()
1646 .take_while(|&&c| c == ' ' || c == '\t')
1647 .count();
1648 let mut cls_start = cls_end_trimmed;
1649 while cls_start > 0 && is_class_char(chars[cls_start - 1]) {
1650 cls_start -= 1;
1651 }
1652 if cls_start == cls_end_trimmed {
1653 return None;
1654 }
1655 let full_class: String = chars[cls_start..cls_end_trimmed].iter().collect();
1656 let short = full_class
1657 .rsplit('\\')
1658 .next()
1659 .unwrap_or(&full_class)
1660 .to_owned();
1661 Some(NamedArgCallee::StaticMethod(short, name))
1662 } else {
1663 Some(NamedArgCallee::Function(name))
1664 }
1665}
1666
1667fn named_arg_hover_value(
1671 source: &str,
1672 doc: &ParsedDoc,
1673 doc_returns: &MethodReturnsMap,
1674 other_docs: &[(
1675 tower_lsp::lsp_types::Url,
1676 std::sync::Arc<crate::ast::ParsedDoc>,
1677 std::sync::Arc<crate::ast::MethodReturnsMap>,
1678 )],
1679 position: Position,
1680 callee: &NamedArgCallee,
1681 label: &str,
1682) -> Option<String> {
1683 let all_docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d, _)| d.as_ref()));
1684
1685 match callee {
1686 NamedArgCallee::Function(name) => {
1687 for d in all_docs() {
1688 if let Some((sig, db)) =
1689 find_param_sig_in_stmts(d.source(), &d.program().stmts, name, None, label)
1690 {
1691 return Some(format_named_param_hover(&sig, db.as_ref(), label));
1692 }
1693 }
1694 None
1695 }
1696 NamedArgCallee::Method(receiver_var, method_name) => {
1697 let type_map = crate::type_map::TypeMap::from_docs_at_position(
1698 doc,
1699 doc_returns,
1700 other_docs.iter().map(|(_, d, r)| (d.as_ref(), r.as_ref())),
1701 None,
1702 position,
1703 );
1704 let class_name = if receiver_var == "$this" {
1705 crate::type_map::enclosing_class_at(source, doc, position)
1706 .or_else(|| type_map.get(receiver_var).map(|s| s.to_string()))
1707 } else {
1708 type_map.get(receiver_var.as_str()).map(|s| s.to_string())
1709 }?;
1710 let first_class = class_name
1711 .split('|')
1712 .next()
1713 .unwrap_or(&class_name)
1714 .to_owned();
1715 for d in all_docs() {
1716 if let Some((sig, db)) = find_param_sig_in_stmts(
1717 d.source(),
1718 &d.program().stmts,
1719 method_name,
1720 Some(&first_class),
1721 label,
1722 ) {
1723 return Some(format_named_param_hover(&sig, db.as_ref(), label));
1724 }
1725 }
1726 None
1727 }
1728 NamedArgCallee::StaticMethod(class_name, method_name) => {
1729 let effective_class = if class_name == "self" || class_name == "static" {
1730 crate::type_map::enclosing_class_at(source, doc, position)
1731 .unwrap_or_else(|| class_name.clone())
1732 } else if class_name == "parent" {
1733 crate::type_map::enclosing_class_at(source, doc, position)
1734 .and_then(|enc| find_parent_class_name(&doc.program().stmts, &enc))
1735 .unwrap_or_else(|| class_name.clone())
1736 } else {
1737 class_name.clone()
1738 };
1739 for d in all_docs() {
1740 if let Some((sig, db)) = find_param_sig_in_stmts(
1741 d.source(),
1742 &d.program().stmts,
1743 method_name,
1744 Some(&effective_class),
1745 label,
1746 ) {
1747 return Some(format_named_param_hover(&sig, db.as_ref(), label));
1748 }
1749 }
1750 None
1751 }
1752 }
1753}
1754
1755fn find_param_sig_in_stmts(
1760 source: &str,
1761 stmts: &[Stmt<'_, '_>],
1762 callee_name: &str,
1763 class_name: Option<&str>,
1764 label: &str,
1765) -> Option<(String, Option<crate::docblock::Docblock>)> {
1766 for stmt in stmts {
1767 match &stmt.kind {
1768 StmtKind::Function(f) if class_name.is_none() && f.name == callee_name => {
1769 let param = f.params.iter().find(|p| p.name == label)?;
1770 let sig = format_single_param(param);
1771 let db = crate::docblock::docblock_before(source, stmt.span.start)
1772 .map(|raw| crate::docblock::parse_docblock(&raw));
1773 return Some((sig, db));
1774 }
1775 StmtKind::Class(c) if class_name == c.name => {
1776 for member in c.members.iter() {
1777 if let ClassMemberKind::Method(m) = &member.kind
1778 && m.name == callee_name
1779 {
1780 let param = m.params.iter().find(|p| p.name == label)?;
1781 let sig = format_single_param(param);
1782 let db = crate::docblock::docblock_before(source, member.span.start)
1783 .map(|raw| crate::docblock::parse_docblock(&raw));
1784 return Some((sig, db));
1785 }
1786 }
1787 }
1788 StmtKind::Trait(t) if class_name == Some(t.name) => {
1789 for member in t.members.iter() {
1790 if let ClassMemberKind::Method(m) = &member.kind
1791 && m.name == callee_name
1792 {
1793 let param = m.params.iter().find(|p| p.name == label)?;
1794 let sig = format_single_param(param);
1795 let db = crate::docblock::docblock_before(source, member.span.start)
1796 .map(|raw| crate::docblock::parse_docblock(&raw));
1797 return Some((sig, db));
1798 }
1799 }
1800 }
1801 StmtKind::Namespace(ns) => {
1802 if let NamespaceBody::Braced(inner) = &ns.body
1803 && let Some(r) =
1804 find_param_sig_in_stmts(source, inner, callee_name, class_name, label)
1805 {
1806 return Some(r);
1807 }
1808 }
1809 _ => {}
1810 }
1811 }
1812 None
1813}
1814
1815fn format_single_param(p: &Param<'_, '_>) -> String {
1816 let mut s = String::new();
1817 if let Some(t) = &p.type_hint {
1818 s.push_str(&format_type_hint(t));
1819 s.push(' ');
1820 }
1821 if p.variadic {
1822 s.push_str("...");
1823 }
1824 s.push('$');
1825 s.push_str(p.name);
1826 if let Some(default) = &p.default {
1827 s.push_str(&format!(" = {}", format_default_value(default)));
1828 }
1829 s
1830}
1831
1832fn format_named_param_hover(
1833 sig: &str,
1834 db: Option<&crate::docblock::Docblock>,
1835 label: &str,
1836) -> String {
1837 let mut value = wrap_php(&format!("(parameter) {}", sig));
1838 if let Some(db) = db {
1840 let matching_param = db.params.iter().find(|p| {
1841 p.name == label
1842 || p.name == format!("${}", label)
1843 || p.name.trim_start_matches('$') == label
1844 });
1845 if let Some(param) = matching_param
1846 && !param.description.is_empty()
1847 {
1848 value.push_str(&format!("\n\n---\n\n{}", param.description));
1849 }
1850 }
1851 value
1852}
1853
1854fn closure_hover(source: &str, doc: &ParsedDoc, position: Position, word: &str) -> Option<String> {
1860 let line_starts = doc.line_starts();
1862 let line = position.line as usize;
1863 let line_start = *line_starts.get(line)? as usize;
1864 let col_byte =
1865 crate::util::utf16_offset_to_byte(&source[line_start..], position.character as usize);
1866 let cursor_byte = (line_start + col_byte) as u32;
1867
1868 find_closure_in_stmts(source, &doc.program().stmts, cursor_byte, word.len() as u32)
1869}
1870
1871fn find_closure_in_stmts(
1874 source: &str,
1875 stmts: &[Stmt<'_, '_>],
1876 cursor_byte: u32,
1877 word_len: u32,
1878) -> Option<String> {
1879 for stmt in stmts {
1880 if let Some(sig) = find_closure_in_stmt(source, stmt, cursor_byte, word_len) {
1881 return Some(sig);
1882 }
1883 }
1884 None
1885}
1886
1887fn find_closure_in_stmt(
1888 source: &str,
1889 stmt: &Stmt<'_, '_>,
1890 cursor_byte: u32,
1891 word_len: u32,
1892) -> Option<String> {
1893 if stmt.span.end < cursor_byte || stmt.span.start > cursor_byte + word_len {
1895 return None;
1896 }
1897 match &stmt.kind {
1898 StmtKind::Expression(expr) | StmtKind::Throw(expr) => {
1899 find_closure_in_expr(source, expr, cursor_byte, word_len)
1900 }
1901 StmtKind::Return(Some(expr)) => find_closure_in_expr(source, expr, cursor_byte, word_len),
1902 StmtKind::Function(f) => find_closure_in_stmts(source, &f.body, cursor_byte, word_len),
1903 StmtKind::Class(c) => {
1904 for member in c.members.iter() {
1905 if let ClassMemberKind::Method(m) = &member.kind
1906 && let Some(body) = &m.body
1907 && let Some(sig) = find_closure_in_stmts(source, body, cursor_byte, word_len)
1908 {
1909 return Some(sig);
1910 }
1911 }
1912 None
1913 }
1914 StmtKind::Namespace(ns) => {
1915 if let NamespaceBody::Braced(inner) = &ns.body {
1916 find_closure_in_stmts(source, inner, cursor_byte, word_len)
1917 } else {
1918 None
1919 }
1920 }
1921 StmtKind::Block(inner) => find_closure_in_stmts(source, inner, cursor_byte, word_len),
1922 StmtKind::If(i) => {
1923 if let Some(sig) = find_closure_in_expr(source, &i.condition, cursor_byte, word_len)
1924 .or_else(|| find_closure_in_stmt(source, i.then_branch, cursor_byte, word_len))
1925 {
1926 return Some(sig);
1927 }
1928 for ei in i.elseif_branches.iter() {
1929 if let Some(sig) =
1930 find_closure_in_expr(source, &ei.condition, cursor_byte, word_len)
1931 .or_else(|| find_closure_in_stmt(source, &ei.body, cursor_byte, word_len))
1932 {
1933 return Some(sig);
1934 }
1935 }
1936 if let Some(e) = &i.else_branch {
1937 find_closure_in_stmt(source, e, cursor_byte, word_len)
1938 } else {
1939 None
1940 }
1941 }
1942 StmtKind::While(w) => find_closure_in_expr(source, &w.condition, cursor_byte, word_len)
1943 .or_else(|| find_closure_in_stmt(source, w.body, cursor_byte, word_len)),
1944 StmtKind::DoWhile(d) => find_closure_in_stmt(source, d.body, cursor_byte, word_len)
1945 .or_else(|| find_closure_in_expr(source, &d.condition, cursor_byte, word_len)),
1946 StmtKind::For(f) => {
1947 for e in f.init.iter() {
1948 if let Some(sig) = find_closure_in_expr(source, e, cursor_byte, word_len) {
1949 return Some(sig);
1950 }
1951 }
1952 for e in f.condition.iter() {
1953 if let Some(sig) = find_closure_in_expr(source, e, cursor_byte, word_len) {
1954 return Some(sig);
1955 }
1956 }
1957 for e in f.update.iter() {
1958 if let Some(sig) = find_closure_in_expr(source, e, cursor_byte, word_len) {
1959 return Some(sig);
1960 }
1961 }
1962 find_closure_in_stmt(source, f.body, cursor_byte, word_len)
1963 }
1964 StmtKind::Foreach(f) => find_closure_in_expr(source, &f.expr, cursor_byte, word_len)
1965 .or_else(|| find_closure_in_stmt(source, f.body, cursor_byte, word_len)),
1966 StmtKind::TryCatch(t) => {
1967 if let Some(sig) = find_closure_in_stmts(source, &t.body, cursor_byte, word_len) {
1968 return Some(sig);
1969 }
1970 for catch in t.catches.iter() {
1971 if let Some(sig) = find_closure_in_stmts(source, &catch.body, cursor_byte, word_len)
1972 {
1973 return Some(sig);
1974 }
1975 }
1976 if let Some(finally) = &t.finally {
1977 find_closure_in_stmts(source, finally, cursor_byte, word_len)
1978 } else {
1979 None
1980 }
1981 }
1982 _ => None,
1983 }
1984}
1985
1986#[allow(clippy::only_used_in_recursion)]
1987fn find_closure_in_expr(
1988 source: &str,
1989 expr: &php_ast::Expr<'_, '_>,
1990 cursor_byte: u32,
1991 word_len: u32,
1992) -> Option<String> {
1993 if expr.span.end < cursor_byte || expr.span.start > cursor_byte + word_len {
1994 return None;
1995 }
1996 match &expr.kind {
1997 ExprKind::Closure(c) if c_span_matches(expr.span.start, cursor_byte, word_len) => {
1998 let params = format_params(&c.params);
1999 let ret = c
2000 .return_type
2001 .as_ref()
2002 .map(|r| format!(": {}", format_type_hint(r)))
2003 .unwrap_or_default();
2004 let static_kw = if c.is_static { "static " } else { "" };
2005 Some(format!("{}function({}){}", static_kw, params, ret))
2006 }
2007 ExprKind::ArrowFunction(af) if c_span_matches(expr.span.start, cursor_byte, word_len) => {
2008 let params = format_params(&af.params);
2009 let ret = af
2010 .return_type
2011 .as_ref()
2012 .map(|r| format!(": {}", format_type_hint(r)))
2013 .unwrap_or_default();
2014 let static_kw = if af.is_static { "static " } else { "" };
2015 Some(format!("{}fn({}){}", static_kw, params, ret))
2016 }
2017 ExprKind::Assign(a) => find_closure_in_expr(source, a.value, cursor_byte, word_len),
2018 ExprKind::FunctionCall(fc) => {
2019 if let Some(sig) = find_closure_in_expr(source, fc.name, cursor_byte, word_len) {
2020 return Some(sig);
2021 }
2022 for arg in fc.args.iter() {
2023 if let Some(sig) = find_closure_in_expr(source, &arg.value, cursor_byte, word_len) {
2024 return Some(sig);
2025 }
2026 }
2027 None
2028 }
2029 ExprKind::MethodCall(mc) => {
2030 for arg in mc.args.iter() {
2031 if let Some(sig) = find_closure_in_expr(source, &arg.value, cursor_byte, word_len) {
2032 return Some(sig);
2033 }
2034 }
2035 None
2036 }
2037 ExprKind::StaticMethodCall(smc) => {
2038 for arg in smc.args.iter() {
2039 if let Some(sig) = find_closure_in_expr(source, &arg.value, cursor_byte, word_len) {
2040 return Some(sig);
2041 }
2042 }
2043 None
2044 }
2045 ExprKind::Parenthesized(inner) => {
2046 find_closure_in_expr(source, inner, cursor_byte, word_len)
2047 }
2048 _ => None,
2049 }
2050}
2051
2052#[inline]
2056fn c_span_matches(span_start: u32, cursor_byte: u32, word_len: u32) -> bool {
2057 span_start <= cursor_byte && cursor_byte < span_start + word_len + 2
2058}
2059
2060#[cfg(test)]
2061mod tests {
2062 use super::*;
2063 use crate::test_utils::cursor;
2064 use crate::type_map::build_method_returns;
2065
2066 fn pos(line: u32, character: u32) -> Position {
2067 Position { line, character }
2068 }
2069
2070 #[test]
2071 fn hover_on_function_name_returns_signature() {
2072 let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
2073 let doc = ParsedDoc::parse(src.clone());
2074 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2075 assert!(result.is_some(), "expected hover result");
2076 if let Some(Hover {
2077 contents: HoverContents::Markup(mc),
2078 ..
2079 }) = result
2080 {
2081 assert!(
2082 mc.value.contains("function greet("),
2083 "expected function signature, got: {}",
2084 mc.value
2085 );
2086 }
2087 }
2088
2089 #[test]
2090 fn hover_on_class_name_returns_class_sig() {
2091 let (src, p) = cursor("<?php\nclass My$0Service {}");
2092 let doc = ParsedDoc::parse(src.clone());
2093 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2094 assert!(result.is_some(), "expected hover result");
2095 if let Some(Hover {
2096 contents: HoverContents::Markup(mc),
2097 ..
2098 }) = result
2099 {
2100 assert!(
2101 mc.value.contains("class MyService"),
2102 "expected class sig, got: {}",
2103 mc.value
2104 );
2105 }
2106 }
2107
2108 #[test]
2109 fn hover_on_unknown_word_returns_none() {
2110 let src = "<?php\n$unknown = 42;";
2111 let doc = ParsedDoc::parse(src.to_string());
2112 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 2), &[]);
2113 assert!(result.is_none(), "expected None for unknown word");
2114 }
2115
2116 #[test]
2117 fn hover_at_column_beyond_line_length_returns_none() {
2118 let src = "<?php\nfunction hi() {}";
2119 let doc = ParsedDoc::parse(src.to_string());
2120 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 999), &[]);
2121 assert!(result.is_none());
2122 }
2123
2124 #[test]
2125 fn word_at_extracts_from_middle_of_identifier() {
2126 let (src, p) = cursor("<?php\nfunction greet$0User() {}");
2127 let word = word_at(&src, p);
2128 assert_eq!(word.as_deref(), Some("greetUser"));
2129 }
2130
2131 #[test]
2132 fn hover_on_class_with_extends_shows_parent() {
2133 let src = "<?php\nclass Dog extends Animal {}";
2134 let doc = ParsedDoc::parse(src.to_string());
2135 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
2136 assert!(result.is_some());
2137 if let Some(Hover {
2138 contents: HoverContents::Markup(mc),
2139 ..
2140 }) = result
2141 {
2142 assert!(
2143 mc.value.contains("extends Animal"),
2144 "expected 'extends Animal', got: {}",
2145 mc.value
2146 );
2147 }
2148 }
2149
2150 #[test]
2151 fn hover_on_class_with_implements_shows_interfaces() {
2152 let src = "<?php\nclass Repo implements Countable, Serializable {}";
2153 let doc = ParsedDoc::parse(src.to_string());
2154 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
2155 assert!(result.is_some());
2156 if let Some(Hover {
2157 contents: HoverContents::Markup(mc),
2158 ..
2159 }) = result
2160 {
2161 assert!(
2162 mc.value.contains("implements Countable, Serializable"),
2163 "expected implements list, got: {}",
2164 mc.value
2165 );
2166 }
2167 }
2168
2169 #[test]
2170 fn hover_on_trait_returns_trait_sig() {
2171 let src = "<?php\ntrait Loggable {}";
2172 let doc = ParsedDoc::parse(src.to_string());
2173 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 8), &[]);
2174 assert!(result.is_some());
2175 if let Some(Hover {
2176 contents: HoverContents::Markup(mc),
2177 ..
2178 }) = result
2179 {
2180 assert!(
2181 mc.value.contains("trait Loggable"),
2182 "expected 'trait Loggable', got: {}",
2183 mc.value
2184 );
2185 }
2186 }
2187
2188 #[test]
2189 fn hover_on_interface_returns_interface_sig() {
2190 let src = "<?php\ninterface Serializable {}";
2191 let doc = ParsedDoc::parse(src.to_string());
2192 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 12), &[]);
2193 assert!(result.is_some(), "expected hover result");
2194 if let Some(Hover {
2195 contents: HoverContents::Markup(mc),
2196 ..
2197 }) = result
2198 {
2199 assert!(
2200 mc.value.contains("interface Serializable"),
2201 "expected interface sig, got: {}",
2202 mc.value
2203 );
2204 }
2205 }
2206
2207 #[test]
2208 fn function_with_no_params_no_return_shows_no_colon() {
2209 let src = "<?php\nfunction init() {}";
2210 let doc = ParsedDoc::parse(src.to_string());
2211 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 10), &[]);
2212 assert!(result.is_some());
2213 if let Some(Hover {
2214 contents: HoverContents::Markup(mc),
2215 ..
2216 }) = result
2217 {
2218 assert!(
2219 mc.value.contains("function init()"),
2220 "expected 'function init()', got: {}",
2221 mc.value
2222 );
2223 assert!(
2224 !mc.value.contains(':'),
2225 "should not contain ':' when no return type, got: {}",
2226 mc.value
2227 );
2228 }
2229 }
2230
2231 #[test]
2232 fn hover_on_enum_returns_enum_sig() {
2233 let src = "<?php\nenum Suit {}";
2234 let doc = ParsedDoc::parse(src.to_string());
2235 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 6), &[]);
2236 assert!(result.is_some());
2237 if let Some(Hover {
2238 contents: HoverContents::Markup(mc),
2239 ..
2240 }) = result
2241 {
2242 assert!(
2243 mc.value.contains("enum Suit"),
2244 "expected 'enum Suit', got: {}",
2245 mc.value
2246 );
2247 }
2248 }
2249
2250 #[test]
2251 fn hover_on_enum_with_implements_shows_interface() {
2252 let src = "<?php\nenum Status: string implements Stringable {}";
2253 let doc = ParsedDoc::parse(src.to_string());
2254 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 6), &[]);
2255 assert!(result.is_some());
2256 if let Some(Hover {
2257 contents: HoverContents::Markup(mc),
2258 ..
2259 }) = result
2260 {
2261 assert!(
2262 mc.value.contains("implements Stringable"),
2263 "expected implements clause, got: {}",
2264 mc.value
2265 );
2266 }
2267 }
2268
2269 #[test]
2270 fn hover_on_enum_case_shows_case_sig() {
2271 let src = "<?php\nenum Status { case Active; case Inactive; }";
2272 let doc = ParsedDoc::parse(src.to_string());
2273 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 21), &[]);
2275 assert!(result.is_some(), "expected hover on enum case");
2276 if let Some(Hover {
2277 contents: HoverContents::Markup(mc),
2278 ..
2279 }) = result
2280 {
2281 assert!(
2282 mc.value.contains("Status::Active"),
2283 "expected 'Status::Active', got: {}",
2284 mc.value
2285 );
2286 }
2287 }
2288
2289 #[test]
2290 fn snapshot_hover_backed_enum_case_shows_value() {
2291 check_hover(
2292 "<?php\nenum Color: string { case Red = 'red'; }",
2293 pos(1, 27),
2294 expect![[r#"
2295 ```php
2296 case Color::Red = 'red'
2297 ```"#]],
2298 );
2299 }
2300
2301 #[test]
2302 fn snapshot_hover_enum_class_const() {
2303 check_hover(
2304 "<?php\nenum Suit { const int MAX = 4; }",
2305 pos(1, 22),
2306 expect![[r#"
2307 ```php
2308 const int MAX = 4
2309 ```"#]],
2310 );
2311 }
2312
2313 #[test]
2314 fn hover_on_trait_method_returns_signature() {
2315 let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
2316 let doc = ParsedDoc::parse(src.to_string());
2317 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 34), &[]);
2319 assert!(result.is_some(), "expected hover on trait method");
2320 if let Some(Hover {
2321 contents: HoverContents::Markup(mc),
2322 ..
2323 }) = result
2324 {
2325 assert!(
2326 mc.value.contains("function log("),
2327 "expected function sig, got: {}",
2328 mc.value
2329 );
2330 }
2331 }
2332
2333 #[test]
2334 fn cross_file_hover_finds_class_in_other_doc() {
2335 use std::sync::Arc;
2336 let src = "<?php\n$x = new PaymentService();";
2337 let other_src = "<?php\nclass PaymentService { public function charge() {} }";
2338 let doc = ParsedDoc::parse(src.to_string());
2339 let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
2340 let other_mr = Arc::new(build_method_returns(&other_doc));
2341 let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
2342 let other_docs = vec![(uri, other_doc, other_mr)];
2343 let result = hover_info(
2345 src,
2346 &doc,
2347 &build_method_returns(&doc),
2348 pos(1, 12),
2349 &other_docs,
2350 );
2351 assert!(result.is_some(), "expected cross-file hover result");
2352 if let Some(Hover {
2353 contents: HoverContents::Markup(mc),
2354 ..
2355 }) = result
2356 {
2357 assert!(
2358 mc.value.contains("PaymentService"),
2359 "expected 'PaymentService', got: {}",
2360 mc.value
2361 );
2362 }
2363 }
2364
2365 #[test]
2366 fn hover_on_variable_shows_type() {
2367 let src = "<?php\n$obj = new Mailer();\n$obj";
2368 let doc = ParsedDoc::parse(src.to_string());
2369 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(2, 2));
2370 assert!(h.is_some());
2371 let text = match h.unwrap().contents {
2372 HoverContents::Markup(m) => m.value,
2373 _ => String::new(),
2374 };
2375 assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
2376 }
2377
2378 #[test]
2379 fn hover_on_builtin_class_shows_stub_info() {
2380 let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
2381 let doc = ParsedDoc::parse(src.to_string());
2382 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(1, 12));
2383 assert!(h.is_some(), "should hover on PDO");
2384 let text = match h.unwrap().contents {
2385 HoverContents::Markup(m) => m.value,
2386 _ => String::new(),
2387 };
2388 assert!(text.contains("PDO"), "hover should mention PDO");
2389 }
2390
2391 #[test]
2392 fn hover_on_property_shows_type() {
2393 let src = "<?php\nclass User { public string $name; public int $age; }\n$u = new User();\n$u->name";
2394 let doc = ParsedDoc::parse(src.to_string());
2395 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(3, 5));
2397 assert!(h.is_some(), "expected hover on property");
2398 let text = match h.unwrap().contents {
2399 HoverContents::Markup(m) => m.value,
2400 _ => String::new(),
2401 };
2402 assert!(text.contains("User"), "should mention class name");
2403 assert!(text.contains("name"), "should mention property name");
2404 assert!(text.contains("string"), "should show type hint");
2405 }
2406
2407 #[test]
2408 fn hover_on_promoted_property_shows_type() {
2409 let src = "<?php\nclass Point {\n public function __construct(\n public float $x,\n public float $y,\n ) {}\n}\n$p = new Point(1.0, 2.0);\n$p->x";
2410 let doc = ParsedDoc::parse(src.to_string());
2411 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(8, 4));
2413 assert!(h.is_some(), "expected hover on promoted property");
2414 let text = match h.unwrap().contents {
2415 HoverContents::Markup(m) => m.value,
2416 _ => String::new(),
2417 };
2418 assert!(text.contains("Point"), "should mention class name");
2419 assert!(text.contains("x"), "should mention property name");
2420 assert!(
2421 text.contains("float"),
2422 "should show type hint for promoted property"
2423 );
2424 }
2425
2426 #[test]
2427 fn hover_on_promoted_property_shows_only_its_param_docblock() {
2428 let src = "<?php\nclass User {\n /**\n * Create a user.\n * @param string $name The user's display name\n * @param int $age The user's age\n * @return void\n * @throws \\InvalidArgumentException\n */\n public function __construct(\n public string $name,\n public int $age,\n ) {}\n}\n$u = new User('Alice', 30);\n$u->name";
2432 let doc = ParsedDoc::parse(src.to_string());
2433 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(15, 4));
2435 assert!(h.is_some(), "expected hover on promoted property");
2436 let text = match h.unwrap().contents {
2437 HoverContents::Markup(m) => m.value,
2438 _ => String::new(),
2439 };
2440 assert!(
2441 text.contains("@param") && text.contains("$name"),
2442 "should show @param for $name"
2443 );
2444 assert!(
2445 !text.contains("$age"),
2446 "should NOT show @param for other parameters"
2447 );
2448 assert!(
2449 !text.contains("@return"),
2450 "should NOT show @return from constructor docblock"
2451 );
2452 assert!(
2453 !text.contains("@throws"),
2454 "should NOT show @throws from constructor docblock"
2455 );
2456 assert!(
2457 !text.contains("Create a user"),
2458 "should NOT show constructor description"
2459 );
2460 }
2461
2462 #[test]
2463 fn hover_on_promoted_property_with_no_param_docblock_shows_type_only() {
2464 let src = "<?php\nclass User {\n /**\n * Create a user.\n * @return void\n */\n public function __construct(\n public string $name,\n ) {}\n}\n$u = new User('Alice');\n$u->name";
2467 let doc = ParsedDoc::parse(src.to_string());
2468 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(11, 4));
2469 assert!(h.is_some(), "expected hover on promoted property");
2470 let text = match h.unwrap().contents {
2471 HoverContents::Markup(m) => m.value,
2472 _ => String::new(),
2473 };
2474 assert!(text.contains("string"), "should show type hint");
2475 assert!(
2476 !text.contains("---"),
2477 "should not append a docblock section"
2478 );
2479 }
2480
2481 #[test]
2482 fn hover_on_use_alias_shows_fqn() {
2483 let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
2484 let doc = ParsedDoc::parse(src.to_string());
2485 let h = hover_at(
2486 src,
2487 &doc,
2488 &build_method_returns(&doc),
2489 &[],
2490 Position {
2491 line: 1,
2492 character: 20,
2493 },
2494 );
2495 assert!(h.is_some());
2496 let text = match h.unwrap().contents {
2497 HoverContents::Markup(m) => m.value,
2498 _ => String::new(),
2499 };
2500 assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
2501 }
2502
2503 #[test]
2504 fn hover_unknown_symbol_returns_none() {
2505 let src = "<?php\nunknownFunc();";
2507 let doc = ParsedDoc::parse(src.to_string());
2508 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 3), &[]);
2509 assert!(
2510 result.is_none(),
2511 "hover on undefined symbol should return None"
2512 );
2513 }
2514
2515 #[test]
2516 fn hover_on_builtin_function_returns_signature() {
2517 let src = "<?php\nstrlen('hello');";
2520 let doc = ParsedDoc::parse(src.to_string());
2521 let result = hover_info(src, &doc, &build_method_returns(&doc), pos(1, 3), &[]);
2522 let h = result.expect("expected hover result for built-in 'strlen'");
2523 let text = match h.contents {
2524 HoverContents::Markup(mc) => mc.value,
2525 _ => String::new(),
2526 };
2527 assert!(
2528 !text.is_empty(),
2529 "hover on strlen should return non-empty content"
2530 );
2531 assert!(
2532 text.contains("strlen"),
2533 "hover content should contain 'strlen', got: {text}"
2534 );
2535 }
2536
2537 #[test]
2538 fn hover_on_property_shows_docblock() {
2539 let src = "<?php\nclass User {\n /** The user's display name. */\n public string $name;\n}\n$u = new User();\n$u->name";
2540 let doc = ParsedDoc::parse(src.to_string());
2541 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
2543 assert!(h.is_some(), "expected hover on property with docblock");
2544 let text = match h.unwrap().contents {
2545 HoverContents::Markup(m) => m.value,
2546 _ => String::new(),
2547 };
2548 assert!(text.contains("User"), "should mention class name");
2549 assert!(text.contains("name"), "should mention property name");
2550 assert!(text.contains("string"), "should show type hint");
2551 assert!(
2552 text.contains("display name"),
2553 "should include docblock description, got: {}",
2554 text
2555 );
2556 }
2557
2558 #[test]
2559 fn hover_on_property_with_var_tag_shows_type_annotation() {
2560 let src = "<?php\nclass User {\n /** @var string */\n public $name;\n}\n$u = new User();\n$u->name";
2564 let doc = ParsedDoc::parse(src.to_string());
2565 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
2566 assert!(h.is_some(), "expected hover on @var-only property");
2567 let text = match h.unwrap().contents {
2568 HoverContents::Markup(m) => m.value,
2569 _ => String::new(),
2570 };
2571 assert!(
2572 text.contains("@var"),
2573 "should show @var annotation, got: {}",
2574 text
2575 );
2576 assert!(
2577 text.contains("string"),
2578 "should show var type, got: {}",
2579 text
2580 );
2581 }
2582
2583 #[test]
2584 fn hover_on_property_with_var_tag_and_description() {
2585 let src = "<?php\nclass User {\n /** @var string The display name. */\n public $name;\n}\n$u = new User();\n$u->name";
2586 let doc = ParsedDoc::parse(src.to_string());
2587 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(6, 5));
2588 assert!(
2589 h.is_some(),
2590 "expected hover on property with @var description"
2591 );
2592 let text = match h.unwrap().contents {
2593 HoverContents::Markup(m) => m.value,
2594 _ => String::new(),
2595 };
2596 assert!(
2597 text.contains("@var"),
2598 "should show @var annotation, got: {}",
2599 text
2600 );
2601 assert!(
2602 text.contains("The display name"),
2603 "should show @var description, got: {}",
2604 text
2605 );
2606 }
2607
2608 #[test]
2609 fn hover_on_this_property_shows_type() {
2610 let src = "<?php\nclass Counter {\n public int $count = 0;\n public function increment(): void {\n $this->count;\n }\n}";
2611 let doc = ParsedDoc::parse(src.to_string());
2612 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(4, 16));
2614 assert!(h.is_some(), "expected hover on $this->property");
2615 let text = match h.unwrap().contents {
2616 HoverContents::Markup(m) => m.value,
2617 _ => String::new(),
2618 };
2619 assert!(text.contains("Counter"), "should mention enclosing class");
2620 assert!(text.contains("count"), "should mention property name");
2621 assert!(text.contains("int"), "should show type hint");
2622 }
2623
2624 #[test]
2625 fn hover_on_nullsafe_property_shows_type() {
2626 let src = "<?php\nclass Profile { public string $bio; }\n$p = new Profile();\n$p?->bio";
2627 let doc = ParsedDoc::parse(src.to_string());
2628 let h = hover_at(src, &doc, &build_method_returns(&doc), &[], pos(3, 5));
2630 assert!(h.is_some(), "expected hover on nullsafe property access");
2631 let text = match h.unwrap().contents {
2632 HoverContents::Markup(m) => m.value,
2633 _ => String::new(),
2634 };
2635 assert!(text.contains("Profile"), "should mention class name");
2636 assert!(text.contains("bio"), "should mention property name");
2637 assert!(text.contains("string"), "should show type hint");
2638 }
2639
2640 use expect_test::{Expect, expect};
2643
2644 fn check_hover(src: &str, position: Position, expect: Expect) {
2645 let doc = ParsedDoc::parse(src.to_string());
2646 let result = hover_info(src, &doc, &build_method_returns(&doc), position, &[]);
2647 let actual = match result {
2648 Some(Hover {
2649 contents: HoverContents::Markup(mc),
2650 ..
2651 }) => mc.value,
2652 Some(_) => "(non-markup hover)".to_string(),
2653 None => "(no hover)".to_string(),
2654 };
2655 expect.assert_eq(&actual);
2656 }
2657
2658 #[test]
2659 fn snapshot_hover_simple_function() {
2660 check_hover(
2661 "<?php\nfunction init() {}",
2662 pos(1, 10),
2663 expect![[r#"
2664 ```php
2665 function init()
2666 ```"#]],
2667 );
2668 }
2669
2670 #[test]
2671 fn snapshot_hover_function_with_return_type() {
2672 check_hover(
2673 "<?php\nfunction greet(string $name): string {}",
2674 pos(1, 10),
2675 expect![[r#"
2676 ```php
2677 function greet(string $name): string
2678 ```"#]],
2679 );
2680 }
2681
2682 #[test]
2683 fn snapshot_hover_class() {
2684 check_hover(
2685 "<?php\nclass MyService {}",
2686 pos(1, 8),
2687 expect![[r#"
2688 ```php
2689 class MyService
2690 ```"#]],
2691 );
2692 }
2693
2694 #[test]
2695 fn snapshot_hover_class_with_extends() {
2696 check_hover(
2697 "<?php\nclass Dog extends Animal {}",
2698 pos(1, 8),
2699 expect![[r#"
2700 ```php
2701 class Dog extends Animal
2702 ```"#]],
2703 );
2704 }
2705
2706 #[test]
2707 fn snapshot_hover_method() {
2708 check_hover(
2709 "<?php\nclass Calc { public function add(int $a, int $b): int {} }",
2710 pos(1, 32),
2711 expect![[r#"
2712 ```php
2713 public function add(int $a, int $b): int
2714 ```"#]],
2715 );
2716 }
2717
2718 #[test]
2719 fn snapshot_hover_trait() {
2720 check_hover(
2721 "<?php\ntrait Loggable {}",
2722 pos(1, 8),
2723 expect![[r#"
2724 ```php
2725 trait Loggable
2726 ```"#]],
2727 );
2728 }
2729
2730 #[test]
2731 fn snapshot_hover_interface() {
2732 check_hover(
2733 "<?php\ninterface Serializable {}",
2734 pos(1, 12),
2735 expect![[r#"
2736 ```php
2737 interface Serializable
2738 ```"#]],
2739 );
2740 }
2741
2742 #[test]
2743 fn snapshot_hover_class_const_with_type_hint() {
2744 check_hover(
2745 "<?php\nclass Config { const string VERSION = '1.0.0'; }",
2746 pos(1, 28),
2747 expect![[r#"
2748 ```php
2749 const string VERSION = '1.0.0'
2750 ```"#]],
2751 );
2752 }
2753
2754 #[test]
2755 fn snapshot_hover_class_const_float_value() {
2756 check_hover(
2757 "<?php\nclass Math { const float PI = 3.14; }",
2758 pos(1, 27),
2759 expect![[r#"
2760 ```php
2761 const float PI = 3.14
2762 ```"#]],
2763 );
2764 }
2765
2766 #[test]
2767 fn snapshot_hover_class_const_infers_type_from_value() {
2768 let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
2769 check_hover(
2770 &src,
2771 p,
2772 expect![[r#"
2773 ```php
2774 const string VERSION = '1.0.0'
2775 ```"#]],
2776 );
2777 }
2778
2779 #[test]
2780 fn snapshot_hover_interface_const_shows_type_and_value() {
2781 let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
2782 check_hover(
2783 &src,
2784 p,
2785 expect![[r#"
2786 ```php
2787 const int MAX = 100
2788 ```"#]],
2789 );
2790 }
2791
2792 #[test]
2793 fn snapshot_hover_trait_const_shows_type_and_value() {
2794 let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
2795 check_hover(
2796 &src,
2797 p,
2798 expect![[r#"
2799 ```php
2800 const string TAG = 'v1'
2801 ```"#]],
2802 );
2803 }
2804
2805 #[test]
2806 fn hover_on_catch_variable_shows_exception_class() {
2807 let (src, p) = cursor("<?php\ntry { } catch (RuntimeException $e$0) { }");
2808 let doc = ParsedDoc::parse(src.clone());
2809 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2810 assert!(result.is_some(), "expected hover result for catch variable");
2811 if let Some(Hover {
2812 contents: HoverContents::Markup(mc),
2813 ..
2814 }) = result
2815 {
2816 assert!(
2817 mc.value.contains("RuntimeException"),
2818 "expected RuntimeException in hover, got: {}",
2819 mc.value
2820 );
2821 }
2822 }
2823
2824 #[test]
2825 fn hover_on_static_var_with_array_default_shows_array() {
2826 let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
2827 let doc = ParsedDoc::parse(src.clone());
2828 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2829 assert!(
2830 result.is_some(),
2831 "expected hover result for static variable"
2832 );
2833 if let Some(Hover {
2834 contents: HoverContents::Markup(mc),
2835 ..
2836 }) = result
2837 {
2838 assert!(
2839 mc.value.contains("array"),
2840 "expected array type in hover, got: {}",
2841 mc.value
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn hover_on_static_var_with_new_shows_class() {
2848 let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
2849 let doc = ParsedDoc::parse(src.clone());
2850 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2851 assert!(
2852 result.is_some(),
2853 "expected hover result for static variable"
2854 );
2855 if let Some(Hover {
2856 contents: HoverContents::Markup(mc),
2857 ..
2858 }) = result
2859 {
2860 assert!(
2861 mc.value.contains("MyService"),
2862 "expected MyService in hover, got: {}",
2863 mc.value
2864 );
2865 }
2866 }
2867
2868 #[test]
2870 fn hover_variable_in_method_does_not_leak_across_methods() {
2871 let (src, p) = cursor(concat!(
2874 "<?php\n",
2875 "class Service {\n",
2876 " public function methodA(): void { $result = new Widget(); }\n",
2877 " public function methodB(): void { $res$0ult = new Invoice(); }\n",
2878 "}\n",
2879 ));
2880 let doc = ParsedDoc::parse(src.clone());
2881 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2882 if let Some(Hover {
2883 contents: HoverContents::Markup(mc),
2884 ..
2885 }) = result
2886 {
2887 assert!(
2888 !mc.value.contains("Widget"),
2889 "Widget from methodA must not appear in methodB hover, got: {}",
2890 mc.value
2891 );
2892 assert!(
2893 mc.value.contains("Invoice"),
2894 "Invoice from methodB should appear in hover, got: {}",
2895 mc.value
2896 );
2897 }
2898 }
2899
2900 #[test]
2902 fn hover_method_call_shows_correct_class_signature() {
2903 let (src, p) = cursor(concat!(
2906 "<?php\n",
2907 "class Mailer { public function process(string $to): bool {} }\n",
2908 "class Queue { public function process(int $id): void {} }\n",
2909 "$mailer = new Mailer();\n",
2910 "$mailer->proc$0ess();\n",
2911 ));
2912 let doc = ParsedDoc::parse(src.clone());
2913 let result = hover_info(&src, &doc, &build_method_returns(&doc), p, &[]);
2914 assert!(result.is_some(), "expected hover on method call");
2915 if let Some(Hover {
2916 contents: HoverContents::Markup(mc),
2917 ..
2918 }) = result
2919 {
2920 assert!(
2921 mc.value.contains("Mailer::process"),
2922 "should show Mailer::process, got: {}",
2923 mc.value
2924 );
2925 assert!(
2926 mc.value.contains("string $to"),
2927 "should show Mailer's params, got: {}",
2928 mc.value
2929 );
2930 assert!(
2931 !mc.value.contains("int $id"),
2932 "must NOT show Queue::process params, got: {}",
2933 mc.value
2934 );
2935 }
2936 }
2937}