diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d8ee14cf595..31c6bd31bfb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1119,7 +1119,7 @@ parameters: - rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. identifier: phpstanApi.instanceofType - count: 4 + count: 5 path: src/Type/Generic/GenericObjectType.php - diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 5994a945569..b4bb8833dc6 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -32,6 +32,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\InstanceofDeprecated; use PHPStan\Type\IntegerRangeType; @@ -172,6 +173,13 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult // an uncertainty originating in possible ObjectType's class subtypes. $objectType = $this->getObjectType(); + // A class name carries no type arguments, so it is compared in the + // parameterization the generic type implies for its class. + $objectType = GenericObjectType::specializeSubclass( + $genericType instanceof TemplateType ? $genericType->getBound() : $genericType, + $objectType, + ); + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType // uncertainty into account. if ($genericType instanceof TemplateType) { diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 7fd22cc8ecb..32889b81bfe 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -129,6 +129,13 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult // an uncertainty originating in possible ObjectType's class subtypes. $objectType = new ObjectType($type->getValue()); + // A class name carries no type arguments, so it is compared in the + // parameterization the generic type implies for its class. + $objectType = GenericObjectType::specializeSubclass( + $genericType instanceof TemplateType ? $genericType->getBound() : $genericType, + $objectType, + ); + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType // uncertainty into account. if ($genericType instanceof TemplateType) { diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index 1373c268ee5..7ee1e94b35f 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -25,8 +25,10 @@ use PHPStan\Type\TypeWithClassName; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_keys; use function array_map; use function count; +use function get_class; use function implode; use function sprintf; @@ -443,13 +445,81 @@ public function changeSubtractedType(?Type $subtractedType): Type // Parent handles sealed type exhaustiveness (returning NeverType when all // allowed subtypes are subtracted, or a single remaining subtype). - if (!$result instanceof ObjectType || $result->getClassName() !== $this->getClassName()) { + if (!$result instanceof ObjectType) { return $result; } + // The remaining subtype comes back as the sealed tag names it, without + // type arguments, and takes the ones this type implies for it. + if ($result->getClassName() !== $this->getClassName()) { + return self::specializeSubclass($this, $result); + } + return new self($this->getClassName(), $this->types, $subtractedType, null, $this->variances); } + /** + * Gives $subclass, a class written without type arguments, the ones + * $supertype implies for it through the class's `@extends` and + * `@implements` tags: Some with Option is Some, Err with + * Result is Err. The class of $supertype itself + * takes its arguments and call-site variance as written: X with X<*> is + * X<*>. + * + * Returns $subclass unchanged unless $supertype is a generic object type + * and $subclass is its class or a generic subtype of it. A subtype also + * stays unchanged when $supertype has call-site variance or does not + * determine every type argument of the subtype - an explicit argument + * would claim more than is known. + */ + public static function specializeSubclass(Type $supertype, Type $subclass): Type + { + if (!$supertype instanceof self || get_class($subclass) !== ObjectType::class) { + return $subclass; + } + + if ($subclass->getClassName() === $supertype->getClassName()) { + return new self( + $supertype->getClassName(), + $supertype->types, + $subclass->getSubtractedType(), + null, + $supertype->variances, + ); + } + + foreach ($supertype->variances as $variance) { + if (!$variance->invariant()) { + return $subclass; + } + } + + $classReflection = $subclass->getClassReflection(); + if ($classReflection === null || !$classReflection->isGeneric()) { + return $subclass; + } + + $templateTypeMap = $classReflection->getTemplateTypeMap(); + $ancestor = (new self($classReflection->getName(), $classReflection->typeMapToList($templateTypeMap))) + ->getAncestorWithClassName($supertype->getClassName()); + if ($ancestor === null) { + return $subclass; + } + + $inferredTypeMap = $ancestor->inferTemplateTypes($supertype); + foreach (array_keys($templateTypeMap->getTypes()) as $templateName) { + if (!$inferredTypeMap->hasType($templateName)) { + return $subclass; + } + } + + return new self( + $classReflection->getName(), + $classReflection->typeMapToList($inferredTypeMap), + $subclass->getSubtractedType(), + ); + } + public function toPhpDocNode(): TypeNode { /** @var IdentifierTypeNode $parent */ diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index cd08b5615f0..0b3af51e315 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -24,6 +24,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateArrayType; use PHPStan\Type\Generic\TemplateBenevolentUnionType; use PHPStan\Type\Generic\TemplateMixedType; @@ -902,6 +903,20 @@ private static function intersectWithSubtractedType( } elseif ($isBAlreadySubtracted->yes()) { $subtractedType = self::remove($a->getSubtractedType(), $b); + // The subtracted type counts only within $a, where a class written + // without type arguments has the ones $a implies: in Option, + // a subtracted Some is used up by Some. + $subtractedWithinA = GenericObjectType::specializeSubclass( + $a->getTypeWithoutSubtractedType(), + $a->getSubtractedType(), + ); + if ( + $subtractedWithinA !== $a->getSubtractedType() + && self::remove($subtractedWithinA, $b) instanceof NeverType + ) { + $subtractedType = new NeverType(); + } + if ( $subtractedType instanceof NeverType || !$subtractedType->isSuperTypeOf($b)->no() @@ -1870,6 +1885,7 @@ public static function doIntersect(Type ...$types): Type // transform IntegerType & ConstantIntegerType to ConstantIntegerType // transform Child & Parent to Child + // transform Child & Parent to Child // transform Object & ~null to Object // transform A & A to A // transform int[] & string to never @@ -1887,7 +1903,13 @@ public static function doIntersect(Type ...$types): Type $isSuperTypeSubtractableA = $typeWithoutSubtractedTypeA->isSuperTypeOf($types[$i]); } if ($isSuperTypeSubtractableA->yes()) { - $types[$i] = self::unionWithSubtractedType($types[$i], $types[$j]->getSubtractedType()); + // A generic class written without type arguments is a subtype of + // every parameterization of its generic ancestors, so it is kept - + // with the arguments the dropped ancestor implies for it. + $types[$i] = self::unionWithSubtractedType( + GenericObjectType::specializeSubclass($typeWithoutSubtractedTypeA, $types[$i]), + $types[$j]->getSubtractedType(), + ); array_splice($types, $j--, 1); $typesCount--; continue 1; @@ -1903,7 +1925,10 @@ public static function doIntersect(Type ...$types): Type $isSuperTypeSubtractableB = $typeWithoutSubtractedTypeB->isSuperTypeOf($types[$j]); } if ($isSuperTypeSubtractableB->yes()) { - $types[$j] = self::unionWithSubtractedType($types[$j], $types[$i]->getSubtractedType()); + $types[$j] = self::unionWithSubtractedType( + GenericObjectType::specializeSubclass($typeWithoutSubtractedTypeB, $types[$j]), + $types[$i]->getSubtractedType(), + ); array_splice($types, $i--, 1); $typesCount--; continue 2; diff --git a/tests/PHPStan/Analyser/nsrt/bug-15266.php b/tests/PHPStan/Analyser/nsrt/bug-15266.php new file mode 100644 index 00000000000..b3180360f70 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15266.php @@ -0,0 +1,50 @@ +> | class-string> $class + * @return class-string> | class-string> + */ +function parametrized(string $class): string +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + return $class; +} + +/** + * @param class-string> | class-string> $class + * @return class-string> | class-string> + */ +function star(string $class): string +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + return $class; +} + +/** + * @param class-string | class-string $class + * @return class-string | class-string + */ +function raw(string $class): string +{ + if ($class !== X::class) { + assertType('class-string', $class); + } + + return $class; +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15289.php b/tests/PHPStan/Analyser/nsrt/bug-15289.php new file mode 100644 index 00000000000..d5effe7d220 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15289.php @@ -0,0 +1,241 @@ += 8.0 + +namespace Bug15289; + +use LogicException; +use function PHPStan\Testing\assertType; + +/** + * @template-covariant T + * @phpstan-sealed Some|None + */ +interface Option {} + +/** + * @template-covariant T + * @implements Option + */ +final class Some implements Option {} + +/** @implements Option */ +final class None implements Option {} + +/** + * @template-covariant T + * @template-covariant E + * @phpstan-sealed Ok|Err + */ +interface Result {} + +/** + * @template-covariant T + * @implements Result + */ +final class Ok implements Result {} + +/** + * @template-covariant E + * @implements Result + */ +final class Err implements Result {} + +/** + * @param Option $o + * @return Option + */ +function afterEmptyBranch(Option $o): Option +{ + if ($o instanceof Some) { + } + assertType('Bug15289\None|Bug15289\Some', $o); + + return $o; // should be reported +} + +/** + * @param Option $o + * @return Option + */ +function inTheBranch(Option $o): Option +{ + if ($o instanceof Some) { + assertType('Bug15289\Some', $o); + + return $o; // should be reported + } + + return $o; // correctly not reported: $o is None here +} + +/** + * @param Result $r + * @return Err + */ +function elseBranch(Result $r): Err +{ + if ($r instanceof Ok) { + throw new LogicException(); + } + assertType('Bug15289\Err', $r); + + return $r; // should be reported +} + +/** @param Option $o */ +function exhaustiveMatch(Option $o): int +{ + return match (true) { + $o instanceof Some => 1, + $o instanceof None => 2, + }; +} + +/** @template-covariant T */ +interface UnsealedOption {} + +/** + * @template-covariant T + * @implements UnsealedOption + */ +final class UnsealedSome implements UnsealedOption {} + +/** + * @param UnsealedOption $o + * @return UnsealedOption + */ +function unsealedControl(UnsealedOption $o): UnsealedOption +{ + if ($o instanceof UnsealedSome) { + assertType('Bug15289\UnsealedSome', $o); + } + assertType('Bug15289\UnsealedOption', $o); + + return $o; // reported +} + +/** @param Result $r */ +function truthyBranchOfEachVariant(Result $r): void +{ + if ($r instanceof Err) { + assertType('Bug15289\Err', $r); + } else { + assertType('Bug15289\Ok', $r); + } +} + +/** + * @template T + * @param Option $o + */ +function templateArgument(Option $o): void +{ + assertType('Bug15289\Option', $o); + if ($o instanceof Some) { + assertType('Bug15289\Some', $o); + } + assertType('Bug15289\None|Bug15289\Some', $o); +} + +/** @template T */ +interface Invariant {} + +/** + * @template T + * @implements Invariant + */ +final class InvariantImpl implements Invariant {} + +/** @param Invariant $i */ +function callSiteVariance(Invariant $i): void +{ + if ($i instanceof InvariantImpl) { + assertType('Bug15289\InvariantImpl', $i); + } +} + +/** @phpstan-assert Some $o */ +function assertSome(Option $o): void +{ +} + +/** @param Option|null $o */ +function otherNarrowings(?Option $o): void +{ + if ($o instanceof Some) { + assertType('Bug15289\Some', $o); + } + + if ($o === null) { + return; + } + + if (is_a($o, Some::class)) { + assertType('Bug15289\Some', $o); + } + + if ($o::class === Some::class) { + assertType('Bug15289\Some', $o); + } + + assertSome($o); + assertType('Bug15289\Some', $o); +} + +/** + * @template-covariant T + * @phpstan-sealed Plain|Tagged + */ +interface Wrapped {} + +/** + * @template-covariant T + * @implements Wrapped + */ +final class Plain implements Wrapped {} + +/** + * @template-covariant T + * @template-covariant Tag + * @implements Wrapped + */ +final class Tagged implements Wrapped {} + +/** @param Wrapped $w */ +function undeterminedArgument(Wrapped $w): void +{ + if ($w instanceof Tagged) { + assertType('Bug15289\Tagged', $w); + } +} + +/** @param Wrapped $w */ +function undeterminedRemainder(Wrapped $w): void +{ + if ($w instanceof Plain) { + assertType('Bug15289\Plain', $w); + return; + } + + assertType('Bug15289\Tagged', $w); +} + +function bareSupertype(UnsealedOption $o): void +{ + assertType('Bug15289\UnsealedOption', $o); + if ($o instanceof UnsealedSome) { + assertType('Bug15289\UnsealedSome', $o); + } +} + +/** + * @template O of Option + * @param O $o + */ +function templateRemainder(Option $o): void +{ + if ($o instanceof None) { + return; + } + + assertType('O of Bug15289\Some (function Bug15289\templateRemainder(), argument)', $o); +} diff --git a/tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php b/tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php new file mode 100644 index 00000000000..b02dfd0f06a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-of-generic-class.php @@ -0,0 +1,51 @@ += 8.0 + +namespace ClassStringOfGenericClass; + +use function PHPStan\Testing\assertType; + +/** @template-covariant T */ +final class Box {} + +/** @template-covariant T */ +interface Option {} + +/** + * @template-covariant T + * @implements Option + */ +final class Some implements Option {} + +/** @implements Option */ +final class StringOption implements Option {} + +/** @param class-string> $c */ +function finalClass(string $c): void +{ + if ($c === Box::class) { + assertType("'ClassStringOfGenericClass\\\\Box'", $c); + return; + } + + assertType('*NEVER*', $c); +} + +/** @param class-string> $c */ +function subclass(string $c): void +{ + if ($c === Some::class) { + assertType("'ClassStringOfGenericClass\\\\Some'", $c); + } + + if ($c === StringOption::class) { + assertType('*NEVER*', $c); + } +} + +/** @param class-string> $c */ +function exhaustiveMatch(string $c): int +{ + return match ($c) { + Box::class => 1, + }; +} diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 063e1d531f0..916c2c8f759 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -515,6 +515,18 @@ public function testBug14412(): void $this->analyse([__DIR__ . '/data/bug-14412.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15289(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-15289.php'], []); + } + + #[RequiresPhp('>= 8.0.0')] + public function testClassStringOfGenericClass(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/class-string-of-generic-class.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testBug13029(): void { diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 7b3deb0bf63..db112d99eed 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -516,4 +516,29 @@ public function testBug15245(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15289(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-15289.php'], [ + [ + 'Function Bug15289\afterEmptyBranch() should return Bug15289\Option but returns Bug15289\None|Bug15289\Some.', + 52, + ], + [ + 'Function Bug15289\inTheBranch() should return Bug15289\Option but returns Bug15289\Some.', + 64, + ], + [ + 'Function Bug15289\elseBranch() should return Bug15289\Err but returns Bug15289\Err.', + 81, + ], + [ + 'Function Bug15289\unsealedControl() should return Bug15289\UnsealedOption but returns Bug15289\UnsealedOption.', + 113, + ], + ]); + } + }