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