Skip to main content

php_lsp/
hover.rs

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