Skip to main content

php_lsp/hover/
formatting.rs

1use php_ast::{MethodDecl, Param, Visibility};
2use tower_lsp::lsp_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::lsp_types::Url,
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::lsp_types::Url,
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::lsp_types::Url,
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::lsp_types::Url,
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 a static method call found by class short name + method name
424/// in the workspace index. Used when the primary mir path cannot resolve a cross-file
425/// static call (e.g. `Str::camel(…)` where `Str` is only known through a `use`-import).
426pub fn method_hover_from_index(
427    class_name: &str,
428    method_name: &str,
429    indexes: &[(
430        tower_lsp::lsp_types::Url,
431        std::sync::Arc<crate::index::file_index::FileIndex>,
432    )],
433) -> Option<Hover> {
434    for (_, idx) in indexes {
435        for cls in &idx.classes {
436            if cls.name.as_ref() != class_name
437                && crate::text::fqn_short_name(cls.fqn.as_ref()) != class_name
438            {
439                continue;
440            }
441            for m in &cls.methods {
442                if m.name.as_ref() != method_name {
443                    continue;
444                }
445                let params_str = m
446                    .params
447                    .iter()
448                    .map(|p| {
449                        let mut s = String::new();
450                        if let Some(t) = &p.type_hint {
451                            s.push_str(&format!("{} ", t));
452                        }
453                        if p.variadic {
454                            s.push_str("...");
455                        }
456                        s.push_str(&format!("${}", p.name));
457                        s
458                    })
459                    .collect::<Vec<_>>()
460                    .join(", ");
461                let ret = m
462                    .return_type
463                    .as_deref()
464                    .map(|r| format!(": {}", r))
465                    .unwrap_or_default();
466                let sig = format!("{}::{}({}){}", class_name, method_name, params_str, ret);
467                let mut value = wrap_php(&sig);
468                if let Some(raw) = &m.docblock {
469                    let db = crate::lang::docblock::parse_docblock(raw);
470                    let md = db.to_markdown();
471                    if !md.is_empty() {
472                        value.push_str("\n\n---\n\n");
473                        value.push_str(&md);
474                    }
475                }
476                return Some(Hover {
477                    contents: HoverContents::Markup(MarkupContent {
478                        kind: MarkupKind::Markdown,
479                        value,
480                    }),
481                    range: None,
482                });
483            }
484        }
485    }
486    None
487}
488
489/// Build a hover for a class/interface/trait/enum found by short name in the workspace index.
490///
491/// `fqn` is the fully-qualified name the caller resolved `word` to (e.g. via
492/// a `use ... as Alias` import), when known. Across a large workspace many
493/// unrelated classes can share a short name (and even the same local alias),
494/// so when `fqn` is available a candidate whose own `cls.fqn` matches it
495/// exactly is preferred over the first short-name match found. Falls back to
496/// the first short-name match when `fqn` is `None` (no `use` import to
497/// disambiguate a genuinely ambiguous bare reference).
498pub fn class_hover_from_index(
499    word: &str,
500    fqn: Option<&str>,
501    indexes: &[(
502        tower_lsp::lsp_types::Url,
503        std::sync::Arc<crate::index::file_index::FileIndex>,
504    )],
505) -> Option<Hover> {
506    let mut first_match: Option<&crate::index::file_index::ClassDef> = None;
507    for (_, idx) in indexes {
508        for cls in &idx.classes {
509            if cls.name.as_ref() == word || cls.fqn.as_ref().trim_start_matches('\\') == word {
510                if let Some(target) = fqn {
511                    if cls.fqn.as_ref().trim_start_matches('\\') == target {
512                        return Some(class_hover_for(cls));
513                    }
514                    if first_match.is_none() {
515                        first_match = Some(cls);
516                    }
517                    continue;
518                }
519                return Some(class_hover_for(cls));
520            }
521        }
522    }
523    first_match.map(class_hover_for)
524}
525
526fn class_hover_for(cls: &crate::index::file_index::ClassDef) -> Hover {
527    use crate::index::file_index::ClassKind;
528
529    let kw = match cls.kind {
530        ClassKind::Interface => "interface",
531        ClassKind::Trait => "trait",
532        ClassKind::Enum => "enum",
533        ClassKind::Class => {
534            if cls.is_abstract {
535                "abstract class"
536            } else if cls.is_readonly {
537                "readonly class"
538            } else {
539                "class"
540            }
541        }
542    };
543    let mut sig = format!("{} {}", kw, &cls.name.to_string());
544    if let Some(parent) = &cls.parent {
545        sig.push_str(&format!(" extends {}", parent));
546    }
547    if !cls.implements.is_empty() {
548        let list: Vec<&str> = cls.implements.iter().map(|s| s.as_ref()).collect();
549        sig.push_str(&format!(" implements {}", list.join(", ")));
550    }
551    Hover {
552        contents: HoverContents::Markup(MarkupContent {
553            kind: MarkupKind::Markdown,
554            value: wrap_php(&sig),
555        }),
556        range: None,
557    }
558}