Skip to content

Commit 1108dbe

Browse files
committed
@phpstan-assert on static methods & Variadic @param template bindings
1 parent a6b0859 commit 1108dbe

31 files changed

Lines changed: 747 additions & 294 deletions

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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.
1313
- **`@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.
14+
- **`@phpstan-assert` on static methods.** Type guard annotations (`@phpstan-assert`, `@phpstan-assert-if-true`, `@phpstan-assert-if-false`) now work on static method calls like `Assert::instanceOf($value, Foo::class)`. Previously only standalone function calls triggered assertion-based narrowing.
1415

1516
### Fixed
1617

18+
- **Variadic `@param` template bindings.** `@param class-string<T> ...$items` now correctly binds the template parameter. Previously the `...` prefix prevented the parameter name from being recognized, so generic return types were not substituted.
1719
- **`@phpstan-type` aliases in foreach.** Type aliases defined via `@phpstan-type` or `@psalm-type` now resolve correctly when the aliased type is iterated in a `foreach` loop, destructured with `list()`/`[]`, or used as a foreach key type.
1820
- **Mixed `->` then `::` accessor chains.** Expressions like `$obj->prop::$staticProp` and `$obj->method()::staticMethod()` now resolve through the full chain instead of losing the instance prefix.
1921
- **Inline `(new Foo)->method()` chaining.** Parenthesized `new` expressions used as the root of a method chain now resolve for completion. Previously only assigned `new` expressions (`$x = new Foo()`) worked.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ deepens that lead and rounds out the remaining feature surface.
8282

8383
| # | Item | Effort | Domain | Doc Link |
8484
|---|---|---|---|---|
85-
| 25 | Function-level `@template` generic resolution | Medium | Type Inference | [type-inference.md §2](todo/type-inference.md#2-function-level-template-generic-resolution) |
8685
| 26 | Inherited docblock type propagation | Medium | Type Inference | [type-inference.md §4](todo/type-inference.md#4-inherited-docblock-type-propagation) |
8786
| 27 | `BackedEnum::from()` / `::tryFrom()` return type refinement | Low | Completion | [completion.md §1](todo/completion.md#1-backedenumfrom--tryfrom-return-type-refinement) |
8887
| 28 | Pipe operator (PHP 8.5) type resolution | Low | Type Inference | [type-inference.md §1](todo/type-inference.md#1-pipe-operator-php-85) |

docs/todo/testing.md

Lines changed: 3 additions & 68 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, **195
4-
pass** and **33 are ignored** because they exercise features or bug
3+
There are **228 fixture tests** in `tests/fixtures/`. Of these, **207
4+
pass** and **21 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 33 ignored fixtures by the underlying work
8+
This document groups the 21 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,42 +23,6 @@ php -l example.php
2323

2424
---
2525

26-
## 2. Function-level `@template` generic resolution (9 fixtures)
27-
28-
**Ref:** [type-inference.md §2](type-inference.md#2-function-level-template-generic-resolution)
29-
**Impact: High · Effort: Medium**
30-
31-
`FunctionInfo` lacks `template_params` and `template_bindings` fields,
32-
so standalone functions and constructors with `@template` cannot infer
33-
generic types from call-site arguments. This blocks Laravel helpers
34-
(`collect()`, `tap()`, `value()`) and constructor-based inference.
35-
36-
**Fixtures:**
37-
38-
- [ ] `generics/constructor_params.fixture``new Foo($bar)` infers `T` from constructor arg
39-
- [ ] `generics/constructor_array_arg.fixture` — constructor with array argument infers `T`
40-
- [ ] `generics/constructor_generic_arg.fixture` — constructor with generic-typed argument
41-
- [ ] `generics/constructor_param_and_extend.fixture` — constructor inference combined with `@extends`
42-
- [ ] `generics/method_returns_templated_generic.fixture` — method returning `Collection<T>` where `T` is inferred from constructor
43-
- [ ] `generics/covariant_template.fixture``@template-covariant` modifier on function-level template
44-
- [ ] `generics/class_string_generic_union.fixture``class-string<T>` with variadic params
45-
- [ ] `generics/class_string_variadic_union.fixture``class-string<T>` with variadic union scenario
46-
- [ ] `generics/class_string_nested_return.fixture``class-string<T>` with nested generic return type
47-
48-
**Implementation notes:**
49-
50-
1. Add `template_params: Vec<String>` and
51-
`template_bindings: Vec<(String, String)>` to `FunctionInfo` in
52-
`types.rs`, mirroring `MethodInfo`.
53-
2. Populate them in `parser/functions.rs` using `extract_template_params`
54-
and `extract_template_param_bindings`.
55-
3. At call sites in `variable/rhs_resolution.rs`, after loading
56-
`FunctionInfo`, check for `template_params`. Infer concrete types
57-
from arguments, build a substitution map, and apply it to the return
58-
type before resolving.
59-
60-
---
61-
6226
## 3. Property-level narrowing (5 fixtures)
6327

6428
**Ref:** [type-inference.md §21](type-inference.md#21-property-level-narrowing)
@@ -88,32 +52,6 @@ check the narrowing state for a matching member access path.
8852

8953
---
9054

91-
## 4. `@phpstan-assert` on static method calls (3 fixtures)
92-
93-
**Ref:** [type-inference.md §18](type-inference.md#18-phpstan-assert-on-static-method-calls)
94-
**Impact: Medium · Effort: Medium**
95-
96-
Type guards declared with `@phpstan-assert`, `@phpstan-assert-if-true`,
97-
and `@phpstan-assert-if-false` only work on standalone function calls
98-
today. Static method calls like `Assert::instanceOf($value, Foo::class)`
99-
do not trigger narrowing.
100-
101-
**Fixtures:**
102-
103-
- [ ] `narrowing/phpstan_assert_static.fixture``Assert::isInstanceOf($x, Foo::class)` narrows `$x`
104-
- [ ] `narrowing/phpstan_assert_if_true.fixture``@phpstan-assert-if-true` on static method
105-
- [ ] `narrowing/phpstan_assert_if_false.fixture``@phpstan-assert-if-false` on static method
106-
107-
**Implementation notes:**
108-
109-
In the narrowing pass, when processing call expressions, check whether
110-
the callee is a static method call (`Foo::bar()`). If so, resolve the
111-
class and method, extract `@phpstan-assert*` tags from the method's
112-
docblock, and apply the same narrowing logic used for standalone
113-
functions.
114-
115-
---
116-
11755
## 5. Attribute context support (3 fixtures)
11856

11957
**Ref:** [signature-help.md §4](signature-help.md#4-attribute-constructor-signature-help)
@@ -347,8 +285,5 @@ Biggest unlocks (Medium effort, many fixtures):
347285

348286
| Task | Fixtures |
349287
|---|---|
350-
| §1 `@implements` generic resolution | 7 |
351-
| §2 Function-level `@template` | 9 |
352288
| §3 Property-level narrowing | 5 |
353-
| §4 `@phpstan-assert` on static methods | 3 |
354289
| §5 Attribute context support | 3 |

docs/todo/type-inference.md

Lines changed: 0 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -41,107 +41,6 @@ callable syntax (`htmlspecialchars(...)`), reuse the existing
4141

4242
---
4343

44-
## 2. Function-level `@template` generic resolution
45-
**Impact: High · Effort: Medium**
46-
47-
`MethodInfo` has `template_params` and `template_bindings` fields that
48-
enable method-level generic resolution at call sites (e.g.
49-
`@template T` + `@param class-string<T> $class` + `@return T`).
50-
`FunctionInfo` has **neither** field, so standalone functions that use
51-
`@template` lose their generic type information entirely.
52-
53-
The only function-level template handling today is
54-
`synthesize_template_conditional` in `parser/functions.rs`, which
55-
converts the narrow `@return T` + `@param class-string<T>` pattern
56-
into a `ConditionalReturnType`. This does not cover the general case
57-
where template params appear inside a generic return type:
58-
59-
```php
60-
/**
61-
* @template TKey of array-key
62-
* @template TValue
63-
* @param array<TKey, TValue> $value
64-
* @return \Illuminate\Support\Collection<TKey, TValue>
65-
*/
66-
function collect($value = []) { ... }
67-
68-
/**
69-
* @template TValue
70-
* @param TValue $value
71-
* @param callable(TValue): TValue $callback
72-
* @return TValue
73-
*/
74-
function tap($value, $callback = null) { ... }
75-
```
76-
77-
After `$users = collect($userArray)`, the result is an unparameterised
78-
`Collection` — element type information is lost, and
79-
`$users->first()->` produces no completions.
80-
81-
This affects Laravel helpers (`collect`, `value`, `retry`, `tap`,
82-
`with`, `transform`, `data_get`), PHPStan/Psalm helper libraries, and
83-
any userland function using `@template`.
84-
85-
### Implementation plan
86-
87-
1. **Add fields to `FunctionInfo`** (in `types.rs`):
88-
89-
```text
90-
pub template_params: Vec<String>,
91-
pub template_bindings: Vec<(String, String)>,
92-
```
93-
94-
Mirror the existing `MethodInfo` fields.
95-
96-
2. **Populate during parsing** (in `parser/functions.rs`):
97-
98-
Extract `@template` tags via `extract_template_params` and
99-
`@param`-based bindings via `extract_template_param_bindings`,
100-
the same functions already used for method-level templates in
101-
`parser/classes.rs`.
102-
103-
3. **Resolve at call sites** (in `variable_resolution.rs`,
104-
`resolve_rhs_function_call`):
105-
106-
After loading the `FunctionInfo` and before falling through to
107-
`type_hint_to_classes`, check whether the function has
108-
`template_params`. If so:
109-
110-
a. For each template param, try to infer the concrete type from
111-
the call-site arguments using `template_bindings` (positional
112-
match between `$paramName` and the `ArgumentList`). Reuse
113-
the existing `resolve_rhs_expression` / `type_hint_to_classes`
114-
to get the argument's type.
115-
116-
b. Build a substitution map `{T => ConcreteType, ...}`.
117-
118-
c. Apply the substitution to `return_type` via
119-
`apply_substitution` before passing it to
120-
`type_hint_to_classes`.
121-
122-
This mirrors what `apply_generic_args` does for class-level
123-
templates, but applied to a function call.
124-
125-
4. **Text-based path** (in `text_resolution.rs`):
126-
127-
The inline chain resolver (`resolve_raw_type_from_call_chain`)
128-
also needs the same treatment for chains like
129-
`collect($arr)->first()->`. After resolving the function's
130-
return type, apply template substitution before continuing the
131-
chain.
132-
133-
**Note:** The existing `synthesize_template_conditional` path for
134-
`@return T` + `@param class-string<T>` should be kept as-is — it
135-
handles the special case where the return type is the template param
136-
itself, which is common for container-style `resolve()`/`app()`
137-
functions. The new general path handles the remaining cases.
138-
139-
See also: `todo-laravel.md` gap 5 (`collect()` and other helper
140-
functions lose generic type info), which is the Laravel-specific
141-
manifestation of this gap.
142-
143-
---
144-
14544
## 3. Parse and resolve `($param is T ? A : B)` return types
14645
**Impact: High · Effort: Medium**
14746

@@ -584,20 +483,6 @@ type:
584483

585484
---
586485

587-
## 18. `@phpstan-assert` on static method calls
588-
**Impact: Medium · Effort: Medium**
589-
590-
`@phpstan-assert` type guards only work on standalone function calls
591-
today. When the assertion is on a static method (e.g.
592-
`Assert::instanceOf($value, Foo::class)`), the narrowing does not
593-
apply to the calling scope.
594-
595-
The same limitation applies to `@phpstan-assert-if-true` and
596-
`@phpstan-assert-if-false` on static method calls.
597-
598-
**Discovered via:** fixture conversion (phpstan_assert_static).
599-
600-
---
601486

602487
## 19. Negated `@phpstan-assert !Type`
603488
**Impact: Medium · Effort: Low-Medium**

example.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,34 @@ public function demo(): void
228228
}
229229

230230

231+
// ── Static Method Assert Narrowing ─────────────────────────────────────────
232+
233+
class StaticAssertNarrowingDemo
234+
{
235+
public function demo(): void
236+
{
237+
// @phpstan-assert on static method — unconditional narrowing
238+
$unknown = getUnknownValue();
239+
StaticAssert::assertRock($unknown);
240+
$unknown->crush(); // narrowed to Rock
241+
242+
// @phpstan-assert-if-true on static method — narrows in then-branch
243+
$sample = pickRockOrBanana();
244+
if (StaticAssert::isRock($sample)) {
245+
$sample->crush(); // narrowed to Rock
246+
}
247+
248+
// @phpstan-assert-if-false on static method — narrows in else-branch
249+
$maybe = pickRockOrBanana();
250+
if (StaticAssert::isNotRock($maybe)) {
251+
$maybe->peel(); // narrowed to Banana
252+
} else {
253+
$maybe->crush(); // narrowed to Rock
254+
}
255+
}
256+
}
257+
258+
231259
// ── Guard Clause Narrowing (Early Return / Throw) ──────────────────────────
232260

233261
class GuardClauseDemo
@@ -363,6 +391,16 @@ public function demo(): void
363391
$mapped->first(); // → Pen (T resolved from argument)
364392

365393
$mapper->wrap(new Product())->first()->getPrice(); // new expression arg → Product
394+
395+
// Variadic class-string<T> → union return type
396+
$locator2 = new ServiceLocator();
397+
$union = $locator2->getAny(Pen::class, Marker::class);
398+
$union->write(); // A|B from variadic class-string<T>
399+
$union->highlight();
400+
401+
// Nested generic return: @return Box<T> with class-string<T> param
402+
$boxed = $locator->wrap(Pen::class);
403+
$boxed->unwrap()->write(); // Box<T>::unwrap() → Pen
366404
}
367405
}
368406

@@ -2819,6 +2857,26 @@ public function get(string $id): object
28192857
{
28202858
return new \stdClass();
28212859
}
2860+
2861+
/**
2862+
* @template T
2863+
* @param class-string<T> ...$ids
2864+
* @return T
2865+
*/
2866+
public function getAny(string ...$ids): object
2867+
{
2868+
return new \stdClass();
2869+
}
2870+
2871+
/**
2872+
* @template T
2873+
* @param class-string<T> $id
2874+
* @return Box<T>
2875+
*/
2876+
public function wrap(string $id): Box
2877+
{
2878+
return new Box();
2879+
}
28222880
}
28232881

28242882
class Factory
@@ -3095,6 +3153,29 @@ function isNotRock(mixed $value): bool
30953153
return !$value instanceof Rock;
30963154
}
30973155

3156+
class StaticAssert
3157+
{
3158+
/** @phpstan-assert Rock $value */
3159+
public static function assertRock(mixed $value): void
3160+
{
3161+
if (!$value instanceof Rock) {
3162+
throw new \InvalidArgumentException('Expected Rock');
3163+
}
3164+
}
3165+
3166+
/** @phpstan-assert-if-true Rock $value */
3167+
public static function isRock(mixed $value): bool
3168+
{
3169+
return $value instanceof Rock;
3170+
}
3171+
3172+
/** @phpstan-assert-if-false Rock $value */
3173+
public static function isNotRock(mixed $value): bool
3174+
{
3175+
return !$value instanceof Rock;
3176+
}
3177+
}
3178+
30983179
// ─── Multi-line @return & Broken Docblock Recovery ──────────────────────────
30993180

31003181
/**

src/completion/call_resolution.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,10 @@ impl Backend {
792792
/// - `$this` / `self` / `static` → current class name
793793
/// - `$this->prop` → property type
794794
/// - `$var` → variable type via assignment scanning
795-
fn resolve_arg_text_to_type(arg_text: &str, ctx: &ResolutionCtx<'_>) -> Option<String> {
795+
pub(crate) fn resolve_arg_text_to_type(
796+
arg_text: &str,
797+
ctx: &ResolutionCtx<'_>,
798+
) -> Option<String> {
796799
let trimmed = arg_text.trim();
797800

798801
// ClassName::class → ClassName

0 commit comments

Comments
 (0)