Skip to main content

php_lsp/hover/
hover_impl.rs

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