Skip to main content

php_lsp/hover/
hover_impl.rs

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