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            None
798        }
799        mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
800            let class_short = fqn_short_name(class);
801            for d in docs() {
802                if let Some(sig) =
803                    scan_enum_case_of_class(&d.program().stmts, class_short, constant)
804                {
805                    return Some(wrap_php(&sig));
806                }
807                if let Some(sig) =
808                    scan_class_const_of_class(&d.program().stmts, class_short, constant)
809                {
810                    return Some(wrap_php(&sig));
811                }
812            }
813            None
814        }
815        _ => None,
816    }
817}
818
819/// Override the return-type portion of a method signature string with mir's
820/// inferred type, when mir provides concrete (non-mixed, non-void) info.
821/// `sig` has the form `"Class::method(params): OldType"` or no return type.
822fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
823    let ty_str = format!("{resolved}");
824    if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
825        return sig;
826    }
827    let Some(paren) = sig.rfind(')') else {
828        return sig;
829    };
830    let rest = &sig[paren + 1..];
831    if let Some(colon_pos) = rest.find(": ") {
832        let declared = rest[colon_pos + 2..].trim();
833        // static/self/parent are late-static-binding — mir resolves them to a
834        // concrete class, losing the polymorphic semantics. Keep declared.
835        if matches!(declared, "static" | "self" | "parent") {
836            return sig;
837        }
838        format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
839    } else {
840        format!("{}: {}", sig, ty_str)
841    }
842}
843
844/// Choose between the declared property type and mir's resolved type. Uses
845/// mir when it adds information (non-mixed, non-void).
846fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
847    let ty_str = format!("{resolved}");
848    if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
849        return declared;
850    }
851    ty_str
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use crate::test_utils::cursor;
858
859    fn pos(line: u32, character: u32) -> Position {
860        Position { line, character }
861    }
862
863    #[test]
864    fn hover_on_function_name_returns_signature() {
865        let (src, p) = cursor("<?php\nfunction g$0reet(string $name): string {}");
866        let doc = ParsedDoc::parse(src.clone());
867        let result = hover_info(&src, &doc, None, p, &[]);
868        assert!(result.is_some(), "expected hover result");
869        if let Some(Hover {
870            contents: HoverContents::Markup(mc),
871            ..
872        }) = result
873        {
874            assert!(
875                mc.value.contains("function greet("),
876                "expected function signature, got: {}",
877                mc.value
878            );
879        }
880    }
881
882    #[test]
883    fn hover_on_class_name_returns_class_sig() {
884        let (src, p) = cursor("<?php\nclass My$0Service {}");
885        let doc = ParsedDoc::parse(src.clone());
886        let result = hover_info(&src, &doc, None, p, &[]);
887        assert!(result.is_some(), "expected hover result");
888        if let Some(Hover {
889            contents: HoverContents::Markup(mc),
890            ..
891        }) = result
892        {
893            assert!(
894                mc.value.contains("class MyService"),
895                "expected class sig, got: {}",
896                mc.value
897            );
898        }
899    }
900
901    #[test]
902    fn hover_on_unknown_word_returns_none() {
903        let src = "<?php\n$unknown = 42;";
904        let doc = ParsedDoc::parse(src.to_string());
905        let result = hover_info(src, &doc, None, pos(1, 2), &[]);
906        assert!(result.is_none(), "expected None for unknown word");
907    }
908
909    #[test]
910    fn hover_at_column_beyond_line_length_returns_none() {
911        let src = "<?php\nfunction hi() {}";
912        let doc = ParsedDoc::parse(src.to_string());
913        let result = hover_info(src, &doc, None, pos(1, 999), &[]);
914        assert!(result.is_none());
915    }
916
917    #[test]
918    fn word_at_extracts_from_middle_of_identifier() {
919        let (src, p) = cursor("<?php\nfunction greet$0User() {}");
920        let word = word_at_position(&src, p);
921        assert_eq!(word.as_deref(), Some("greetUser"));
922    }
923
924    #[test]
925    fn hover_on_class_with_extends_shows_parent() {
926        let src = "<?php\nclass Dog extends Animal {}";
927        let doc = ParsedDoc::parse(src.to_string());
928        let result = hover_info(src, &doc, None, pos(1, 8), &[]);
929        assert!(result.is_some());
930        if let Some(Hover {
931            contents: HoverContents::Markup(mc),
932            ..
933        }) = result
934        {
935            assert!(
936                mc.value.contains("extends Animal"),
937                "expected 'extends Animal', got: {}",
938                mc.value
939            );
940        }
941    }
942
943    #[test]
944    fn hover_on_class_with_implements_shows_interfaces() {
945        let src = "<?php\nclass Repo implements Countable, Serializable {}";
946        let doc = ParsedDoc::parse(src.to_string());
947        let result = hover_info(src, &doc, None, pos(1, 8), &[]);
948        assert!(result.is_some());
949        if let Some(Hover {
950            contents: HoverContents::Markup(mc),
951            ..
952        }) = result
953        {
954            assert!(
955                mc.value.contains("implements Countable, Serializable"),
956                "expected implements list, got: {}",
957                mc.value
958            );
959        }
960    }
961
962    #[test]
963    fn hover_on_trait_returns_trait_sig() {
964        let src = "<?php\ntrait Loggable {}";
965        let doc = ParsedDoc::parse(src.to_string());
966        let result = hover_info(src, &doc, None, pos(1, 8), &[]);
967        assert!(result.is_some());
968        if let Some(Hover {
969            contents: HoverContents::Markup(mc),
970            ..
971        }) = result
972        {
973            assert!(
974                mc.value.contains("trait Loggable"),
975                "expected 'trait Loggable', got: {}",
976                mc.value
977            );
978        }
979    }
980
981    #[test]
982    fn hover_on_interface_returns_interface_sig() {
983        let src = "<?php\ninterface Serializable {}";
984        let doc = ParsedDoc::parse(src.to_string());
985        let result = hover_info(src, &doc, None, pos(1, 12), &[]);
986        assert!(result.is_some(), "expected hover result");
987        if let Some(Hover {
988            contents: HoverContents::Markup(mc),
989            ..
990        }) = result
991        {
992            assert!(
993                mc.value.contains("interface Serializable"),
994                "expected interface sig, got: {}",
995                mc.value
996            );
997        }
998    }
999
1000    #[test]
1001    fn function_with_no_params_no_return_shows_no_colon() {
1002        let src = "<?php\nfunction init() {}";
1003        let doc = ParsedDoc::parse(src.to_string());
1004        let result = hover_info(src, &doc, None, pos(1, 10), &[]);
1005        assert!(result.is_some());
1006        if let Some(Hover {
1007            contents: HoverContents::Markup(mc),
1008            ..
1009        }) = result
1010        {
1011            assert!(
1012                mc.value.contains("function init()"),
1013                "expected 'function init()', got: {}",
1014                mc.value
1015            );
1016            assert!(
1017                !mc.value.contains(':'),
1018                "should not contain ':' when no return type, got: {}",
1019                mc.value
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn hover_on_enum_returns_enum_sig() {
1026        let src = "<?php\nenum Suit {}";
1027        let doc = ParsedDoc::parse(src.to_string());
1028        let result = hover_info(src, &doc, None, pos(1, 6), &[]);
1029        assert!(result.is_some());
1030        if let Some(Hover {
1031            contents: HoverContents::Markup(mc),
1032            ..
1033        }) = result
1034        {
1035            assert!(
1036                mc.value.contains("enum Suit"),
1037                "expected 'enum Suit', got: {}",
1038                mc.value
1039            );
1040        }
1041    }
1042
1043    #[test]
1044    fn hover_on_enum_with_implements_shows_interface() {
1045        let src = "<?php\nenum Status: string implements Stringable {}";
1046        let doc = ParsedDoc::parse(src.to_string());
1047        let result = hover_info(src, &doc, None, pos(1, 6), &[]);
1048        assert!(result.is_some());
1049        if let Some(Hover {
1050            contents: HoverContents::Markup(mc),
1051            ..
1052        }) = result
1053        {
1054            assert!(
1055                mc.value.contains("implements Stringable"),
1056                "expected implements clause, got: {}",
1057                mc.value
1058            );
1059        }
1060    }
1061
1062    #[test]
1063    fn hover_on_enum_case_shows_case_sig() {
1064        let src = "<?php\nenum Status { case Active; case Inactive; }";
1065        let doc = ParsedDoc::parse(src.to_string());
1066        // "Active" starts at col 19: "enum Status { case Active;"
1067        let result = hover_info(src, &doc, None, pos(1, 21), &[]);
1068        assert!(result.is_some(), "expected hover on enum case");
1069        if let Some(Hover {
1070            contents: HoverContents::Markup(mc),
1071            ..
1072        }) = result
1073        {
1074            assert!(
1075                mc.value.contains("Status::Active"),
1076                "expected 'Status::Active', got: {}",
1077                mc.value
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn snapshot_hover_backed_enum_case_shows_value() {
1084        check_hover(
1085            "<?php\nenum Color: string { case Red = 'red'; }",
1086            pos(1, 27),
1087            expect![[r#"
1088                ```php
1089                case Color::Red = 'red'
1090                ```"#]],
1091        );
1092    }
1093
1094    #[test]
1095    fn snapshot_hover_enum_class_const() {
1096        check_hover(
1097            "<?php\nenum Suit { const int MAX = 4; }",
1098            pos(1, 22),
1099            expect![[r#"
1100                ```php
1101                const int MAX = 4
1102                ```"#]],
1103        );
1104    }
1105
1106    #[test]
1107    fn hover_on_trait_method_returns_signature() {
1108        let src = "<?php\ntrait Loggable { public function log(string $msg): void {} }";
1109        let doc = ParsedDoc::parse(src.to_string());
1110        // "log" at "trait Loggable { public function log(" — col 33
1111        let result = hover_info(src, &doc, None, pos(1, 34), &[]);
1112        assert!(result.is_some(), "expected hover on trait method");
1113        if let Some(Hover {
1114            contents: HoverContents::Markup(mc),
1115            ..
1116        }) = result
1117        {
1118            assert!(
1119                mc.value.contains("function log("),
1120                "expected function sig, got: {}",
1121                mc.value
1122            );
1123        }
1124    }
1125
1126    #[test]
1127    fn cross_file_hover_finds_class_in_other_doc() {
1128        use std::sync::Arc;
1129        let src = "<?php\n$x = new PaymentService();";
1130        let other_src = "<?php\nclass PaymentService { public function charge() {} }";
1131        let doc = ParsedDoc::parse(src.to_string());
1132        let other_doc = Arc::new(ParsedDoc::parse(other_src.to_string()));
1133        let uri = tower_lsp::lsp_types::Url::parse("file:///other.php").unwrap();
1134        let other_docs = vec![(uri, other_doc)];
1135        // Hover on "PaymentService" in line 1
1136        let result = hover_info(src, &doc, None, pos(1, 12), &other_docs);
1137        assert!(result.is_some(), "expected cross-file hover result");
1138        if let Some(Hover {
1139            contents: HoverContents::Markup(mc),
1140            ..
1141        }) = result
1142        {
1143            assert!(
1144                mc.value.contains("PaymentService"),
1145                "expected 'PaymentService', got: {}",
1146                mc.value
1147            );
1148        }
1149    }
1150
1151    #[test]
1152    fn hover_on_variable_shows_type() {
1153        let src = "<?php\n$obj = new Mailer();\n$obj";
1154        let doc = ParsedDoc::parse(src.to_string());
1155        let h = hover_at(src, &doc, None, &[], pos(2, 2));
1156        assert!(h.is_some());
1157        let text = match h.unwrap().contents {
1158            HoverContents::Markup(m) => m.value,
1159            _ => String::new(),
1160        };
1161        assert!(text.contains("Mailer"), "hover on $obj should show Mailer");
1162    }
1163
1164    #[test]
1165    fn hover_on_builtin_class_shows_stub_info() {
1166        let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
1167        let doc = ParsedDoc::parse(src.to_string());
1168        let h = hover_at(src, &doc, None, &[], pos(1, 12));
1169        assert!(h.is_some(), "should hover on PDO");
1170        let text = match h.unwrap().contents {
1171            HoverContents::Markup(m) => m.value,
1172            _ => String::new(),
1173        };
1174        assert!(text.contains("PDO"), "hover should mention PDO");
1175    }
1176
1177    #[test]
1178    fn hover_on_use_alias_shows_fqn() {
1179        let src = "<?php\nuse App\\Mail\\Mailer;\n$m = new Mailer();";
1180        let doc = ParsedDoc::parse(src.to_string());
1181        let h = hover_at(
1182            src,
1183            &doc,
1184            None,
1185            &[],
1186            Position {
1187                line: 1,
1188                character: 20,
1189            },
1190        );
1191        assert!(h.is_some());
1192        let text = match h.unwrap().contents {
1193            HoverContents::Markup(m) => m.value,
1194            _ => String::new(),
1195        };
1196        assert!(text.contains("App\\Mail\\Mailer"), "should show full FQN");
1197    }
1198
1199    #[test]
1200    fn hover_unknown_symbol_returns_none() {
1201        // `unknownFunc` is not defined anywhere — hover should return None.
1202        let src = "<?php\nunknownFunc();";
1203        let doc = ParsedDoc::parse(src.to_string());
1204        let result = hover_info(src, &doc, None, pos(1, 3), &[]);
1205        assert!(
1206            result.is_none(),
1207            "hover on undefined symbol should return None"
1208        );
1209    }
1210
1211    #[test]
1212    fn hover_on_builtin_function_returns_signature() {
1213        // `strlen` is a built-in function; hovering should return a non-empty
1214        // string that contains "strlen".
1215        let src = "<?php\nstrlen('hello');";
1216        let doc = ParsedDoc::parse(src.to_string());
1217        let result = hover_info(src, &doc, None, pos(1, 3), &[]);
1218        let h = result.expect("expected hover result for built-in 'strlen'");
1219        let text = match h.contents {
1220            HoverContents::Markup(mc) => mc.value,
1221            _ => String::new(),
1222        };
1223        assert!(
1224            !text.is_empty(),
1225            "hover on strlen should return non-empty content"
1226        );
1227        assert!(
1228            text.contains("strlen"),
1229            "hover content should contain 'strlen', got: {text}"
1230        );
1231    }
1232
1233    // ── Snapshot tests ───────────────────────────────────────────────────────
1234
1235    use expect_test::{Expect, expect};
1236
1237    fn check_hover(src: &str, position: Position, expect: Expect) {
1238        let doc = ParsedDoc::parse(src.to_string());
1239        let result = hover_info(src, &doc, None, position, &[]);
1240        let actual = match result {
1241            Some(Hover {
1242                contents: HoverContents::Markup(mc),
1243                ..
1244            }) => mc.value,
1245            Some(_) => "(non-markup hover)".to_string(),
1246            None => "(no hover)".to_string(),
1247        };
1248        expect.assert_eq(&actual);
1249    }
1250
1251    #[test]
1252    fn snapshot_hover_simple_function() {
1253        check_hover(
1254            "<?php\nfunction init() {}",
1255            pos(1, 10),
1256            expect![[r#"
1257                ```php
1258                function init()
1259                ```"#]],
1260        );
1261    }
1262
1263    #[test]
1264    fn snapshot_hover_function_with_return_type() {
1265        check_hover(
1266            "<?php\nfunction greet(string $name): string {}",
1267            pos(1, 10),
1268            expect![[r#"
1269                ```php
1270                function greet(string $name): string
1271                ```"#]],
1272        );
1273    }
1274
1275    #[test]
1276    fn snapshot_hover_class() {
1277        check_hover(
1278            "<?php\nclass MyService {}",
1279            pos(1, 8),
1280            expect![[r#"
1281                ```php
1282                class MyService
1283                ```"#]],
1284        );
1285    }
1286
1287    #[test]
1288    fn snapshot_hover_class_with_extends() {
1289        check_hover(
1290            "<?php\nclass Dog extends Animal {}",
1291            pos(1, 8),
1292            expect![[r#"
1293                ```php
1294                class Dog extends Animal
1295                ```"#]],
1296        );
1297    }
1298
1299    #[test]
1300    fn snapshot_hover_method() {
1301        check_hover(
1302            "<?php\nclass Calc { public function add(int $a, int $b): int {} }",
1303            pos(1, 32),
1304            expect![[r#"
1305                ```php
1306                public function add(int $a, int $b): int
1307                ```"#]],
1308        );
1309    }
1310
1311    #[test]
1312    fn snapshot_hover_trait() {
1313        check_hover(
1314            "<?php\ntrait Loggable {}",
1315            pos(1, 8),
1316            expect![[r#"
1317                ```php
1318                trait Loggable
1319                ```"#]],
1320        );
1321    }
1322
1323    #[test]
1324    fn snapshot_hover_interface() {
1325        check_hover(
1326            "<?php\ninterface Serializable {}",
1327            pos(1, 12),
1328            expect![[r#"
1329                ```php
1330                interface Serializable
1331                ```"#]],
1332        );
1333    }
1334
1335    #[test]
1336    fn snapshot_hover_class_const_with_type_hint() {
1337        check_hover(
1338            "<?php\nclass Config { const string VERSION = '1.0.0'; }",
1339            pos(1, 28),
1340            expect![[r#"
1341                ```php
1342                const string VERSION = '1.0.0'
1343                ```"#]],
1344        );
1345    }
1346
1347    #[test]
1348    fn snapshot_hover_class_const_float_value() {
1349        check_hover(
1350            "<?php\nclass Math { const float PI = 3.14; }",
1351            pos(1, 27),
1352            expect![[r#"
1353                ```php
1354                const float PI = 3.14
1355                ```"#]],
1356        );
1357    }
1358
1359    #[test]
1360    fn snapshot_hover_class_const_infers_type_from_value() {
1361        let (src, p) = cursor("<?php\nclass Config { const VERSION$0 = '1.0.0'; }");
1362        check_hover(
1363            &src,
1364            p,
1365            expect![[r#"
1366                ```php
1367                const string VERSION = '1.0.0'
1368                ```"#]],
1369        );
1370    }
1371
1372    #[test]
1373    fn snapshot_hover_interface_const_shows_type_and_value() {
1374        let (src, p) = cursor("<?php\ninterface Limits { const int MA$0X = 100; }");
1375        check_hover(
1376            &src,
1377            p,
1378            expect![[r#"
1379                ```php
1380                const int MAX = 100
1381                ```"#]],
1382        );
1383    }
1384
1385    #[test]
1386    fn snapshot_hover_trait_const_shows_type_and_value() {
1387        let (src, p) = cursor("<?php\ntrait HasVersion { const string TAG$0 = 'v1'; }");
1388        check_hover(
1389            &src,
1390            p,
1391            expect![[r#"
1392                ```php
1393                const string TAG = 'v1'
1394                ```"#]],
1395        );
1396    }
1397
1398    #[test]
1399    fn hover_on_static_var_with_array_default_shows_array() {
1400        let (src, p) = cursor("<?php\nfunction counter() { static $cach$0e = []; }");
1401        let doc = ParsedDoc::parse(src.clone());
1402        let result = hover_info(&src, &doc, None, p, &[]);
1403        assert!(
1404            result.is_some(),
1405            "expected hover result for static variable"
1406        );
1407        if let Some(Hover {
1408            contents: HoverContents::Markup(mc),
1409            ..
1410        }) = result
1411        {
1412            assert!(
1413                mc.value.contains("array"),
1414                "expected array type in hover, got: {}",
1415                mc.value
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn hover_on_static_var_with_new_shows_class() {
1422        let (src, p) = cursor("<?php\nfunction make() { static $inst$0ance = new MyService(); }");
1423        let doc = ParsedDoc::parse(src.clone());
1424        let result = hover_info(&src, &doc, None, p, &[]);
1425        assert!(
1426            result.is_some(),
1427            "expected hover result for static variable"
1428        );
1429        if let Some(Hover {
1430            contents: HoverContents::Markup(mc),
1431            ..
1432        }) = result
1433        {
1434            assert!(
1435                mc.value.contains("MyService"),
1436                "expected MyService in hover, got: {}",
1437                mc.value
1438            );
1439        }
1440    }
1441
1442    // Gap 1: variables defined in one method must not pollute hover in another method.
1443    #[test]
1444    fn hover_variable_in_method_does_not_leak_across_methods() {
1445        // $result is defined as Widget in methodA but the cursor is in methodB.
1446        // Before the fix, $result from methodA would appear in methodB's hover.
1447        let (src, p) = cursor(concat!(
1448            "<?php\n",
1449            "class Service {\n",
1450            "    public function methodA(): void { $result = new Widget(); }\n",
1451            "    public function methodB(): void { $res$0ult = new Invoice(); }\n",
1452            "}\n",
1453        ));
1454        let doc = ParsedDoc::parse(src.clone());
1455        let result = hover_info(&src, &doc, None, p, &[]);
1456        if let Some(Hover {
1457            contents: HoverContents::Markup(mc),
1458            ..
1459        }) = result
1460        {
1461            assert!(
1462                !mc.value.contains("Widget"),
1463                "Widget from methodA must not appear in methodB hover, got: {}",
1464                mc.value
1465            );
1466            assert!(
1467                mc.value.contains("Invoice"),
1468                "Invoice from methodB should appear in hover, got: {}",
1469                mc.value
1470            );
1471        }
1472    }
1473}