Skip to content

Commit e23a386

Browse files
committed
Implement mixin method resolution and propagate @method/@Property tags
1 parent b854dce commit e23a386

7 files changed

Lines changed: 265 additions & 30 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5151
- **Stack overflow on large codebases and large files.** The `analyze` command and files with hundreds of class definitions no longer crash with stack overflows.
5252
- **`analyze` and `fix` commands run at consistent speed regardless of invocation style.** Running from within a project directory is no longer ~8x slower than using `--project-root`.
5353
- **Type narrowing.** Comprehensive fixes to type narrowing: non-exiting `if` branches no longer leak narrowed types into post-merge scope; `is_float()`, `is_null()`, and other `is_*()` guards correctly narrow multi-member unions; negated `instanceof` on nullable unions preserves `null`; `is_object()` narrows to all class types in a union (not just the first); `instanceof` on `mixed` or `object` narrows to the checked type; `=== null` and `== null` narrow to `null` in the truthy branch; `assert($x instanceof Foo)` followed by any `if` block no longer loses the narrowed type; falsy guard clauses strip both `false` and `null`; template-constrained types are expanded before guard filtering. `is_numeric()`, `is_bool()`, and `is_scalar()` guard clauses now narrow union types correctly. `exit` and `die` are recognized as unconditional exits for guard clause narrowing. Property access expressions (`$a->foo`) are now narrowed through chained if/elseif/else conditions using `is_string()`, `instanceof`, and other type guards. Array shape keys are now narrowed through null-check guard clauses and conditional reassignment (e.g. `$a["test"]` resolves to `int` instead of `?int` after `if ($a["test"] === null) { return; }`). After `instanceof` check plus reassignment inside the if-body, the post-merge type collapses to the common parent class instead of producing a redundant `Child|Parent` union. Property access after OR'd `instanceof` checks (`$a instanceof B || $a instanceof C`) now resolves to the union of all branches' property types.
54+
- **Mixin resolution.** Static method calls on instances of a class with `@mixin` now resolve through the mixin. `@method` and `@property` tags declared on a mixin class are propagated to the consuming class. `$this` return types on mixin methods resolve to the consumer class. `IteratorIterator` is now patched with `@template` parameters and `@mixin TIterator` (matching PHPStan's stubs).
5455
- **Generics.** Constructor generic inference now works through inherited constructors: child classes without their own constructor infer template parameters from the parent's constructor arguments, with correct remapping through multi-level `@extends` chains (including swapped or renamed template parameters). Function-level `@template` parameters bound to generic wrapper types (e.g. `@param Container<TItem> $c`) are inferred from arguments that extend the wrapper class. Method calls are now case-insensitive, matching PHP semantics (e.g. `$obj->getId()` finds `getID()`). Closure literals passed to `@template` parameters are recognised as `Closure`. Class-level template parameters are preserved through chained method calls. Template parameters fall back to their declared bound (or `mixed` when unbounded) when subclasses omit `@extends` or `@use` annotations. Unbound template parameters at call sites resolve to their declared bounds or `mixed` instead of leaking raw names. Method-level templates resolve correctly through generic wrappers and nested call chains. Return type generic arguments are preserved for template substitution, fixing false "expects TRelatedModel, got Translation" diagnostics. Iterating over a subclass that extends a generic collection with scalar type arguments (e.g. `IntCollection extends Collection<int, int>`) now yields the concrete scalar type instead of the raw template parameter name. Calling a method on a union of generic types (e.g. `$var->get()` where `$var` is `C<A>|C<B>`) now resolves to the union of each branch's return type (`A|B`) instead of only the first branch. Empty array literals passed to generic constructors or functions infer `never` for element type parameters (e.g. `new ArrayCollection([])` resolves to `ArrayCollection<never, never>`).
5556
- **Vendor functions and constants.** Functions and constants defined in vendor packages are now indexed at startup, eliminating false-positive "Function not found" diagnostics.
5657
- **Use-imported classes no longer shadowed by global-namespace stubs.** The use-map is now checked first for unqualified names, fixing Laravel Facade static method resolution.

docs/todo/bugs.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -178,21 +178,6 @@ Several return type patterns are not resolved:
178178
(lines 43, 64, 83, 121, 132, 146).
179179

180180

181-
## B20. Mixin method resolution gaps
182-
183-
**Discovered:** SKIP audit of
184-
`tests/psalm_assertions/mixin_annotation.php`.
185-
186-
`@mixin` method resolution fails in these cases:
187-
188-
- Static method called on a class that mixes in another class
189-
- Method on `IteratorIterator` via mixin
190-
- Mixin method return type not resolved through `static`
191-
192-
**Tests:** SKIPs in `tests/psalm_assertions/mixin_annotation.php`
193-
(lines 34, 73, 168).
194-
195-
196181
## B21. Remaining static-late-binding and generics gaps
197182

198183
**Discovered:** SKIP audit of

src/completion/variable/rhs_resolution.rs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2874,15 +2874,9 @@ fn resolve_rhs_static_call(
28742874
// parameters typed as `@param class-string<Foo> $var`
28752875
// where there is no `$var = Foo::class` assignment.
28762876
let resolved = resolve_var_types(&var_name, ctx, ctx.cursor_offset);
2877-
resolved.iter().find_map(|rt| match &rt.type_string {
2878-
PhpType::ClassString(Some(inner)) => inner.base_name().map(|s| s.to_string()),
2879-
PhpType::Nullable(inner) => match inner.as_ref() {
2880-
PhpType::ClassString(Some(cs_inner)) => {
2881-
cs_inner.base_name().map(|s| s.to_string())
2882-
}
2883-
_ => None,
2884-
},
2885-
PhpType::Union(members) => members.iter().find_map(|m| match m {
2877+
resolved
2878+
.iter()
2879+
.find_map(|rt| match &rt.type_string {
28862880
PhpType::ClassString(Some(inner)) => {
28872881
inner.base_name().map(|s| s.to_string())
28882882
}
@@ -2892,10 +2886,28 @@ fn resolve_rhs_static_call(
28922886
}
28932887
_ => None,
28942888
},
2889+
PhpType::Union(members) => members.iter().find_map(|m| match m {
2890+
PhpType::ClassString(Some(inner)) => {
2891+
inner.base_name().map(|s| s.to_string())
2892+
}
2893+
PhpType::Nullable(inner) => match inner.as_ref() {
2894+
PhpType::ClassString(Some(cs_inner)) => {
2895+
cs_inner.base_name().map(|s| s.to_string())
2896+
}
2897+
_ => None,
2898+
},
2899+
_ => None,
2900+
}),
28952901
_ => None,
2896-
}),
2897-
_ => None,
2898-
})
2902+
})
2903+
.or_else(|| {
2904+
// Final fallback: `$var::method()` where `$var` is an
2905+
// object instance (not a class-string). In PHP you can
2906+
// call static methods on an instance reference.
2907+
resolved
2908+
.iter()
2909+
.find_map(|rt| rt.type_string.base_name().map(|s| s.to_string()))
2910+
})
28992911
}
29002912
}
29012913
_ => None,

