Skip to main content

php_lsp/hover/
hover_impl.rs

1use std::cell::OnceCell;
2use std::sync::Arc;
3
4use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position, Url};
5
6use crate::document::ast::ParsedDoc;
7use crate::lang::docblock::find_docblock;
8use crate::lang::php_names::{is_php_builtin, php_doc_url};
9use crate::text::{fqn_short_name, word_at_position, word_range_at};
10use crate::types::resolve::{Declaration, resolve_declaration};
11use crate::types::symbol_map::{SymbolMap, is_hoverable_kind};
12use crate::types::type_map::TypeMap;
13
14use super::closures::closure_hover;
15use super::formatting::{declaration_signature, wrap_php};
16use super::members::{
17    find_property_info, resolve_method_docblock, scan_class_const_of_class,
18    scan_enum_case_of_class, scan_method_of_class,
19};
20use super::named_args::{extract_named_arg_callee, is_named_arg_at, named_arg_hover_value};
21use super::parsing::{extract_static_class_before_cursor, resolve_use_alias};
22
23/// Hover handles every declaration kind except properties (covered by the
24/// dedicated mir-primary path) and promoted parameters.
25fn is_hoverable(decl: &Declaration<'_>) -> bool {
26    !matches!(
27        decl,
28        Declaration::Property { .. } | Declaration::PromotedParam { .. }
29    )
30}
31
32pub(crate) fn hover_info(
33    source: &str,
34    doc: &ParsedDoc,
35    analysis: Option<&mir_analyzer::FileAnalysis>,
36    position: Position,
37    other_docs: &[(Url, Arc<ParsedDoc>)],
38) -> Option<Hover> {
39    hover_at(source, doc, analysis, other_docs, position, None)
40}
41
42/// Indexed variant: uses pre-computed [`SymbolMap`]s for the cross-file
43/// declaration lookup (path 4/5), eliminating repeated AST walks on stable
44/// files. All other paths (named-arg hover, mir-member hover, static-prop
45/// hover) still use `other_docs` since they require full AST traversal.
46pub fn hover_info_with_maps(
47    source: &str,
48    doc: &ParsedDoc,
49    analysis: Option<&mir_analyzer::FileAnalysis>,
50    position: Position,
51    other_docs: &[(Url, Arc<ParsedDoc>)],
52    other_maps: &[(Url, Arc<SymbolMap>)],
53    session: Option<&mir_analyzer::AnalysisSession>,
54) -> Option<Hover> {
55    hover_at_core(
56        source,
57        doc,
58        analysis,
59        other_docs,
60        position,
61        session,
62        |resolved_word| {
63            for (_, sym_map) in other_maps {
64                if let Some(entry) = sym_map.lookup(resolved_word, |e| is_hoverable_kind(e.kind))
65                    && let Some(sig) = &entry.signature
66                {
67                    return Some((sig.clone(), entry.doc_markdown.clone()));
68                }
69            }
70            None
71        },
72    )
73}
74
75/// Full hover: the cross-file declaration lookup walks `other_docs` with
76/// [`resolve_declaration`]. See [`hover_at_core`] for the shared body.
77pub fn hover_at(
78    source: &str,
79    doc: &ParsedDoc,
80    analysis: Option<&mir_analyzer::FileAnalysis>,
81    other_docs: &[(Url, Arc<ParsedDoc>)],
82    position: Position,
83    session: Option<&mir_analyzer::AnalysisSession>,
84) -> Option<Hover> {
85    hover_at_core(
86        source,
87        doc,
88        analysis,
89        other_docs,
90        position,
91        session,
92        |resolved_word| {
93            for (_, other) in other_docs {
94                if let Some(sig) =
95                    resolve_declaration(&other.program().stmts, resolved_word, &is_hoverable)
96                        .and_then(|d| declaration_signature(&d, resolved_word))
97                {
98                    let md = find_docblock(&other.program().stmts, resolved_word)
99                        .map(|db| db.to_markdown())
100                        .filter(|md| !md.is_empty());
101                    return Some((sig, md));
102                }
103            }
104            None
105        },
106    )
107}
108
109fn builtin_class_hover(
110    stub: crate::types::type_map::ClassMembers,
111    name: &str,
112    range: Option<tower_lsp::lsp_types::Range>,
113) -> Hover {
114    let method_names: Vec<&str> = stub
115        .methods
116        .iter()
117        .filter(|(_, is_static)| !is_static)
118        .map(|(n, _)| n.as_str())
119        .take(8)
120        .collect();
121    let static_names: Vec<&str> = stub
122        .methods
123        .iter()
124        .filter(|(_, is_static)| *is_static)
125        .map(|(n, _)| n.as_str())
126        .take(4)
127        .collect();
128    let mut lines = vec![format!("**{}** — built-in class", name)];
129    if !method_names.is_empty() {
130        lines.push(format!(
131            "Methods: {}",
132            method_names
133                .iter()
134                .map(|n| format!("`{n}`"))
135                .collect::<Vec<_>>()
136                .join(", ")
137        ));
138    }
139    if !static_names.is_empty() {
140        lines.push(format!(
141            "Static: {}",
142            static_names
143                .iter()
144                .map(|n| format!("`{n}`"))
145                .collect::<Vec<_>>()
146                .join(", ")
147        ));
148    }
149    if let Some(parent) = &stub.parent {
150        lines.push(format!("Extends: `{parent}`"));
151    }
152    Hover {
153        contents: HoverContents::Markup(MarkupContent {
154            kind: MarkupKind::Markdown,
155            value: lines.join("\n\n"),
156        }),
157        range,
158    }
159}
160
161/// Shared hover implementation. Every path is identical between the two public
162/// entry points except the cross-file declaration lookup, which the caller
163/// supplies via `resolve_cross_file`: [`hover_at`] walks `other_docs` with
164/// `resolve_declaration`; [`hover_info_with_maps`] does an O(1) `SymbolMap`
165/// lookup. The closure returns the `(signature, doc_markdown)` to render.
166fn hover_at_core(
167    source: &str,
168    doc: &ParsedDoc,
169    analysis: Option<&mir_analyzer::FileAnalysis>,
170    other_docs: &[(Url, Arc<ParsedDoc>)],
171    position: Position,
172    session: Option<&mir_analyzer::AnalysisSession>,
173    resolve_cross_file: impl Fn(&str) -> Option<(String, Option<String>)>,
174) -> Option<Hover> {
175    let hover_range = word_range_at(source, position);
176
177    if let Some(line_text) = source.lines().nth(position.line as usize) {
178        let trimmed = line_text.trim();
179        if trimmed.starts_with("use ") {
180            let (prefix, content) = if trimmed.starts_with("use function ") {
181                (
182                    "use function ",
183                    trimmed.strip_prefix("use function ").unwrap_or(""),
184                )
185            } else if trimmed.starts_with("use const ") {
186                (
187                    "use const ",
188                    trimmed.strip_prefix("use const ").unwrap_or(""),
189                )
190            } else {
191                ("use ", trimmed.strip_prefix("use ").unwrap_or(""))
192            };
193            let fqn = content.trim_end_matches(';').trim();
194            if !fqn.is_empty() {
195                let maybe_word = word_at_position(source, position);
196                let alias = fqn_short_name(fqn);
197                let matches = match &maybe_word {
198                    Some(w) => w == alias || fqn.contains(w.as_str()),
199                    None => true,
200                };
201                if matches {
202                    return Some(Hover {
203                        contents: HoverContents::Markup(MarkupContent {
204                            kind: MarkupKind::Markdown,
205                            value: format!("`{}{};`", prefix, fqn),
206                        }),
207                        range: hover_range,
208                    });
209                }
210            }
211        }
212    }
213
214    let word = word_at_position(source, position)?;
215
216    if let Some(line_text) = source.lines().nth(position.line as usize)
217        && extract_static_class_before_cursor(line_text, position.character as usize).is_none()
218    {
219        let keyword_doc: Option<&str> = match word.as_str() {
220            "match" => Some("`match` — evaluates an expression against a set of arms (PHP 8.0)"),
221            "null" => Some("`null` — the null value; a variable has no value"),
222            "true" => Some("`true` — boolean true"),
223            "false" => Some("`false` — boolean false"),
224            "abstract" => Some(
225                "`abstract` — declares an abstract class or method that must be implemented by a subclass",
226            ),
227            "readonly" => {
228                Some("`readonly` — property or class that can only be initialised once (PHP 8.1)")
229            }
230            "yield" => Some("`yield` — produces a value from a generator function"),
231            "never" => Some(
232                "`never` — return type indicating the function always throws or exits (PHP 8.1)",
233            ),
234            "throw" => {
235                Some("`throw` — throws an exception; can be used as an expression (PHP 8.0)")
236            }
237            _ => None,
238        };
239        if let Some(doc_str) = keyword_doc {
240            return Some(Hover {
241                contents: HoverContents::Markup(MarkupContent {
242                    kind: MarkupKind::Markdown,
243                    value: doc_str.to_string(),
244                }),
245                range: hover_range,
246            });
247        }
248    }
249
250    let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
251    let type_map =
252        || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
253
254    if let Some(line_text) = source.lines().nth(position.line as usize)
255        && !word.starts_with('$')
256        && is_named_arg_at(line_text, position.character as usize, &word)
257        && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
258        && let Some(value) = named_arg_hover_value(
259            source,
260            doc,
261            other_docs,
262            position,
263            &callee,
264            &word,
265            analysis,
266            &type_map_cell,
267        )
268    {
269        return Some(Hover {
270            contents: HoverContents::Markup(MarkupContent {
271                kind: MarkupKind::Markdown,
272                value,
273            }),
274            range: hover_range,
275        });
276    }
277
278    if word.starts_with('$') {
279        // Resolve mir type once; it may be None (no info), a class type, or a
280        // non-class type (scalar, callable, …).
281        let mir_ty = analysis.and_then(|a| {
282            let off =
283                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
284            crate::types::type_query::type_at_offset(a, off)
285        });
286
287        // 1. mir knows a class type — highest fidelity, always preferred.
288        if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
289        {
290            return Some(Hover {
291                contents: HoverContents::Markup(MarkupContent {
292                    kind: MarkupKind::Markdown,
293                    value: format!("`{word}` `{ty}`"),
294                }),
295                range: hover_range,
296            });
297        }
298        // 2. TypeMap has a class type — fallback for when mir has no class info
299        //    (e.g. Closure for first-class callables, or when analysis is None).
300        if let Some(class_name) = type_map().get(&word) {
301            return Some(Hover {
302                contents: HoverContents::Markup(MarkupContent {
303                    kind: MarkupKind::Markdown,
304                    value: format!("`{word}` `{class_name}`"),
305                }),
306                range: hover_range,
307            });
308        }
309        // 3. mir has a specific non-class type (e.g. callable, int, string).
310        //    Use it rather than showing nothing — respects "use mir if available".
311        //    Suppress `mixed` since it means "unknown" and adds no information.
312        if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
313            return Some(Hover {
314                contents: HoverContents::Markup(MarkupContent {
315                    kind: MarkupKind::Markdown,
316                    value: format!("`{word}` `{ty}`"),
317                }),
318                range: hover_range,
319            });
320        }
321    }
322
323    if word.starts_with('$')
324        && let Some(line_text) = source.lines().nth(position.line as usize)
325        && let Some(class_name) =
326            extract_static_class_before_cursor(line_text, position.character as usize)
327    {
328        let prop_name = word.trim_start_matches('$');
329        let effective_class = if class_name == "self" || class_name == "static" {
330            crate::types::type_map::enclosing_class_at(source, doc, position)
331                .unwrap_or(class_name.clone())
332        } else {
333            class_name.clone()
334        };
335        for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
336            if let Some((modifiers, type_str, db)) =
337                find_property_info(d, &effective_class, prop_name)
338            {
339                let sig = format!(
340                    "(property) {}{}::${}{}",
341                    modifiers,
342                    effective_class,
343                    prop_name,
344                    if type_str.is_empty() {
345                        String::new()
346                    } else {
347                        format!(": {}", type_str)
348                    }
349                );
350                let mut value = wrap_php(&sig);
351                if let Some(doc) = db {
352                    let md = doc.to_markdown();
353                    if !md.is_empty() {
354                        value.push_str("\n\n---\n\n");
355                        value.push_str(&md);
356                    }
357                }
358                return Some(Hover {
359                    contents: HoverContents::Markup(MarkupContent {
360                        kind: MarkupKind::Markdown,
361                        value,
362                    }),
363                    range: hover_range,
364                });
365            }
366        }
367    }
368
369    // Property declaration hover: cursor on `$prop` inside a class body.
370    // The mir path only fires for property ACCESSES; this covers the declaration
371    // line itself where no access symbol exists.
372    if word.starts_with('$')
373        && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
374        && let prop_name = word.trim_start_matches('$')
375        && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
376    {
377        let sig = format!(
378            "(property) {}{}::${}{}",
379            modifiers,
380            class_name,
381            prop_name,
382            if type_str.is_empty() {
383                String::new()
384            } else {
385                format!(": {type_str}")
386            }
387        );
388        let mut value = wrap_php(&sig);
389        if let Some(doc_block) = db {
390            let md = doc_block.to_markdown();
391            if !md.is_empty() {
392                value.push_str("\n\n---\n\n");
393                value.push_str(&md);
394            }
395        }
396        return Some(Hover {
397            contents: HoverContents::Markup(MarkupContent {
398                kind: MarkupKind::Markdown,
399                value,
400            }),
401            range: hover_range,
402        });
403    }
404
405    if !word.starts_with('$')
406        && let Some(sym) = analysis.and_then(|a| {
407            let off =
408                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
409            a.symbol_at(off)
410        })
411    {
412        let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
413        if mir_hover.is_some() {
414            return mir_hover.map(|value| Hover {
415                contents: HoverContents::Markup(MarkupContent {
416                    kind: MarkupKind::Markdown,
417                    value,
418                }),
419                range: hover_range,
420            });
421        }
422    }
423
424    if (word == "function" || word == "fn")
425        && let Some(sig) = closure_hover(source, doc, position, &word)
426    {
427        return Some(Hover {
428            contents: HoverContents::Markup(MarkupContent {
429                kind: MarkupKind::Markdown,
430                value: wrap_php(&sig),
431            }),
432            range: hover_range,
433        });
434    }
435
436    let all_stmts = &*doc.program().stmts as &[_];
437    let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
438
439    // Current-doc: still uses the AST walker (doc is already in memory, fast).
440    let current_doc_found =
441        resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
442            .and_then(|d| declaration_signature(&d, &resolved_word))
443            .map(|sig| {
444                let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
445                    .map(|db| db.to_markdown())
446                    .filter(|md| !md.is_empty());
447                (sig, doc_md)
448            });
449
450    // Cross-file: delegated to the caller's strategy (AST walk or symbol-map lookup).
451    let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
452
453    if let Some((sig, doc_md)) = found {
454        let mut value = wrap_php(&sig);
455        if let Some(md) = doc_md
456            && !md.is_empty()
457        {
458            value.push_str("\n\n---\n\n");
459            value.push_str(&md);
460        }
461        if is_php_builtin(&resolved_word) {
462            value.push_str(&format!(
463                "\n\n[php.net documentation]({})",
464                php_doc_url(&resolved_word)
465            ));
466        }
467        return Some(Hover {
468            contents: HoverContents::Markup(MarkupContent {
469                kind: MarkupKind::Markdown,
470                value,
471            }),
472            range: hover_range,
473        });
474    }
475
476    if is_php_builtin(&resolved_word) {
477        let value = format!(
478            "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
479            resolved_word,
480            php_doc_url(&resolved_word)
481        );
482        return Some(Hover {
483            contents: HoverContents::Markup(MarkupContent {
484                kind: MarkupKind::Markdown,
485                value,
486            }),
487            range: hover_range,
488        });
489    }
490
491    if let Some(stub) =
492        session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
493    {
494        return Some(builtin_class_hover(stub, &resolved_word, hover_range));
495    }
496
497    None
498}
499
500/// Produce hover markdown for a member access resolved by mir. Returns the
501/// hover value string (not the full `Hover` struct — the caller wraps it).
502/// `None` means mir has no symbol here; the caller falls through to `resolve_declaration`.
503fn mir_member_hover(
504    sym: &mir_analyzer::ResolvedSymbol,
505    word: &str,
506    doc: &ParsedDoc,
507    other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
508) -> Option<String> {
509    let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
510    match &sym.kind {
511        mir_analyzer::ReferenceKind::MethodCall { class, .. }
512        | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
513            let class_short = fqn_short_name(class);
514            for d in docs() {
515                if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
516                    // Augment declared return type with mir's inferred type when richer.
517                    let sig = augment_return_type(sig, &sym.resolved_type);
518                    let mut value = wrap_php(&sig);
519                    let all =
520                        std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
521                    if let Some(db) = resolve_method_docblock(all, class_short, word) {
522                        let md = db.to_markdown();
523                        if !md.is_empty() {
524                            value.push_str("\n\n---\n\n");
525                            value.push_str(&md);
526                        }
527                    }
528                    return Some(value);
529                }
530            }
531            None
532        }
533        mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
534            let class_short = fqn_short_name(class);
535            for d in docs() {
536                if let Some((modifiers, declared_type, db)) =
537                    find_property_info(d, class_short, property)
538                {
539                    // Use mir's resolved type when it's more specific than declared.
540                    let type_str = augment_property_type(declared_type, &sym.resolved_type);
541                    let sig = format!(
542                        "(property) {}{}::${}{}",
543                        modifiers,
544                        class_short,
545                        property,
546                        if type_str.is_empty() {
547                            String::new()
548                        } else {
549                            format!(": {}", type_str)
550                        }
551                    );
552                    let mut value = wrap_php(&sig);
553                    if let Some(doc_block) = db {
554                        let md = doc_block.to_markdown();
555                        if !md.is_empty() {
556                            value.push_str("\n\n---\n\n");
557                            value.push_str(&md);
558                        }
559                    }
560                    return Some(value);
561                }
562            }
563            // No declared property — dynamic access via __get. Show mir's resolved
564            // type (the __get return type) rather than falling through to no hover.
565            let ty_str = format!("{}", sym.resolved_type);
566            if !matches!(ty_str.as_str(), "" | "void" | "never") {
567                let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
568                return Some(wrap_php(&sig));
569            }
570            None
571        }
572        mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
573            let class_short = fqn_short_name(class);
574            for d in docs() {
575                if let Some(sig) =
576                    scan_enum_case_of_class(&d.program().stmts, class_short, constant)
577                {
578                    return Some(wrap_php(&sig));
579                }
580                if let Some(sig) =
581                    scan_class_const_of_class(&d.program().stmts, class_short, constant)
582                {
583                    return Some(wrap_php(&sig));
584                }
585            }
586            None
587        }
588        _ => None,
589    }
590}
591
592/// Override the return-type portion of a method signature string with mir's
593/// inferred type, when mir provides concrete (non-mixed, non-void) info.
594/// `sig` has the form `"Class::method(params): OldType"` or no return type.
595fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
596    let ty_str = format!("{resolved}");
597    if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
598        return sig;
599    }
600    let Some(paren) = sig.rfind(')') else {
601        return sig;
602    };
603    let rest = &sig[paren + 1..];
604    if let Some(colon_pos) = rest.find(": ") {
605        let declared = rest[colon_pos + 2..].trim();
606        // static/self/parent are late-static-binding — mir resolves them to a
607        // concrete class, losing the polymorphic semantics. Keep declared.
608        if matches!(declared, "static" | "self" | "parent") {
609            return sig;
610        }
611        format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
612    } else {
613        format!("{}: {}", sig, ty_str)
614    }
615}
616
617/// Choose between the declared property type and mir's resolved type. Uses
618/// mir when it adds information (non-mixed, non-void).
619fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
620    let ty_str = format!("{resolved}");
621    if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
622        return declared;
623    }
624    ty_str
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use crate::test_utils::cursor;
631
632    fn pos(line: u32, character: u32) -> Position {
633        Position { line, character }
634    }
635
636    #[test]
637    fn word_at_extracts_from_middle_of_identifier() {
638        let (src, p) = cursor("<?php\nfunction greet$0User() {}");
639        let word = word_at_position(&src, p);
640        assert_eq!(word.as_deref(), Some("greetUser"));
641    }
642
643    #[test]
644    fn hover_on_builtin_class_requires_session() {
645        // Without a session, built-in class hover returns None (stubs are
646        // queried through the mir AnalysisSession). Full coverage lives in
647        // the integration tests that use TestServer with a real session.
648        let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
649        let doc = ParsedDoc::parse(src.to_string());
650        let h = hover_at(src, &doc, None, &[], pos(1, 12), None);
651        assert!(h.is_none(), "built-in class hover requires a session");
652    }
653}