Skip to content

Commit 5f1aee6

Browse files
committed
Patch SPL iterator stubs for generic type propagation
1 parent 754b133 commit 5f1aee6

6 files changed

Lines changed: 238 additions & 27 deletions

File tree

docs/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- **Array shape keys with special characters.** Array literal keys containing backslashes, newlines, or other non-identifier characters are now properly quoted and escaped in type display (e.g. `array{'foo\\bar\nbaz': string}` instead of a truncated type string).
2727
- **Static method calls on class-string unions.** `$variable::method()` where `$variable` holds a union of class-strings now resolves through all possible classes and unions their return types.
2828
- **Trait `self` return type resolution through inheritance.** When a trait method has return type `self`, calling it on a subclass now correctly resolves to the class that uses the trait (the declaring class), not the calling subclass. `static` and `$this` continue to resolve to the runtime class.
29-
- **`NoRewindIterator` generic resolution.** Added stub patch so `new NoRewindIterator($generator)` propagates the wrapped iterator's type parameters.
29+
- **SPL iterator generic type propagation.** `CachingIterator`, `InfiniteIterator`, `LimitIterator`, `NoRewindIterator`, `CallbackFilterIterator`, and `FilterIterator` now propagate the wrapped iterator's generic type parameters. Calling `->current()` or `->key()` on any of these decorators returns the correct element type instead of `mixed`.
30+
- **`ArrayIterator` constructor generic inference.** `new ArrayIterator($typedArray)` now infers `TKey` and `TValue` from the array argument's generic type, so the resulting iterator preserves key and value types through the chain.
3031
- **Template param inference from type bounds.** When a class template parameter has a bound like `TIterator as Iterator<TKey, TValue>` and `TIterator` is resolved to a concrete generic type, the nested template params (`TKey`, `TValue`) are now inferred from the concrete type's generic arguments.
31-
- **`@psalm-param`/`@phpstan-param` priority over `@param`.** When a docblock contained both `@param array $x` and `@psalm-param array<TKey, T> $x`, the parameter type was resolved from whichever tag appeared first in the docblock. Now `@phpstan-param` always takes precedence over `@psalm-param`, which always takes precedence over `@param`, matching PHPStan and Psalm behaviour. This fixes generic constructor inference when stubs use paired `@param`/`@psalm-param` tags.
32+
- **`@psalm-param`/`@phpstan-param` priority over `@param`.** When a docblock contained both `@param array $x` and `@psalm-param array<TKey, T> $x`, the parameter type was resolved from whichever tag appeared first in the docblock. Now `@phpstan-param` always takes precedence over `@psalm-param`, which always takes precedence over `@param`, matching PHPStan and Psalm behaviour.
3233
- **`static` return type through first-class callables.** `self::method(...)()`, `static::method(...)()`, `parent::method(...)()`, and `$this->method(...)()` now preserve `static` in the return type when the underlying method declares `@return static`.
3334
- **ArrayAccess array-access assignment.** `$obj[$key] = $val` on objects implementing `ArrayAccess` no longer overwrites the variable's generic type annotation with an array type.
3435
- **Method-level `@template` with `key-of` bound.** When a method declares `@template K as key-of<TData>` and returns `TData[K]`, passing a string literal argument (e.g. `$config->get('name')`) now infers K from the literal and resolves the return type to the specific array shape value type.

docs/todo/bugs.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ to second-guess upstream output.
2020
Remaining failures have multiple root causes (the original
2121
multi-namespace theory was incorrect for most of them):
2222