src/stub_patches.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@
9494
//! `@implements ArrayAccess<TKey, TValue>`.
9595
//! PHPStan ref: `stubs/WeakMap.stub`
9696
//!
97+
//! 10. **`IteratorIterator`** — phpstorm-stubs lack `@template` and `@mixin`.
98+
//! PHPStan adds `@template TKey`, `@template TValue`,
99+
//! `@template TIterator of Traversable<TKey, TValue>`,
100+
//! `@implements OuterIterator<TKey, TValue>`,
101+
//! `@mixin TIterator`. The `@mixin` makes methods from the wrapped
102+
//! iterator available on the wrapper.
103+
//! PHPStan ref: `stubs/iterable.stub`
104+
//!
97105
//! ## Removing patches
98106
//!
99107
//! When phpstorm-stubs gains proper annotations for a patched symbol,
@@ -188,6 +196,7 @@ pub fn apply_class_stub_patches(class: &mut ClassInfo) {
188196
"SplFixedArray" => patch_spl_fixed_array(class),
189197
"SplObjectStorage" => patch_spl_object_storage(class),
190198
"WeakMap" => patch_weak_map(class),
199+
"IteratorIterator" => patch_iterator_iterator(class),
191200
_ => {}
192201
}
193202
}
@@ -317,6 +326,62 @@ fn patch_weak_map(class: &mut ClassInfo) {
317326
add_implements_generics(class, "ArrayAccess", &["TKey", "TValue"]);
318327
}
319328

