Skip to content

Commit 969837e

Browse files
committed
Resolve method-level template params to bounds inside method bodies
1 parent 92d2873 commit 969837e

3 files changed

Lines changed: 442 additions & 64 deletions

File tree

docs/todo/type-inference.md

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -412,64 +412,17 @@ emits the concrete callable signature in the `@param` tag.
412412

413413
---
414414

415-
## T18. Method-level template parameter resolution at call sites
416-
**Impact: Medium · Effort: Medium**
417-
418-
When a method declares `@template T of SomeType` and uses `T` as both
419-
a parameter and return type, PHPantom should resolve `T` to the
420-
concrete type of the argument passed at the call site. Currently,
421-
the template parameter name (e.g. `T`, `TRelation`) is left as the
422-
resolved type string, and member access on the return value fails
423-
with "subject type 'T' could not be resolved".
424-
425-
**Reproducer:**
426-
427-
```php
428-
class ProductRepository
429-
{
430-
/**
431-
* @template T of Builder|QueryBuilder
432-
* @param T $query
433-
* @return T
434-
*/
435-
private static function filterDisabled(BuilderContract $query, Country $code): BuilderContract
436-
{
437-
$query->where(...); // "subject type 'T' could not be resolved"
438-
return $query;
439-
}
440-
}
441-
```
442-
443-
```php
444-
trait GetMarketTrait
445-
{
446-
/**
447-
* @template TRelation of Relation
448-
* @param TRelation $relation
449-
* @return TRelation
450-
*/
451-
protected function whereCurrentMarket(Relation $relation): Relation
452-
{
453-
$relation->getQuery()->where(...);
454-
// "subject type 'TRelation' could not be resolved"
455-
}
456-
}
457-
```
458-
459-
**What should work:** Inside the method body, `$query` should be
460-
resolved using the `@template` bound (`Builder|QueryBuilder`) rather
461-
than the bare template name. At call sites, `T` should be substituted
462-
with the concrete argument type.
463-
464-
**Where to fix:**
465-
- `src/completion/variable/resolution.rs` — when resolving a parameter
466-
variable, check for `@template` annotations that bind the parameter
467-
type and use the bound type (or concrete call-site type) instead of
468-
the raw template name.
469-
- `src/completion/resolver.rs` — may need method-level template
470-
substitution logic similar to class-level generic substitution.
471-
472-
**Impact in shared codebase:** ~2 diagnostics.
415+
## ~~T18. Method-level template parameter resolution at call sites~~ ✅ Resolved
416+
417+
**Resolution:** Added `substitute_template_param_bounds()` in
418+
`src/completion/variable/resolution.rs` which replaces bare template
419+
parameter names (e.g. `T`, `TRelation`) with their `@template T of Bound`
420+
upper bounds before class resolution. Applied in both
421+
`resolve_variable_in_members` (method-level) and `try_resolve_in_function`
422+
(standalone function-level). Call-site substitution was already handled by
423+
`build_method_template_subs`; this fix addresses the **inside-the-body**
424+
case where `$query->where(...)` previously failed with "subject type 'T'
425+
could not be resolved".
473426

474427
---
475428

src/completion/variable/resolution.rs

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -608,12 +608,12 @@ fn try_resolve_in_function(
608608
&mut results,
609609
);
610610

611-
// ── Substitute method-level template params in
612-
// `class-string<T>` for standalone function parameters ──────
613-
// Same logic as in `resolve_variable_in_members`: when the
614-
// resolved parameter type is `class-string<T>` and `T` is a
615-
// function-level template with an upper bound, replace `T`
616-
// with the bound so that `$class::` resolves static members.
611+
// ── Substitute function-level template params with their
612+
// bounds for standalone function parameters ─────────────────
613+
// When the resolved parameter type is `class-string<T>` or a
614+
// bare template name `T` and `T` is a function-level template
615+
// with an upper bound, replace `T` with the bound so that
616+
// member access resolves against the bound type.
617617
let func_start = func.span().start.offset as usize;
618618
for rt in results.iter_mut() {
619619
if matches!(&rt.type_string, PhpType::ClassString(Some(inner)) if matches!(inner.as_ref(), PhpType::Named(_)))
@@ -624,6 +624,12 @@ fn try_resolve_in_function(
624624
func_start,
625625
);
626626
}
627+
// Bare template param → bound (e.g. `T` → `Builder|QueryBuilder`).
628+
rt.type_string = substitute_template_param_bounds(
629+
rt.type_string.clone(),
630+
ctx.content,
631+
func_start,
632+
);
627633
}
628634

629635
walk_statements_for_assignments(func.body.statements.iter(), &body_ctx, &mut results, false);
@@ -792,6 +798,22 @@ fn resolve_variable_in_members<'b>(
792798
raw_docblock_type.as_deref(),
793799
);
794800

