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