Skip to main content

php_lsp/
hover.rs

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