Skip to main content

php_lsp/hover/
formatting.rs

1use php_ast::{MethodDecl, Param, Visibility};
2use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind};
3
4use crate::document::ast::format_type_hint;
5use crate::lang::php_names::{is_php_builtin, php_doc_url};
6use crate::types::resolve::Declaration;
7
8/// Format an expression literal value.
9pub(crate) fn format_expr_literal(expr: &php_ast::Expr<'_, '_>) -> Option<String> {
10    use php_ast::ExprKind;
11    match &expr.kind {
12        ExprKind::Int(n) => Some(n.to_string()),
13        ExprKind::Float(f) => Some(f.to_string()),
14        ExprKind::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
15        ExprKind::String(s) => Some(format!("'{}'", s)),
16        _ => None,
17    }
18}
19
20/// Format a class/interface/enum constant declaration for hover display.
21pub(crate) fn format_class_const(c: &php_ast::ClassConstDecl<'_, '_>) -> String {
22    use php_ast::ExprKind;
23    let type_str = c
24        .type_hint
25        .as_ref()
26        .map(|t| format!("{} ", format_type_hint(t)))
27        .or_else(|| match &c.value.kind {
28            ExprKind::Int(_) => Some("int ".to_string()),
29            ExprKind::String(_) => Some("string ".to_string()),
30            ExprKind::Float(_) => Some("float ".to_string()),
31            ExprKind::Bool(_) => Some("bool ".to_string()),
32            _ => None,
33        })
34        .unwrap_or_default();
35    let value_str = format_expr_literal(&c.value)
36        .map(|v| format!(" = {v}"))
37        .unwrap_or_default();
38    format!("const {}{}{}", type_str, c.name, value_str)
39}
40
41pub fn format_params_str(params: &[Param<'_, '_>]) -> String {
42    format_params(params)
43}
44
45pub(crate) fn format_params(params: &[Param<'_, '_>]) -> String {
46    params
47        .iter()
48        .map(|p| {
49            let mut s = String::new();
50            if p.by_ref {
51                s.push('&');
52            }
53            if let Some(t) = &p.type_hint {
54                s.push_str(&format!("{} ", format_type_hint(t)));
55            }
56            if p.variadic {
57                s.push_str("...");
58            }
59            s.push_str(&format!("${}", p.name));
60            if let Some(default) = &p.default {
61                s.push_str(&format!(" = {}", format_default_value(default)));
62            }
63            s
64        })
65        .collect::<Vec<_>>()
66        .join(", ")
67}
68
69/// Format a default parameter value for display in signatures.
70pub(crate) fn format_default_value(expr: &php_ast::Expr<'_, '_>) -> String {
71    use php_ast::ExprKind;
72    match &expr.kind {
73        ExprKind::Int(n) => n.to_string(),
74        ExprKind::Float(f) => f.to_string(),
75        ExprKind::String(s) => format!("'{}'", s),
76        ExprKind::Bool(b) => {
77            if *b {
78                "true".to_string()
79            } else {
80                "false".to_string()
81            }
82        }
83        ExprKind::Null => "null".to_string(),
84        ExprKind::Array(items) => {
85            if items.is_empty() {
86                "[]".to_string()
87            } else {
88                "[...]".to_string()
89            }
90        }
91        _ => "...".to_string(),
92    }
93}
94
95pub(crate) fn wrap_php(sig: &str) -> String {
96    format!("```php\n{}\n```", sig)
97}
98
99/// Format a method/function-style member signature, e.g.
100/// `public static function foo(int $x): void`.
101pub(crate) fn method_signature(m: &MethodDecl<'_, '_>) -> String {
102    let prefix = format_method_prefix(
103        m.visibility.as_ref(),
104        m.is_static,
105        m.is_abstract,
106        m.is_final,
107    );
108    let params = format_params(&m.params);
109    let ret = m
110        .return_type
111        .as_ref()
112        .map(|r| format!(": {}", format_type_hint(r)))
113        .unwrap_or_default();
114    format!("{}function {}({}){}", prefix, m.name, params, ret)
115}
116
117/// Render the hover signature for a resolved declaration. Returns `None` for
118/// kinds rendered elsewhere (properties via mir-primary path).
119pub(crate) fn declaration_signature(decl: &Declaration<'_>, word: &str) -> Option<String> {
120    let sig = match decl {
121        Declaration::Function { decl: f, .. } => {
122            let params = format_params(&f.params);
123            let ret = f
124                .return_type
125                .as_ref()
126                .map(|r| format!(": {}", format_type_hint(r)))
127                .unwrap_or_default();
128            format!("function {}({}){}", word, params, ret)
129        }
130        Declaration::Class { decl: c, .. } => {
131            let kw = if c.modifiers.is_abstract {
132                "abstract class"
133            } else if c.modifiers.is_final {
134                "final class"
135            } else if c.modifiers.is_readonly {
136                "readonly class"
137            } else {
138                "class"
139            };
140            let mut sig = format!("{} {}", kw, word);
141            if let Some(ext) = &c.extends {
142                sig.push_str(&format!(" extends {}", ext.to_string_repr()));
143            }
144            if !c.implements.is_empty() {
145                let ifaces: Vec<String> = c
146                    .implements
147                    .iter()
148                    .map(|i| i.to_string_repr().into_owned())
149                    .collect();
150                sig.push_str(&format!(" implements {}", ifaces.join(", ")));
151            }
152            sig
153        }
154        Declaration::Interface { .. } => format!("interface {}", word),
155        Declaration::Trait { .. } => format!("trait {}", word),
156        Declaration::Enum { decl: e, .. } => {
157            let mut sig = if let Some(scalar) = &e.scalar_type {
158                format!("enum {}: {}", word, scalar.to_string_repr())
159            } else {
160                format!("enum {}", word)
161            };
162            if !e.implements.is_empty() {
163                let ifaces: Vec<String> = e
164                    .implements
165                    .iter()
166                    .map(|i| i.to_string_repr().into_owned())
167                    .collect();
168                sig.push_str(&format!(" implements {}", ifaces.join(", ")));
169            }
170            sig
171        }
172        Declaration::Method { method, .. } => method_signature(method),
173        Declaration::ClassConst { konst, .. } => format_class_const(konst),
174        Declaration::EnumCase {
175            case, enum_name, ..
176        } => {
177            let value_str = case
178                .value
179                .as_ref()
180                .and_then(format_expr_literal)
181                .map(|v| format!(" = {v}"))
182                .unwrap_or_default();
183            format!("case {}::{}{}", enum_name, case.name, value_str)
184        }
185        Declaration::Property { .. } | Declaration::PromotedParam { .. } => return None,
186    };
187    Some(sig)
188}
189
190fn visibility_str(v: &Visibility) -> &'static str {
191    match v {
192        Visibility::Public => "public",
193        Visibility::Protected => "protected",
194        Visibility::Private => "private",
195    }
196}
197
198pub(crate) fn format_method_prefix(
199    visibility: Option<&Visibility>,
200    is_static: bool,
201    is_abstract: bool,
202    is_final: bool,
203) -> String {
204    let mut parts: Vec<&str> = Vec::new();
205    if let Some(v) = visibility {
206        parts.push(visibility_str(v));
207    }
208    if is_abstract {
209        parts.push("abstract");
210    }
211    if is_final {
212        parts.push("final");
213    }
214    if is_static {
215        parts.push("static");
216    }
217    if parts.is_empty() {
218        String::new()
219    } else {
220        parts.join(" ") + " "
221    }
222}
223
224pub(crate) fn format_prop_prefix(
225    visibility: Option<&Visibility>,
226    is_static: bool,
227    is_readonly: bool,
228) -> String {
229    let mut parts: Vec<&str> = Vec::new();
230    if let Some(v) = visibility {
231        parts.push(visibility_str(v));
232    }
233    if is_static {
234        parts.push("static");
235    }
236    if is_readonly {
237        parts.push("readonly");
238    }
239    if parts.is_empty() {
240        String::new()
241    } else {
242        parts.join(" ") + " "
243    }
244}
245
246/// Return a function/method signature string from a `FileIndex` slice.
247/// A class whose FQN or short name matches `class_hint` (case-insensitive,
248/// ignoring a leading `\`). Empty/absent hint matches every class — the
249/// original unscoped behavior.
250fn class_matches_hint(cls: &crate::index::file_index::ClassDef, class_hint: Option<&str>) -> bool {
251    let Some(hint) = class_hint else { return true };
252    let hint = hint.trim_start_matches('\\');
253    cls.fqn.trim_start_matches('\\').eq_ignore_ascii_case(hint)
254        || cls.name.eq_ignore_ascii_case(hint)
255}
256
257pub fn signature_for_symbol_from_index(
258    name: &str,
259    indexes: &[(
260        tower_lsp_server::ls_types::Uri,
261        std::sync::Arc<crate::index::file_index::FileIndex>,
262    )],
263) -> Option<String> {
264    signature_for_symbol_from_index_scoped(name, indexes, None)
265}
266
267/// Like [`signature_for_symbol_from_index`], but when `class_hint` is given,
268/// only a method whose owning class matches it is considered — disambiguates
269/// same-named methods on unrelated classes (e.g. two classes both declaring
270/// `save()`) instead of returning whichever one is indexed first. A hint
271/// also skips the free-function search entirely, since a hint means the
272/// symbol is known to be a method.
273pub fn signature_for_symbol_from_index_scoped(
274    name: &str,
275    indexes: &[(
276        tower_lsp_server::ls_types::Uri,
277        std::sync::Arc<crate::index::file_index::FileIndex>,
278    )],
279    class_hint: Option<&str>,
280) -> Option<String> {
281    for (_, idx) in indexes {
282        if class_hint.is_none() {
283            for f in &idx.functions {
284                if f.name.as_ref() == name {
285                    let params_str = f
286                        .params
287                        .iter()
288                        .map(|p| {
289                            let mut s = String::new();
290                            if let Some(t) = &p.type_hint {
291                                s.push_str(&format!("{} ", t));
292                            }
293                            if p.variadic {
294                                s.push_str("...");
295                            }
296                            s.push_str(&format!("${}", p.name));
297                            s
298                        })
299                        .collect::<Vec<_>>()
300                        .join(", ");
301                    let ret = f
302                        .return_type
303                        .as_deref()
304                        .map(|r| format!(": {}", r))
305                        .unwrap_or_default();
306                    return Some(format!("function {}({}){}", name, params_str, ret));
307                }
308            }
309        }
310        for cls in &idx.classes {
311            if !class_matches_hint(cls, class_hint) {
312                continue;
313            }
314            for m in &cls.methods {
315                if m.name.as_ref() == name {
316                    let params_str = m
317                        .params
318                        .iter()
319                        .map(|p| {
320                            let mut s = String::new();
321                            if let Some(t) = &p.type_hint {
322                                s.push_str(&format!("{} ", t));
323                            }
324                            if p.variadic {
325                                s.push_str("...");
326                            }
327                            s.push_str(&format!("${}", p.name));
328                            s
329                        })
330                        .collect::<Vec<_>>()
331                        .join(", ");
332                    let ret = m
333                        .return_type
334                        .as_deref()
335                        .map(|r| format!(": {}", r))
336                        .unwrap_or_default();
337                    return Some(format!("function {}({}){}", name, params_str, ret));
338                }
339            }
340        }
341    }
342    None
343}
344
345/// Return hover documentation for a symbol from a `FileIndex` slice.
346pub fn docs_for_symbol_from_index(
347    name: &str,
348    indexes: &[(
349        tower_lsp_server::ls_types::Uri,
350        std::sync::Arc<crate::index::file_index::FileIndex>,
351    )],
352) -> Option<String> {
353    docs_for_symbol_from_index_scoped(name, indexes, None)
354}
355
356/// Like [`docs_for_symbol_from_index`], but scoped to a class hint — see
357/// [`signature_for_symbol_from_index_scoped`]. Also skips the PHP-builtin
358/// fallback when a class hint is given, since a hint means the symbol is
359/// known to be a user-defined method, not a global builtin function.
360pub fn docs_for_symbol_from_index_scoped(
361    name: &str,
362    indexes: &[(
363        tower_lsp_server::ls_types::Uri,
364        std::sync::Arc<crate::index::file_index::FileIndex>,
365    )],
366    class_hint: Option<&str>,
367) -> Option<String> {
368    if let Some(sig) = signature_for_symbol_from_index_scoped(name, indexes, class_hint) {
369        let mut value = wrap_php(&sig);
370        for (_, idx) in indexes {
371            if class_hint.is_none() {
372                for f in &idx.functions {
373                    if f.name.as_ref() == name {
374                        if let Some(raw) = &f.docblock {
375                            let db = crate::lang::docblock::parse_docblock(raw);
376                            let md = db.to_markdown();
377                            if !md.is_empty() {
378                                value.push_str("\n\n---\n\n");
379                                value.push_str(&md);
380                            }
381                        }
382                        break;
383                    }
384                }
385            }
386            for cls in &idx.classes {
387                if !class_matches_hint(cls, class_hint) {
388                    continue;
389                }
390                for m in &cls.methods {
391                    if m.name.as_ref() == name {
392                        if let Some(raw) = &m.docblock {
393                            let db = crate::lang::docblock::parse_docblock(raw);
394                            let md = db.to_markdown();
395                            if !md.is_empty() {
396                                value.push_str("\n\n---\n\n");
397                                value.push_str(&md);
398                            }
399                        }
400                        break;
401                    }
402                }
403            }
404        }
405        if class_hint.is_none() && is_php_builtin(name) {
406            value.push_str(&format!(
407                "\n\n[php.net documentation]({})",
408                php_doc_url(name)
409            ));
410        }
411        return Some(value);
412    }
413    if class_hint.is_none() && is_php_builtin(name) {
414        return Some(format!(
415            "```php\nfunction {}()\n```\n\n[php.net documentation]({})",
416            name,
417            php_doc_url(name)
418        ));
419    }
420    None
421}
422
423/// Build a hover for `method_name` on `cls`, if declared there. Shared by the
424/// O(1) `resolve_class_ref` path and the legacy linear scan below.
425fn method_hover_from_class(
426    class_name: &str,
427    method_name: &str,
428    cls: &crate::index::file_index::ClassDef,
429) -> Option<Hover> {
430    for m in &cls.methods {
431        if m.name.as_ref() != method_name {
432            continue;
433        }
434        let params_str = m
435            .params
436            .iter()
437            .map(|p| {
438                let mut s = String::new();
439                if let Some(t) = &p.type_hint {
440                    s.push_str(&format!("{} ", t));
441                }
442                if p.variadic {
443                    s.push_str("...");
444                }
445                s.push_str(&format!("${}", p.name));
446                s
447            })
448            .collect::<Vec<_>>()
449            .join(", ");
450        let ret = m
451            .return_type
452            .as_deref()
453            .map(|r| format!(": {}", r))
454            .unwrap_or_default();
455        let sig = format!("{}::{}({}){}", class_name, method_name, params_str, ret);
456        let mut value = wrap_php(&sig);
457        if let Some(raw) = &m.docblock {
458            let db = crate::lang::docblock::parse_docblock(raw);
459            let md = db.to_markdown();
460            if !md.is_empty() {
461                value.push_str("\n\n---\n\n");
462                value.push_str(&md);
463            }
464        }
465        return Some(Hover {
466            contents: HoverContents::Markup(MarkupContent {
467                kind: MarkupKind::Markdown,
468                value,
469            }),
470            range: None,
471        });
472    }
473    None
474}
475
476/// Build a hover for a static method call found by class short name + method name
477/// in the workspace index. Used when the primary mir path cannot resolve a cross-file
478/// static call (e.g. `Str::camel(…)` where `Str` is only known through a `use`-import).
479pub fn method_hover_from_index(
480    class_name: &str,
481    method_name: &str,
482    indexes: &[(
483        tower_lsp_server::ls_types::Uri,
484        std::sync::Arc<crate::index::file_index::FileIndex>,
485    )],
486) -> Option<Hover> {
487    for (_, idx) in indexes {
488        for cls in &idx.classes {
489            if cls.name.as_ref() != class_name
490                && crate::text::fqn_short_name(cls.fqn.as_ref()) != class_name
491            {
492                continue;
493            }
494            if let Some(h) = method_hover_from_class(class_name, method_name, cls) {
495                return Some(h);
496            }
497        }
498    }
499    None
500}
501
502/// O(1) variant of [`class_hover_from_index`]: resolves `word`/`fqn` via
503/// `WorkspaceIndexData::resolve_class_ref` first, falling back to the full
504/// linear scan only when the index doesn't resolve the class — preserves
505/// every edge case the scan covers (e.g. names the aggregate's short-name
506/// map doesn't disambiguate the same way), just skips the common-case cost.
507pub fn class_hover_from_workspace_index(
508    word: &str,
509    fqn: Option<&str>,
510    wi: &crate::db::workspace_index::WorkspaceIndexData,
511) -> Option<Hover> {
512    if let Some(cr) = wi.resolve_class_ref(fqn.unwrap_or(word))
513        && let Some((_, cls)) = wi.at(cr)
514    {
515        return Some(class_hover_for(cls));
516    }
517    class_hover_from_index(word, fqn, &wi.files)
518}
519
520/// O(1) variant of [`method_hover_from_index`], same fallback contract as
521/// [`class_hover_from_workspace_index`].
522pub fn method_hover_from_workspace_index(
523    class_name: &str,
524    method_name: &str,
525    wi: &crate::db::workspace_index::WorkspaceIndexData,
526) -> Option<Hover> {
527    if let Some(cr) = wi.resolve_class_ref(class_name)
528        && let Some((_, cls)) = wi.at(cr)
529        && let Some(h) = method_hover_from_class(class_name, method_name, cls)
530    {
531        return Some(h);
532    }
533    method_hover_from_index(class_name, method_name, &wi.files)
534}
535
536/// Build a hover for a class/interface/trait/enum found by short name in the workspace index.
537///
538/// `fqn` is the fully-qualified name the caller resolved `word` to (e.g. via
539/// a `use ... as Alias` import), when known. Across a large workspace many
540/// unrelated classes can share a short name (and even the same local alias),
541/// so when `fqn` is available a candidate whose own `cls.fqn` matches it
542/// exactly is preferred over the first short-name match found. Falls back to
543/// the first short-name match when `fqn` is `None` (no `use` import to
544/// disambiguate a genuinely ambiguous bare reference).
545pub fn class_hover_from_index(
546    word: &str,
547    fqn: Option<&str>,
548    indexes: &[(
549        tower_lsp_server::ls_types::Uri,
550        std::sync::Arc<crate::index::file_index::FileIndex>,
551    )],
552) -> Option<Hover> {
553    let mut first_match: Option<&crate::index::file_index::ClassDef> = None;
554    for (_, idx) in indexes {
555        for cls in &idx.classes {
556            if cls.name.as_ref() == word || cls.fqn.as_ref().trim_start_matches('\\') == word {
557                if let Some(target) = fqn {
558                    if cls.fqn.as_ref().trim_start_matches('\\') == target {
559                        return Some(class_hover_for(cls));
560                    }
561                    if first_match.is_none() {
562                        first_match = Some(cls);
563                    }
564                    continue;
565                }
566                return Some(class_hover_for(cls));
567            }
568        }
569    }
570    first_match.map(class_hover_for)
571}
572
573fn class_hover_for(cls: &crate::index::file_index::ClassDef) -> Hover {
574    use crate::index::file_index::ClassKind;
575
576    let kw = match cls.kind {
577        ClassKind::Interface => "interface",
578        ClassKind::Trait => "trait",
579        ClassKind::Enum => "enum",
580        ClassKind::Class => {
581            if cls.is_abstract {
582                "abstract class"
583            } else if cls.is_readonly {
584                "readonly class"
585            } else {
586                "class"
587            }
588        }
589    };
590    let mut sig = format!("{} {}", kw, cls.name);
591    if let Some(parent) = &cls.parent {
592        sig.push_str(&format!(" extends {}", parent));
593    }
594    if !cls.implements.is_empty() {
595        let list: Vec<&str> = cls.implements.iter().map(|s| s.as_ref()).collect();
596        sig.push_str(&format!(" implements {}", list.join(", ")));
597    }
598    Hover {
599        contents: HoverContents::Markup(MarkupContent {
600            kind: MarkupKind::Markdown,
601            value: wrap_php(&sig),
602        }),
603        range: None,
604    }
605}