Skip to content

Commit a6b0859

Browse files
committed
@implements generic resolution
1 parent 9bd0a6f commit a6b0859

17 files changed

Lines changed: 874 additions & 136 deletions

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- **Enum case properties.** Completing on an enum case variable (`$case->`) now shows `name` (on all enums) and `value` (on backed enums) inherited from the `UnitEnum` and `BackedEnum` interfaces.
13+
- **`@implements` generic resolution.** When a class declares `@implements SomeInterface<ConcreteType>`, template parameters on the interface's methods and properties are now substituted with the concrete types. Works with `@template-implements` and `@phpstan-implements` aliases, multiple `@implements` annotations on the same class, parameter type substitution (not just return types), and chained resolution through parent classes (e.g. `Test2 extends Test1<int>` where `Test1` has `@implements Iterator<TKey, string>`). Foreach iteration over classes implementing generic iterable interfaces (including through intermediate interface chains like `TypedCollection extends IteratorAggregate`) now resolves value and key types correctly.
1314

1415
### Fixed
1516

docs/todo/testing.md

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# PHPantom — Ignored Fixture Tasks
22

3-
There are **228 fixture tests** in `tests/fixtures/`. Of these, **188
4-
pass** and **40 are ignored** because they exercise features or bug
3+
There are **228 fixture tests** in `tests/fixtures/`. Of these, **195
4+
pass** and **33 are ignored** because they exercise features or bug
55
fixes that are not yet implemented. Each ignored fixture has a
66
`// ignore:` comment explaining what is missing.
77

8-
This document groups the 40 ignored fixtures by the underlying work
8+
This document groups the 33 ignored fixtures by the underlying work
99
needed to un-ignore them. Tasks are ordered by the number of fixtures
1010
they unblock (descending), then by estimated effort. Once a task is
1111
complete, remove the `// ignore:` line from each fixture, verify the
@@ -23,38 +23,6 @@ php -l example.php
2323

2424
---
2525

