Skip to main content

php_lsp/
hover.rs

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