diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index b31124c17a..794993a4f6 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -11,11 +11,13 @@ use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_map; +use function array_merge; use function count; use function implode; use function is_bool; @@ -80,20 +82,26 @@ public function getPrototype(): ClassMemberReflection public function getVariants(): array { + $allVariants = array_merge(...array_map( + static fn (MethodReflection $method) => $method->getVariants(), + $this->methods, + )); + $combined = ParametersAcceptorSelector::combineAcceptors($allVariants); + $returnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getReturnType(), $method->getVariants())), $this->methods)); $phpDocReturnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getPhpDocReturnType(), $method->getVariants())), $this->methods)); $nativeReturnType = TypeCombinator::intersect(...array_map(static fn (MethodReflection $method): Type => TypeCombinator::intersect(...array_map(static fn (ParametersAcceptor $acceptor): Type => $acceptor->getNativeReturnType(), $method->getVariants())), $this->methods)); - return array_map(static fn (ExtendedParametersAcceptor $acceptor): ExtendedParametersAcceptor => new ExtendedFunctionVariant( - $acceptor->getTemplateTypeMap(), - $acceptor->getResolvedTemplateTypeMap(), - $acceptor->getParameters(), - $acceptor->isVariadic(), + return [new ExtendedFunctionVariant( + $combined->getTemplateTypeMap(), + $combined->getResolvedTemplateTypeMap(), + $combined->getParameters(), + $combined->isVariadic(), $returnType, $phpDocReturnType, $nativeReturnType, - $acceptor->getCallSiteVarianceMap(), - ), $this->methods[0]->getVariants()); + $combined->getCallSiteVarianceMap(), + )]; } public function getOnlyVariant(): ExtendedParametersAcceptor @@ -211,7 +219,20 @@ public function acceptsNamedArguments(): TrinaryLogic public function getSelfOutType(): ?Type { - return null; + $types = []; + foreach ($this->methods as $method) { + $selfOutType = $method->getSelfOutType(); + if ($selfOutType === null) { + return null; + } + $types[] = $selfOutType; + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::intersect(...$types); } public function returnsByReference(): TrinaryLogic @@ -226,7 +247,19 @@ public function isAbstract(): TrinaryLogic public function getAttributes(): array { - return $this->methods[0]->getAttributes(); + $result = []; + $seen = []; + foreach ($this->methods as $method) { + foreach ($method->getAttributes() as $attribute) { + if (isset($seen[$attribute->getName()])) { + continue; + } + $seen[$attribute->getName()] = true; + $result[] = $attribute; + } + } + + return $result; } public function mustUseReturnValue(): TrinaryLogic @@ -236,7 +269,7 @@ public function mustUseReturnValue(): TrinaryLogic public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock { - return $this->methods[0]->getResolvedPhpDoc(); + return null; } } diff --git a/src/Reflection/Type/IntersectionTypePropertyReflection.php b/src/Reflection/Type/IntersectionTypePropertyReflection.php index b85dfda0d8..b0bc336d67 100644 --- a/src/Reflection/Type/IntersectionTypePropertyReflection.php +++ b/src/Reflection/Type/IntersectionTypePropertyReflection.php @@ -202,7 +202,19 @@ public function isPrivateSet(): bool public function getAttributes(): array { - return $this->properties[0]->getAttributes(); + $result = []; + $seen = []; + foreach ($this->properties as $property) { + foreach ($property->getAttributes() as $attribute) { + if (isset($seen[$attribute->getName()])) { + continue; + } + $seen[$attribute->getName()] = true; + $result[] = $attribute; + } + } + + return $result; } public function isDummy(): TrinaryLogic diff --git a/src/Reflection/Type/UnionTypeMethodReflection.php b/src/Reflection/Type/UnionTypeMethodReflection.php index 89557b4e2c..ba0b5cefcc 100644 --- a/src/Reflection/Type/UnionTypeMethodReflection.php +++ b/src/Reflection/Type/UnionTypeMethodReflection.php @@ -4,17 +4,22 @@ use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\Assertions; +use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassMemberReflection; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Reflection\Php\ExtendedDummyParameter; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_filter; use function array_map; use function array_merge; +use function array_values; use function count; use function implode; use function is_bool; @@ -22,6 +27,9 @@ final class UnionTypeMethodReflection implements ExtendedMethodReflection { + /** @var list|null */ + private ?array $cachedVariants = null; + /** * @param ExtendedMethodReflection[] $methods */ @@ -79,9 +87,82 @@ public function getPrototype(): ClassMemberReflection public function getVariants(): array { - $variants = array_merge(...array_map(static fn (MethodReflection $method) => $method->getVariants(), $this->methods)); + if ($this->cachedVariants !== null) { + return $this->cachedVariants; + } + + $allVariants = array_merge(...array_map(static fn (MethodReflection $method) => $method->getVariants(), $this->methods)); + $combined = ParametersAcceptorSelector::combineAcceptors($allVariants); + + // Fast path: when all methods come from the same class (e.g. enum cases, + // or multiple subtypes of the same base), params are identical — skip + // the expensive per-parameter intersection. + $declaringClasses = []; + foreach ($this->methods as $method) { + $declaringClasses[$method->getDeclaringClass()->getName()] = true; + } + + if (count($declaringClasses) <= 1) { + return $this->cachedVariants = [$combined]; + } - return [ParametersAcceptorSelector::combineAcceptors($variants)]; + // combineAcceptors unions parameter types, but for union types we need + // to intersect them: the argument must be valid for ALL possible methods + // since we don't know which runtime type the object is. + $intersectedParams = []; + foreach ($combined->getParameters() as $i => $param) { + $types = []; + $nativeTypes = []; + $phpDocTypes = []; + foreach ($this->methods as $method) { + $variantTypes = []; + $variantNativeTypes = []; + $variantPhpDocTypes = []; + foreach ($method->getVariants() as $variant) { + $variantParams = $variant->getParameters(); + if (!isset($variantParams[$i])) { + continue; + } + $variantTypes[] = $variantParams[$i]->getType(); + $variantNativeTypes[] = $variantParams[$i]->getNativeType(); + $variantPhpDocTypes[] = $variantParams[$i]->getPhpDocType(); + } + if ($variantTypes !== []) { + $types[] = count($variantTypes) === 1 ? $variantTypes[0] : TypeCombinator::union(...$variantTypes); + } + if ($variantNativeTypes !== []) { + $nativeTypes[] = count($variantNativeTypes) === 1 ? $variantNativeTypes[0] : TypeCombinator::union(...$variantNativeTypes); + } + if ($variantPhpDocTypes !== []) { + $phpDocTypes[] = count($variantPhpDocTypes) === 1 ? $variantPhpDocTypes[0] : TypeCombinator::union(...$variantPhpDocTypes); + } + } + + $intersectedParams[] = new ExtendedDummyParameter( + $param->getName(), + count($types) > 1 ? TypeCombinator::intersect(...$types) : ($types[0] ?? $param->getType()), + $param->isOptional(), + $param->passedByReference(), + $param->isVariadic(), + $param->getDefaultValue(), + count($nativeTypes) > 1 ? TypeCombinator::intersect(...$nativeTypes) : ($nativeTypes[0] ?? $param->getNativeType()), + count($phpDocTypes) > 1 ? TypeCombinator::intersect(...$phpDocTypes) : ($phpDocTypes[0] ?? $param->getPhpDocType()), + $param->getOutType(), + $param->isImmediatelyInvokedCallable(), + $param->getClosureThisType(), + $param->getAttributes(), + ); + } + + return $this->cachedVariants = [new ExtendedFunctionVariant( + $combined->getTemplateTypeMap(), + $combined->getResolvedTemplateTypeMap(), + $intersectedParams, + $combined->isVariadic(), + $combined->getReturnType(), + $combined->getPhpDocReturnType(), + $combined->getNativeReturnType(), + )]; } public function getOnlyVariant(): ExtendedParametersAcceptor @@ -194,7 +275,20 @@ public function acceptsNamedArguments(): TrinaryLogic public function getSelfOutType(): ?Type { - return null; + $types = []; + foreach ($this->methods as $method) { + $selfOutType = $method->getSelfOutType(); + if ($selfOutType === null) { + return null; + } + $types[] = $selfOutType; + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); } public function returnsByReference(): TrinaryLogic @@ -209,7 +303,21 @@ public function isAbstract(): TrinaryLogic public function getAttributes(): array { - return $this->methods[0]->getAttributes(); + $result = null; + foreach ($this->methods as $method) { + $methodAttributes = $method->getAttributes(); + if ($result === null) { + $result = $methodAttributes; + continue; + } + $methodAttributeNames = []; + foreach ($methodAttributes as $attribute) { + $methodAttributeNames[$attribute->getName()] = true; + } + $result = array_filter($result, static fn (AttributeReflection $a) => isset($methodAttributeNames[$a->getName()])); + } + + return array_values($result ?? []); } public function mustUseReturnValue(): TrinaryLogic @@ -219,7 +327,7 @@ public function mustUseReturnValue(): TrinaryLogic public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock { - return $this->methods[0]->getResolvedPhpDoc(); + return null; } } diff --git a/src/Reflection/Type/UnionTypePropertyReflection.php b/src/Reflection/Type/UnionTypePropertyReflection.php index a02751d0d4..9af5d94461 100644 --- a/src/Reflection/Type/UnionTypePropertyReflection.php +++ b/src/Reflection/Type/UnionTypePropertyReflection.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection\Type; +use PHPStan\Reflection\AttributeReflection; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; @@ -9,7 +10,9 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_filter; use function array_map; +use function array_values; use function count; use function implode; @@ -202,7 +205,21 @@ public function isPrivateSet(): bool public function getAttributes(): array { - return $this->properties[0]->getAttributes(); + $result = null; + foreach ($this->properties as $property) { + $propertyAttributes = $property->getAttributes(); + if ($result === null) { + $result = $propertyAttributes; + continue; + } + $propertyAttributeNames = []; + foreach ($propertyAttributes as $attribute) { + $propertyAttributeNames[$attribute->getName()] = true; + } + $result = array_filter($result, static fn (AttributeReflection $a) => isset($propertyAttributeNames[$a->getName()])); + } + + return array_values($result ?? []); } public function isDummy(): TrinaryLogic diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 0ab14fd52a..6d4038b422 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3853,4 +3853,50 @@ public function testBug13805(): void $this->analyse([__DIR__ . '/data/bug-13805.php'], []); } + public function testUnionIntersectionMethodVariants(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/union-intersection-method-variants.php'], [ + [ + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsInt::process() expects int|string, true given.', + 38, + ], + [ + 'Method UnionIntersectionMethodVariants\TwoParams::transform() invoked with 0 parameters, 1-2 required.', + 52, + ], + [ + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsString::process() expects string, null given.', + 68, + ], + [ + 'Parameter #1 $x of method UnionIntersectionMethodVariants\AcceptsString::process() expects string, int given.', + 69, + ], + [ + 'Method UnionIntersectionMethodVariants\TwoParams::transform() invoked with 0 parameters, 1-2 required.', + 96, + ], + ]); + } + + public function testBug9664(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-9664.php'], [ + [ + 'Parameter #1 $foo of method Bug9664\Entity1::setFoo() expects string, null given.', + 17, + ], + [ + 'Parameter #1 $foo of method Bug9664\Entity1::setFoo() expects string, null given.', + 22, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-9664.php b/tests/PHPStan/Rules/Methods/data/bug-9664.php new file mode 100644 index 0000000000..16b874d096 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9664.php @@ -0,0 +1,28 @@ +setFoo(null); // Should error: Entity1::setFoo() does not accept null +} + +function foo1(Entity1 $entity): void +{ + $entity->setFoo(null); // Error +} + +function foo2(Entity2 $entity): void +{ + $entity->setFoo(null); // OK +} diff --git a/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php b/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php new file mode 100644 index 0000000000..bddebf98a2 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/union-intersection-method-variants.php @@ -0,0 +1,98 @@ +process(42); // OK - int satisfies AcceptsInt + $obj->process('hello'); // OK - string satisfies AcceptsString + $obj->process(true); // ERROR - bool doesn't satisfy either + } + + /** + * Intersection with different param counts. + * TwoParams needs (int, string), OneParam needs (int). + * Implementation must handle both, so it has (int $x, string $y = optional). + * + * @param TwoParams&OneParam $obj + */ + public function testIntersectionParamCount($obj): void + { + $obj->transform(42, 'hello'); // OK - satisfies TwoParams + $obj->transform(42); // OK - satisfies OneParam + $obj->transform(); // ERROR - both require at least 1 param + } +} + +class UnionTests +{ + /** + * Union with overlapping types: string vs ?string. + * Intersected param type: string & (string|null) = string. + * This is the phpstan/phpstan#9664 scenario. + * + * @param AcceptsString|AcceptsNullableString $obj + */ + public function testUnionOverlappingParams($obj): void + { + $obj->process('hello'); // OK - string accepted by both + $obj->process(null); // ERROR - null not accepted by AcceptsString + $obj->process(42); // ERROR - int not accepted by either + } + + /** + * Union with completely disjoint types: int vs string. + * Intersected param type: int & string = never. + * NeverType::accepts() returns yes (bottom type semantics: + * unreachable code, so no parameter errors are reported). + * + * @param AcceptsInt|AcceptsString $obj + */ + public function testUnionDisjointParams($obj): void + { + $obj->process(42); // no error (never accepts everything) + $obj->process('hello'); // no error (never accepts everything) + $obj->process(true); // no error (never accepts everything) + } + + /** + * Union with different param counts. + * + * @param TwoParams|OneParam $obj + */ + public function testUnionParamCount($obj): void + { + $obj->transform(42, 'hello'); // OK + $obj->transform(42); // OK - OneParam only needs 1 + $obj->transform(); // ERROR - both need at least 1 + } +}