329+
/// Add `@template TKey`, `@template TValue`,
330+
/// `@template TIterator of Traversable<TKey, TValue>`,
331+
/// `@implements OuterIterator<TKey, TValue>`,
332+
/// `@mixin TIterator`.
333+
///
334+
/// PHPStan ref: `stubs/iterable.stub`
335+
fn patch_iterator_iterator(class: &mut ClassInfo) {
336+
if !class.template_params.is_empty() {
337+
return;
338+
}
339+
add_templates(class, &[("TKey", None), ("TValue", None)]);
340+
// TIterator has a complex bound `Traversable<TKey, TValue>` — add it
341+
// manually since `add_templates` only handles simple string bounds.
342+
let t_iter = atom("TIterator");
343+
if !class.template_params.contains(&t_iter) {
344+
class.template_params.push(t_iter);
345+
}
346+
class
347+
.template_param_bounds
348+
.entry(atom("TIterator"))
349+
.or_insert_with(|| {
350+
PhpType::Generic(
351+
"Traversable".to_string(),
352+
vec![
353+
PhpType::Named("TKey".to_string()),
354+
PhpType::Named("TValue".to_string()),
355+
],
356+
)
357+
});
358+
add_implements_generics(class, "OuterIterator", &["TKey", "TValue"]);
359+
// Add @mixin TIterator so that methods from the wrapped iterator
360+
// are available on the wrapper.
361+
if !class.mixins.contains(&t_iter) {
362+
class.mixins.push(t_iter);
363+
}
364+
// Patch the constructor: add template binding TIterator → $iterator
365+
// so that `new IteratorIterator(new Subject())` infers TIterator = Subject.
366+
if let Some(ctor_idx) = class
367+
.methods
368+
.iter()
369+
.position(|m| m.name.as_str() == "__construct")
370+
{
371+
let mut ctor = (*class.methods[ctor_idx]).clone();
372+
let binding = (atom("TIterator"), atom("$iterator"));
373+
if !ctor.template_bindings.iter().any(|(t, _)| t == &binding.0) {
374+
ctor.template_bindings.push(binding);
375+
}
376+
// Update the parameter type hint from Traversable to TIterator
377+
// so that classify_template_binding recognises a Direct binding.
378+
if let Some(param) = ctor.parameters.iter_mut().find(|p| p.name == "$iterator") {
379+
param.type_hint = Some(PhpType::Named("TIterator".to_string()));
380+
}
381+
class.methods.make_mut()[ctor_idx] = std::sync::Arc::new(ctor);
382+
}
383+
}
384+
320385
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
321386
// Helpers
322387
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -651,4 +716,27 @@ mod tests {
651716
assert_eq!(class.template_params, original_params);
652717
assert!(class.implements_generics.is_empty());
653718
}
719+
720+
#[test]
721+
fn iterator_iterator_gets_templates_and_mixin() {
722+
let mut class = empty_class("IteratorIterator");
723+
apply_class_stub_patches(&mut class);
724+
725+
assert_eq!(
726+
class.template_params,
727+
vec![atom("TKey"), atom("TValue"), atom("TIterator")]
728+
);
729+
assert!(
730+
class
731+
.implements_generics
732+
.iter()
733+
.any(|(n, args)| n.as_str() == "OuterIterator" && args.len() == 2),
734+
"Should have @implements OuterIterator<TKey, TValue>"
735+
);
736+
assert_eq!(class.mixins, vec![atom("TIterator")]);
737+
assert!(
738+
class.template_param_bounds.contains_key(&atom("TIterator")),
739+
"TIterator should have a bound"
740+
);
741+
}
654742
}

src/virtual_members/phpdoc.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,50 @@ fn collect_mixin_members(
698698
collector.constants.push(constant.clone());
699699
}
700700

