Skip to main content

php_lsp/hover/
hover_impl.rs

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