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