diff --git a/CLAUDE.md b/CLAUDE.md index f93091704c..a5849a4edc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -399,10 +399,6 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines - Use imperative voice without "Returns the..." preambles when a brief note suffices. Prefer `/** Replaces unresolved TemplateTypes with their bounds. */` over a multi-line block. - Preserve `@api` and type tags on their own lines, with no redundant description alongside them. -### UnionTypeMethodReflection and IntersectionTypeMethodReflection parity - -When methods are called on union types (`Foo|Bar`), the resolved method reflection is a `UnionTypeMethodReflection` that wraps the individual method reflections. Similarly, `IntersectionTypeMethodReflection` handles intersection types. These two classes must maintain feature parity for things like `getAsserts()`, `getSelfOutType()`, etc. When one class correctly combines member data (e.g. `IntersectionTypeMethodReflection::getAsserts()` iterating over methods and calling `intersectWith()`), the other should do the same rather than returning empty/null. The `Assertions::intersectWith()` method merges assertion tag lists from multiple sources. - ## Important dependencies - `nikic/php-parser` ^5.7.0 - PHP AST parsing diff --git a/src/Reflection/Assertions.php b/src/Reflection/Assertions.php index 30f9106b77..208b1f4f82 100644 --- a/src/Reflection/Assertions.php +++ b/src/Reflection/Assertions.php @@ -5,10 +5,12 @@ use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\Tag\AssertTag; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_filter; use function array_map; use function array_merge; use function count; +use function sprintf; /** * Collection of @phpstan-assert annotations on a function or method. @@ -91,12 +93,76 @@ public function mapTypes(callable $callable): self { $assertTagsCallback = static fn (AssertTag $tag): AssertTag => $tag->withType($callable($tag->getType())); - return new self(array_map($assertTagsCallback, $this->asserts)); + return self::create(array_map($assertTagsCallback, $this->asserts)); } + /** + * @deprecated use union() or intersect() instead + */ public function intersectWith(Assertions $other): self { - return new self(array_merge($this->getAll(), $other->getAll())); + return $this->union($other); + } + + public function union(Assertions $other): self + { + if ($this === self::$empty) { + return $other; + } + if ($other === self::$empty) { + return $this; + } + + return self::create(array_merge($this->getAll(), $other->getAll())); + } + + public function intersect(Assertions $other): self + { + if ($this === self::$empty) { + return $other; + } + if ($other === self::$empty) { + return $this; + } + + $otherAsserts = $other->getAll(); + $thisAsserts = $this->getAll(); + + $merged = []; + foreach ($thisAsserts as $thisAssert) { + $key = self::getAssertKey($thisAssert); + + foreach ($otherAsserts as $otherAssert) { + if (self::getAssertKey($otherAssert) !== $key) { + continue; + } + + $merged[] = $thisAssert->withType(TypeCombinator::union($thisAssert->getType(), $otherAssert->getType())); + } + } + + return self::create($merged); + } + + private static function getAssertKey(AssertTag $assert): string + { + return sprintf( + '%s-%s-%s', + $assert->getParameter()->describe(), + $assert->getIf(), + $assert->isNegated() ? '1' : '0', + ); + } + + /** + * @param AssertTag[] $asserts + */ + private static function create(array $asserts): self + { + if (count($asserts) === 0) { + return self::createEmpty(); + } + return new self($asserts); } public static function createEmpty(): self @@ -116,11 +182,8 @@ public static function createEmpty(): self public static function createFromResolvedPhpDocBlock(ResolvedPhpDocBlock $phpDocBlock): self { $tags = $phpDocBlock->getAssertTags(); - if (count($tags) === 0) { - return self::createEmpty(); - } - return new self($tags); + return self::create($tags); } } diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index b1b52f3d33..b31124c17a 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -198,7 +198,7 @@ public function getAsserts(): Assertions $assertions = Assertions::createEmpty(); foreach ($this->methods as $method) { - $assertions = $assertions->intersectWith($method->getAsserts()); + $assertions = $assertions->union($method->getAsserts()); } return $assertions; diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 135fcfc39d..89557b4e2c 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -181,7 +181,7 @@ public function getAsserts(): Assertions $assertions = Assertions::createEmpty(); foreach ($this->methods as $method) { - $assertions = $assertions->intersectWith($method->getAsserts()); + $assertions = $assertions->intersect($method->getAsserts()); } return $assertions; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14108.php b/tests/PHPStan/Analyser/nsrt/bug-14108.php new file mode 100644 index 0000000000..af7667bf16 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14108.php @@ -0,0 +1,52 @@ += 8.0 + +namespace Bug14108; + +use function PHPStan\Testing\assertType; + +class Foo +{ + public function __construct(private ?string $param) + { + } + + public function getParam(): ?string + { + return $this->param; + } + + /** + * @phpstan-assert string $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +class Bar +{ + public function __construct(private ?int $param) + { + } + + public function getParam(): ?int + { + return $this->param; + } + + /** + * @phpstan-assert int $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +function test(Foo|Bar $fooOrBar): void +{ + assertType('int|string|null', $fooOrBar->getParam()); + + $fooOrBar->narrowGetParam(); + + assertType('int|string', $fooOrBar->getParam()); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14108b.php b/tests/PHPStan/Analyser/nsrt/bug-14108b.php new file mode 100644 index 0000000000..738fcdc5cb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14108b.php @@ -0,0 +1,52 @@ += 8.0 + +namespace Bug14108b; + +use function PHPStan\Testing\assertType; + +class Foo +{ + public function __construct(private ?string $param) + { + } + + public function getParam(): ?string + { + return $this->param; + } + + /** + * @phpstan-assert !null $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +class Bar +{ + public function __construct(private ?int $param) + { + } + + public function getParam(): ?int + { + return $this->param; + } + + /** + * @phpstan-assert int $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +function test(Foo|Bar $fooOrBar): void +{ + assertType('int|string|null', $fooOrBar->getParam()); + + $fooOrBar->narrowGetParam(); + + assertType('int|string|null', $fooOrBar->getParam()); // could be 'int|string' +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14108c.php b/tests/PHPStan/Analyser/nsrt/bug-14108c.php new file mode 100644 index 0000000000..f95876d99b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14108c.php @@ -0,0 +1,52 @@ += 8.0 + +namespace Bug14108c; + +use function PHPStan\Testing\assertType; + +class Foo +{ + public function __construct(private ?string $param) + { + } + + public function getParam(): ?string + { + return $this->param; + } + + /** + * @phpstan-assert =string $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +class Bar +{ + public function __construct(private ?int $param) + { + } + + public function getParam(): ?int + { + return $this->param; + } + + /** + * @phpstan-assert int $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +function test(Foo|Bar $fooOrBar): void +{ + assertType('int|string|null', $fooOrBar->getParam()); + + $fooOrBar->narrowGetParam(); + + assertType('int|string', $fooOrBar->getParam()); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14108d.php b/tests/PHPStan/Analyser/nsrt/bug-14108d.php new file mode 100644 index 0000000000..7a04985253 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14108d.php @@ -0,0 +1,59 @@ += 8.0 + +namespace Bug14108d; + +use function PHPStan\Testing\assertType; + +class Foo +{ + public function __construct(private ?string $param) + { + } + + public function getParam(): ?string + { + return $this->param; + } + + /** + * @phpstan-assert-if-true string $this->getParam() + */ + public function narrowGetParam(): bool + { + } +} + +class Bar +{ + public function __construct(private ?int $param) + { + } + + public function getParam(): ?int + { + return $this->param; + } + + /** + * @phpstan-assert int $this->getParam() + */ + public function narrowGetParam(): void + { + } +} + +function test(Foo|Bar $fooOrBar): void +{ + assertType('int|string|null', $fooOrBar->getParam()); + + $fooOrBar->narrowGetParam(); + + assertType('int|string|null', $fooOrBar->getParam()); + + if ($fooOrBar->narrowGetParam()) { + assertType('int|string|null', $fooOrBar->getParam()); // could be 'int|string' + } else { + assertType('int|string|null', $fooOrBar->getParam()); + } + assertType('int|string|null', $fooOrBar->getParam()); +}