23-
- **Lines 16, 29, 41, 56, 68:** SPL iterator stubs
24-
(`CachingIterator`, `InfiniteIterator`, `LimitIterator`,
25-
`CallbackFilterIterator`, `NoRewindIterator`) lack `@template`
26-
annotations, so generic type propagation through iterator
27-
decorator constructors is impossible with current stubs.
23+
- **Lines 16, 29, 41, 56, 68:** `range()` returns bare `array` in
24+
phpstorm-stubs (no generic args), so the type information is lost
25+
before it reaches the iterator constructors. The iterator
26+
decorator stub patches themselves work correctly when the input
27+
array has generic type annotations (see
28+
`tests/psalm_assertions/spl_iterator_patches.php`).
2829
- **Lines 602, 788:** Union generic method resolution and static
2930
method template inference work correctly in single-namespace
3031
files. The failures are caused by the multi-namespace test
@@ -42,6 +43,10 @@ multi-namespace theory was incorrect for most of them):
4243
`extract_param_raw_type_from_info` returned the first match in
4344
document order instead of respecting `@phpstan-param` >
4445
`@psalm-param` > `@param` priority.
46+
- SPL iterator decorator stubs (`CachingIterator`,
47+
`InfiniteIterator`, `LimitIterator`, `CallbackFilterIterator`,
48+
`FilterIterator`) and `ArrayIterator` constructor patched with
49+
`@template` annotations matching PHPStan's stubs.
4550

4651
**Tests:** SKIPs in `tests/psalm_assertions/template_class_template.php`
4752
(lines 16, 29, 41, 56, 68, 602, 788).
@@ -72,10 +77,10 @@ tests that may now pass. Run
7277
`cargo nextest run --test assert_type_runner --no-fail-fast` with
7378
the SKIP removed to verify.
7479

75-
Remaining SKIPs (11) are:
76-
- `template_class_template.php` (7) — B14: SPL stubs lack
77-
@template annotations (5), multi-namespace test runner
78-
limitation (2)
80+
Remaining SKIPs (7) are:
81+
- `template_class_template.php` (5) — `range()` returns bare
82+
`array`, no generic info to propagate through iterator chain;
83+
(2) — multi-namespace test runner limitation
7984
- `magic_method_annotation.php` (3) — B14 cross-namespace
8085
resolution in single-file test runner
8186
- `mixin_annotation.php` (1) — `IteratorIterator` not in fixture

src/completion/variable/rhs_resolution.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,7 +1450,16 @@ fn resolve_generic_wrapper_template(
14501450
wrapper_name,
14511451
"array" | "list" | "non-empty-array" | "non-empty-list"
14521452
) {
1453-
return resolve_array_literal_generic(tpl_position, arg_text, rctx);
1453+
// Try to infer from array literal first.
1454+
if let Some(result) = resolve_array_literal_generic(tpl_position, arg_text, rctx) {
1455+
return Some(result);
1456+
}
1457+
// If the argument is not a literal (e.g. a variable), resolve its
1458+
// type and extract the generic arg at the given position.
1459+
if let Some(resolved) = Backend::resolve_arg_text_to_type(arg_text, rctx) {
1460+
return extract_generic_arg_at_position(&resolved, tpl_position);
1461+
}
1462+
return None;
14541463
}
14551464

14561465
// Load the wrapper class.
@@ -1488,7 +1497,7 @@ fn resolve_generic_wrapper_template(
14881497
wrapper_subs.get(wrapper_tpl.as_str()).cloned()
14891498
}
14901499

1491-
/// Infer a generic type argument from an array literal.
1500+
/// Extract a generic type argument from an array literal.
14921501
///
14931502
/// For `@param array<TKey, TValue> $kv` with argument `["a" => 1]`:
14941503
/// - `tpl_position == 0` → key type (`string`)
@@ -1557,6 +1566,19 @@ fn resolve_array_literal_generic(
15571566
}
15581567
}
15591568

1569+
/// Extract the generic type argument at a given position from a resolved type.
1570+
///
1571+
/// For `array<int, string>` with position 0 → `int`, position 1 → `string`.
1572+
/// For `list<User>` with position 0 → `User`.
1573+
/// Also handles `PhpType::Array(inner)` as a single-arg generic.
1574+
fn extract_generic_arg_at_position(ty: &PhpType, position: usize) -> Option<PhpType> {
1575+
match ty {
1576+
PhpType::Generic(_, args) => args.get(position).cloned(),
1577+
PhpType::Array(inner) if position == 0 => Some(inner.as_ref().clone()),
1578+
_ => None,
1579+
}
1580+
}
1581+
15601582
/// Resolve `$arr[0]` / `$arr[$key]` by extracting the generic element
15611583
/// type from the base array's annotation or assignment.
15621584
fn resolve_rhs_array_access<'b>(

src/stub_patches.rs

