Skip to content

Commit 885ba5b

Browse files
committed
Fix false-positive type errors on generic class methods via return types
1 parent 6efa489 commit 885ba5b

5 files changed

Lines changed: 354 additions & 50 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- **Exception types in `catch` clauses matched incorrectly across namespaces.** Thrown exception types are now matched against `catch` clause types regardless of whether short names or fully-qualified names are used. The `@throws` tag comparison in docblock update actions now resolves names through the class loader, fixing false "missing @throws" suggestions for root-namespace exceptions (e.g. `RuntimeException`) in namespaced files without an explicit `use` import.
3434
- **False "undefined variable" diagnostics for by-reference parameters in inherited static methods and constructors.** By-reference parameters in parent class static methods and constructors are now correctly resolved when called on a child class, preventing false diagnostics for variables defined via the call. Class lookup for by-reference resolution now tries the fully-qualified name first, preventing incorrect matches when multiple classes share a short name across namespaces.
3535
- **Nested `match(true)` expressions.** Multiple `match(true)` expressions sharing narrowed variable names no longer produce incorrect diagnostics.
36+
- **False-positive type errors on generic class methods resolved through return types.** When a method returns a generic class (e.g. `@return HasMany<Translation, Tag>`), calling a method on the result whose parameter references a class-level template parameter (e.g. `@param TRelatedModel $model`) no longer produces a spurious "expects TRelatedModel, got Translation" diagnostic. The return type's generic arguments are now preserved and used for template substitution.
3637
- **Variables prefixed with `$this` receiving stale type information.** Variables like `$thisItem` or `$thisValue` no longer receive stale type information from `$this`-related narrowing.
3738
- **False-positive type errors on generic class methods and templated static methods.** Class-level `@template` parameters (e.g. `Collection<User>`) are now substituted into method parameter types before checking argument compatibility. Method-level `@template` parameters (e.g. PHPUnit's `assertSame`) are resolved from call-site argument types, covering literals, variables, enum cases, property accesses, and method call return types.
3839

docs/todo/bugs.md

Lines changed: 83 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -133,22 +133,32 @@ foreach ($regexes as $type => $regex) {
133133
}
134134
```
135135

136-
## B5 — Unresolved function-level template return type leaks through to argument diagnostics
136+
## B5 — Unresolved template parameters leak raw names into argument diagnostics
137137

138-
When a function declares `@template TReduceReturnType` and
139-
`@return TReduceReturnType` but no argument binds the template
140-
parameter, the return type resolves to the raw template name
141-
`TReduceReturnType` instead of `mixed` or remaining unresolved.
142-
When the result is passed to a function expecting a concrete type
143-
(e.g. `takes_int(int $x)`), the type error checker sees
144-
"TReduceReturnType vs int" and fires a false positive diagnostic.
138+
When a template parameter is not bound to a concrete type (either
139+
because no argument carries the binding, or because the class was
140+
instantiated without a generic annotation), the raw template name
141+
(e.g. `TValue`, `TKey`, `TReduceReturnType`, `TClosure`) leaks
142+
through to the type error checker. The diagnostic then reports
143+
"expects TValue, got string" instead of recognising the parameter
144+
as unresolved and suppressing the check.
145145

146-
Template substitution should either resolve the return type from
147-
the call-site arguments or fall back to the template's bound
148-
(defaulting to `mixed`) so the raw name never leaks through to
149-
downstream diagnostics.
146+
This affects both function-level and class-level templates:
150147

151-
Reproducer:
148+
- **Function-level:** `@template TReduceReturnType` with
149+
`@return TReduceReturnType` where no argument binds the param.
150+
- **Class-level:** `Collection<TKey, TValue>` where the Collection
151+
was created without a generic annotation (e.g. `collect([])`,
152+
`new Collection()`). Methods like `push($item)` still have
153+
`@param TValue $item` with the raw template name, so passing
154+
a `string` fires "expects TValue, got string".
155+
156+
Template substitution should either resolve the parameter from the
157+
call-site arguments / class-level generic annotation, or fall back
158+
to the template's bound (defaulting to `mixed`) so the raw name
159+
never leaks through to downstream diagnostics.
160+
161+
Reproducer (function-level):
152162

153163
```php
154164
/**
@@ -165,45 +175,20 @@ function test(): void {
165175
}
166176
```
167177

168-
## B6 — Class-level template substitution doesn't propagate to parameter types in type error checker
169-
170-
When a generic class like `HasMany<TRelatedModel, TDeclaringModel>`
171-
has a method with `@param TRelatedModel $model`, and the class is
172-
instantiated with concrete type arguments (e.g.
173-
`HasMany<Translation, Tag>`), the type error checker does not
174-
substitute `TRelatedModel → Translation` in the parameter type.
175-
The diagnostic sees "expects TRelatedModel, got Translation" and
176-
fires a false positive.
177-
178-
The completion/hover pipeline already performs class-level template
179-
substitution via `build_substitution_map` in `inheritance.rs`, but
180-
the type error diagnostic collector does not apply the same
181-
substitution when comparing argument types against parameter types
182-
on generic class methods.
183-
184-
Reproducer:
178+
Reproducer (class-level):
185179

186180
```php
187181
/**
188-
* @template TRelatedModel
189-
* @template TDeclaringModel
182+
* @template TValue
190183
*/
191-
class HasMany {
192-
/** @param TRelatedModel $model */
193-
public function save($model): void {}
194-
}
195-
196-
class Translation {}
197-
class Tag {
198-
/** @return HasMany<Translation, Tag> */
199-
public function translations(): HasMany { return new HasMany(); }
184+
class Collection {
185+
/** @param TValue $item */
186+
public function push($item): void {}
200187
}
201188

202189
function test(): void {
203-
$tag = new Tag();
204-
$translation = new Translation();
205-
$tag->translations()->save($translation);
206-
// false positive: "expects TRelatedModel, got Translation"
190+
$items = new Collection(); // no generic annotation
191+
$items->push('hello'); // false positive: "expects TValue, got string"
207192
}
208193
```
209194

@@ -242,3 +227,56 @@ class FooTest extends TestCase {
242227
}
243228
```
244229

230+
## B8 — Class-level template parameters lost through chained method calls
231+
232+
When a method returns a generic class (e.g. `Collection<Product>`)
233+
and the next method in the chain returns `self<TItem>` or another
234+
type referencing a class-level template parameter, the template
235+
substitution is lost because `resolve_call_return_types_expr`
236+
converts intermediate `ResolvedType`s (which carry generic args) to
237+
`Vec<Arc<ClassInfo>>` via `into_arced_classes`, discarding the
238+
`type_string` field that holds the generic parameters.
239+
240+
The first call in the chain now correctly propagates generics (B6
241+
fix), but the second call resolves the base through
242+
`resolve_call_return_types_expr``MethodCall`
243+
`into_arced_classes`, which strips the generic info before the
244+
method's return type can be template-substituted.
245+
246+
Fixing this requires threading `ResolvedType` (with its
247+
`type_string`) through the `MethodCall` arm of
248+
`resolve_call_return_types_expr` so that class-level template
249+
arguments survive into the method return-type resolution step.
250+
251+
Reproducer:
252+
253+
```php
254+
/**
255+
* @template TItem
256+
*/
257+
class Collection {
258+
/** @param TItem $item */
259+
public function add($item): void {}
260+
261+
/** @return self<TItem> */
262+
public function filter(): self { return $this; }
263+
}
264+
265+
class Product {}
266+
267+
class Store {
268+
/** @return Collection<Product> */
269+
public function products(): Collection { return new Collection(); }
270+
}
271+
272+
function test(): void {
273+
$store = new Store();
274+
$product = new Product();
275+
// First level works (B6 fix): $store->products()->add($product)
276+
// Second level fails: $store->products()->filter()->add($product)
277+
// false positive: "expects TItem, got Product"
278+
$store->products()->filter()->add($product);
279+
}
280+
```
281+
282+

src/completion/call_resolution.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,10 +500,17 @@ impl Backend {
500500
/// [`SubjectExpr::StaticMethodCall`], [`SubjectExpr::FunctionCall`],
501501
/// [`SubjectExpr::Variable`], or [`SubjectExpr::NewExpr`].
502502
/// Any other variant falls through to `resolve_target_classes_expr`.
503-
pub(crate) fn resolve_call_return_types_expr(
503+
///
504+
/// Like [`resolve_call_return_types_expr`](Self::resolve_call_return_types_expr)
505+
/// but also captures the raw return type hint (before class resolution)
506+
/// into `return_type_hint_out` when provided. This preserves generic
507+
/// type parameters (e.g. `HasMany<Translation, Tag>`) that would
508+
/// otherwise be lost when converting to `Vec<Arc<ClassInfo>>`.
509+
pub(crate) fn resolve_call_return_types_expr_with_hint(
504510
callee: &SubjectExpr,
505511
text_args: &str,
506512
ctx: &ResolutionCtx<'_>,
513+
mut return_type_hint_out: Option<&mut Option<PhpType>>,
507514
) -> Vec<Arc<ClassInfo>> {
508515
match callee {
509516
// ── Instance method call: base->method(…) ───────────────
@@ -515,8 +522,37 @@ impl Backend {
515522
super::resolver::resolve_target_classes_expr(base, AccessKind::Arrow, ctx),
516523
);
517524

525+
// Capture the raw return type hint while we iterate
526+
// the owner classes below. We grab it from the first
527+
// owner that has a matching method — before the return
528+
// type gets flattened into ClassInfo.
529+
let mut hint_captured = false;
518530
let mut results = Vec::new();
531+
519532
for owner in &lhs_classes {
533+
// Capture the return type hint from the first owner
534+
// that has the method, before class resolution loses
535+
// generic parameters. The resolve_class_fully call
536+
// is cached, so this doesn't duplicate work done by
537+
// resolve_method_return_types_with_args below.
538+
if !hint_captured && let Some(ref mut hint_out) = return_type_hint_out {
539+
let merged = crate::virtual_members::resolve_class_fully_maybe_cached(
540+
owner,
541+
ctx.class_loader,
542+
ctx.resolved_class_cache,
543+
);
544+
if let Some(m) = merged
545+
.methods
546+
.iter()
547+
.find(|m| m.name.eq_ignore_ascii_case(method_name))
548+
{
549+
if let Some(ref ret) = m.return_type {
550+
**hint_out = Some(ret.clone());
551+
}
552+
hint_captured = true;
553+
}
554+
}
555+
520556
let split_args = split_text_args(text_args);
521557
let arg_refs = split_args.to_vec();
522558
let template_subs =
@@ -559,6 +595,25 @@ impl Backend {
559595
};
560596

561597
if let Some(ref owner) = owner_class {
598+
// Capture return type hint for static method calls.
599+
// The resolve_class_fully call is cached, so this
600+
// doesn't duplicate work.
601+
if let Some(ref mut hint_out) = return_type_hint_out {
602+
let merged = crate::virtual_members::resolve_class_fully_maybe_cached(
603+
owner,
604+
ctx.class_loader,
605+
ctx.resolved_class_cache,
606+
);
607+
if let Some(m) = merged
608+
.methods
609+
.iter()
610+
.find(|m| m.name.eq_ignore_ascii_case(method_name))
611+
&& let Some(ref ret) = m.return_type
612+
{
613+
**hint_out = Some(ret.clone());
614+
}
615+
}
616+
562617
let split_args = split_text_args(text_args);
563618
let arg_refs = split_args.to_vec();
564619
let template_subs =
@@ -698,6 +753,10 @@ impl Backend {
698753
}
699754

700755
if let Some(ref ret) = func_info.return_type {
756+
// Capture the function's return type hint.
757+
if let Some(ref mut hint_out) = return_type_hint_out {
758+
**hint_out = Some(ret.clone());
759+
}
701760
return super::type_resolution::type_hint_to_classes_typed(
702761
ret,
703762
"",
@@ -858,6 +917,17 @@ impl Backend {
858917
}
859918
}
860919

920+
/// Thin wrapper around [`resolve_call_return_types_expr_with_hint`]
921+
/// that discards the return type hint. Existing callers that don't
922+
/// need generic type preservation use this.
923+
pub(crate) fn resolve_call_return_types_expr(
924+
callee: &SubjectExpr,
925+
text_args: &str,
926+
ctx: &ResolutionCtx<'_>,
927+
) -> Vec<Arc<ClassInfo>> {
928+
Self::resolve_call_return_types_expr_with_hint(callee, text_args, ctx, None)
929+
}
930+
861931
/// Resolve a method call's return type, taking into account PHPStan
862932
/// conditional return types when `text_args` is provided, and
863933
/// method-level `@template` substitutions when `template_subs` is

src/completion/resolver.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,11 +408,37 @@ pub(crate) fn resolve_target_classes_expr(
408408

409409
// ── Call expression ─────────────────────────────────────
410410
SubjectExpr::CallExpr { callee, args_text } => {
411+
// First pass: resolve the call to classes (cheap).
411412
let classes = Backend::resolve_call_return_types_expr(callee, args_text, ctx);
412-
classes
413-
.into_iter()
414-
.map(|arc| ResolvedType::from_class(Arc::unwrap_or_clone(arc)))
415-
.collect()
413+
// Only capture the raw return type hint when at least one
414+
// resolved class has template parameters. Non-generic
415+
// classes don't benefit from the hint, and skipping it
416+
// avoids an extra resolve_class_fully lookup on every
417+
// call expression in builder chains.
418+
let needs_hint = classes.iter().any(|c| !c.template_params.is_empty());
419+
if needs_hint {
420+
let mut hint: Option<PhpType> = None;
421+
let classes2 = Backend::resolve_call_return_types_expr_with_hint(
422+
callee,
423+
args_text,
424+
ctx,
425+
Some(&mut hint),
426+
);
427+
if let Some(h) = hint {
428+
let class_vec: Vec<ClassInfo> =
429+
classes2.into_iter().map(Arc::unwrap_or_clone).collect();
430+
return ResolvedType::from_classes_with_hint(class_vec, h);
431+
}
432+
classes2
433+
.into_iter()
434+
.map(|arc| ResolvedType::from_class(Arc::unwrap_or_clone(arc)))
435+
.collect()
436+
} else {
437+
classes
438+
.into_iter()
439+
.map(|arc| ResolvedType::from_class(Arc::unwrap_or_clone(arc)))
440+
.collect()
441+
}
416442
}
417443

418444
// ── Property chain ──────────────────────────────────────

0 commit comments

Comments
 (0)