Skip to main content

php_lsp/analysis/
inlay_hints.rs

1use std::collections::HashMap;
2
3use php_ast::{
4    ClassMemberKind, EnumMemberKind, Expr, ExprKind, NamespaceBody, Param, Stmt, StmtKind,
5};
6use serde_json::json;
7use tower_lsp_server::ls_types::{InlayHint, InlayHintKind, InlayHintLabel, Position, Range};
8
9use crate::db::workspace_index::FuncSignature;
10use crate::document::ast::{ParsedDoc, SourceView, format_type_hint};
11use crate::text::fqn_short_name;
12
13/// Resolve a foreach value/key variable's class (short name) for its type hint.
14///
15/// mir-primary: queries the recorded `ResolvedSymbol` at the variable's byte
16/// offset — works whenever mir knows the array element type (e.g. typed arrays,
17/// `@var list<T>` annotations, `enum::cases()`, and `array_map`/`array_filter`
18/// results, whose element type mir infers from the callback return type).
19fn foreach_var_class(
20    analysis: Option<&mir_analyzer::FileAnalysis>,
21    var_offset: u32,
22) -> Option<String> {
23    analysis
24        .and_then(|a| crate::types::type_query::type_at_offset(a, var_offset))
25        .and_then(crate::types::type_query::primary_class_name)
26        .map(|fqcn| fqn_short_name(&fqcn).to_string())
27}
28
29/// Returns parameter-name inlay hints AND return-type hints for all
30/// function/method declarations and calls in `doc`.
31///
32/// `workspace_defs` is the workspace-wide function/method signature map
33/// (`WorkspaceIndexData::func_signatures`, cached per revision); definitions
34/// not found in the current document fall back to it so that calls to
35/// cross-file functions/methods still get parameter-name hints.
36pub fn inlay_hints(
37    _source: &str,
38    doc: &ParsedDoc,
39    analysis: Option<&mir_analyzer::FileAnalysis>,
40    range: Range,
41    workspace_defs: &HashMap<String, FuncSignature>,
42) -> Vec<InlayHint> {
43    let sv = doc.view();
44    let defs = collect_defs(&doc.program().stmts);
45    let mut hints = Vec::new();
46    hints_in_stmts(
47        sv,
48        &doc.program().stmts,
49        &defs,
50        workspace_defs,
51        analysis,
52        range,
53        &mut hints,
54    );
55    hints
56}
57
58// === Definition collection ===
59
60fn collect_defs(stmts: &[Stmt<'_, '_>]) -> HashMap<String, FuncSignature> {
61    let mut map = HashMap::new();
62    collect_defs_stmts(stmts, &mut map);
63    map
64}
65
66/// Extract param names and whether the last param is variadic from a param list.
67fn params_from_list(params: &[Param<'_, '_>]) -> (Vec<String>, bool) {
68    let names = params.iter().map(|p| p.name.to_string()).collect();
69    let variadic_last = params.last().map(|p| p.variadic).unwrap_or(false);
70    (names, variadic_last)
71}
72
73fn collect_defs_stmts(stmts: &[Stmt<'_, '_>], map: &mut HashMap<String, FuncSignature>) {
74    for stmt in stmts {
75        match &stmt.kind {
76            StmtKind::Function(f) => {
77                let (params, variadic_last) = params_from_list(&f.params);
78                let return_type = f.return_type.as_ref().map(|t| format_type_hint(t));
79                map.insert(
80                    f.name.to_string(),
81                    FuncSignature {
82                        params,
83                        variadic_last,
84                        return_type,
85                    },
86                );
87            }
88            StmtKind::Class(c) => {
89                for member in c.body.members.iter() {
90                    if let ClassMemberKind::Method(m) = &member.kind {
91                        let (params, variadic_last) = params_from_list(&m.params);
92                        let return_type = m.return_type.as_ref().map(|t| format_type_hint(t));
93                        let func_def = FuncSignature {
94                            params: params.clone(),
95                            variadic_last,
96                            return_type: return_type.clone(),
97                        };
98                        // Register with qualified key "ClassName::methodName" for unambiguous lookup
99                        if let Some(cn) = c.name {
100                            let qualified = format!("{}::{}", cn, m.name);
101                            map.insert(qualified, func_def.clone());
102                        }
103                        // Register __construct under the class name so `new ClassName(...)` gets hints.
104                        if m.name == "__construct"
105                            && let Some(class_name) = c.name
106                        {
107                            map.insert(
108                                class_name.to_string(),
109                                FuncSignature {
110                                    params: params.clone(),
111                                    variadic_last,
112                                    return_type: None,
113                                },
114                            );
115                        }
116                        map.insert(m.name.to_string(), func_def);
117                    }
118                }
119            }
120            StmtKind::Trait(t) => {
121                for member in t.body.members.iter() {
122                    if let ClassMemberKind::Method(m) = &member.kind {
123                        let (params, variadic_last) = params_from_list(&m.params);
124                        let return_type = m.return_type.as_ref().map(|t| format_type_hint(t));
125                        let func_def = FuncSignature {
126                            params,
127                            variadic_last,
128                            return_type,
129                        };
130                        // Register with qualified key for unambiguous lookup
131                        let qualified = format!("{}::{}", t.name, m.name);
132                        map.insert(qualified, func_def.clone());
133                        map.insert(m.name.to_string(), func_def);
134                    }
135                }
136            }
137            StmtKind::Enum(e) => {
138                for member in e.body.members.iter() {
139                    if let EnumMemberKind::Method(m) = &member.kind {
140                        let (params, variadic_last) = params_from_list(&m.params);
141                        let return_type = m.return_type.as_ref().map(|t| format_type_hint(t));
142                        let func_def = FuncSignature {
143                            params,
144                            variadic_last,
145                            return_type,
146                        };
147                        // Register with qualified key for unambiguous lookup
148                        let qualified = format!("{}::{}", e.name, m.name);
149                        map.insert(qualified, func_def.clone());
150                        map.insert(m.name.to_string(), func_def);
151                    }
152                }
153            }
154            StmtKind::Namespace(ns) => {
155                if let NamespaceBody::Braced(inner) = &ns.body {
156                    collect_defs_stmts(&inner.stmts, map);
157                }
158            }
159            // Register closure/arrow-function variables so `$fn(...)` call sites get hints.
160            StmtKind::Expression(e) => {
161                if let ExprKind::Assign(assign) = &e.kind
162                    && let ExprKind::Variable(var_name) = &assign.target.kind
163                {
164                    let key = format!("${}", var_name.as_str());
165                    match &assign.value.kind {
166                        ExprKind::Closure(c) => {
167                            let (params, variadic_last) = params_from_list(&c.params);
168                            let return_type = c.return_type.as_ref().map(|t| format_type_hint(t));
169                            map.insert(
170                                key,
171                                FuncSignature {
172                                    params,
173                                    variadic_last,
174                                    return_type,
175                                },
176                            );
177                        }
178                        ExprKind::ArrowFunction(a) => {
179                            let (params, variadic_last) = params_from_list(&a.params);
180                            let return_type = a.return_type.as_ref().map(|t| format_type_hint(t));
181                            map.insert(
182                                key,
183                                FuncSignature {
184                                    params,
185                                    variadic_last,
186                                    return_type,
187                                },
188                            );
189                        }
190                        _ => {}
191                    }
192                }
193            }
194            _ => {}
195        }
196    }
197}
198
199// === AST walking ===
200
201fn hints_in_stmts(
202    sv: SourceView<'_>,
203    stmts: &[Stmt<'_, '_>],
204    defs: &HashMap<String, FuncSignature>,
205    workspace_defs: &HashMap<String, FuncSignature>,
206    analysis: Option<&mir_analyzer::FileAnalysis>,
207    range: Range,
208    out: &mut Vec<InlayHint>,
209) {
210    for stmt in stmts {
211        hints_in_stmt(sv, stmt, defs, workspace_defs, analysis, range, out);
212    }
213}
214
215fn hints_in_stmt(
216    sv: SourceView<'_>,
217    stmt: &Stmt<'_, '_>,
218    defs: &HashMap<String, FuncSignature>,
219    workspace_defs: &HashMap<String, FuncSignature>,
220    analysis: Option<&mir_analyzer::FileAnalysis>,
221    range: Range,
222    out: &mut Vec<InlayHint>,
223) {
224    match &stmt.kind {
225        StmtKind::Expression(e) => hints_in_expr(sv, e, defs, workspace_defs, analysis, range, out),
226        StmtKind::Return(Some(v)) => {
227            hints_in_expr(sv, v, defs, workspace_defs, analysis, range, out)
228        }
229        StmtKind::Echo(exprs) => {
230            for expr in exprs.iter() {
231                hints_in_expr(sv, expr, defs, workspace_defs, analysis, range, out);
232            }
233        }
234        StmtKind::Function(f) => {
235            hints_in_stmts(
236                sv,
237                &f.body.stmts,
238                defs,
239                workspace_defs,
240                analysis,
241                range,
242                out,
243            );
244        }
245        StmtKind::Class(c) => {
246            for member in c.body.members.iter() {
247                if let ClassMemberKind::Method(m) = &member.kind
248                    && let Some(body) = &m.body
249                {
250                    hints_in_stmts(sv, &body.stmts, defs, workspace_defs, analysis, range, out);
251                }
252            }
253        }
254        StmtKind::Trait(t) => {
255            for member in t.body.members.iter() {
256                if let ClassMemberKind::Method(m) = &member.kind
257                    && let Some(body) = &m.body
258                {
259                    hints_in_stmts(sv, &body.stmts, defs, workspace_defs, analysis, range, out);
260                }
261            }
262        }
263        StmtKind::Enum(e) => {
264            for member in e.body.members.iter() {
265                if let EnumMemberKind::Method(m) = &member.kind
266                    && let Some(body) = &m.body
267                {
268                    hints_in_stmts(sv, &body.stmts, defs, workspace_defs, analysis, range, out);
269                }
270            }
271        }
272        StmtKind::Namespace(ns) => {
273            if let NamespaceBody::Braced(inner) = &ns.body {
274                hints_in_stmts(sv, &inner.stmts, defs, workspace_defs, analysis, range, out);
275            }
276        }
277        StmtKind::If(i) => {
278            hints_in_expr(sv, &i.condition, defs, workspace_defs, analysis, range, out);
279            hints_in_stmt(
280                sv,
281                i.then_branch,
282                defs,
283                workspace_defs,
284                analysis,
285                range,
286                out,
287            );
288            for ei in i.elseif_branches.iter() {
289                hints_in_expr(
290                    sv,
291                    &ei.condition,
292                    defs,
293                    workspace_defs,
294                    analysis,
295                    range,
296                    out,
297                );
298                hints_in_stmt(sv, &ei.body, defs, workspace_defs, analysis, range, out);
299            }
300            if let Some(e) = &i.else_branch {
301                hints_in_stmt(sv, e, defs, workspace_defs, analysis, range, out);
302            }
303        }
304        StmtKind::While(w) => {
305            hints_in_expr(sv, &w.condition, defs, workspace_defs, analysis, range, out);
306            hints_in_stmt(sv, w.body, defs, workspace_defs, analysis, range, out);
307        }
308        StmtKind::For(f) => {
309            for e in f.init.iter() {
310                hints_in_expr(sv, e, defs, workspace_defs, analysis, range, out);
311            }
312            for cond in f.condition.iter() {
313                hints_in_expr(sv, cond, defs, workspace_defs, analysis, range, out);
314            }
315            for e in f.update.iter() {
316                hints_in_expr(sv, e, defs, workspace_defs, analysis, range, out);
317            }
318            hints_in_stmt(sv, f.body, defs, workspace_defs, analysis, range, out);
319        }
320        StmtKind::Foreach(f) => {
321            hints_in_expr(sv, &f.expr, defs, workspace_defs, analysis, range, out);
322            // Emit type hint after the value variable, e.g. `foreach ($arr as $item /* : Foo */)`.
323            if let ExprKind::Variable(_) = &f.value.kind
324                && let Some(ty) = foreach_var_class(analysis, f.value.span.start)
325            {
326                let pos = sv.position_of(f.value.span.end);
327                if pos_in_range(pos, range) {
328                    out.push(make_foreach_type_hint(pos, &ty));
329                }
330            }
331            // Emit type hint after the key variable if present, e.g. `foreach ($map as $key => $value)`.
332            if let Some(key_expr) = &f.key
333                && let ExprKind::Variable(_) = &key_expr.kind
334                && let Some(ty) = foreach_var_class(analysis, key_expr.span.start)
335            {
336                let pos = sv.position_of(key_expr.span.end);
337                if pos_in_range(pos, range) {
338                    out.push(make_foreach_type_hint(pos, &ty));
339                }
340            }
341            hints_in_stmt(sv, f.body, defs, workspace_defs, analysis, range, out);
342        }
343        StmtKind::TryCatch(t) => {
344            hints_in_stmts(
345                sv,
346                &t.body.stmts,
347                defs,
348                workspace_defs,
349                analysis,
350                range,
351                out,
352            );
353            for catch in t.catches.iter() {
354                hints_in_stmts(
355                    sv,
356                    &catch.body.stmts,
357                    defs,
358                    workspace_defs,
359                    analysis,
360                    range,
361                    out,
362                );
363            }
364            if let Some(finally) = &t.finally {
365                hints_in_stmts(
366                    sv,
367                    &finally.stmts,
368                    defs,
369                    workspace_defs,
370                    analysis,
371                    range,
372                    out,
373                );
374            }
375        }
376        StmtKind::Block(stmts) => {
377            hints_in_stmts(sv, &stmts.stmts, defs, workspace_defs, analysis, range, out)
378        }
379        StmtKind::DoWhile(d) => {
380            hints_in_stmt(sv, d.body, defs, workspace_defs, analysis, range, out);
381            hints_in_expr(sv, &d.condition, defs, workspace_defs, analysis, range, out);
382        }
383        StmtKind::Switch(s) => {
384            hints_in_expr(sv, &s.expr, defs, workspace_defs, analysis, range, out);
385            for case in s.body.cases.iter() {
386                if let Some(v) = &case.value {
387                    hints_in_expr(sv, v, defs, workspace_defs, analysis, range, out);
388                }
389                hints_in_stmts(sv, &case.body, defs, workspace_defs, analysis, range, out);
390            }
391        }
392        _ => {}
393    }
394}
395
396fn hints_in_expr(
397    sv: SourceView<'_>,
398    expr: &Expr<'_, '_>,
399    defs: &HashMap<String, FuncSignature>,
400    workspace_defs: &HashMap<String, FuncSignature>,
401    analysis: Option<&mir_analyzer::FileAnalysis>,
402    range: Range,
403    out: &mut Vec<InlayHint>,
404) {
405    match &expr.kind {
406        ExprKind::FunctionCall(f) => {
407            // Look up by identifier name or by variable name (for closure vars like `$fn(...)`).
408            let key: Option<String> = ident_name(f.name).map(|n| n.to_string()).or_else(|| {
409                if let ExprKind::Variable(n) = &f.name.kind {
410                    Some(format!("${}", n.as_str()))
411                } else {
412                    None
413                }
414            });
415            if let Some(k) = key
416                && let Some(def) = defs.get(&k).or_else(|| workspace_defs.get(&k))
417            {
418                emit_param_hints(sv, &f.args, def, &k, range, out);
419            }
420            hints_in_expr(sv, f.name, defs, workspace_defs, analysis, range, out);
421            for arg in f.args.iter() {
422                hints_in_expr(sv, &arg.value, defs, workspace_defs, analysis, range, out);
423            }
424        }
425        ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
426            if let Some(name) = ident_name(m.method)
427                && let Some(def) = defs.get(name).or_else(|| workspace_defs.get(name))
428            {
429                emit_param_hints(sv, &m.args, def, name, range, out);
430            }
431            hints_in_expr(sv, m.object, defs, workspace_defs, analysis, range, out);
432            for arg in m.args.iter() {
433                hints_in_expr(sv, &arg.value, defs, workspace_defs, analysis, range, out);
434            }
435        }
436        ExprKind::StaticMethodCall(m) => {
437            if let Some(name) = ident_name(m.method)
438                && let Some(def) = defs.get(name).or_else(|| workspace_defs.get(name))
439            {
440                emit_param_hints(sv, &m.args, def, name, range, out);
441            }
442            hints_in_expr(sv, m.class, defs, workspace_defs, analysis, range, out);
443            for arg in m.args.iter() {
444                hints_in_expr(sv, &arg.value, defs, workspace_defs, analysis, range, out);
445            }
446        }
447        ExprKind::New(n) => {
448            if let Some(class_name) = ident_name(n.class)
449                && let Some(def) = defs
450                    .get(class_name)
451                    .or_else(|| workspace_defs.get(class_name))
452            {
453                emit_param_hints(sv, &n.args, def, class_name, range, out);
454            }
455            for arg in n.args.iter() {
456                hints_in_expr(sv, &arg.value, defs, workspace_defs, analysis, range, out);
457            }
458        }
459        ExprKind::Assign(a) => {
460            // Emit return-type hint after a function call on the RHS
461            emit_return_type_hint(sv, a.value, defs, workspace_defs, range, out);
462            hints_in_expr(sv, a.target, defs, workspace_defs, analysis, range, out);
463            hints_in_expr(sv, a.value, defs, workspace_defs, analysis, range, out);
464        }
465        // Walk into closure bodies so nested function calls get hints.
466        ExprKind::Closure(c) => {
467            hints_in_stmts(
468                sv,
469                &c.body.stmts,
470                defs,
471                workspace_defs,
472                analysis,
473                range,
474                out,
475            );
476        }
477        // Walk into arrow function bodies so nested calls get hints.
478        // No return-type hint: the annotation is already visible in the source,
479        // and php-lsp has no type inference to supply hints for unannotated fns.
480        ExprKind::ArrowFunction(a) => {
481            hints_in_expr(sv, a.body, defs, workspace_defs, analysis, range, out);
482        }
483        ExprKind::Parenthesized(e) => {
484            hints_in_expr(sv, e, defs, workspace_defs, analysis, range, out)
485        }
486        ExprKind::Ternary(t) => {
487            hints_in_expr(sv, t.condition, defs, workspace_defs, analysis, range, out);
488            if let Some(then_expr) = t.then_expr {
489                hints_in_expr(sv, then_expr, defs, workspace_defs, analysis, range, out);
490            }
491            hints_in_expr(sv, t.else_expr, defs, workspace_defs, analysis, range, out);
492        }
493        ExprKind::NullCoalesce(n) => {
494            hints_in_expr(sv, n.left, defs, workspace_defs, analysis, range, out);
495            hints_in_expr(sv, n.right, defs, workspace_defs, analysis, range, out);
496        }
497        ExprKind::Binary(b) => {
498            hints_in_expr(sv, b.left, defs, workspace_defs, analysis, range, out);
499            hints_in_expr(sv, b.right, defs, workspace_defs, analysis, range, out);
500        }
501        ExprKind::CloneWith(target, withs) => {
502            hints_in_expr(sv, target, defs, workspace_defs, analysis, range, out);
503            hints_in_expr(sv, withs, defs, workspace_defs, analysis, range, out);
504        }
505        ExprKind::Match(m) => {
506            hints_in_expr(sv, m.subject, defs, workspace_defs, analysis, range, out);
507            for arm in m.arms.iter() {
508                if let Some(conds) = &arm.conditions {
509                    for c in conds.iter() {
510                        hints_in_expr(sv, c, defs, workspace_defs, analysis, range, out);
511                    }
512                }
513                hints_in_expr(sv, &arm.body, defs, workspace_defs, analysis, range, out);
514            }
515        }
516        _ => {}
517    }
518}
519
520fn emit_param_hints(
521    sv: SourceView<'_>,
522    args: &[php_ast::Arg<'_, '_>],
523    def: &FuncSignature,
524    func_name: &str,
525    range: Range,
526    out: &mut Vec<InlayHint>,
527) {
528    for (i, arg) in args.iter().enumerate() {
529        // An unpacked/spread argument (`...$args`) consumes an unknown number
530        // of positional parameters, so raw arg-list index no longer lines up
531        // with the real parameter index for it *or anything after it* —
532        // suppress hints for the rest of the call, not just the unpack itself.
533        if arg.unpack {
534            break;
535        }
536        // Skip named arguments (they already have the label in sv.source()).
537        if arg.name.is_some() {
538            continue;
539        }
540        // For a variadic last param, repeat its name for every excess argument.
541        let param = if let Some(p) = def.params.get(i) {
542            p
543        } else if def.variadic_last {
544            match def.params.last() {
545                Some(p) => p,
546                None => continue,
547            }
548        } else {
549            continue;
550        };
551        let pos = sv.position_of(arg.span.start);
552        if pos_in_range(pos, range) {
553            out.push(make_param_hint(pos, param, func_name));
554        }
555    }
556}
557
558fn emit_return_type_hint(
559    sv: SourceView<'_>,
560    expr: &Expr<'_, '_>,
561    defs: &HashMap<String, FuncSignature>,
562    workspace_defs: &HashMap<String, FuncSignature>,
563    range: Range,
564    out: &mut Vec<InlayHint>,
565) {
566    let name = match &expr.kind {
567        ExprKind::FunctionCall(f) => ident_name(f.name),
568        ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => ident_name(m.method),
569        ExprKind::StaticMethodCall(m) => ident_name(m.method),
570        _ => return,
571    };
572    if let Some(name) = name
573        && let Some(def) = defs.get(name).or_else(|| workspace_defs.get(name))
574        && let Some(ret_type) = &def.return_type
575    {
576        if ret_type == "void" {
577            return;
578        }
579        let pos = sv.position_of(expr.span.end);
580        if pos_in_range(pos, range) {
581            out.push(make_return_hint(pos, ret_type, name));
582        }
583    }
584}
585
586fn ident_name<'a>(expr: &'a Expr<'_, '_>) -> Option<&'a str> {
587    if let ExprKind::Identifier(name) = &expr.kind {
588        Some(name)
589    } else {
590        None
591    }
592}
593
594fn make_param_hint(position: Position, param_name: &str, func_name: &str) -> InlayHint {
595    InlayHint {
596        position,
597        label: InlayHintLabel::String(format!("{}:", param_name)),
598        kind: Some(InlayHintKind::PARAMETER),
599        text_edits: None,
600        tooltip: None,
601        padding_left: None,
602        padding_right: Some(true),
603        data: Some(json!({"php_lsp_fn": func_name})),
604    }
605}
606
607fn make_return_hint(position: Position, ret_type: &str, func_name: &str) -> InlayHint {
608    InlayHint {
609        position,
610        label: InlayHintLabel::String(format!(": {ret_type}")),
611        kind: Some(InlayHintKind::TYPE),
612        text_edits: None,
613        tooltip: None,
614        padding_left: Some(true),
615        padding_right: None,
616        data: Some(json!({"php_lsp_fn": func_name})),
617    }
618}
619
620fn make_foreach_type_hint(position: Position, ty: &str) -> InlayHint {
621    InlayHint {
622        position,
623        label: InlayHintLabel::String(format!(": {ty}")),
624        kind: Some(InlayHintKind::TYPE),
625        text_edits: None,
626        tooltip: None,
627        padding_left: Some(true),
628        padding_right: None,
629        data: None,
630    }
631}
632
633fn pos_in_range(pos: Position, range: Range) -> bool {
634    if pos.line < range.start.line || pos.line > range.end.line {
635        return false;
636    }
637    if pos.line == range.start.line && pos.character < range.start.character {
638        return false;
639    }
640    if pos.line == range.end.line && pos.character >= range.end.character {
641        return false;
642    }
643    true
644}