Lines changed: 162 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@
4141
//! iterator available on the wrapper.
4242
//! PHPStan ref: `stubs/iterable.stub`
4343
//!
44+
//! 3. **`FilterIterator`** -- extends `IteratorIterator` but stubs lack
45+
//! `@template` params. PHPStan adds the same three template params
46+
//! and `@template-extends IteratorIterator<TKey, TValue, TIterator>`.
47+
//!
48+
//! 4. **`NoRewindIterator`**, **`CachingIterator`**, **`InfiniteIterator`**,
49+
//! **`LimitIterator`** -- all extend `IteratorIterator`. Same template
50+
//! params + `@extends` generics + constructor binding `TIterator → $iterator`.
51+
//!
52+
//! 5. **`CallbackFilterIterator`** -- extends `FilterIterator`.
53+
//! Same template params + `@extends FilterIterator<TKey, TValue, TIterator>`
54+
//! + constructor binding.
55+
//!
4456
//! ## Removing patches
4557
//!
4658
//! When phpstorm-stubs gains proper annotations for a patched symbol,
@@ -82,7 +94,13 @@ pub fn apply_class_stub_patches(class: &mut ClassInfo) {
8294
match class.name.as_str() {
8395
"WeakMap" => patch_weak_map(class),
8496
"IteratorIterator" => patch_iterator_iterator(class),
97+
"FilterIterator" => patch_filter_iterator(class),
8598
"NoRewindIterator" => patch_no_rewind_iterator(class),
99+
"CachingIterator" => patch_caching_iterator(class),
100+
"InfiniteIterator" => patch_infinite_iterator(class),
101+
"LimitIterator" => patch_limit_iterator(class),
102+
"CallbackFilterIterator" => patch_callback_filter_iterator(class),
103+
"ArrayIterator" => patch_array_iterator(class),
86104
_ => {}
87105
}
88106
}
@@ -131,6 +149,13 @@ fn patch_iterator_iterator(class: &mut ClassInfo) {
131149
if !class.mixins.contains(&t_iter) {
132150
class.mixins.push(t_iter);
133151
}
152+
153+
// Patch current() → TValue and key() → TKey.
154+
// phpstorm-stubs declare `current(): mixed` and `key(): mixed` which
155+
// hides the generic type. PHPStan's stubs override these.
156+
patch_method_return_type(class, "current", PhpType::Named("TValue".to_string()));
157+
patch_method_return_type(class, "key", PhpType::Named("TKey".to_string()));
158+
134159
// Patch the constructor: add template binding TIterator → $iterator
135160
// so that `new IteratorIterator(new Subject())` infers TIterator = Subject.
136161
if let Some(ctor_idx) = class
@@ -153,16 +178,125 @@ fn patch_iterator_iterator(class: &mut ClassInfo) {
153178
}
154179

155180
/// Add `@template TKey`, `@template TValue`,
156-
/// `@template TIterator of Iterator<TKey, TValue>`,
157-
/// `@extends IteratorIterator<TKey, TValue, TIterator>`,
158-
/// and constructor template binding `TIterator → $iterator`.
181+
/// `@template TIterator of Traversable<TKey, TValue>`,
182+
/// `@extends IteratorIterator<TKey, TValue, TIterator>`.
183+
///
184+
/// `FilterIterator` is abstract and extends `IteratorIterator`.
185+
/// PHPStan ref: `stubs/iterable.stub`
186+
fn patch_filter_iterator(class: &mut ClassInfo) {
187+
if !class.template_params.is_empty() {
188+
return;
189+
}
190+
patch_iterator_iterator_subclass(class, "IteratorIterator");
191+
patch_method_return_type(class, "current", PhpType::Named("TValue".to_string()));
192+
patch_method_return_type(class, "key", PhpType::Named("TKey".to_string()));
193+
}
194+
195+
/// Patch `NoRewindIterator` with template params inherited from `IteratorIterator`.
159196
///
160197
/// Without this patch, `new NoRewindIterator(generator())` resolves as
161198
/// bare `NoRewindIterator` without propagating the generator's type params.
199+
/// PHPStan ref: `stubs/iterable.stub`
162200
fn patch_no_rewind_iterator(class: &mut ClassInfo) {
163201
if !class.template_params.is_empty() {
164202
return;
165203
}
204+
patch_iterator_iterator_subclass(class, "IteratorIterator");
205+
patch_constructor_iterator_binding(class);
206+
}
207+
208+
/// Patch `CachingIterator` with template params inherited from `IteratorIterator`.
209+
///
210+
/// `CachingIterator` extends `IteratorIterator` and wraps an iterator.
211+
/// PHPStan ref: `stubs/iterable.stub`
212+
fn patch_caching_iterator(class: &mut ClassInfo) {
213+
if !class.template_params.is_empty() {
214+
return;
215+
}
216+
patch_iterator_iterator_subclass(class, "IteratorIterator");
217+
patch_method_return_type(class, "current", PhpType::Named("TValue".to_string()));
218+
patch_method_return_type(class, "key", PhpType::Named("TKey".to_string()));
219+
patch_constructor_iterator_binding(class);
220+
}
221+
222+
/// Patch `InfiniteIterator` with template params inherited from `IteratorIterator`.
223+
///
224+
/// PHPStan ref: `stubs/iterable.stub`
225+
fn patch_infinite_iterator(class: &mut ClassInfo) {
226+
if !class.template_params.is_empty() {
227+
return;
228+
}
229+
patch_iterator_iterator_subclass(class, "IteratorIterator");
230+
patch_constructor_iterator_binding(class);
231+
}
232+
233+
/// Patch `LimitIterator` with template params inherited from `IteratorIterator`.
234+
///
235+
/// PHPStan ref: `stubs/iterable.stub`
236+
fn patch_limit_iterator(class: &mut ClassInfo) {
237+
if !class.template_params.is_empty() {
238+
return;
239+
}
240+
patch_iterator_iterator_subclass(class, "IteratorIterator");
241+
patch_method_return_type(class, "current", PhpType::Named("TValue".to_string()));
242+
patch_method_return_type(class, "key", PhpType::Named("TKey".to_string()));
243+
patch_constructor_iterator_binding(class);
244+
}
245+
246+
/// Patch `CallbackFilterIterator` with template params inherited from `FilterIterator`.
247+
///
248+
/// `CallbackFilterIterator` extends `FilterIterator` (not `IteratorIterator` directly).
249+
/// PHPStan ref: `stubs/iterable.stub`
250+
fn patch_callback_filter_iterator(class: &mut ClassInfo) {
251+
if !class.template_params.is_empty() {
252+
return;
253+
}
254+
patch_iterator_iterator_subclass(class, "FilterIterator");
255+
patch_constructor_iterator_binding(class);
256+
}
257+
258+
/// Patch `ArrayIterator` constructor to bind template params from the `$array` arg.
259+
///
260+
/// phpstorm-stubs declare `@template TKey of array-key` and `@template TValue`
261+
/// on the class, but the constructor's `@param` is just `object|array` with no
262+
/// generics. PHPStan's stubs use `@param array<TKey, TValue> $array`.
263+
/// PHPStan ref: `stubs/iterable.stub`
264+
fn patch_array_iterator(class: &mut ClassInfo) {
265+
if let Some(ctor_idx) = class
266+
.methods
267+
.iter()
268+
.position(|m| m.name.as_str() == "__construct")
269+
{
270+
let mut ctor = (*class.methods[ctor_idx]).clone();
271+
272+
// Add template bindings: TKey → $array, TValue → $array
273+
for tpl_name in ["TKey", "TValue"] {
274+
let binding = (atom(tpl_name), atom("$array"));
275+
if !ctor.template_bindings.iter().any(|(t, _)| t == &binding.0) {
276+
ctor.template_bindings.push(binding);
277+
}
278+
}
279+
280+
// Set the parameter type hint to array<TKey, TValue> so that
281+
// classify_template_binding can determine the GenericWrapper mode.
282+
if let Some(param) = ctor.parameters.iter_mut().find(|p| p.name == "$array") {
283+
param.type_hint = Some(PhpType::Generic(
284+
"array".to_string(),
285+
vec![
286+
PhpType::Named("TKey".to_string()),
287+
PhpType::Named("TValue".to_string()),
288+
],
289+
));
290+
}
291+
292+
class.methods.make_mut()[ctor_idx] = std::sync::Arc::new(ctor);
293+
}
294+
}
295+
296+
/// Shared helper: add `@template TKey, TValue, TIterator` and
297+
/// `@extends <parent><TKey, TValue, TIterator>` to an `IteratorIterator`
298+
/// subclass (or sub-subclass like `CallbackFilterIterator`).
299+
fn patch_iterator_iterator_subclass(class: &mut ClassInfo, parent: &str) {
166300
add_templates(class, &[("TKey", None), ("TValue", None)]);
167301
let t_iter = atom("TIterator");
168302
if !class.template_params.contains(&t_iter) {
@@ -173,30 +307,33 @@ fn patch_no_rewind_iterator(class: &mut ClassInfo) {
173307
.entry(atom("TIterator"))
174308
.or_insert_with(|| {
175309
PhpType::Generic(
176-
"Iterator".to_string(),
310+
"Traversable".to_string(),
177311
vec![
178312
PhpType::Named("TKey".to_string()),
179313
PhpType::Named("TValue".to_string()),
180314
],
181315
)
182316
});
183-
// Add @extends generics so inheritance resolves TKey/TValue.
184-
let iter_iter_atom = atom("IteratorIterator");
317+
let parent_atom = atom(parent);
185318
if !class
186319
.extends_generics
187320
.iter()
188-
.any(|(n, _)| *n == iter_iter_atom)
321+
.any(|(n, _)| *n == parent_atom)
189322
{
190323
class.extends_generics.push((
191-
iter_iter_atom,
324+
parent_atom,
192325
vec![
193326
PhpType::Named("TKey".to_string()),
194327
PhpType::Named("TValue".to_string()),
195328
PhpType::Named("TIterator".to_string()),
196329
],
197330
));
198331
}
199-
// Patch constructor: bind TIterator from the $iterator parameter.
332+
}
333+
334+
/// Shared helper: patch the constructor to bind `TIterator` from the
335+
/// `$iterator` parameter.
336+
fn patch_constructor_iterator_binding(class: &mut ClassInfo) {
200337
if let Some(ctor_idx) = class
201338
.methods
202339
.iter()
@@ -214,6 +351,22 @@ fn patch_no_rewind_iterator(class: &mut ClassInfo) {
214351
}
215352
}
216353

354+
/// Override a method's return type on a class.
355+
///
356+
/// If the method exists, replaces its `return_type` with the given type.
357+
/// Used to patch stub methods like `current(): mixed` → `current(): TValue`.
358+
fn patch_method_return_type(class: &mut ClassInfo, method_name: &str, return_type: PhpType) {
359+
if let Some(idx) = class
360+
.methods
361+
.iter()
362+
.position(|m| m.name.as_str() == method_name)
363+
{
364+
let mut method = (*class.methods[idx]).clone();
365+
method.return_type = Some(return_type);
366+
class.methods.make_mut()[idx] = std::sync::Arc::new(method);
367+
}
368+
}
369+
217370
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
218371
// Helpers
219372
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
// Test that iterator decorator patches propagate generic types.
3+
4+
/** @var ArrayIterator<int, string> $inner */
5+
6+
$cachingIter = new CachingIterator($inner);
7+
$cachingValue = $cachingIter->current();
8+
assertType('string', $cachingValue);
9+
10+
$infiniteIter = new InfiniteIterator($inner);
11+
$infiniteValue = $infiniteIter->current();
12+
assertType('string', $infiniteValue);
13+
14+
$limitIter = new LimitIterator($inner, 0, 10);
15+
$limitValue = $limitIter->current();
16+
assertType('string', $limitValue);
17+
18+
$noRewindIter = new NoRewindIterator($inner);
19+
$noRewindValue = $noRewindIter->current();
20+
assertType('string', $noRewindValue);
21+
22+
$cbFilterIter = new CallbackFilterIterator($inner, function ($v) { return true; });
23+
$cbFilterValue = $cbFilterIter->current();
24+
assertType('string', $cbFilterValue);
25+
26+
/** @var array<string, int> $typedArray */
27+
$arrayIter = new ArrayIterator($typedArray);
28+
assertType('ArrayIterator<string, int>', $arrayIter);
29+
$arrayIterValue = $arrayIter->current();
30+
assertType('int', $arrayIterValue);

0 commit comments

Comments
 (0)