Skip to content

Commit 22db360

Browse files
committed
Add PHPStan * wildcard support in generic type positions
1 parent 004ef4f commit 22db360

8 files changed

Lines changed: 489 additions & 5 deletions

File tree

docs/CHANGELOG.md

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

5757
### Fixed
5858

59+
- **PHPStan `*` wildcard in generic type arguments.** Type strings like `Relation<TRelatedModel, *, *>` now parse correctly instead of falling back to an unparsed blob. Previously, `mago-type-syntax` rejected the `*` token, causing the entire type to be treated as a single unknown class name (e.g. `Class 'Relation<TRelatedModel, *, *>|string' not found`). The `*` wildcard is now replaced with `mixed` before parsing, matching PHPStan's semantics. Member references like `Foo::*` and constant patterns like `int-mask-of<self::FOO_*>` are not affected.
5960
- **Closure parameter inference from function-level `@template` bindings.** Functions like `array_any`, `array_all`, and `array_find` that declare `@template` parameters bound through an array parameter and used in a `callable` parameter now infer concrete types for untyped closure arguments. For example, `array_any($this->items, fn($item) => $item->name !== '')` where `$this->items` is `array<int, Product>` now resolves `$item` as `Product`. Previously `$item` was unresolved, producing false-positive `unresolved_member_access` diagnostics. This also fixes a related issue where `@param` tags in phpstorm-stubs that omit the parameter name (e.g. `@param callable(TValue, TKey): bool` without `$callback`) failed to enrich the parameter's type hint, preventing callable parameter type extraction.
6061
- **Property chain arguments in template substitution.** Expressions like `$this->items` or `$obj->prop` passed as arguments to templated functions now resolve their type for template binding. Previously only bare variables (e.g. `$items`) were resolved, so `array_any($this->items, fn($x) => ...)` could not infer the element type even when the property had a fully substituted generic type.
6162
- **Variable reassignment inside `try` blocks now tracked by the analyzer.** When a variable was reassigned to a different type inside a `try` block, subsequent accesses within the same block still resolved against the original type, producing false-positive `unknown_member` diagnostics. The same applied to `catch` and `finally` blocks. Assignments preceding the cursor within the same block are now treated as sequential.

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ within the same impact tier.
2424
| # | Item | Impact | Effort |
2525
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------ |
2626
| B6 | [Scope methods not found on Builder in analyzer chains](todo/bugs.md#b6-scope-methods-not-found-on-builder-in-analyzer-chains) | High | Medium |
27+
| L11 | [`whereHas` closure parameter type from relation traversal](todo/laravel.md#l11-wherehas--wheredoesnthave-closure-parameter-type-from-relation-traversal) | Medium | Medium |
2728
| B7 | [PHPDoc `@param` generic array type not merged with native `array` hint](todo/bugs.md#b7-phpdoc-param-generic-array-type-not-merged-with-native-array-hint) | Low | Medium |
2829
| B8 | [Variadic parameter element type lost in `foreach`](todo/bugs.md#b8-variadic-parameter-element-type-lost-in-foreach) | Low | Low |
2930
| B9 | [Eloquent relationship property lookup is case-sensitive](todo/bugs.md#b9-eloquent-relationship-property-lookup-is-case-sensitive) | Low | Low |

docs/todo/bugs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,4 @@ properties on Eloquent models.
129129

130130
**Impact:** 1 direct diagnostic (`FlowService:477`) plus 1 cascading
131131
(`FlowService:517` — compound with `Collection::reduce()` type loss).
132+

docs/todo/laravel.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,5 +428,122 @@ hard-coding the two known classes. A simpler approach: add
428428
`@method` tags to bundled stubs for the most common dynamic `with*`
429429
methods, or document this as a known limitation.
430430

431+
#### L11. `whereHas` / `whereDoesntHave` closure parameter type from relation traversal
432+
433+
**Impact: Medium · Effort: Medium**
434+
435+
When a closure is passed to `whereHas`, `orWhereHas`,
436+
`whereDoesntHave`, `orWhereDoesntHave`, `withWhereHas`, or `has`
437+
(with a callback), the closure parameter `$query` should be typed as
438+
`Builder<TRelatedModel>` where `TRelatedModel` is the model at the
439+
end of the relation chain.
440+
441+
Simple case (already works via builder forwarding and callable param
442+
inference):
443+
444+
```php
445+
Brand::whereHas('orders', function ($q) {
446+
$q-> // $q is Builder<Brand> — TModel substituted by builder forwarding
447+
});
448+
```
449+
450+
This works because the scaffolding `Builder::whereHas` has
451+
`@param (\Closure(Builder<TModel>): mixed)|null $callback`, and
452+
builder forwarding substitutes `TModel` with the concrete model. The
453+
result is `Builder<Brand>` — correct for the receiver model but not
454+
for the related model.
455+
456+
Dot-notation case (does not work):
457+
458+
```php
459+
ArticleCategoryTranslation::whereHas('category.articles', function ($q) {
460+
$q-> // should be Builder<Article>, currently Builder<ArticleCategoryTranslation>
461+
});
462+
```
463+
464+
To resolve this properly, PHPantom needs to:
465+
466+
1. Detect that `whereHas` (and related methods) is being called on a
467+
model or builder.
468+
2. Extract the first argument (the relation name string).
469+
3. Walk the dot-separated relation chain: start from the calling
470+
model, look up each segment as a relationship method, extract the
471+
`TRelatedModel` from the relationship return type's generic args.
472+
4. Use the final related model as the `TRelatedModel` for the
473+
closure parameter.
474+
475+
The real Laravel `QueriesRelationships` trait declares `whereHas` as:
476+
477+
```php
478+
/**
479+
* @template TRelatedModel of \Illuminate\Database\Eloquent\Model
480+
* @param Relation<TRelatedModel, *, *>|string $relation
481+
* @param (\Closure(Builder<TRelatedModel>): mixed)|null $callback
482+
* @return $this
483+
*/
484+
public function whereHas($relation, ?Closure $callback = null, ...);
485+
```
486+
487+
But `TRelatedModel` cannot be inferred from the string argument via
488+
normal template binding because there is no `::class` reference — it
489+
requires semantic knowledge of the model's relationship methods.
490+
491+
### Implementation approach
492+
493+
Add a Laravel-specific hook in `infer_callable_params_from_receiver`
494+
(or in `find_callable_params_on_method`) that intercepts calls to the
495+
known relation-existence methods. When the method is one of
496+
`whereHas`, `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave`,
497+
`withWhereHas`, or `has`:
498+
499+
1. Resolve the receiver to a model class (or a `Builder<Model>`).
500+
2. Extract the string literal from the first argument.
501+
3. Call a new helper `resolve_relation_chain(model, "category.articles")`
502+
that splits on `.` and follows each segment:
503+
- Find the method on the model.
504+
- Extract the `TRelated` type from its relationship return type
505+
(using the existing `extract_related_type` from
506+
`relationships.rs`).
507+
- Load that class and continue with the next segment.
508+
4. Return `Builder<FinalRelatedModel>` as the inferred closure
509+
parameter type, overriding the default callable param inference.
510+
511+
The `resolve_relation_chain` helper can reuse
512+
`classify_relationship` and `extract_related_type` from
513+
`src/virtual_members/laravel/relationships.rs`, plus
514+
`infer_relationship_from_body` as a fallback when no `@return`
515+
annotation is present.
516+
517+
### Affected methods
518+
519+
All methods from the `QueriesRelationships` trait that accept a
520+
relation name and a closure callback:
521+
522+
| Method | Callback arg position |
523+
|--------|-----------------------|
524+
| `has` | 1 (optional) |
525+
| `orHas` | 1 (optional) |
526+
| `doesntHave` | 1 (optional) |
527+
| `orDoesntHave` | 1 (optional) |
528+
| `whereHas` | 1 |
529+
| `orWhereHas` | 1 |
530+
| `withWhereHas` | 1 |
531+
| `whereDoesntHave` | 1 |
532+
| `orWhereDoesntHave` | 1 |
533+
| `whereRelation` | 1 (Builder in 2nd arg) |
534+
535+
### Where to change
536+
537+
- `src/virtual_members/laravel/relationships.rs` — add
538+
`resolve_relation_chain(model, chain_str, class_loader)` helper.
539+
- `src/completion/variable/closure_resolution.rs` — in
540+
`infer_callable_params_from_receiver` or
541+
`find_callable_params_on_method`, add a check: if the method is one
542+
of the known relation-existence methods and the receiver extends
543+
`Model` (or is `Builder<Model>`), run `resolve_relation_chain` and
544+
return `Builder<FinalModel>` instead of the raw callable param types.
545+
- Tests: `tests/integration/completion_laravel.rs` — test simple
546+
relation, dot-notation chain, body-inferred relations, and missing
547+
relation (should fall back to `Builder<Model>`).
431548

432549

src/diagnostics/unknown_classes.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,4 +982,37 @@ mod tests {
982982
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
983983
);
984984
}
985+
986+
#[test]
987+
fn no_false_positive_for_star_wildcard_in_generic() {
988+
// PHPStan `*` wildcards in generic positions (e.g.
989+
// `Relation<TRelatedModel, *, *>`) must not cause the entire
990+
// type string to be reported as an unknown class.
991+
let backend = Backend::new_test();
992+
let uri = "file:///test.php";
993+
let content = concat!(
994+
"<?php\n",
995+
"namespace App;\n",
996+
"\n",
997+
"class Relation {}\n",
998+
"\n",
999+
"class Foo {\n",
1000+
" /**\n",
1001+
" * @param Relation<string, *, *>|string \\$relation\n",
1002+
" * @return void\n",
1003+
" */\n",
1004+
" public function bar($relation): void {}\n",
1005+
"}\n",
1006+
);
1007+
1008+
let diags = collect(&backend, uri, content);
1009+
// The `Relation` class is defined locally — no diagnostic expected.
1010+
// Before the fix, the entire `Relation<string, *, *>|string` was
1011+
// emitted as a single ClassReference and flagged as unknown.
1012+
assert!(
1013+
diags.is_empty(),
1014+
"Star wildcards in generic positions must not cause false unknown_class diagnostics, got: {:?}",
1015+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1016+
);
1017+
}
9851018
}

0 commit comments

Comments
 (0)