801+
// ── Substitute method-level template params with
802+
// their bounds before class resolution ──────────
803+
// When the effective type contains a bare template
804+
// parameter name (e.g. `T` from `@template T of
805+
// Builder|QueryBuilder`), replace it with the bound
806+
// type so that `$query->where(...)` resolves members
807+
// on `Builder|QueryBuilder` instead of failing with
808+
// "subject type 'T' could not be resolved".
809+
let effective_type = effective_type.map(|ty| {
810+
substitute_template_param_bounds(
811+
ty,
812+
ctx.content,
813+
method.span().start.offset as usize,
814+
)
815+
});
816+
795817
let resolved_from_effective = effective_type
796818
.as_ref()
797819
.map(|ty| {
@@ -998,6 +1020,102 @@ fn resolve_variable_in_members<'b>(
9981020
/// is being unconditionally reassigned). When `conditional` is `true`,
9991021
/// a new assignment **adds** to the list (the variable *might* be this
10001022
/// type).
1023+
1024+
/// Substitute method/function-level template parameter names with their
1025+
/// upper bounds from `@template T of Bound` annotations.
1026+
///
1027+
/// This handles the general case where a parameter type IS a template
1028+
/// parameter (e.g. `@param T $query` where `@template T of Builder`).
1029+
/// Without this substitution, `T` remains an unresolvable named type
1030+
/// and member access on `$query` fails with "subject type 'T' could not
1031+
/// be resolved".
1032+
///
1033+
/// Works on any `PhpType` structure — bare names, unions, intersections,
1034+
/// nullable wrappers, generics, etc. — via `PhpType::substitute`.
1035+
fn substitute_template_param_bounds(
1036+
ty: PhpType,
1037+
content: &str,
1038+
method_start_offset: usize,
1039+
) -> PhpType {
1040+
// Quick check: only act when the type contains at least one bare
1041+
// identifier that could be a template parameter. This avoids the
1042+
// docblock parse for the common case where the type is a concrete
1043+
// class name or scalar.
1044+
if !type_may_contain_template_param(&ty) {
1045+
return ty;
1046+
}
1047+
1048+
let before = &content[..method_start_offset];
1049+
let docblock = extract_preceding_docblock(before);
1050+
1051+
let Some(docblock) = docblock else {
1052+
return ty;
1053+
};
1054+
1055+
let bounds = docblock::extract_template_params_with_bounds(docblock);
1056+
if bounds.is_empty() {
1057+
return ty;
1058+
}
1059+
1060+
let mut subs = std::collections::HashMap::new();
1061+
for (name, bound) in bounds {
1062+
if let Some(bound_str) = bound {
1063+
subs.insert(name, PhpType::parse(&bound_str));
1064+
}
1065+
}
1066+
1067+
if subs.is_empty() {
1068+
return ty;
1069+
}
1070+
1071+
ty.substitute(&subs)
1072+
}
1073+
1074+
/// Check whether a `PhpType` tree may contain a bare template parameter
1075+
/// name — i.e. a `Named` variant whose value is not a well-known scalar
1076+
/// or pseudo-type. This is a cheap pre-filter so that we only parse the
1077+
/// docblock when there is a realistic chance of finding a substitution.
1078+
fn type_may_contain_template_param(ty: &PhpType) -> bool {
1079+
match ty {
1080+
PhpType::Named(name) => {
1081+
// Well-known scalars/pseudo-types are never template params.
1082+
!matches!(
1083+
name.as_str(),
1084+
"int"
1085+
| "float"
1086+
| "string"
1087+
| "bool"
1088+
| "null"
1089+
| "void"
1090+
| "never"
1091+
| "mixed"
1092+
| "object"
1093+
| "array"
1094+
| "iterable"
1095+
| "callable"
1096+
| "resource"
1097+
| "true"
1098+
| "false"
1099+
| "self"
1100+
| "static"
1101+
| "$this"
1102+
| "parent"
1103+
| "class-string"
1104+
)
1105+
}
1106+
PhpType::Union(members) | PhpType::Intersection(members) => {
1107+
members.iter().any(type_may_contain_template_param)
1108+
}
1109+
PhpType::Nullable(inner) => type_may_contain_template_param(inner),
1110+
PhpType::Generic(base, args) => {
1111+
// Check if the base itself could be a template param, or any arg.
1112+
type_may_contain_template_param(&PhpType::Named(base.clone()))
1113+
|| args.iter().any(type_may_contain_template_param)
1114+
}
1115+
_ => false,
1116+
}
1117+
}
1118+
10011119
/// Substitute method-level template parameters inside `class-string<T>`
10021120
/// types with their upper bounds from `@template T of Bound` annotations.
10031121
///

0 commit comments

Comments
 (0)