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            "void" => Some("`void` — return type indicating the function returns no value"),
194            "bool" => Some("`bool` — boolean type: `true` or `false`"),
195            "int" => Some("`int` — integer type"),
196            "float" => Some("`float` — floating-point number type"),
197            "string" => Some("`string` — string type"),
198            "mixed" => Some("`mixed` — any type (no type constraint)"),
199            "object" => Some("`object` — any class instance"),
200            "iterable" => Some("`iterable` — array or Traversable (PHP 7.1)"),
201            "array" => Some("`array` — ordered map type"),
202            "callable" => Some(
203                "`callable` — any callable: Closure, function-name string, or `[object, method]` array",
204            ),
205            "self" => Some("`self` — the class in which the method is defined"),
206            "static" => Some(
207                "`static` — the class on which the method was called (late static binding, PHP 5.3)",
208            ),
209            "parent" => Some("`parent` — the parent class of the current class"),
210            _ => None,
211        };
212        if let Some(doc_str) = keyword_doc {
213            return Some(Hover {
214                contents: HoverContents::Markup(MarkupContent {
215                    kind: MarkupKind::Markdown,
216                    value: doc_str.to_string(),
217                }),
218                range: hover_range,
219            });
220        }
221        let magic_doc: Option<&str> = match word.as_str() {
222            "__CLASS__" => Some("`__CLASS__` — name of the current class"),
223            "__DIR__" => Some("`__DIR__` — directory of the current file"),
224            "__FILE__" => Some("`__FILE__` — absolute path of the current file"),
225            "__FUNCTION__" => Some("`__FUNCTION__` — name of the current function or closure"),
226            "__LINE__" => Some("`__LINE__` — current line number in the file"),
227            "__METHOD__" => Some("`__METHOD__` — current method name (`ClassName::methodName`)"),
228            "__NAMESPACE__" => Some("`__NAMESPACE__` — name of the current namespace"),
229            "__TRAIT__" => Some("`__TRAIT__` — name of the current trait"),
230            _ => None,
231        };
232        if let Some(doc_str) = magic_doc {
233            return Some(Hover {
234                contents: HoverContents::Markup(MarkupContent {
235                    kind: MarkupKind::Markdown,
236                    value: doc_str.to_string(),
237                }),
238                range: hover_range,
239            });
240        }
241    }
242
243    let type_map_cell: OnceCell<TypeMap> = OnceCell::new();
244    let type_map =
245        || type_map_cell.get_or_init(|| TypeMap::from_doc_at_position(doc, None, position));
246
247    if let Some(line_text) = source.lines().nth(position.line as usize)
248        && !word.starts_with('$')
249        && is_named_arg_at(line_text, position.character as usize, &word)
250        && let Some(callee) = extract_named_arg_callee(line_text, position.character as usize)
251        && let Some(value) = named_arg_hover_value(
252            source,
253            doc,
254            other_docs,
255            position,
256            &callee,
257            &word,
258            analysis,
259            &type_map_cell,
260        )
261    {
262        return Some(Hover {
263            contents: HoverContents::Markup(MarkupContent {
264                kind: MarkupKind::Markdown,
265                value,
266            }),
267            range: hover_range,
268        });
269    }
270
271    // Static property access (`Foo::$bar`) — checked before the generic mir-type
272    // fallback below, since mir also records a plain `ResolvedSymbol` type for
273    // these and would otherwise win with a lower-fidelity `` `$bar` `type` ``.
274    if word.starts_with('$')
275        && let Some(line_text) = source.lines().nth(position.line as usize)
276        && let Some(class_name) =
277            extract_static_class_before_cursor(line_text, position.character as usize)
278    {
279        let prop_name = word.trim_start_matches('$');
280        let effective_class = if class_name == "self" || class_name == "static" {
281            crate::types::type_map::enclosing_class_at(source, doc, position)
282                .unwrap_or(class_name.clone())
283        } else {
284            class_name.clone()
285        };
286        for d in std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref())) {
287            if let Some((modifiers, type_str, db)) =
288                find_property_info(d, &effective_class, prop_name)
289            {
290                let sig = format!(
291                    "(property) {}{}::${}{}",
292                    modifiers,
293                    effective_class,
294                    prop_name,
295                    if type_str.is_empty() {
296                        String::new()
297                    } else {
298                        format!(": {}", type_str)
299                    }
300                );
301                let mut value = wrap_php(&sig);
302                if let Some(doc) = db {
303                    let md = doc.to_markdown();
304                    if !md.is_empty() {
305                        value.push_str("\n\n---\n\n");
306                        value.push_str(&md);
307                    }
308                }
309                return Some(Hover {
310                    contents: HoverContents::Markup(MarkupContent {
311                        kind: MarkupKind::Markdown,
312                        value,
313                    }),
314                    range: hover_range,
315                });
316            }
317        }
318    }
319
320    if word.starts_with('$') {
321        // Resolve mir type once; it may be None (no info), a class type, or a
322        // non-class type (scalar, callable, …).
323        let mir_ty = analysis.and_then(|a| {
324            let off =
325                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
326            crate::types::type_query::type_at_offset(a, off)
327        });
328
329        // 1. mir knows a class type — highest fidelity, always preferred.
330        if let Some(ty) = mir_ty.filter(|ty| !crate::types::type_query::class_names(ty).is_empty())
331        {
332            return Some(Hover {
333                contents: HoverContents::Markup(MarkupContent {
334                    kind: MarkupKind::Markdown,
335                    value: format!("`{word}` `{ty}`"),
336                }),
337                range: hover_range,
338            });
339        }
340        // 2. TypeMap has a class type — fallback for when mir has no class info
341        //    (e.g. Closure for first-class callables, or when analysis is None).
342        if let Some(class_name) = type_map().get(&word) {
343            return Some(Hover {
344                contents: HoverContents::Markup(MarkupContent {
345                    kind: MarkupKind::Markdown,
346                    value: format!("`{word}` `{class_name}`"),
347                }),
348                range: hover_range,
349            });
350        }
351        // 3. mir has a specific non-class type (e.g. callable, int, string).
352        //    Use it rather than showing nothing — respects "use mir if available".
353        //    Suppress `mixed` since it means "unknown" and adds no information.
354        if let Some(ty) = mir_ty.filter(|ty| ty.to_string() != "mixed") {
355            return Some(Hover {
356                contents: HoverContents::Markup(MarkupContent {
357                    kind: MarkupKind::Markdown,
358                    value: format!("`{word}` `{ty}`"),
359                }),
360                range: hover_range,
361            });
362        }
363    }
364
365    // Property declaration hover: cursor on `$prop` inside a class body.
366    // The mir path only fires for property ACCESSES; this covers the declaration
367    // line itself where no access symbol exists.
368    if word.starts_with('$')
369        && let Some(class_name) = crate::types::type_map::enclosing_class_at(source, doc, position)
370        && let prop_name = word.trim_start_matches('$')
371        && let Some((modifiers, type_str, db)) = find_property_info(doc, &class_name, prop_name)
372    {
373        let sig = format!(
374            "(property) {}{}::${}{}",
375            modifiers,
376            class_name,
377            prop_name,
378            if type_str.is_empty() {
379                String::new()
380            } else {
381                format!(": {type_str}")
382            }
383        );
384        let mut value = wrap_php(&sig);
385        if let Some(doc_block) = db {
386            let md = doc_block.to_markdown();
387            if !md.is_empty() {
388                value.push_str("\n\n---\n\n");
389                value.push_str(&md);
390            }
391        }
392        return Some(Hover {
393            contents: HoverContents::Markup(MarkupContent {
394                kind: MarkupKind::Markdown,
395                value,
396            }),
397            range: hover_range,
398        });
399    }
400
401    if !word.starts_with('$')
402        && let Some(sym) = analysis.and_then(|a| {
403            let off =
404                word_range_at(source, position).map(|r| doc.view().byte_of_position(r.start))?;
405            a.symbol_at(off)
406        })
407    {
408        let mir_hover = mir_member_hover(sym, &word, doc, other_docs);
409        if mir_hover.is_some() {
410            return mir_hover.map(|value| Hover {
411                contents: HoverContents::Markup(MarkupContent {
412                    kind: MarkupKind::Markdown,
413                    value,
414                }),
415                range: hover_range,
416            });
417        }
418    }
419
420    if (word == "function" || word == "fn")
421        && let Some(sig) = closure_hover(source, doc, position, &word)
422    {
423        return Some(Hover {
424            contents: HoverContents::Markup(MarkupContent {
425                kind: MarkupKind::Markdown,
426                value: wrap_php(&sig),
427            }),
428            range: hover_range,
429        });
430    }
431
432    let all_stmts = &*doc.program().stmts as &[_];
433    let resolved_word = resolve_use_alias(all_stmts, &word).unwrap_or_else(|| word.clone());
434
435    // Current-doc: still uses the AST walker (doc is already in memory, fast).
436    let current_doc_found =
437        resolve_declaration(&doc.program().stmts, &resolved_word, &is_hoverable)
438            .and_then(|d| declaration_signature(&d, &resolved_word))
439            .map(|sig| {
440                let doc_md = find_docblock(&doc.program().stmts, &resolved_word)
441                    .map(|db| db.to_markdown())
442                    .filter(|md| !md.is_empty());
443                (sig, doc_md)
444            });
445
446    // Cross-file: delegated to the caller's strategy (AST walk or symbol-map lookup).
447    let found = current_doc_found.or_else(|| resolve_cross_file(&resolved_word));
448
449    if let Some((sig, doc_md)) = found {
450        let mut value = wrap_php(&sig);
451        if let Some(md) = doc_md
452            && !md.is_empty()
453        {
454            value.push_str("\n\n---\n\n");
455            value.push_str(&md);
456        }
457        if is_php_builtin(&resolved_word) {
458            value.push_str(&format!(
459                "\n\n[php.net documentation]({})",
460                php_doc_url(&resolved_word)
461            ));
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 is_php_builtin(&resolved_word) {
473        let value = format!(
474            "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
475            resolved_word,
476            php_doc_url(&resolved_word)
477        );
478        return Some(Hover {
479            contents: HoverContents::Markup(MarkupContent {
480                kind: MarkupKind::Markdown,
481                value,
482            }),
483            range: hover_range,
484        });
485    }
486
487    if let Some(stub) =
488        session.and_then(|s| crate::types::stub_members::stub_class_members(s, &resolved_word))
489    {
490        return Some(builtin_class_hover(stub, &resolved_word, hover_range));
491    }
492
493    None
494}
495
496/// Produce hover markdown for a member access resolved by mir. Returns the
497/// hover value string (not the full `Hover` struct — the caller wraps it).
498/// `None` means mir has no symbol here; the caller falls through to `resolve_declaration`.
499fn mir_member_hover(
500    sym: &mir_analyzer::ResolvedSymbol,
501    word: &str,
502    doc: &ParsedDoc,
503    other_docs: &[(tower_lsp::lsp_types::Url, std::sync::Arc<ParsedDoc>)],
504) -> Option<String> {
505    let docs = || std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
506    match &sym.kind {
507        mir_analyzer::ReferenceKind::MethodCall { class, .. }
508        | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
509            let class_short = fqn_short_name(class);
510            for d in docs() {
511                if let Some(sig) = scan_method_of_class(&d.program().stmts, class_short, word) {
512                    // Augment declared return type with mir's inferred type when richer.
513                    let sig = augment_return_type(sig, &sym.resolved_type);
514                    let mut value = wrap_php(&sig);
515                    let all =
516                        std::iter::once(doc).chain(other_docs.iter().map(|(_, d)| d.as_ref()));
517                    if let Some(db) = resolve_method_docblock(all, class_short, word) {
518                        let md = db.to_markdown();
519                        if !md.is_empty() {
520                            value.push_str("\n\n---\n\n");
521                            value.push_str(&md);
522                        }
523                    }
524                    return Some(value);
525                }
526            }
527            None
528        }
529        mir_analyzer::ReferenceKind::PropertyAccess { class, property } => {
530            let class_short = fqn_short_name(class);
531            for d in docs() {
532                if let Some((modifiers, declared_type, db)) =
533                    find_property_info(d, class_short, property)
534                {
535                    // Use mir's resolved type when it's more specific than declared.
536                    let type_str = augment_property_type(declared_type, &sym.resolved_type);
537                    let sig = format!(
538                        "(property) {}{}::${}{}",
539                        modifiers,
540                        class_short,
541                        property,
542                        if type_str.is_empty() {
543                            String::new()
544                        } else {
545                            format!(": {}", type_str)
546                        }
547                    );
548                    let mut value = wrap_php(&sig);
549                    if let Some(doc_block) = db {
550                        let md = doc_block.to_markdown();
551                        if !md.is_empty() {
552                            value.push_str("\n\n---\n\n");
553                            value.push_str(&md);
554                        }
555                    }
556                    return Some(value);
557                }
558            }
559            // No declared property — dynamic access via __get. Show mir's resolved
560            // type (the __get return type) rather than falling through to no hover.
561            let ty_str = format!("{}", sym.resolved_type);
562            if !matches!(ty_str.as_str(), "" | "void" | "never") {
563                let sig = format!("(property) {}::${}: {}", class_short, property, ty_str);
564                return Some(wrap_php(&sig));
565            }
566            None
567        }
568        mir_analyzer::ReferenceKind::ConstantAccess { class, constant } => {
569            let class_short = fqn_short_name(class);
570            for d in docs() {
571                if let Some(sig) =
572                    scan_enum_case_of_class(&d.program().stmts, class_short, constant)
573                {
574                    return Some(wrap_php(&sig));
575                }
576                if let Some(sig) =
577                    scan_class_const_of_class(&d.program().stmts, class_short, constant)
578                {
579                    return Some(wrap_php(&sig));
580                }
581            }
582            None
583        }
584        _ => None,
585    }
586}
587
588/// Override the return-type portion of a method signature string with mir's
589/// inferred type, when mir provides concrete (non-mixed, non-void) info.
590/// `sig` has the form `"Class::method(params): OldType"` or no return type.
591fn augment_return_type(sig: String, resolved: &mir_analyzer::Type) -> String {
592    let ty_str = format!("{resolved}");
593    if matches!(ty_str.as_str(), "mixed" | "void" | "never" | "null") {
594        return sig;
595    }
596    let Some(paren) = sig.rfind(')') else {
597        return sig;
598    };
599    let rest = &sig[paren + 1..];
600    if let Some(colon_pos) = rest.find(": ") {
601        let declared = rest[colon_pos + 2..].trim();
602        // static/self/parent are late-static-binding — mir resolves them to a
603        // concrete class, losing the polymorphic semantics. Keep declared.
604        if matches!(declared, "static" | "self" | "parent") {
605            return sig;
606        }
607        format!("{}: {}", &sig[..paren + 1 + colon_pos], ty_str)
608    } else {
609        format!("{}: {}", sig, ty_str)
610    }
611}
612
613/// Choose between the declared property type and mir's resolved type. Uses
614/// mir when it adds information (non-mixed, non-void).
615fn augment_property_type(declared: String, resolved: &mir_analyzer::Type) -> String {
616    let ty_str = format!("{resolved}");
617    if matches!(ty_str.as_str(), "mixed" | "void" | "never") {
618        return declared;
619    }
620    ty_str
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::test_utils::cursor;
627
628    fn pos(line: u32, character: u32) -> Position {
629        Position { line, character }
630    }
631
632    #[test]
633    fn word_at_extracts_from_middle_of_identifier() {
634        let (src, p) = cursor("<?php\nfunction greet$0User() {}");
635        let word = word_at_position(&src, p);
636        assert_eq!(word.as_deref(), Some("greetUser"));
637    }
638
639    #[test]
640    fn hover_on_builtin_class_requires_session() {
641        // Without a session, built-in class hover returns None (stubs are
642        // queried through the mir AnalysisSession). Full coverage lives in
643        // the integration tests that use TestServer with a real session.
644        let src = "<?php\n$pdo = new PDO('sqlite::memory:');\n$pdo->query('SELECT 1');";
645        let doc = ParsedDoc::parse(src.to_string());
646        let h = hover_info_with_maps(src, &doc, None, pos(1, 12), &[], &[], None);
647        assert!(h.is_none(), "built-in class hover requires a session");
648    }
649}