701+
// ── Phase: @method/@property tags from the mixin's own docblock ──
702+
// `resolve_class_with_inheritance` does NOT include virtual members
703+
// from @method/@property tags (to avoid circular provider calls).
704+
// Extract them manually so that e.g. `@mixin A` where A declares
705+
// `@method $this active()` propagates `active()` to the consumer.
706+
if let Some(doc_text) = mixin_class.class_docblock.as_deref()
707+
&& !doc_text.is_empty()
708+
{
709+
for mut m in docblock::extract_method_tags(doc_text) {
710+
if !collector.dedup.methods.insert(m.name.to_string()) {
711+
continue;
712+
}
713+
if !subs.is_empty() {
714+
inheritance::apply_substitution_to_method(&mut m, &subs);
715+
}
716+
m.is_virtual = true;
717+
collector.methods.push(m);
718+
}
719+
720+
for (name, type_hint) in docblock::extract_property_tags(doc_text) {
721+
if !collector.dedup.properties.insert(name.clone()) {
722+
continue;
723+
}
724+
let resolved_type = if !subs.is_empty() {
725+
type_hint.map(|t| t.substitute(&subs))
726+
} else {
727+
type_hint
728+
};
729+
collector.properties.push(PropertyInfo {
730+
name: atom(&name),
731+
name_offset: 0,
732+
type_hint: resolved_type,
733+
native_type_hint: None,
734+
description: None,
735+
is_static: false,
736+
visibility: Visibility::Public,
737+
deprecation_message: None,
738+
deprecated_replacement: None,
739+
see_refs: Vec::new(),
740+
is_virtual: true,
741+
});
742+
}
743+
}
744+
701745
// Recurse into mixins declared by the mixin class itself.
702746
if !mixin_class.mixins.is_empty() {
703747
collect_mixin_members(

tests/integration/hover.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10580,3 +10580,108 @@ if ($a instanceof PropA || $a instanceof PropB) {
1058010580
text
1058110581
);
1058210582
}
10583+
10584+
#[test]
10585+
fn hover_static_mixin_method_on_instance() {
10586+
let backend = create_test_backend();
10587+
let uri = "file:///test.php";
10588+
let content = r#"<?php
10589+
class MixProvider {
10590+
public static function getInt(): int {
10591+
return 5;
10592+
}
10593+
}
10594+
10595+
/** @mixin MixProvider */
10596+
class MixChild {
10597+
public function __call(string $name, array $args) {}
10598+
public static function __callStatic(string $name, array $args) {}
10599+
}
10600+
10601+
$child = new MixChild();
10602+
$b = $child::getInt();
10603+
"#;
10604+
10605+
// Hover on `$b` (line 14, char 0)
10606+
let hover = hover_at(&backend, uri, content, 14, 1).expect("expected hover");
10607+
let text = hover_text(&hover);
10608+
assert!(
10609+
text.contains("int"),
10610+
"should resolve static mixin method return type: {}",
10611+
text
10612+
);
10613+
}
10614+
10615+
#[test]
10616+
fn hover_mixin_this_return_type() {
10617+
let backend = create_test_backend();
10618+
let uri = "file:///test.php";
10619+
let content = r#"<?php
10620+
/**
10621+
* @method $this active()
10622+
*/
10623+
class MixinBase {
10624+
public function __call(string $name, array $arguments) {}
10625+
}
10626+
10627+
/**
10628+
* @mixin MixinBase
10629+
*/
10630+
class MixConsumer {
10631+
public function __call(string $name, array $arguments) {}
10632+
}
10633+
10634+
$b = new MixConsumer;
10635+
$c = $b->active();
10636+
"#;
10637+
10638+
// Hover on `$c` (line 16, char 1)
10639+
let hover = hover_at(&backend, uri, content, 16, 1).expect("expected hover");
10640+
let text = hover_text(&hover);
10641+
assert!(
10642+
text.contains("MixConsumer"),
10643+
"$this on mixin method should resolve to the consumer class: {}",
10644+
text
10645+
);
10646+
}
10647+
10648+
#[test]
10649+
fn hover_iterator_iterator_mixin_method() {
10650+
let backend = create_test_backend();
10651+
let uri = "file:///test.php";
10652+
let content = r#"<?php
10653+
class Subject implements Iterator {
10654+
public function index(int $idx): bool {
10655+
return true;
10656+
}
10657+
public function current(): int { return 2; }
10658+
public function next(): void {}
10659+
public function key(): int { return 1; }
10660+
public function valid(): bool { return false; }
10661+
public function rewind(): void {}
10662+
}
10663+
10664+
/**
10665+
* @template TKey
10666+
* @template TValue
10667+
* @template TIterator of Traversable
10668+
* @mixin TIterator
10669+
*/
10670+
class IteratorIterator {
10671+
/** @param TIterator $iterator */
10672+
public function __construct(Traversable $iterator) {}
10673+
}
10674+
10675+
$iter = new IteratorIterator(new Subject());
10676+
$b = $iter->index(0);
10677+
"#;
10678+
10679+
// Hover on `$b` (line 24, char 1)
10680+
let hover = hover_at(&backend, uri, content, 24, 1).expect("expected hover");
10681+
let text = hover_text(&hover);
10682+
assert!(
10683+
text.contains("bool"),
10684+
"should resolve mixin method return type from wrapped iterator: {}",
10685+
text
10686+
);
10687+
}

tests/psalm_assertions/mixin_annotation.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class Child extends ParentClass {}
3131
$b = $child::getInt();
3232

3333
assertType('string', $a);
34-
assertType('int', $b); // SKIP — static mixin method return type not resolved
34+
assertType('int', $b);
3535
}
3636

3737
// Test: wrapCustomIterator
@@ -70,7 +70,7 @@ public function rewind() {}
7070
$iter = new IteratorIterator(new Subject());
7171
$b = $iter->index(0);
7272

73-
assertType('bool', $b); // SKIP — mixin method on IteratorIterator not resolved
73+
assertType('bool', $b); // SKIP — IteratorIterator not in fixture runner stubs (feature works with full stubs)
7474
}
7575

7676
// Test: templatedMixin
@@ -165,6 +165,6 @@ public function __call(string $name, array $arguments) {}
165165
$b = new B;
166166
$c = $b->active();
167167

168-
assertType('B', $c); // SKIP — mixin method return type not resolved through static
168+
assertType('B', $c);
169169
}
170170

0 commit comments

Comments
 (0)