diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 12d3dcec1ff..d7f3caee4f4 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -29,10 +29,12 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\ConditionalType; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; +use PHPStan\Type\LateResolvableType; use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; @@ -279,11 +281,11 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->implementsTags = $this->getImplementsTags(); $result->usesTags = $this->getUsesTags(); $result->paramTags = self::mergeParamTags($this->getParamTags(), $parent, $parameterMapping, $parentClass); - $result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parent, $parameterMapping, $parentClass); + $result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parent, $parameterMapping, $parentClass, $result->paramTags); $result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parent, $parameterMapping); $result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping); - $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass); - $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass); + $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass, $result->paramTags); + $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass, $result->paramTags); $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent); $result->mixinTags = $this->getMixinTags(); $result->requireExtendsTags = $this->getRequireExtendsTags(); @@ -291,7 +293,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->sealedTypeTags = $this->getSealedTags(); $result->typeAliasTags = $this->getTypeAliasTags(); $result->typeAliasImportTags = $this->getTypeAliasImportTags(); - $result->assertTags = self::mergeAssertTags($this->getAssertTags(), $parent, $parameterMapping, $parentClass); + $result->assertTags = self::mergeAssertTags($this->getAssertTags(), $parent, $parameterMapping, $parentClass, $result->paramTags); $result->selfOutTypeTag = self::mergeSelfOutTypeTags($this->getSelfOutTag(), $parent); $result->deprecatedTag = self::mergeDeprecatedTags($this->getDeprecatedTag(), $this->isNotDeprecated(), $parent); $result->isDeprecated = $result->deprecatedTag !== null; @@ -929,11 +931,13 @@ private static function mergeOneParentParamTags(array $paramTags, self $parent, { $parentParamTags = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamTags()); + $inheritedNames = []; foreach ($parentParamTags as $name => $parentParamTag) { if (array_key_exists($name, $paramTags)) { continue; } + $inheritedNames[] = $name; $paramTags[$name] = self::resolveTemplateTypeInTag( $parentParamTag->withType($parameterMapping->transformConditionalReturnTypeWithParameterNameMapping($parentParamTag->getType())), $parentClass, @@ -941,19 +945,31 @@ private static function mergeOneParentParamTags(array $paramTags, self $parent, ); } + // conditional types referencing a parameter can be simplified only once all the parameter types are known + foreach ($inheritedNames as $name) { + $paramTag = $paramTags[$name]; + $paramTags[$name] = $paramTag->withType(self::simplifyConditionalTypes($paramTag->getType(), $paramTags)); + } + return $paramTags; } - private static function mergeReturnTags(?ReturnTag $returnTag, ClassReflection $classReflection, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): ?ReturnTag + /** + * @param array $mergedParamTags + */ + private static function mergeReturnTags(?ReturnTag $returnTag, ClassReflection $classReflection, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): ?ReturnTag { if ($returnTag !== null) { return $returnTag; } - return self::mergeOneParentReturnTag($returnTag, $classReflection, $parent, $parameterMapping, $parentClass); + return self::mergeOneParentReturnTag($returnTag, $classReflection, $parent, $parameterMapping, $parentClass, $mergedParamTags); } - private static function mergeOneParentReturnTag(?ReturnTag $returnTag, ClassReflection $classReflection, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): ?ReturnTag + /** + * @param array $mergedParamTags + */ + private static function mergeOneParentReturnTag(?ReturnTag $returnTag, ClassReflection $classReflection, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): ?ReturnTag { $parentReturnTag = $parent->getReturnTag(); if ($parentReturnTag === null) { @@ -986,14 +1002,16 @@ static function (Type $type, callable $traverse) use ($classReflection): Type { )->toImplicit(), $parentClass, TemplateTypeVariance::createCovariant(), + $mergedParamTags, ); } /** * @param array $assertTags + * @param array $mergedParamTags * @return array */ - private static function mergeAssertTags(array $assertTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): array + private static function mergeAssertTags(array $assertTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): array { if (count($assertTags) > 0) { return $assertTags; @@ -1006,6 +1024,7 @@ private static function mergeAssertTags(array $assertTags, self $parent, Inherit )->toImplicit(), $parentClass, TemplateTypeVariance::createCovariant(), + $mergedParamTags, ), $parent->getAssertTags(), ); @@ -1049,18 +1068,20 @@ private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent): ?T /** * @param array $paramOutTags + * @param array $mergedParamTags * @return array */ - private static function mergeParamOutTags(array $paramOutTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): array + private static function mergeParamOutTags(array $paramOutTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): array { - return self::mergeOneParentParamOutTags($paramOutTags, $parent, $parameterMapping, $parentClass); + return self::mergeOneParentParamOutTags($paramOutTags, $parent, $parameterMapping, $parentClass, $mergedParamTags); } /** * @param array $paramOutTags + * @param array $mergedParamTags * @return array */ - private static function mergeOneParentParamOutTags(array $paramOutTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): array + private static function mergeOneParentParamOutTags(array $paramOutTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): array { $parentParamOutTags = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamOutTags()); @@ -1073,6 +1094,7 @@ private static function mergeOneParentParamOutTags(array $paramOutTags, self $pa $parentParamTag->withType($parameterMapping->transformConditionalReturnTypeWithParameterNameMapping($parentParamTag->getType())), $parentClass, TemplateTypeVariance::createCovariant(), + $mergedParamTags, ); } @@ -1137,18 +1159,20 @@ private static function mergeOneParentParamPureUnlessCallableIsImpure(array $par /** * @param array $paramsClosureThisTags + * @param array $mergedParamTags * @return array */ - private static function mergeParamClosureThisTags(array $paramsClosureThisTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): array + private static function mergeParamClosureThisTags(array $paramsClosureThisTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): array { - return self::mergeOneParentParamClosureThisTag($paramsClosureThisTags, $parent, $parameterMapping, $parentClass); + return self::mergeOneParentParamClosureThisTag($paramsClosureThisTags, $parent, $parameterMapping, $parentClass, $mergedParamTags); } /** * @param array $paramsClosureThisTags + * @param array $mergedParamTags * @return array */ - private static function mergeOneParentParamClosureThisTag(array $paramsClosureThisTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass): array + private static function mergeOneParentParamClosureThisTag(array $paramsClosureThisTags, self $parent, InheritedPhpDocParameterMapping $parameterMapping, ClassReflection $parentClass, array $mergedParamTags): array { $parentClosureThisTags = $parameterMapping->transformArrayKeysWithParameterNameMapping($parent->getParamClosureThisTags()); @@ -1163,6 +1187,7 @@ private static function mergeOneParentParamClosureThisTag(array $paramsClosureTh ), $parentClass, TemplateTypeVariance::createContravariant(), + $mergedParamTags, ); } @@ -1181,12 +1206,14 @@ private static function mergePureTags(?bool $isPure, self $parent): ?bool /** * @template T of TypedTag * @param T $tag + * @param array $mergedParamTags * @return T */ private static function resolveTemplateTypeInTag( TypedTag $tag, ClassReflection $classReflection, TemplateTypeVariance $positionVariance, + array $mergedParamTags = [], ): TypedTag { $type = TemplateTypeHelper::resolveTemplateTypes( @@ -1195,7 +1222,80 @@ private static function resolveTemplateTypeInTag( $classReflection->getCallSiteVarianceMap(), $positionVariance, ); + $type = self::simplifyConditionalTypes($type, $mergedParamTags); + return $tag->withType($type); } + /** + * A conditional type inherited from a parent can become decided once the parent's template types + * are substituted with the arguments from the extends/implements/uses tag. Keeping the undecided + * conditional type around would make rules complain about PHPDoc the subclass does not even have. + * + * @param array $mergedParamTags + */ + private static function simplifyConditionalTypes(Type $type, array $mergedParamTags): Type + { + if (!$type->hasTemplateOrLateResolvableType()) { + return $type; + } + + return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($mergedParamTags): Type { + if ($type instanceof ConditionalType) { + // the late static bound type is known only at the call site + if (self::containsStaticType($type->getSubject())) { + return $traverse($type); + } + + if (!$type->isResolvable()) { + return $traverse($type); + } + + return $traverse($type->resolve()); + } + + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (!array_key_exists($parameterName, $mergedParamTags)) { + return $traverse($type); + } + + $parameterType = $mergedParamTags[$parameterName]->getType(); + if (self::containsStaticType($parameterType)) { + return $traverse($type); + } + + // the argument type at the call site is always a subtype of the parameter type, + // so only a condition already decided by the parameter type stays decided there + $isSuperType = $type->getTarget()->isSuperTypeOf($parameterType); + if (!$isSuperType->yes() && !$isSuperType->no()) { + return $traverse($type); + } + + $conditionalType = $type->toConditional($parameterType); + if (!$conditionalType instanceof LateResolvableType) { + return $traverse($type); + } + + return $traverse($conditionalType->resolve()); + } + + return $traverse($type); + }); + } + + private static function containsStaticType(Type $type): bool + { + $containsStaticType = false; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$containsStaticType): Type { + if ($type instanceof StaticType) { + $containsStaticType = true; + } + + return $containsStaticType ? $type : $traverse($type); + }); + + return $containsStaticType; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15128.php b/tests/PHPStan/Analyser/nsrt/bug-15128.php new file mode 100644 index 00000000000..5c504a0996b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15128.php @@ -0,0 +1,121 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15128; + +use Override; +use function PHPStan\Testing\assertType; + +/** + * @template T of int|string + */ +class A +{ + + /** + * @param T $val + */ + public function __construct( + protected int|string $val, + ) + { + } + + /** + * @return (T is int ? string : int) + */ + public function foo(): int|string + { + return is_string($this->val) ? intval($this->val) : (string) $this->val; + } + + /** + * @param T $val + * @return ($val is int ? string : int) + */ + public function bar($val) + { + throw new \Exception(); + } + +} + +/** + * @extends A + */ +class B extends A +{ + + #[Override] + public function foo(): int + { + return intval($this->val); + } + + #[Override] + public function bar($val) + { + throw new \Exception(); + } + +} + +/** + * @extends A + */ +class C extends A +{ + + #[Override] + public function foo(): int|string + { + return is_string($this->val) ? intval($this->val) : (string) $this->val; + } + + #[Override] + public function bar($val) + { + throw new \Exception(); + } + +} + +/** + * @template U of int|string + * @extends A + */ +class D extends A +{ + + #[Override] + public function foo(): int|string + { + return is_string($this->val) ? intval($this->val) : (string) $this->val; + } + + #[Override] + public function bar($val) + { + throw new \Exception(); + } + +} + +/** + * @param B $b + * @param C $c + * @param D $d + */ +function test($b, $c, $d): void +{ + assertType('int', $b->foo()); + assertType('int', $b->bar('foo')); + + assertType('int|string', $c->foo()); + assertType('int', $c->bar('foo')); + assertType('string', $c->bar(1)); + + assertType('int', $d->foo()); + assertType('int', $d->bar('foo')); +} diff --git a/tests/PHPStan/Analyser/nsrt/conditional-type-inherited-static-subject.php b/tests/PHPStan/Analyser/nsrt/conditional-type-inherited-static-subject.php new file mode 100644 index 00000000000..7ec25344b2e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-type-inherited-static-subject.php @@ -0,0 +1,67 @@ +foo()); + assertType('int', $b->foo()); + assertType('int', $c->foo()); + assertType('int|string', $notMarked->foo()); + assertType('int', $markedLater->foo()); +} diff --git a/tests/PHPStan/Rules/PhpDoc/MethodConditionalReturnTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/MethodConditionalReturnTypeRuleTest.php index 5a86e06f2df..d3907bc7aec 100644 --- a/tests/PHPStan/Rules/PhpDoc/MethodConditionalReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/MethodConditionalReturnTypeRuleTest.php @@ -112,4 +112,16 @@ public function testBug10938(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-10938.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug15128(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-15128.php'], []); + } + + #[RequiresPhp('>= 8.0')] + public function testConditionalReturnTypeInheritance(): void + { + $this->analyse([__DIR__ . '/data/conditional-return-type-inheritance.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/conditional-return-type-inheritance.php b/tests/PHPStan/Rules/PhpDoc/data/conditional-return-type-inheritance.php new file mode 100644 index 00000000000..78c3313f692 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/conditional-return-type-inheritance.php @@ -0,0 +1,146 @@ += 8.0 + +declare(strict_types = 1); + +namespace ConditionalReturnTypeInheritance; + +/** + * @template T of int|string + */ +interface Iface +{ + + /** + * @param T $val + * @return (T is int ? string : int) + */ + public function fromTemplate($val); + + /** + * @param T $val + * @return ($val is int ? string : int) + */ + public function fromParameter($val); + + /** + * @param T $val + * @return (T is not int ? string : int) + */ + public function negated($val); + + /** + * @param T $val + * @return list<(T is int ? string : int)> + */ + public function nested($val); + + /** + * @param T $val + * @param-out (T is int ? string : int) $ref + */ + public function paramOut($val, &$ref): void; + + /** + * @param T $val + * @param (T is int ? string : int) $other + */ + public function inParam($val, $other): void; + +} + +/** + * @implements Iface + */ +final class Impl implements Iface +{ + + public function fromTemplate($val) + { + return 1; + } + + public function fromParameter($val) + { + return 1; + } + + public function negated($val) + { + return 'foo'; + } + + public function nested($val) + { + return []; + } + + public function paramOut($val, &$ref): void + { + } + + public function inParam($val, $other): void + { + } + +} + +/** + * @template U of int|string + * @extends Iface + */ +interface StillGeneric extends Iface +{ + +} + +/** + * @template T of int|string + */ +trait Tr +{ + + /** + * @return (T is int ? string : int) + */ + abstract public function fromTrait(); + +} + +/** + * @template T of int|string + */ +abstract class Base +{ + + /** @use Tr */ + use Tr; + + /** + * @return (T is int ? string : int) + */ + abstract public function fromBase(); + +} + +/** + * @extends Base + */ +abstract class Mid extends Base +{ + +} + +final class Leaf extends Mid +{ + + public function fromTrait() + { + return 1; + } + + public function fromBase() + { + return 1; + } + +}