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