Skip to main content

php_lsp/hover/
hover_impl.rs

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