Skip to main content

php_lsp/hover/
hover_impl.rs

1use std::cell::OnceCell;
2use std::sync::Arc;
3
4use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position, Url};
5
6use crate::ast::ParsedDoc;
7use crate::docblock::find_docblock;
8use crate::resolve::{Declaration, resolve_declaration};
9use crate::symbol_map::{SymbolMap, is_hoverable_kind};
10use crate::type_map::TypeMap;
11use crate::util::{fqn_short_name, is_php_builtin, php_doc_url, word_at_position, word_range_at};
12
13use super::closures::closure_hover;
14use super::formatting::{declaration_signature, wrap_php};
15use super::members::{
16    find_property_info, resolve_method_docblock, scan_class_const_of_class,
17    scan_enum_case_of_class, scan_method_of_class,
18};
19use super::named_args::{extract_named_arg_callee, is_named_arg_at, named_arg_hover_value};
20use super::parsing::{extract_static_class_before_cursor, resolve_use_alias};
21
22/// Hover handles every declaration kind except properties (covered by the
23/// dedicated mir-primary path) and promoted parameters.
24fn is_hoverable(decl: &Declaration<'_>) -> bool {
25    !matches!(
26        decl,
27        Declaration::Property { .. } | Declaration::PromotedParam { .. }
28    )
29}
30
31pub(crate) fn hover_info(
32    source: &str,
33    doc: &ParsedDoc,
34    analysis: Option<&mir_analyzer::FileAnalysis>,
35    position: Position,
36    other_docs: &[(Url, Arc<ParsedDoc>)],
37) -> Option<Hover> {
38    hover_at(source, doc, analysis, other_docs, position)
39}
40
41/// Indexed variant: uses pre-computed [`SymbolMap`]s for the cross-file
42/// declaration lookup (path 4/5), eliminating repeated AST walks on stable
43/// files. All other paths (named-arg hover, mir-member hover, static-prop
44/// hover) still use `other_docs` since they require full AST traversal.
45pub fn hover_info_with_maps(
46    source: &str,
47    doc: &ParsedDoc,
48    analysis: Option<&mir_analyzer::FileAnalysis>,
49    position: Position,
50    other_docs: &[(Url, Arc<ParsedDoc>)],
51    other_maps: &[(Url, Arc<SymbolMap>)],
52) -> Option<Hover> {
53    hover_at_with_maps(source, doc, analysis, other_docs, other_maps, position)
54}
55
56/// Full hover implementation.
57pub fn hover_at(
58    source: &str,
59    doc: &ParsedDoc,
60    analysis: Option<&mir_analyzer::FileAnalysis>,
61    other_docs: &[(Url, Arc<ParsedDoc>)],
62    position: Position,
63) -> Option<Hover> {
64    let hover_range = word_range_at(source, position);
65
66    // Hover on a `use` line shows the full FQN — check before word_at since the
67    // cursor may be past the last word boundary.
68    if let Some(line_text) = source.lines().nth(position.line as usize) {
69        let trimmed = line_text.trim();
70        if trimmed.starts_with("use ") {
71            let (prefix, content) = if trimmed.starts_with("use function ") {
72                (
73                    "use function ",
74                    trimmed.strip_prefix("use function ").unwrap_or(""),
75                )
76            } else if trimmed.starts_with("use const ") {
77                (
78                    "use const ",
79                    trimmed.strip_prefix("use const ").unwrap_or(""),
80                )
81            } else {
82                ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
83            };
84            let fqn = content.trim_end_matches(';').trim();
85            if !fqn.is_empty() {
86                let maybe_word = word_at_position(source, position);
87                let alias = fqn_short_name(fqn);
88                let matches = match &maybe_word {
89                    Some(w) => w == alias || fqn.contains(w.as_str()),
90                    None => true,
91                };
92                if matches {
93                    return Some(Hover {
94                        contents: HoverContents::Markup(MarkupContent {
95                            kind: MarkupKind::Markdown,
96                            value: format!("`{}{};`", prefix, fqn),
97                        }),
98                        range: hover_range,
99                    });
100                }
101            }
102        }
103    }
104
105    let word = word_at_position(source, position)?;
106
107    // Keyword hover — must be checked before the static-access path so that
108    // `static::foo()` still falls through.  The `::` guard prevents this branch
109    // from firing for `Class::static` or `self::method`.
110    if let Some(line_text) = source.lines().nth(position.line as usize)
111        && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
112    {
113        let keyword_doc: Option<&str> = match word.as_str() {
114            "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
115            "null" => Some("`null` — the null value; a variable has no value"),
116            "true" => Some("`true` — boolean true"),
117            "false" => Some("`false` — boolean false"),
118            "abstract" => Some(
119                "`abstract` — declares an abstract class or method that must be implemented by a subclass",
120            ),
121            "readonly" => {
122                Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
123            }
124            "yield" => Some("`yield` — produces a value from a generator function"),
125            "never" => Some(
126                "`never` — return type indicating the function always throws or exits (PHP 8.1)",
127            ),
128            "throw" => {
129                Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
130            }
131            _ => None,
132        };
133        if let Some(doc_str) = keyword_doc {
134            return Some(Hover {
135                contents: HoverContents::Markup(MarkupContent {
136                    kind: MarkupKind::Markdown,
137                    value: doc_str.to_string(),
138                }),
139                range: hover_range,
140            });
141        }
142    }
143
144    // TypeMap is expensive; build lazily and reuse across branches (including
145    // named-arg resolution below, which also needs a TypeMap as a mir fallback).
146    let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
147    let type_map =
148        || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
149
150    // Named argument hover: `foo(label: $x)` — hovering the label shows the
151    // parameter type and description.
152    if let Some(line_text) = source.lines().nth(position.line as usize)
153        && !word.starts_with('$')
154        && is_named_arg_at(line_text, position.character as usize, &word)
155        && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
156        && let Some(value) = named_arg_hover_value(
157            source,
158            doc,
159            other_docs,
160            position,
161            &callee,
162            &word,
163            analysis,
164            &type_map_cell,
165        )
166    {
167        return Some(Hover {
168            contents: HoverContents::Markup(MarkupContent {
169                kind: MarkupKind::Markdown,
170                value,
171            }),
172            range: hover_range,
173        });
174    }
175
176    // Hover on $variable shows its inferred type. mir-primary: when mir
177    // recorded a class-typed symbol, show its full rendering (generics, unions,
178    // narrowing — e.g. `Collection<User>`, `Foo|Bar`). Scalar/`mixed` types are
179    // skipped so we fall through to the TypeMap short-name fallback (covers
180    // bindings where mir records no class-typed symbol).
181    if word.starts_with('$') {
182        if let Some(ty) = analysis.and_then(|a| {
183            let off =
184                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
185            crate::type_query::type_at_offset(a, off)
186        }) && !crate::type_query::class_names(ty).is_empty()
187        {
188            return Some(Hover {
189                contents: HoverContents::Markup(MarkupContent {
190                    kind: MarkupKind::Markdown,
191                    value: format!("`{word}` `{ty}`"),
192                }),
193                range: hover_range,
194            });
195        }
196        if let Some(class_name) = type_map().get(&word) {
197            return Some(Hover {
198                contents: HoverContents::Markup(MarkupContent {
199                    kind: MarkupKind::Markdown,
200                    value: format!("`{}` `{}`", word, class_name),
201                }),
202                range: hover_range,
203            });
204        }
205    }
206
207    // Hover on ClassName::$staticProp — word begins with '$' but is not a local var.
208    if word.starts_with('$')
209        && let Some(line_text) = source.lines().nth(position.line as usize)
210        && let Some(class_name) =
211            extract_static_class_before_cursor(line_text, position.character as usize)
212    {
213        let prop_name = word.trim_start_matches('$');
214        let effective_class = if class_name == "self" || class_name == "static" {
215            crate::type_map::enclosing_class_at(source, doc, position).unwrap_or(class_name.clone())
216        } else {
217            class_name.clone()
218        };
219        for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
220            if let Some((modifiers, type_str, db)) =
221                find_property_info(d, &effective_class, prop_name)
222            {
223                let sig = format!(
224                    "(property) {}{}::${}{}",
225                    modifiers,
226                    effective_class,
227                    prop_name,
228                    if type_str.is_empty() {
229                        String::new()
230                    } else {
231                        format!(": {}", type_str)
232                    }
233                );
234                let mut value = wrap_php(&sig);
235                if let Some(doc) = db {
236                    let md = doc.to_markdown();
237                    if !md.is_empty() {
238                        value.push_str("\n\n---\n\n");
239                        value.push_str(&md);
240                    }
241                }
242                return Some(Hover {
243                    contents: HoverContents::Markup(MarkupContent {
244                        kind: MarkupKind::Markdown,
245                        value,
246                    }),
247                    range: hover_range,
248                });
249            }
250        }
251    }
252
253    // mir-primary member hover: when mir recorded a resolved symbol at the
254    // cursor position, use its class name directly instead of extracting the
255    // receiver via TypeMap. This handles chains like `$a->b()->c()` where
256    // TypeMap has no variable to look up.
257    if !word.starts_with('$')
258        && let Some(sym) = analysis.and_then(|a| {
259            let off =
260                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
261            a.symbol_at(off)
262        })
263    {
264        let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
265        if mir_hover.is_some() {
266            return mir_hover.map(|value| Hover {
267                contents: HoverContents::Markup(MarkupContent {
268                    kind: MarkupKind::Markdown,
269                    value,
270                }),
271                range: hover_range,
272            });
273        }
274    }
275
276    // Closure / arrow function hover: `function($x) {}` or `fn($x) => $x`.
277    // Must run before `scan_statements` so the keyword doesn't fall through to
278    // the named-function path (which won't find anything for an anonymous fn).
279    if (word == "function" || word == "fn")
280        && let Some(sig) = closure_hover(source, doc, position, &word)
281    {
282        return Some(Hover {
283            contents: HoverContents::Markup(MarkupContent {
284                kind: MarkupKind::Markdown,
285                value: wrap_php(&sig),
286            }),
287            range: hover_range,
288        });
289    }
290
291    // Resolve use-import aliases: `use Foo\Bar as Baz` — hovering on `Baz`
292    // should show what `Bar` is.
293    let all_stmts = &*doc.program().stmts as &[_];
294    let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
295
296    // Search current document first, then cross-file (using resolved name).
297    let found = resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
298        .and_then(|d| declaration_signature(&d, &resolved_word))
299        .map(|sig| (sig, source, doc));
300    let found = found.or_else(|| {
301        for (_, other) in other_docs {
302            if let Some(sig) =
303                resolve_declaration(&other.program().stmts, &resolved_word, &is_hoverable)
304                    .and_then(|d| declaration_signature(&d, &resolved_word))
305            {
306                return Some((sig, other.source(), other.as_ref()));
307            }
308        }
309        None
310    });
311
312    if let Some((sig, _sig_source, sig_doc)) = found {
313        let mut value = wrap_php(&sig);
314        if let Some(db) = find_docblock(&sig_doc.program().stmts, &resolved_word) {
315            let md = db.to_markdown();
316            if !md.is_empty() {
317                value.push_str("\n\n---\n\n");
318                value.push_str(&md);
319            }
320        }
321        if is_php_builtin(&resolved_word) {
322            value.push_str(&format!(
323                "\n\n[php.net documentation]({})",
324                php_doc_url(&resolved_word)
325            ));
326        }
327        return Some(Hover {
328            contents: HoverContents::Markup(MarkupContent {
329                kind: MarkupKind::Markdown,
330                value,
331            }),
332            range: hover_range,
333        });
334    }
335
336    // Fallback: built-in function with no user-defined counterpart.
337    if is_php_builtin(&resolved_word) {
338        let value = format!(
339            "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
340            resolved_word,
341            php_doc_url(&resolved_word)
342        );
343        return Some(Hover {
344            contents: HoverContents::Markup(MarkupContent {
345                kind: MarkupKind::Markdown,
346                value,
347            }),
348            range: hover_range,
349        });
350    }
351
352    // Hover on a built-in class name shows stub info.
353    if let Some(stub) = crate::stubs::builtin_class_members(&resolved_word) {
354        let method_names: Vec<&str> = stub
355            .methods
356            .iter()
357            .filter(|(_, is_static)| !is_static)
358            .map(|(n, _)| n.as_str())
359            .take(8)
360            .collect();
361        let static_names: Vec<&str> = stub
362            .methods
363            .iter()
364            .filter(|(_, is_static)| *is_static)
365            .map(|(n, _)| n.as_str())
366            .take(4)
367            .collect();
368        let mut lines = vec![format!("**{}** — built-in class", resolved_word)];
369        if !method_names.is_empty() {
370            lines.push(format!(
371                "Methods: {}",
372                method_names
373                    .iter()
374                    .map(|n| format!("`{n}`"))
375                    .collect::<Vec<_>>()
376                    .join(", ")
377            ));
378        }
379        if !static_names.is_empty() {
380            lines.push(format!(
381                "Static: {}",
382                static_names
383                    .iter()
384                    .map(|n| format!("`{n}`"))
385                    .collect::<Vec<_>>()
386                    .join(", ")
387            ));
388        }
389        if let Some(parent) = &stub.parent {
390            lines.push(format!("Extends: `{parent}`"));
391        }
392        return Some(Hover {
393            contents: HoverContents::Markup(MarkupContent {
394                kind: MarkupKind::Markdown,
395                value: lines.join("\n\n"),
396            }),
397            range: hover_range,
398        });
399    }
400
401    None
402}
403
404/// Indexed variant of [`hover_at`]: replaces the cross-file `resolve_declaration`
405/// walk with O(1) symbol map lookup. All other paths are identical.
406fn hover_at_with_maps(
407    source: &str,
408    doc: &ParsedDoc,
409    analysis: Option<&mir_analyzer::FileAnalysis>,
410    other_docs: &[(Url, Arc<ParsedDoc>)],
411    other_maps: &[(Url, Arc<SymbolMap>)],
412    position: Position,
413) -> Option<Hover> {
414    let hover_range = word_range_at(source, position);
415
416    if let Some(line_text) = source.lines().nth(position.line as usize) {
417        let trimmed = line_text.trim();
418        if trimmed.starts_with("use ") {
419            let (prefix, content) = if trimmed.starts_with("use function ") {
420                (
421                    "use function ",
422                    trimmed.strip_prefix("use function ").unwrap_or(""),
423                )
424            } else if trimmed.starts_with("use const ") {
425                (
426                    "use const ",
427                    trimmed.strip_prefix("use const ").unwrap_or(""),
428                )
429            } else {
430                ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
431            };
432            let fqn = content.trim_end_matches(';').trim();
433            if !fqn.is_empty() {
434                let maybe_word = word_at_position(source, position);
435                let alias = fqn_short_name(fqn);
436                let matches = match &maybe_word {
437                    Some(w) => w == alias || fqn.contains(w.as_str()),
438                    None => true,
439                };
440                if matches {
441                    return Some(Hover {
442                        contents: HoverContents::Markup(MarkupContent {
443                            kind: MarkupKind::Markdown,
444                            value: format!("`{}{};`", prefix, fqn),
445                        }),
446                        range: hover_range,
447                    });
448                }
449            }
450        }
451    }
452
453    let word = word_at_position(source, position)?;
454
455    if let Some(line_text) = source.lines().nth(position.line as usize)
456        && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
457    {
458        let keyword_doc: Option<&str> = match word.as_str() {
459            "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
460            "null" => Some("`null` — the null value; a variable has no value"),
461            "true" => Some("`true` — boolean true"),
462            "false" => Some("`false` — boolean false"),
463            "abstract" => Some(
464                "`abstract` — declares an abstract class or method that must be implemented by a subclass",
465            ),
466            "readonly" => {
467                Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
468            }
469            "yield" => Some("`yield` — produces a value from a generator function"),
470            "never" => Some(
471                "`never` — return type indicating the function always throws or exits (PHP 8.1)",
472            ),
473            "throw" => {
474                Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
475            }
476            _ => None,
477        };
478        if let Some(doc_str) = keyword_doc {
479            return Some(Hover {
480                contents: HoverContents::Markup(MarkupContent {
481                    kind: MarkupKind::Markdown,
482                    value: doc_str.to_string(),
483                }),
484                range: hover_range,
485            });
486        }
487    }
488
489    let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
490    let type_map =
491        || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
492
493    if let Some(line_text) = source.lines().nth(position.line as usize)
494        && !word.starts_with('$')
495        && is_named_arg_at(line_text, position.character as usize, &word)
496        && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
497        && let Some(value) = named_arg_hover_value(
498            source,
499            doc,
500            other_docs,
501            position,
502            &callee,
503            &word,
504            analysis,
505            &type_map_cell,
506        )
507    {
508        return Some(Hover {
509            contents: HoverContents::Markup(MarkupContent {
510                kind: MarkupKind::Markdown,
511                value,
512            }),
513            range: hover_range,
514        });
515    }
516
517    if word.starts_with('$') {
518        if let Some(ty) = analysis.and_then(|a| {
519            let off =
520                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
521            crate::type_query::type_at_offset(a, off)
522        }) && !crate::type_query::class_names(ty).is_empty()
523        {
524            return Some(Hover {
525                contents: HoverContents::Markup(MarkupContent {
526                    kind: MarkupKind::Markdown,
527                    value: format!("`{word}` `{ty}`"),
528                }),
529                range: hover_range,
530            });
531        }
532        if let Some(class_name) = type_map().get(&word) {
533            return Some(Hover {
534                contents: HoverContents::Markup(MarkupContent {
535                    kind: MarkupKind::Markdown,
536                    value: format!("`{}` `{}`", word, class_name),
537                }),
538                range: hover_range,
539            });
540        }
541    }
542
543    if word.starts_with('$')
544        && let Some(line_text) = source.lines().nth(position.line as usize)
545        && let Some(class_name) =
546            extract_static_class_before_cursor(line_text, position.character as usize)
547    {
548        let prop_name = word.trim_start_matches('$');
549        let effective_class = if class_name == "self" || class_name == "static" {
550            crate::type_map::enclosing_class_at(source, doc, position).unwrap_or(class_name.clone())
551        } else {
552            class_name.clone()
553        };
554        for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
555            if let Some((modifiers, type_str, db)) =
556                find_property_info(d, &effective_class, prop_name)
557            {
558                let sig = format!(
559                    "(property) {}{}::${}{}",
560                    modifiers,
561                    effective_class,
562                    prop_name,
563                    if type_str.is_empty() {
564                        String::new()
565                    } else {
566                        format!(": {}", type_str)
567                    }
568                );
569                let mut value = wrap_php(&sig);
570                if let Some(doc) = db {
571                    let md = doc.to_markdown();
572                    if !md.is_empty() {
573                        value.push_str("\n\n---\n\n");
574                        value.push_str(&md);
575                    }
576                }
577                return Some(Hover {
578                    contents: HoverContents::Markup(MarkupContent {
579                        kind: MarkupKind::Markdown,
580                        value,
581                    }),
582                    range: hover_range,
583                });
584            }
585        }
586    }
587
588    if !word.starts_with('$')
589        && let Some(sym) = analysis.and_then(|a| {
590            let off =
591                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
592            a.symbol_at(off)
593        })
594    {
595        let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
596        if mir_hover.is_some() {
597            return mir_hover.map(|value| Hover {
598                contents: HoverContents::Markup(MarkupContent {
599                    kind: MarkupKind::Markdown,
600                    value,
601                }),
602                range: hover_range,
603            });
604        }
605    }
606
607    if (word == "function" || word == "fn")
608        && let Some(sig) = closure_hover(source, doc, position, &word)
609    {
610        return Some(Hover {
611            contents: HoverContents::Markup(MarkupContent {
612                kind: MarkupKind::Markdown,
613                value: wrap_php(&sig),
614            }),
615            range: hover_range,
616        });
617    }
618
619    let all_stmts = &*doc.program().stmts as &[_];
620    let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
621
622    // Current-doc: still uses the AST walker (doc is already in memory, fast).
623    let current_doc_found =
624        resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
625            .and_then(|d| declaration_signature(&d, &resolved_word))
626            .map(|sig| {
627                let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
628                    .map(|db| db.to_markdown())
629                    .filter(|md| !md.is_empty());
630                (sig, doc_md)
631            });
632
633    // Cross-file: O(1) symbol map lookup — no AST walk.
634    let found = current_doc_found.or_else(|| {
635        for (_, sym_map) in other_maps {
636            if let Some(entry) = sym_map.lookup(&resolved_word, |e| is_hoverable_kind(e.kind))
637                && let Some(sig) = &entry.signature
638            {
639                return Some((sig.clone(), entry.doc_markdown.clone()));
640            }
641        }
642        None
643    });
644
645    if let Some((sig, doc_md)) = found {
646        let mut value = wrap_php(&sig);
647        if let Some(md) = doc_md
648            && !md.is_empty()
649        {
650            value.push_str("\n\n---\n\n");
651            value.push_str(&md);
652        }
653        if is_php_builtin(&resolved_word) {
654            value.push_str(&format!(
655                "\n\n[php.net documentation]({})",
656                php_doc_url(&resolved_word)
657            ));
658        }
659        return Some(Hover {
660            contents: HoverContents::Markup(MarkupContent {
661                kind: MarkupKind::Markdown,
662                value,
663            }),
664            range: hover_range,
665        });
666    }
667
668    if is_php_builtin(&resolved_word) {
669        let value = format!(
670            "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
671            resolved_word,
672            php_doc_url(&resolved_word)
673        );
674        return Some(Hover {
675            contents: HoverContents::Markup(MarkupContent {
676                kind: MarkupKind::Markdown,
677                value,
678            }),
679            range: hover_range,
680        });
681    }
682
683    if let Some(stub) = crate::stubs::builtin_class_members(&resolved_word) {
684        let method_names: Vec<&str> = stub
685            .methods
686            .iter()
687            .filter(|(_, is_static)| !is_static)
688            .map(|(n, _)| n.as_str())
689            .take(8)
690            .collect();
691        let static_names: Vec<&str> = stub
692            .methods
693            .iter()
694            .filter(|(_, is_static)| *is_static)
695            .map(|(n, _)| n.as_str())
696            .take(4)
697            .collect();
698        let mut lines = vec![format!("**{}** — built-in class", resolved_word)];
699        if !method_names.is_empty() {
700            lines.push(format!(
701                "Methods: {}",
702                method_names
703                    .iter()
704                    .map(|n| format!("`{n}`"))
705                    .collect::<Vec<_>>()
706                    .join(", ")
707            ));
708        }
709        if !static_names.is_empty() {
710            lines.push(format!(
711                "Static: {}",
712                static_names
713                    .iter()
714                    .map(|n| format!("`{n}`"))
715                    .collect::<Vec<_>>()
716                    .join(", ")
717            ));
718        }
719        if let Some(parent) = &stub.parent {
720            lines.push(format!("Extends: `{parent}`"));
721        }
722        return Some(Hover {
723            contents: HoverContents::Markup(MarkupContent {
724                kind: MarkupKind::Markdown,
725                value: lines.join("\n\n"),
726            }),
727            range: hover_range,
728        });
729    }
730
731    None
732}
733
734/// Produce hover markdown for a member access resolved by mir. Returns the
735/// hover value string (not the full `Hover` struct — the caller wraps it).
736/// `None` means mir has no symbol here; the caller falls through to `resolve_declaration`.
737fn mir_member_hover(
738    sym: &mir_analyzer::ResolvedSymbol,
739    word: &str,
740    doc: &ParsedDoc,
741    other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
742) -> Option<String> {
743    let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
744    match &sym.kind {
745        mir_analyzer::ReferenceKind::MethodCall { class, .. }
746        | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
747            let class_short = fqn_short_name(class);
748            for d in docs() {
749                if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
750                    // Augment declared return type with mir's inferred type when richer.
751                    let sig = augment_return_type(sig, &sym.resolved_type);
752                    let mut value = wrap_php(&sig);
753                    let all =
754                        std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
755                    if let Some(db) = resolve_method_docblock(all, class_short, word) {
756                        let md = db.to_markdown();
757                        if !md.is_empty() {
758                            value.push_str("\n\n---\n\n");
759                            value.push_str(&md);
760                        }
761                    }
762                    return Some(value);
763                }
764            }
765            None
766        }
767        mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
768            let class_short = fqn_short_name(class);
769            for d in docs() {
770                if let Some((modifiers, declared_type, db)) =
771                    find_property_info(d, class_short, property)
772                {
773                    // Use mir's resolved type when it's more specific than declared.
774                    let type_str = augment_property_type(declared_type, &sym.resolved_type);
775                    let sig = format!(
776                        "(property) {}{}::${}{}",
777                        modifiers,
778                        class_short,
779                        property,
780                        if type_str.is_empty() {
781                            String::new()
782                        } else {
783                            format!(": {}", type_str)
784                        }
785                    );
786                    let mut value = wrap_php(&sig);
787                    if let Some(doc_block) = db {
788                        let md = doc_block.to_markdown();
789                        if !md.is_empty() {
790                            value.push_str("\n\n---\n\n");
791                            value.push_str(&md);
792                        }
793                    }
794                    return Some(value);
795                }
796            }
797            // No declared property — dynamic access via __get. Show mir's resolved
798            // type (the __get return type) rather than falling through to no hover.
799            let ty_str = format!("{}", sym.resolved_type);
800            if !matches!(ty_str.as_str(), "" | "void" | "never") {
801                let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
802                return Some(wrap_php(&sig));
803            }
804            None
805        }
806        mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
807            let class_short = fqn_short_name(class);
808            for d in docs() {
809                if let Some(sig) =
810                    scan_enum_case_of_class(&d.program().stmts, class_short, constant)
811                {
812                    return Some(wrap_php(&sig));
813                }
814                if let Some(sig) =
815                    scan_class_const_of_class(&d.program().stmts, class_short, constant)
816                {
817                    return Some(wrap_php(&sig));
818                }
819            }
820            None
821        }
822        _ => None,
823    }
824}
825
826/// Override the return-type portion of a method signature string with mir's
827/// inferred type, when mir provides concrete (non-mixed, non-void) info.
828/// `sig` has the form `"Class::method(params): OldType"` or no return type.
829fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
830    let ty_str = format!("{resolved}");
831    if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
832        return sig;
833    }
834    let Some(paren) = sig.rfind(')') else {
835        return sig;
836    };
837    let rest = &sig[paren + 1..];
838    if let Some(colon_pos) = rest.find(": ") {
839        let declared = rest[colon_pos + 2..].trim();
840        // static/self/parent are late-static-binding — mir resolves them to a
841        // concrete class, losing the polymorphic semantics. Keep declared.
842        if matches!(declared, "static" | "self" | "parent") {
843            return sig;
844        }
845        format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
846    } else {
847        format!("{}: {}", sig, ty_str)
848    }
849}
850
851/// Choose between the declared property type and mir's resolved type. Uses
852/// mir when it adds information (non-mixed, non-void).
853fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
854    let ty_str = format!("{resolved}");
855    if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
856        return declared;
857    }
858    ty_str
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::test_utils::cursor;
865
866    fn pos(line: u32, character: u32) -> Position {
867        Position { line, character }
868    }
869
870    #[test]
871    fn hover_on_function_name_returns_signature() {
872        let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
873        let doc = ParsedDoc::parse(src.clone());
874        let result = hover_info(&src, &doc, None, p, &[]);
875        assert!(result.is_some(), "expected hover result");
876        if let Some(Hover {
877            contents: HoverContents::Markup(mc),
878            ..
879        }) = result
880        {
881            assert!(
882                mc.value.contains("function greet("),
883                "expected function signature, got: {}",
884                mc.value
885            );
886        }
887    }
888
889    #[test]
890    fn hover_on_class_name_returns_class_sig() {
891        let (src, p) = cursor("<?php\nclass My$0Service {}");
892        let doc = ParsedDoc::parse(src.clone());
893        let result = hover_info(&src, &doc, None, p, &[]);
894        assert!(result.is_some(), "expected hover result");
895        if let Some(Hover {
896            contents: HoverContents::Markup(mc),
897            ..
898        }) = result
899        {
900            assert!(
901                mc.value.contains("class MyService"),
902                "expected class sig, got: {}",
903                mc.value
904            );
905        }
906    }
907
908    #[test]
909    fn hover_on_unknown_word_returns_none() {
910        let src = "<?php\n$unknown = 42;";
911        let doc = ParsedDoc::parse(src.to_string());
912        let result = hover_info(src, &doc, None, pos(1, 2), &[]);
913        assert!(result.is_none(), "expected None for unknown word");
914    }
915
916    #[test]
917    fn hover_at_column_beyond_line_length_returns_none() {
918        let src = "<?php\nfunction hi() {}";
919        let doc = ParsedDoc::parse(src.to_string());
920        let result = hover_info(src, &doc, None, pos(1, 999), &[]);
921        assert!(result.is_none());
922    }
923
924    #[test]
925    fn word_at_extracts_from_middle_of_identifier() {
926        let (src, p) = cursor("<?php\nfunction greet$0User() {}");
927        let word = word_at_position(&src, p);
928        assert_eq!(word.as_deref(), Some("greetUser"));
929    }
930
931    #[test]
932    fn hover_on_class_with_extends_shows_parent() {
933        let src = "<?php\nclass Dog extends Animal {}";
934        let doc = ParsedDoc::parse(src.to_string());
935        let result = hover_info(src, &doc, None, pos(1, 8), &[]);
936        assert!(result.is_some());
937        if let Some(Hover {
938            contents: HoverContents::Markup(mc),
939            ..
940        }) = result
941        {
942            assert!(
943                mc.value.contains("extends Animal"),
944                "expected 'extends Animal', got: {}",
945                mc.value
946            );
947        }
948    }
949
950    #[test]
951    fn hover_on_class_with_implements_shows_interfaces() {
952        let src = "<?php\nclass Repo implements Countable, Serializable {}";
953        let doc = ParsedDoc::parse(src.to_string());
954        let result = hover_info(src, &doc, None, pos(1, 8), &[]);
955        assert!(result.is_some());
956        if let Some(Hover {
957            contents: HoverContents::Markup(mc),
958            ..
959        }) = result
960        {
961            assert!(
962                mc.value.contains("implements Countable, Serializable"),
963                "expected implements list, got: {}",
964                mc.value
965            );
966        }
967    }
968
969    #[test]
970    fn hover_on_trait_returns_trait_sig() {
971        let src = "<?php\ntrait Loggable {}";
972        let doc = ParsedDoc::parse(src.to_string());
973        let result = hover_info(src, &doc, None, pos(1, 8), &[]);
974        assert!(result.is_some());
975        if let Some(Hover {
976            contents: HoverContents::Markup(mc),
977            ..
978        }) = result
979        {
980            assert!(
981                mc.value.contains("trait Loggable"),
982                "expected 'trait Loggable', got: {}",
983                mc.value
984            );
985        }
986    }
987
988    #[test]
989    fn hover_on_interface_returns_interface_sig() {
990        let src = "<?php\ninterface Serializable {}";
991        let doc = ParsedDoc::parse(src.to_string());
992        let result = hover_info(src, &doc, None, pos(1, 12), &[]);
993        assert!(result.is_some(), "expected hover result");
994        if let Some(Hover {
995            contents: HoverContents::Markup(mc),
996            ..
997        }) = result
998        {
999            assert!(
1000                mc.value.contains("interface Serializable"),
1001                "expected interface sig, got: {}",
1002                mc.value
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn function_with_no_params_no_return_shows_no_colon() {
1009        let src = "<?php\nfunction init() {}";
1010        let doc = ParsedDoc::parse(src.to_string());
1011        let result = hover_info(src, &doc, None, pos(1, 10), &[]);
1012        assert!(result.is_some());
1013        if let Some(Hover {
1014            contents: HoverContents::Markup(mc),
1015            ..
1016        }) = result
1017        {
1018            assert!(
1019                mc.value.contains("function init()"),
1020                "expected 'function init()', got: {}",
1021                mc.value
1022            );
1023            assert!(
1024                !mc.value.contains(':'),
1025                "should not contain ':' when no return type, got: {}",
1026                mc.value
1027            );
1028        }
1029    }
1030
1031    #[test]
1032    fn hover_on_enum_returns_enum_sig() {
1033        let src = "<?php\nenum Suit {}";
1034        let doc = ParsedDoc::parse(src.to_string());
1035        let result = hover_info(src, &doc, None, pos(1, 6), &[]);
1036        assert!(result.is_some());
1037        if let Some(Hover {
1038            contents: HoverContents::Markup(mc),
1039            ..
1040        }) = result
1041        {
1042            assert!(
1043                mc.value.contains("enum Suit"),
1044                "expected 'enum Suit', got: {}",
1045                mc.value
1046            );
1047        }
1048    }
1049
1050    #[test]
1051    fn hover_on_enum_with_implements_shows_interface() {
1052        let src = "<?php\nenum Status: string implements Stringable {}";
1053        let doc = ParsedDoc::parse(src.to_string());
1054        let result = hover_info(src, &doc, None, pos(1, 6), &[]);
1055        assert!(result.is_some());
1056        if let Some(Hover {
1057            contents: HoverContents::Markup(mc),
1058            ..
1059        }) = result
1060        {
1061            assert!(
1062                mc.value.contains("implements Stringable"),
1063                "expected implements clause, got: {}",
1064                mc.value
1065            );
1066        }
1067    }
1068
1069    #[test]
1070    fn hover_on_enum_case_shows_case_sig() {
1071        let src = "<?php\nenum Status { case Active; case Inactive; }";
1072        let doc = ParsedDoc::parse(src.to_string());
1073        // "Active" starts at col 19: "enum Status { case Active;"
1074        let result = hover_info(src, &doc, None, pos(1, 21), &[]);
1075        assert!(result.is_some(), "expected hover on enum case");
1076        if let Some(Hover {
1077            contents: HoverContents::Markup(mc),
1078            ..
1079        }) = result
1080        {
1081            assert!(
1082                mc.value.contains("Status::Active"),
1083                "expected 'Status::Active', got: {}",
1084                mc.value
1085            );
1086        }
1087    }
1088
1089    #[test]
1090    fn snapshot_hover_backed_enum_case_shows_value() {
1091        check_hover(
1092            "<?php\nenum Color: string { case Red = 'red'; }",
1093            pos(1, 27),
1094            expect![[r#"
1095                ```php
1096                case Color::Red = 'red'
1097                ```"#]],
1098        );
1099    }
1100
1101    #[test]
1102    fn snapshot_hover_enum_class_const() {
1103        check_hover(
1104            "<?php\nenum Suit { const int MAX = 4; }",
1105            pos(1, 22),
1106            expect![[r#"
1107                ```php
1108                const int MAX = 4
1109                ```"#]],
1110        );
1111    }
1112
1113    #[test]
1114    fn hover_on_trait_method_returns_signature() {
1115        let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
1116        let doc = ParsedDoc::parse(src.to_string());
1117        // "log" at "trait Loggable { public function log(" — col 33
1118        let result = hover_info(src, &doc, None, pos(1, 34), &[]);
1119        assert!(result.is_some(), "expected hover on trait method");
1120        if let Some(Hover {
1121            contents: HoverContents::Markup(mc),
1122            ..
1123        }) = result
1124        {
1125            assert!(
1126                mc.value.contains("function log("),
1127                "expected function sig, got: {}",
1128                mc.value
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn cross_file_hover_finds_class_in_other_doc() {
1135        use std::sync::Arc;
1136        let src = "<?php\n$x = new PaymentService();";
1137        let other_src = "<?php\nclass PaymentService { public function charge() {} }";
1138        let doc = ParsedDoc::parse(src.to_string());
1139        let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
1140        let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
1141        let other_docs = vec![(uri, other_doc)];
1142        // Hover on "PaymentService" in line 1
1143        let result = hover_info(src, &doc, None, pos(1, 12), &other_docs);
1144        assert!(result.is_some(), "expected cross-file hover result");
1145        if let Some(Hover {
1146            contents: HoverContents::Markup(mc),
1147            ..
1148        }) = result
1149        {
1150            assert!(
1151                mc.value.contains("PaymentService"),
1152                "expected 'PaymentService', got: {}",
1153                mc.value
1154            );
1155        }
1156    }
1157
1158    #[test]
1159    fn hover_on_variable_shows_type() {
1160        let src = "<?php\n$obj = new Mailer();\n$obj";
1161        let doc = ParsedDoc::parse(src.to_string());
1162        let h = hover_at(src, &doc, None, &[], pos(2, 2));
1163        assert!(h.is_some());
1164        let text = match h.unwrap().contents {
1165            HoverContents::Markup(m) => m.value,
1166            _ => String::new(),
1167        };
1168        assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
1169    }
1170
1171    #[test]
1172    fn hover_on_builtin_class_shows_stub_info() {
1173        let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
1174        let doc = ParsedDoc::parse(src.to_string());
1175        let h = hover_at(src, &doc, None, &[], pos(1, 12));
1176        assert!(h.is_some(), "should hover on PDO");
1177        let text = match h.unwrap().contents {
1178            HoverContents::Markup(m) => m.value,
1179            _ => String::new(),
1180        };
1181        assert!(text.contains("PDO"), "hover should mention PDO");
1182    }
1183
1184    #[test]
1185    fn hover_on_use_alias_shows_fqn() {
1186        let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
1187        let doc = ParsedDoc::parse(src.to_string());
1188        let h = hover_at(
1189            src,
1190            &doc,
1191            None,
1192            &[],
1193            Position {
1194                line: 1,
1195                character: 20,
1196            },
1197        );
1198        assert!(h.is_some());
1199        let text = match h.unwrap().contents {
1200            HoverContents::Markup(m) => m.value,
1201            _ => String::new(),
1202        };
1203        assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
1204    }
1205
1206    #[test]
1207    fn hover_unknown_symbol_returns_none() {
1208        // `unknownFunc` is not defined anywhere — hover should return None.
1209        let src = "<?php\nunknownFunc();";
1210        let doc = ParsedDoc::parse(src.to_string());
1211        let result = hover_info(src, &doc, None, pos(1, 3), &[]);
1212        assert!(
1213            result.is_none(),
1214            "hover on undefined symbol should return None"
1215        );
1216    }
1217
1218    #[test]
1219    fn hover_on_builtin_function_returns_signature() {
1220        // `strlen` is a built-in function; hovering should return a non-empty
1221        // string that contains "strlen".
1222        let src = "<?php\nstrlen('hello');";
1223        let doc = ParsedDoc::parse(src.to_string());
1224        let result = hover_info(src, &doc, None, pos(1, 3), &[]);
1225        let h = result.expect("expected hover result for built-in 'strlen'");
1226        let text = match h.contents {
1227            HoverContents::Markup(mc) => mc.value,
1228            _ => String::new(),
1229        };
1230        assert!(
1231            !text.is_empty(),
1232            "hover on strlen should return non-empty content"
1233        );
1234        assert!(
1235            text.contains("strlen"),
1236            "hover content should contain 'strlen', got: {text}"
1237        );
1238    }
1239
1240    // ── Snapshot tests ───────────────────────────────────────────────────────
1241
1242    use expect_test::{Expect, expect};
1243
1244    fn check_hover(src: &str, position: Position, expect: Expect) {
1245        let doc = ParsedDoc::parse(src.to_string());
1246        let result = hover_info(src, &doc, None, position, &[]);
1247        let actual = match result {
1248            Some(Hover {
1249                contents: HoverContents::Markup(mc),
1250                ..
1251            }) => mc.value,
1252            Some(_) => "(non-markup hover)".to_string(),
1253            None => "(no hover)".to_string(),
1254        };
1255        expect.assert_eq(&actual);
1256    }
1257
1258    #[test]
1259    fn snapshot_hover_simple_function() {
1260        check_hover(
1261            "<?php\nfunction init() {}",
1262            pos(1, 10),
1263            expect![[r#"
1264                ```php
1265                function init()
1266                ```"#]],
1267        );
1268    }
1269
1270    #[test]
1271    fn snapshot_hover_function_with_return_type() {
1272        check_hover(
1273            "<?php\nfunction greet(string $name): string {}",
1274            pos(1, 10),
1275            expect![[r#"
1276                ```php
1277                function greet(string $name): string
1278                ```"#]],
1279        );
1280    }
1281
1282    #[test]
1283    fn snapshot_hover_class() {
1284        check_hover(
1285            "<?php\nclass MyService {}",
1286            pos(1, 8),
1287            expect![[r#"
1288                ```php
1289                class MyService
1290                ```"#]],
1291        );
1292    }
1293
1294    #[test]
1295    fn snapshot_hover_class_with_extends() {
1296        check_hover(
1297            "<?php\nclass Dog extends Animal {}",
1298            pos(1, 8),
1299            expect![[r#"
1300                ```php
1301                class Dog extends Animal
1302                ```"#]],
1303        );
1304    }
1305
1306    #[test]
1307    fn snapshot_hover_method() {
1308        check_hover(
1309            "<?php\nclass Calc { public function add(int $a, int $b): int {} }",
1310            pos(1, 32),
1311            expect![[r#"
1312                ```php
1313                public function add(int $a, int $b): int
1314                ```"#]],
1315        );
1316    }
1317
1318    #[test]
1319    fn snapshot_hover_trait() {
1320        check_hover(
1321            "<?php\ntrait Loggable {}",
1322            pos(1, 8),
1323            expect![[r#"
1324                ```php
1325                trait Loggable
1326                ```"#]],
1327        );
1328    }
1329
1330    #[test]
1331    fn snapshot_hover_interface() {
1332        check_hover(
1333            "<?php\ninterface Serializable {}",
1334            pos(1, 12),
1335            expect![[r#"
1336                ```php
1337                interface Serializable
1338                ```"#]],
1339        );
1340    }
1341
1342    #[test]
1343    fn snapshot_hover_class_const_with_type_hint() {
1344        check_hover(
1345            "<?php\nclass Config { const string VERSION = '1.0.0'; }",
1346            pos(1, 28),
1347            expect![[r#"
1348                ```php
1349                const string VERSION = '1.0.0'
1350                ```"#]],
1351        );
1352    }
1353
1354    #[test]
1355    fn snapshot_hover_class_const_float_value() {
1356        check_hover(
1357            "<?php\nclass Math { const float PI = 3.14; }",
1358            pos(1, 27),
1359            expect![[r#"
1360                ```php
1361                const float PI = 3.14
1362                ```"#]],
1363        );
1364    }
1365
1366    #[test]
1367    fn snapshot_hover_class_const_infers_type_from_value() {
1368        let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
1369        check_hover(
1370            &src,
1371            p,
1372            expect![[r#"
1373                ```php
1374                const string VERSION = '1.0.0'
1375                ```"#]],
1376        );
1377    }
1378
1379    #[test]
1380    fn snapshot_hover_interface_const_shows_type_and_value() {
1381        let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
1382        check_hover(
1383            &src,
1384            p,
1385            expect![[r#"
1386                ```php
1387                const int MAX = 100
1388                ```"#]],
1389        );
1390    }
1391
1392    #[test]
1393    fn snapshot_hover_trait_const_shows_type_and_value() {
1394        let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
1395        check_hover(
1396            &src,
1397            p,
1398            expect![[r#"
1399                ```php
1400                const string TAG = 'v1'
1401                ```"#]],
1402        );
1403    }
1404
1405    #[test]
1406    fn hover_on_static_var_with_array_default_shows_array() {
1407        let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
1408        let doc = ParsedDoc::parse(src.clone());
1409        let result = hover_info(&src, &doc, None, p, &[]);
1410        assert!(
1411            result.is_some(),
1412            "expected hover result for static variable"
1413        );
1414        if let Some(Hover {
1415            contents: HoverContents::Markup(mc),
1416            ..
1417        }) = result
1418        {
1419            assert!(
1420                mc.value.contains("array"),
1421                "expected array type in hover, got: {}",
1422                mc.value
1423            );
1424        }
1425    }
1426
1427    #[test]
1428    fn hover_on_static_var_with_new_shows_class() {
1429        let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
1430        let doc = ParsedDoc::parse(src.clone());
1431        let result = hover_info(&src, &doc, None, p, &[]);
1432        assert!(
1433            result.is_some(),
1434            "expected hover result for static variable"
1435        );
1436        if let Some(Hover {
1437            contents: HoverContents::Markup(mc),
1438            ..
1439        }) = result
1440        {
1441            assert!(
1442                mc.value.contains("MyService"),
1443                "expected MyService in hover, got: {}",
1444                mc.value
1445            );
1446        }
1447    }
1448
1449    // Gap 1: variables defined in one method must not pollute hover in another method.
1450    #[test]
1451    fn hover_variable_in_method_does_not_leak_across_methods() {
1452        // $result is defined as Widget in methodA but the cursor is in methodB.
1453        // Before the fix, $result from methodA would appear in methodB's hover.
1454        let (src, p) = cursor(concat!(
1455            "<?php\n",
1456            "class Service {\n",
1457            "    public function methodA(): void { $result = new Widget(); }\n",
1458            "    public function methodB(): void { $res$0ult = new Invoice(); }\n",
1459            "}\n",
1460        ));
1461        let doc = ParsedDoc::parse(src.clone());
1462        let result = hover_info(&src, &doc, None, p, &[]);
1463        if let Some(Hover {
1464            contents: HoverContents::Markup(mc),
1465            ..
1466        }) = result
1467        {
1468            assert!(
1469                !mc.value.contains("Widget"),
1470                "Widget from methodA must not appear in methodB hover, got: {}",
1471                mc.value
1472            );
1473            assert!(
1474                mc.value.contains("Invoice"),
1475                "Invoice from methodB should appear in hover, got: {}",
1476                mc.value
1477            );
1478        }
1479    }
1480}