Skip to content

Commit 60e189e

Browse files
committed
fix(diagnostics): resolve callable return type for call-result invocations
When a function/method returns a callable type and the return value is immediately invoked — e.g. `makeCallable('1', '2')('test')` — the LSP previously used the outer function's parameter signature for the inner invocation. This caused: - False argument_count_mismatch diagnostics (expected 2 args, got 1) - Wrong inlay hint parameter names ($a instead of $c) The fix intercepts CallExpr resolution: after resolving the inner callee, check if its return type is callable. If so, use the callable's parameter signature instead of the callee's own params. Handles: - Typed callable: `callable(string): string` — extracts params - Bare callable/Closure: flags accepts_any_args to suppress diagnostics - Union types and nullable: recursively checks branches Closes #184
1 parent 31cc5e2 commit 60e189e

2 files changed

Lines changed: 138 additions & 1 deletion

File tree

src/completion/call_resolution.rs

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ impl Backend {
793793

794794
let effective_args_text = call_args_text.or(args_text_from_parse);
795795

796-
match effective {
796+
let result = match effective {
797797
// ── Constructor: `new ClassName` or `new ClassName()` ────
798798
SubjectExpr::NewExpr { class_name } => {
799799
let resolved_class_name =
@@ -875,7 +875,31 @@ impl Backend {
875875

876876
// ── Anything else doesn't resolve to a callable ─────────
877877
_ => None,
878+
};
879+
880+
// ── Call-result invocation ──────────────────────────────────
881+
// When the original expression was a `CallExpr`, the resolved
882+
// target describes the inner callee (e.g. `makeCallable`), but
883+
// the actual call is on the callee's *return value*:
884+
//
885+
// makeCallable('1', '2')('test')
886+
// ^^^^^^^^^^^^^^^^^^^^^^^^ ← inner callee resolved above
887+
// ^^^^^^^ ← outer call on the return value
888+
//
889+
// If the return type is a typed callable (`callable(string): T`)
890+
// use its parameter signature. For bare `callable` without a
891+
// parameter spec, flag `accepts_any_args` so that argument-count
892+
// diagnostics are suppressed and inlay hints don't show the
893+
// wrong parameter names.
894+
if matches!(&parsed, SubjectExpr::CallExpr { .. })
895+
&& let Some(ref target) = result
896+
&& let Some(ref return_type) = target.return_type
897+
&& let Some(invoked) = callable_type_as_target(return_type)
898+
{
899+
return Some(invoked);
878900
}
901+
902+
result
879903
}
880904

881905
/// Resolve the return type of a call expression given a structured
@@ -2803,3 +2827,64 @@ impl Backend {
28032827
None
28042828
}
28052829
}
2830+
2831+
/// Convert a callable `PhpType` to a `ResolvedCallableTarget`.
2832+
///
2833+
/// Used when a function/method returns a callable type and that return
2834+
/// value is immediately invoked: `makeCallable('1', '2')('test')`.
2835+
///
2836+
/// - `PhpType::Callable { params, return_type, .. }` (typed callable like
2837+
/// `callable(string): string`) -> params are converted to `ParameterInfo`.
2838+
/// - `PhpType::Named("callable")` or `PhpType::Named("Closure")` (bare
2839+
/// callable without parameter specification) -> returns a target with
2840+
/// `accepts_any_args: true` so diagnostics are suppressed.
2841+
/// - Other types -> returns `None` (not a callable).
2842+
fn callable_type_as_target(return_type: &PhpType) -> Option<ResolvedCallableTarget> {
2843+
match return_type {
2844+
PhpType::Callable {
2845+
params,
2846+
return_type,
2847+
..
2848+
} => {
2849+
let parameters: Vec<ParameterInfo> = params
2850+
.iter()
2851+
.enumerate()
2852+
.map(|(i, p)| ParameterInfo {
2853+
name: crate::atom::atom(&format!("$param{}", i + 1)),
2854+
is_required: !p.optional && !p.variadic,
2855+
type_hint: Some(p.type_hint.clone()),
2856+
native_type_hint: None,
2857+
description: None,
2858+
default_value: None,
2859+
is_variadic: p.variadic,
2860+
is_reference: false,
2861+
closure_this_type: None,
2862+
})
2863+
.collect();
2864+
Some(ResolvedCallableTarget {
2865+
parameters,
2866+
return_type: return_type.as_deref().cloned(),
2867+
accepts_any_args: false,
2868+
})
2869+
}
2870+
PhpType::Named(name)
2871+
if name.eq_ignore_ascii_case("callable") || name.eq_ignore_ascii_case("Closure") =>
2872+
{
2873+
Some(ResolvedCallableTarget {
2874+
parameters: vec![],
2875+
return_type: None,
2876+
accepts_any_args: true,
2877+
})
2878+
}
2879+
PhpType::Union(members) => {
2880+
for member in members {
2881+
if let Some(target) = callable_type_as_target(member) {
2882+
return Some(target);
2883+
}
2884+
}
2885+
None
2886+
}
2887+
PhpType::Nullable(inner) => callable_type_as_target(inner),
2888+
_ => None,
2889+
}
2890+
}

tests/integration/inlay_hints.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,3 +1368,55 @@ transform([1, 2, 3], function ($x) { return $x * 2; });
13681368
all
13691369
);
13701370
}
1371+
1372+
// ─── Call-result invocation: callable return type (#184) ────────────────────
1373+
1374+
#[tokio::test]
1375+
async fn callable_return_invocation_no_wrong_hints() {
1376+
// When a function returns `callable` and the result is immediately
1377+
// invoked, inlay hints must NOT show the outer function's parameter
1378+
// names on the inner call's arguments.
1379+
//
1380+
// Before the fix, `makeCallable('1', '2')('test')` showed `$a:`
1381+
// on `'test'` instead of suppressing the hint (bare `callable`
1382+
// has no known parameter names).
1383+
let backend = create_test_backend();
1384+
let uri = Url::parse("file:///test/callable_invoke.php").unwrap();
1385+
let text = r#"<?php
1386+
function makeCallable(string $a, string $b): callable
1387+
{
1388+
return fn (string $c) => "$a $b $c";
1389+
}
1390+
1391+
makeCallable('1', '2')('test');
1392+
"#;
1393+
1394+
let hints = inlay_hints_for(&backend, &uri, text).await;
1395+
1396+
// Line 6: `makeCallable('1', '2')('test');`
1397+
let line6_hints = hints_at_line(&hints, 6);
1398+
let line6_labels: Vec<String> = line6_hints.iter().map(|h| hint_label(h)).collect();
1399+
1400+
// The outer call `makeCallable('1', '2')` should show `$a:` and `$b:`.
1401+
assert!(
1402+
line6_labels.iter().any(|l| l == "a:"),
1403+
"Should show 'a:' hint for makeCallable's first arg; got: {:?}",
1404+
line6_labels
1405+
);
1406+
assert!(
1407+
line6_labels.iter().any(|l| l == "b:"),
1408+
"Should show 'b:' hint for makeCallable's second arg; got: {:?}",
1409+
line6_labels
1410+
);
1411+
1412+
// The inner call `('test')` should NOT show `$a:` — it's invoking
1413+
// a bare `callable`, not `makeCallable`.
1414+
// Count how many `a:` hints there are — should be exactly 1 (from
1415+
// the outer call), not 2.
1416+
let a_count = line6_labels.iter().filter(|l| *l == "a:").count();
1417+
assert_eq!(
1418+
a_count, 1,
1419+
"Should have exactly 1 'a:' hint (outer call only), got {}: {:?}",
1420+
a_count, line6_labels
1421+
);
1422+
}

0 commit comments

Comments
 (0)