26-
## 1. `@implements` generic resolution (7 fixtures)
27-
28-
**Ref:** [type-inference.md §17](type-inference.md#17-implements-generic-resolution)
29-
**Impact: Medium-High · Effort: Medium**
30-
31-
When a class declares `@implements SomeInterface<ConcreteType>`, the
32-
generic parameters are not substituted into inherited interface methods.
33-
This already works for `@extends` on classes, so the substitution
34-
infrastructure exists. The `@implements` path needs the same wiring.
35-
36-
**Fixtures:**
37-
38-
- [ ] `generics/class_implements_single.fixture``@implements Repo<User>` resolves `T` on method return
39-
- [ ] `generics/class_implements_multiple.fixture` — multiple `@implements` with different concrete types
40-
- [ ] `generics/class_template_implements.fixture``@template-implements` syntax (alias for `@implements`)
41-
- [ ] `generics/implements_parameter_type.fixture``@implements` resolves `T` on method parameters
42-
- [ ] `generics/iterator_aggregate_complex.fixture``@implements IteratorAggregate<Item>` for foreach
43-
- [ ] `generics/nested_iterator_chain_gh1875.fixture``@implements Iterator<Item>` through nested chain
44-
- [ ] `foreach/iterator_aggregate_key_value.fixture``@implements IteratorAggregate<K, V>` with key+value types
45-
46-
**Implementation notes:**
47-
48-
In `inheritance.rs`, `build_substitution_map` handles `@extends`
49-
annotations. Add the same logic for `@implements` annotations on the
50-
class. When merging interface methods in `resolve_class_fully_inner`
51-
(virtual_members), apply the substitution map from `@implements` to
52-
each inherited interface method's types. The `@template-implements`
53-
syntax should be treated as an alias, same as `@template-extends` is
54-
for `@extends`.
55-
56-
---
57-
5826
## 2. Function-level `@template` generic resolution (9 fixtures)
5927

6028
**Ref:** [type-inference.md §2](type-inference.md#2-function-level-template-generic-resolution)

docs/todo/type-inference.md

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -584,40 +584,6 @@ type:
584584

585585
---
586586

587-
## 17. `@implements` generic resolution
588-
**Impact: Medium-High · Effort: Medium**
589-
590-
When a class declares `@implements SomeInterface<ConcreteType>`, the
591-
generic parameters should be substituted into inherited interface
592-
methods. This works for `@extends` on classes but the `@implements`
593-
path is not wired up.
594-
595-
**Example:**
596-
597-
```php
598-
/** @template T */
599-
interface Repository {
600-
/** @return T */
601-
public function find(int $id);
602-
}
603-
604-
/** @implements Repository<User> */
605-
class UserRepository implements Repository {
606-
public function find(int $id) { /* ... */ }
607-
}
608-
609-
$repo = new UserRepository();
610-
$repo->find(1); // should resolve to User
611-
```
612-
613-
A related variant is `@implements` through an extended interface chain
614-
with key+value types (e.g. `@implements IteratorAggregate<string, Item>`).
615-
616-
**Discovered via:** fixture conversion (class_implements_single,
617-
class_implements_multiple, implements_parameter_type).
618-
619-
---
620-
621587
## 18. `@phpstan-assert` on static method calls
622588
**Impact: Medium · Effort: Medium**
623589

example.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,26 @@ public function demo(): void
305305
}
306306

307307

308+
// ── @implements Generic Resolution ─────────────────────────────────────────
309+
310+
class ImplementsGenericDemo
311+
{
312+
public function demo(): void
313+
{
314+
$repo = new PenStorage();
315+
$repo->find(1)->write(); // @implements Storage<Pen> → Pen
316+
317+
$penCatalog = new PenCatalog();
318+
$penCatalog->find(1)->write(); // @template-implements alias
319+
320+
$items = new ItemIterableCollection();
321+
foreach ($items as $item) {
322+
$item->write(); // @implements IteratorAggregate<Pen>
323+
}
324+
}
325+
}
326+
327+
308328
// ── Conditional Return Types ────────────────────────────────────────────────
309329

310330
class ConditionalReturnDemo
@@ -2677,6 +2697,47 @@ class CachingPenRepository extends PenRepository
26772697
public function clearCache(): void {}
26782698
}
26792699

