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        if let Some(ty) = analysis.and_then(|a| {
280            let off =
281                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
282            crate::types::type_query::type_at_offset(a, off)
283        }) && !crate::types::type_query::class_names(ty).is_empty()
284        {
285            return Some(Hover {
286                contents: HoverContents::Markup(MarkupContent {
287                    kind: MarkupKind::Markdown,
288                    value: format!("`{word}` `{ty}`"),
289                }),
290                range: hover_range,
291            });
292        }
293        if let Some(class_name) = type_map().get(&word) {
294            return Some(Hover {
295                contents: HoverContents::Markup(MarkupContent {
296                    kind: MarkupKind::Markdown,
297                    value: format!("`{}` `{}`", word, class_name),
298                }),
299                range: hover_range,
300            });
301        }
302    }
303
304    if word.starts_with('$')
305        && let Some(line_text) = source.lines().nth(position.line as usize)
306        && let Some(class_name) =
307            extract_static_class_before_cursor(line_text, position.character as usize)
308    {
309        let prop_name = word.trim_start_matches('$');
310        let effective_class = if class_name == "self" || class_name == "static" {
311            crate::types::type_map::enclosing_class_at(source, doc, position)
312                .unwrap_or(class_name.clone())
313        } else {
314            class_name.clone()
315        };
316        for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
317            if let Some((modifiers, type_str, db)) =
318                find_property_info(d, &effective_class, prop_name)
319            {
320                let sig = format!(
321                    "(property) {}{}::${}{}",
322                    modifiers,
323                    effective_class,
324                    prop_name,
325                    if type_str.is_empty() {
326                        String::new()
327                    } else {
328                        format!(": {}", type_str)
329                    }
330                );
331                let mut value = wrap_php(&sig);
332                if let Some(doc) = db {
333                    let md = doc.to_markdown();
334                    if !md.is_empty() {
335                        value.push_str("\n\n---\n\n");
336                        value.push_str(&md);
337                    }
338                }
339                return Some(Hover {
340                    contents: HoverContents::Markup(MarkupContent {
341                        kind: MarkupKind::Markdown,
342                        value,
343                    }),
344                    range: hover_range,
345                });
346            }
347        }
348    }
349
350    // Property declaration hover: cursor on `$prop` inside a class body.
351    // The mir path only fires for property ACCESSES; this covers the declaration
352    // line itself where no access symbol exists.
353    if word.starts_with('$')
354        && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
355        && let prop_name = word.trim_start_matches('$')
356        && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
357    {
358        let sig = format!(
359            "(property) {}{}::${}{}",
360            modifiers,
361            class_name,
362            prop_name,
363            if type_str.is_empty() {
364                String::new()
365            } else {
366                format!(": {type_str}")
367            }
368        );
369        let mut value = wrap_php(&sig);
370        if let Some(doc_block) = db {
371            let md = doc_block.to_markdown();
372            if !md.is_empty() {
373                value.push_str("\n\n---\n\n");
374                value.push_str(&md);
375            }
376        }
377        return Some(Hover {
378            contents: HoverContents::Markup(MarkupContent {
379                kind: MarkupKind::Markdown,
380                value,
381            }),
382            range: hover_range,
383        });
384    }
385
386    if !word.starts_with('$')
387        && let Some(sym) = analysis.and_then(|a| {
388            let off =
389                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
390            a.symbol_at(off)
391        })
392    {
393        let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
394        if mir_hover.is_some() {
395            return mir_hover.map(|value| Hover {
396                contents: HoverContents::Markup(MarkupContent {
397                    kind: MarkupKind::Markdown,
398                    value,
399                }),
400                range: hover_range,
401            });
402        }
403    }
404
405    if (word == "function" || word == "fn")
406        && let Some(sig) = closure_hover(source, doc, position, &word)
407    {
408        return Some(Hover {
409            contents: HoverContents::Markup(MarkupContent {
410                kind: MarkupKind::Markdown,
411                value: wrap_php(&sig),
412            }),
413            range: hover_range,
414        });
415    }
416
417    let all_stmts = &*doc.program().stmts as &[_];
418    let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
419
420    // Current-doc: still uses the AST walker (doc is already in memory, fast).
421    let current_doc_found =
422        resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
423            .and_then(|d| declaration_signature(&d, &resolved_word))
424            .map(|sig| {
425                let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
426                    .map(|db| db.to_markdown())
427                    .filter(|md| !md.is_empty());
428                (sig, doc_md)
429            });
430
431    // Cross-file: delegated to the caller's strategy (AST walk or symbol-map lookup).
432    let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
433
434    if let Some((sig, doc_md)) = found {
435        let mut value = wrap_php(&sig);
436        if let Some(md) = doc_md
437            && !md.is_empty()
438        {
439            value.push_str("\n\n---\n\n");
440            value.push_str(&md);
441        }
442        if is_php_builtin(&resolved_word) {
443            value.push_str(&format!(
444                "\n\n[php.net documentation]({})",
445                php_doc_url(&resolved_word)
446            ));
447        }
448        return Some(Hover {
449            contents: HoverContents::Markup(MarkupContent {
450                kind: MarkupKind::Markdown,
451                value,
452            }),
453            range: hover_range,
454        });
455    }
456
457    if is_php_builtin(&resolved_word) {
458        let value = format!(
459            "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
460            resolved_word,
461            php_doc_url(&resolved_word)
462        );
463        return Some(Hover {
464            contents: HoverContents::Markup(MarkupContent {
465                kind: MarkupKind::Markdown,
466                value,
467            }),
468            range: hover_range,
469        });
470    }
471
472    if let Some(stub) =
473        session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
474    {
475        return Some(builtin_class_hover(stub, &resolved_word, hover_range));
476    }
477
478    None
479}
480
481/// Produce hover markdown for a member access resolved by mir. Returns the
482/// hover value string (not the full `Hover` struct — the caller wraps it).
483/// `None` means mir has no symbol here; the caller falls through to `resolve_declaration`.
484fn mir_member_hover(
485    sym: &mir_analyzer::ResolvedSymbol,
486    word: &str,
487    doc: &ParsedDoc,
488    other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
489) -> Option<String> {
490    let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
491    match &sym.kind {
492        mir_analyzer::ReferenceKind::MethodCall { class, .. }
493        | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
494            let class_short = fqn_short_name(class);
495            for d in docs() {
496                if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
497                    // Augment declared return type with mir's inferred type when richer.
498                    let sig = augment_return_type(sig, &sym.resolved_type);
499                    let mut value = wrap_php(&sig);
500                    let all =
501                        std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
502                    if let Some(db) = resolve_method_docblock(all, class_short, word) {
503                        let md = db.to_markdown();
504                        if !md.is_empty() {
505                            value.push_str("\n\n---\n\n");
506                            value.push_str(&md);
507                        }
508                    }
509                    return Some(value);
510                }
511            }
512            None
513        }
514        mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
515            let class_short = fqn_short_name(class);
516            for d in docs() {
517                if let Some((modifiers, declared_type, db)) =
518                    find_property_info(d, class_short, property)
519                {
520                    // Use mir's resolved type when it's more specific than declared.
521                    let type_str = augment_property_type(declared_type, &sym.resolved_type);
522                    let sig = format!(
523                        "(property) {}{}::${}{}",
524                        modifiers,
525                        class_short,
526                        property,
527                        if type_str.is_empty() {
528                            String::new()
529                        } else {
530                            format!(": {}", type_str)
531                        }
532                    );
533                    let mut value = wrap_php(&sig);
534                    if let Some(doc_block) = db {
535                        let md = doc_block.to_markdown();
536                        if !md.is_empty() {
537                            value.push_str("\n\n---\n\n");
538                            value.push_str(&md);
539                        }
540                    }
541                    return Some(value);
542                }
543            }
544            // No declared property — dynamic access via __get. Show mir's resolved
545            // type (the __get return type) rather than falling through to no hover.
546            let ty_str = format!("{}", sym.resolved_type);
547            if !matches!(ty_str.as_str(), "" | "void" | "never") {
548                let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
549                return Some(wrap_php(&sig));
550            }
551            None
552        }
553        mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
554            let class_short = fqn_short_name(class);
555            for d in docs() {
556                if let Some(sig) =
557                    scan_enum_case_of_class(&d.program().stmts, class_short, constant)
558                {
559                    return Some(wrap_php(&sig));
560                }
561                if let Some(sig) =
562                    scan_class_const_of_class(&d.program().stmts, class_short, constant)
563                {
564                    return Some(wrap_php(&sig));
565                }
566            }
567            None
568        }
569        _ => None,
570    }
571}
572
573/// Override the return-type portion of a method signature string with mir's
574/// inferred type, when mir provides concrete (non-mixed, non-void) info.
575/// `sig` has the form `"Class::method(params): OldType"` or no return type.
576fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
577    let ty_str = format!("{resolved}");
578    if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
579        return sig;
580    }
581    let Some(paren) = sig.rfind(')') else {
582        return sig;
583    };
584    let rest = &sig[paren + 1..];
585    if let Some(colon_pos) = rest.find(": ") {
586        let declared = rest[colon_pos + 2..].trim();
587        // static/self/parent are late-static-binding — mir resolves them to a
588        // concrete class, losing the polymorphic semantics. Keep declared.
589        if matches!(declared, "static" | "self" | "parent") {
590            return sig;
591        }
592        format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
593    } else {
594        format!("{}: {}", sig, ty_str)
595    }
596}
597
598/// Choose between the declared property type and mir's resolved type. Uses
599/// mir when it adds information (non-mixed, non-void).
600fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
601    let ty_str = format!("{resolved}");
602    if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
603        return declared;
604    }
605    ty_str
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::test_utils::cursor;
612
613    fn pos(line: u32, character: u32) -> Position {
614        Position { line, character }
615    }
616
617    #[test]
618    fn word_at_extracts_from_middle_of_identifier() {
619        let (src, p) = cursor("<?php\nfunction greet$0User() {}");
620        let word = word_at_position(&src, p);
621        assert_eq!(word.as_deref(), Some("greetUser"));
622    }
623
624    #[test]
625    fn hover_on_builtin_class_requires_session() {
626        // Without a session, built-in class hover returns None (stubs are
627        // queried through the mir AnalysisSession). Full coverage lives in
628        // the integration tests that use TestServer with a real session.
629        let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
630        let doc = ParsedDoc::parse(src.to_string());
631        let h = hover_at(src, &doc, None, &[], pos(1, 12), None);
632        assert!(h.is_none(), "built-in class hover requires a session");
633    }
634}