2700+
// ─── @implements Generic Resolution ─────────────────────────────────────────
2701+
2702+
/**
2703+
* @template TEntity
2704+
*/
2705+
interface Storage
2706+
{
2707+
/** @return TEntity */
2708+
public function find(int $id);
2709+
2710+
/** @return TEntity[] */
2711+
public function findAll();
2712+
}
2713+
2714+
/** @implements Storage<Pen> */
2715+
class PenStorage implements Storage
2716+
{
2717+
public function find(int $id) { return new Pen(); }
2718+
public function findAll() { return [new Pen()]; }
2719+
}
2720+
2721+
/** @template-implements Storage<Pen> */
2722+
class PenCatalog implements Storage
2723+
{
2724+
public function find(int $id) { return new Pen(); }
2725+
public function findAll() { return [new Pen()]; }
2726+
}
2727+
2728+
/**
2729+
* @template T
2730+
* @implements \IteratorAggregate<int, T>
2731+
*/
2732+
class IterableCollection implements \IteratorAggregate
2733+
{
2734+
/** @return \ArrayIterator<int, T> */
2735+
public function getIterator(): \ArrayIterator { return new \ArrayIterator([]); }
2736+
}
2737+
2738+
/** @extends IterableCollection<Pen> */
2739+
class ItemIterableCollection extends IterableCollection {}
2740+
26802741
/**
26812742
* @template TKey of array-key
26822743
* @template-covariant TValue

src/completion/call_resolution.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,10 +701,17 @@ impl Backend {
701701

702702
// First check the class itself
703703
if let Some(method) = class_info.methods.iter().find(|m| m.name == method_name) {
704-
return resolve_method(method);
704+
let result = resolve_method(method);
705+
if !result.is_empty() {
706+
return result;
707+
}
708+
// Fall through to the merged class — the method may lack a
709+
// return type here but have one filled in from an interface
710+
// via `@implements` generic resolution.
705711
}
706712

707-
// Walk up the inheritance chain
713+
// Walk up the inheritance chain (also merges interface members
714+
// with `@implements` generic substitutions applied).
708715
let merged = crate::virtual_members::resolve_class_fully_maybe_cached(
709716
class_info,
710717
class_loader,

src/completion/variable/foreach_resolution.rs

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ pub(in crate::completion) fn try_resolve_foreach_value_type<'b>(
160160
ctx.class_loader,
161161
ctx.resolved_class_cache,
162162
);
163-
if let Some(value_type) = extract_iterable_element_type_from_class(&merged) {
163+
if let Some(value_type) =
164+
extract_iterable_element_type_from_class(&merged, ctx.class_loader)
165+
{
164166
push_foreach_resolved_types(&value_type, ctx, results, conditional);
165167
return;
166168
}
@@ -261,7 +263,7 @@ pub(in crate::completion) fn try_resolve_foreach_key_type<'b>(
261263
ctx.class_loader,
262264
ctx.resolved_class_cache,
263265
);
264-
if let Some(key_type) = extract_iterable_key_type_from_class(&merged) {
266+
if let Some(key_type) = extract_iterable_key_type_from_class(&merged, ctx.class_loader) {
265267
push_foreach_resolved_types(&key_type, ctx, results, conditional);
266268
return;
267269
}
@@ -352,7 +354,10 @@ const ITERABLE_IFACE_NAMES: &[&str] = &[
352354
/// Returns `None` when no generic iterable annotation is found or
353355
/// when the element type is a scalar (scalars have no completable
354356
/// members).
355-
fn extract_iterable_element_type_from_class(class: &ClassInfo) -> Option<String> {
357+
fn extract_iterable_element_type_from_class(
358+
class: &ClassInfo,
359+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
360+
) -> Option<String> {
356361
// 1. Check implements_generics for known iterable interfaces.
357362
for (name, args) in &class.implements_generics {
358363
let short = short_name(name);
@@ -364,6 +369,23 @@ fn extract_iterable_element_type_from_class(class: &ClassInfo) -> Option<String>
364369
}
365370
}
366371

372+
// 1b. Check implements_generics for interfaces that transitively
373+
// extend a known iterable interface (e.g. `TypedCollection`
374+
// extends `IteratorAggregate`).
375+
for (name, args) in &class.implements_generics {
376+
let short = short_name(name);
377+
if !ITERABLE_IFACE_NAMES.contains(&short)
378+
&& !args.is_empty()
379+
&& let Some(iface) = class_loader(name)
380+
&& is_transitive_iterable(&iface, class_loader)
381+
{
382+
let value = args.last().unwrap();
383+
if !docblock::types::is_scalar(value) {
384+
return Some(value.clone());
385+
}
386+
}
387+
}
388+
367389
// 2. Check extends_generics — common for collection subclasses
368390
// like `@extends Collection<int, User>`.
369391
for (_, args) in &class.extends_generics {
@@ -386,7 +408,10 @@ fn extract_iterable_element_type_from_class(class: &ClassInfo) -> Option<String>
386408
/// Returns `None` when no suitable annotation is found or when only a
387409
/// single type parameter is present (single-param generics have an
388410
/// implicit `int` key which is scalar).
389-
fn extract_iterable_key_type_from_class(class: &ClassInfo) -> Option<String> {
411+
fn extract_iterable_key_type_from_class(
412+
class: &ClassInfo,
413+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
414+
) -> Option<String> {
390415
// 1. Check implements_generics for known iterable interfaces.
391416
for (name, args) in &class.implements_generics {
392417
let short = short_name(name);
@@ -398,6 +423,22 @@ fn extract_iterable_key_type_from_class(class: &ClassInfo) -> Option<String> {
398423
}
399424
}
400425

426+
// 1b. Check implements_generics for interfaces that transitively
427+
// extend a known iterable interface.
428+
for (name, args) in &class.implements_generics {
429+
let short = short_name(name);
430+
if !ITERABLE_IFACE_NAMES.contains(&short)
431+
&& args.len() >= 2
432+
&& let Some(iface) = class_loader(name)
433+
&& is_transitive_iterable(&iface, class_loader)
434+
{
435+
let key = &args[0];
436+
if !docblock::types::is_scalar(key) {
437+
return Some(key.clone());
438+
}
439+
}
440+
}
441+
401442
// 2. Check extends_generics.
402443
for (_, args) in &class.extends_generics {
403444
if args.len() >= 2 {
@@ -411,6 +452,39 @@ fn extract_iterable_key_type_from_class(class: &ClassInfo) -> Option<String> {
411452
None
412453
}
413454

455+
/// Check whether an interface transitively extends a known iterable
456+
/// interface (e.g. `TypedCollection extends IteratorAggregate`).
457+
fn is_transitive_iterable(
458+
iface: &ClassInfo,
459+
class_loader: &dyn Fn(&str) -> Option<ClassInfo>,
460+
) -> bool {
461+
// Check direct interfaces.
462+
for parent in &iface.interfaces {
463+
let s = short_name(parent);
464+
if ITERABLE_IFACE_NAMES.contains(&s) {
465+
return true;
466+
}
467+
}
468+
// Check extends_generics for the interface-extends-interface pattern.
469+
for (name, _) in &iface.extends_generics {
470+
let s = short_name(name);
471+
if ITERABLE_IFACE_NAMES.contains(&s) {
472+
return true;
473+
}
474+
}
475+
// Check parent class (interfaces use `parent_class` for extends).
476+
if let Some(ref parent_name) = iface.parent_class {
477+
let s = short_name(parent_name);
478+
if ITERABLE_IFACE_NAMES.contains(&s) {
479+
return true;
480+
}
481+
if let Some(parent) = class_loader(parent_name) {
482+
return is_transitive_iterable(&parent, class_loader);
483+
}
484+
}
485+
false
486+
}
487+
414488
// ─── Destructuring Resolution ───────────────────────────────────────
415489

416490
/// Check whether the target variable appears inside an array/list

src/completion/variable/resolution.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,43 @@ fn resolve_variable_in_members<'b>(
369369
break;
370370
}
371371
}
372+
373+
// Neither native hint nor docblock resolved.
374+
// Check the fully-resolved class (with interface
375+
// members merged and `@implements` generics applied)
376+
// for a more specific parameter type. This handles
377+
// cases where the class declares `map(object $entity)`
378+
// but the interface has `@param TEntity $entity` with
379+
// `@implements Interface<Boo>` substituting `TEntity`.
380+
let method_name = method.name.value.to_string();
381+
let merged = crate::virtual_members::resolve_class_fully_maybe_cached(
382+
ctx.current_class,
383+
ctx.class_loader,
384+
ctx.resolved_class_cache,
385+
);
386+
if let Some(merged_method) =
387+
merged.methods.iter().find(|m| m.name == method_name)
388+
{
389+
// Find the matching parameter by name.
390+
// ParameterInfo.name includes the `$` prefix.
391+
if let Some(merged_param) = merged_method
392+
.parameters
393+
.iter()
394+
.find(|p| p.name == ctx.var_name)
395+
&& let Some(ref hint) = merged_param.type_hint
396+
{
397+
let resolved = crate::completion::type_resolution::type_hint_to_classes(
398+
hint,
399+
&ctx.current_class.name,
400+
ctx.all_classes,
401+
ctx.class_loader,
402+
);
403+
if !resolved.is_empty() {
404+
param_results = resolved;
405+
break;
406+
}
407+
}
408+
}
372409
}
373410
}
374411

0 commit comments

Comments
 (0)