diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index cceef0e7c0..c12e225334 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -57,6 +57,7 @@ use PHPStan\Type\FloatType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; @@ -86,6 +87,7 @@ use function array_merge; use function array_reverse; use function array_shift; +use function array_values; use function count; use function in_array; use function is_string; @@ -2577,6 +2579,75 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty return $specifiedTypes; } + private function resolveClassStringComparison( + ClassConstFetch $classExpr, + ConstantStringType $constType, + Expr $originalClassExpr, + TypeSpecifierContext $context, + Scope $scope, + Expr $rootExpr, + ): SpecifiedTypes + { + if (!$classExpr->class instanceof Expr) { + throw new ShouldNotHappenException(); + } + + $className = $constType->getValue(); + if ($className === '') { + throw new ShouldNotHappenException(); + } + + if (!$this->reflectionProvider->hasClass($className)) { + return $this->specifyTypesInCondition( + $scope, + new Instanceof_( + $classExpr->class, + new Name($className), + ), + $context, + )->unionWith( + $this->create($originalClassExpr, $constType, $context, $scope), + )->setRootExpr($rootExpr); + } + + $classReflection = $this->reflectionProvider->getClass($className); + $narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal()); + $currentVarType = $scope->getType($classExpr->class); + + $intersected = TypeCombinator::intersect($narrowedType, $currentVarType); + if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) { + $narrowedType = $intersected; + } else { + $currentReflections = $currentVarType->getObjectClassReflections(); + $childTemplateTypes = $classReflection->getTemplateTypeMap()->getTypes(); + if ( + count($childTemplateTypes) > 0 + && count($currentReflections) === 1 + && count($currentReflections[0]->getTemplateTypeMap()->getTypes()) > 0 + ) { + $freshChild = new GenericObjectType($className, array_values($childTemplateTypes)); + $ancestor = $freshChild->getAncestorWithClassName($currentReflections[0]->getName()); + if ($ancestor !== null) { + $inferredMap = $ancestor->inferTemplateTypes($currentVarType); + $resolved = []; + foreach ($childTemplateTypes as $name => $tType) { + $resolved[] = $inferredMap->getType($name) ?? $tType; + } + $narrowedType = new GenericObjectType($className, $resolved); + } + } + } + + return $this->create( + $classExpr->class, + $narrowedType, + $context, + $scope, + )->unionWith( + $this->create($originalClassExpr, $constType, $context, $scope), + )->setRootExpr($rootExpr); + } + private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; @@ -2878,22 +2949,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $rightType->getValue() !== '' && strtolower($unwrappedLeftExpr->name->toString()) === 'class' ) { - if ($this->reflectionProvider->hasClass($rightType->getValue())) { - return $this->create( - $unwrappedLeftExpr->class, - new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - return $this->specifyTypesInCondition( - $scope, - new Instanceof_( - $unwrappedLeftExpr->class, - new Name($rightType->getValue()), - ), + return $this->resolveClassStringComparison( + $unwrappedLeftExpr, + $rightType, + $leftExpr, $context, - )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $scope, + $expr, + ); } $leftType = $scope->getType($leftExpr); @@ -2909,23 +2972,14 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $leftType->getValue() !== '' && strtolower($unwrappedRightExpr->name->toString()) === 'class' ) { - if ($this->reflectionProvider->hasClass($leftType->getValue())) { - return $this->create( - $unwrappedRightExpr->class, - new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); - } - - return $this->specifyTypesInCondition( - $scope, - new Instanceof_( - $unwrappedRightExpr->class, - new Name($leftType->getValue()), - ), + return $this->resolveClassStringComparison( + $unwrappedRightExpr, + $leftType, + $rightExpr, $context, - )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + $scope, + $expr, + ); } if ($context->false()) { diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index b4ac3eff88..e845f12200 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -18,10 +18,13 @@ use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StaticType; use PHPStan\Type\StringType; +use PHPStan\Type\SubtractableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_keys; use function count; use function sprintf; @@ -219,6 +222,67 @@ public function tryRemove(Type $typeToRemove): ?Type if ($classReflection->isFinal() && $genericObjectClassNames[0] === $typeToRemove->getValue()) { return new NeverType(); } + + $allowedSubTypes = $classReflection->getAllowedSubTypes(); + if ($allowedSubTypes !== null) { + $classToRemove = $typeToRemove->getValue(); + + $subtractedClassNames = []; + if ($generic instanceof SubtractableType) { + $existingSubtracted = $generic->getSubtractedType(); + if ($existingSubtracted !== null) { + foreach (TypeUtils::flattenTypes($existingSubtracted) as $type) { + foreach ($type->getObjectClassNames() as $name) { + $subtractedClassNames[$name] = true; + } + } + } + } + + $isAllowedSubType = false; + $remainingAllowedSubTypes = []; + foreach ($allowedSubTypes as $allowedSubType) { + $names = $allowedSubType->getObjectClassNames(); + if (count($names) === 1) { + if ($names[0] === $classToRemove) { + $isAllowedSubType = true; + continue; + } + if (isset($subtractedClassNames[$names[0]])) { + continue; + } + } + $remainingAllowedSubTypes[] = $allowedSubType; + } + + if ($isAllowedSubType) { + if (count($remainingAllowedSubTypes) === 0) { + return new NeverType(); + } + + // Concrete parent: narrow directly to avoid self-referential Foo~Foo subtraction + if ($genericObjectClassNames[0] === $classToRemove) { + if (count($remainingAllowedSubTypes) === 1) { + return new self($remainingAllowedSubTypes[0]); + } + + return new self(TypeCombinator::union(...$remainingAllowedSubTypes)); + } + + $subtractedClassNames[$classToRemove] = true; + $subtractedTypes = []; + foreach (array_keys($subtractedClassNames) as $name) { + $subtractedTypes[] = new ObjectType($name); + } + $newSubtracted = count($subtractedTypes) === 1 + ? $subtractedTypes[0] + : new UnionType($subtractedTypes); + + return new self( + new ObjectType($genericObjectClassNames[0], $newSubtracted), + ); + } + } } } elseif (count($genericObjectClassNames) > 1) { $objectTypeToRemove = new ObjectType($typeToRemove->getValue()); diff --git a/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php b/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php new file mode 100644 index 0000000000..f5716eaf1c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php @@ -0,0 +1,135 @@ += 8.0 + +declare(strict_types = 1); + +namespace ClassStringMatchPreservesGenerics; + +use function PHPStan\Testing\assertType; + +/** @template T */ +abstract class Animal { + /** @return T */ + abstract public function value(): mixed; +} + +/** + * @template T + * @extends Animal + */ +class Cat extends Animal { + /** @param T $val */ + public function __construct(private mixed $val) {} + /** @return T */ + public function value(): mixed { return $this->val; } +} + +/** + * @template T + * @extends Animal + */ +class Dog extends Animal { + /** @return never */ + public function value(): never { throw new \RuntimeException(); } +} + +/** @param Cat|Dog $a */ +function unionMatchPreservesGeneric(Animal $a): void { + match ($a::class) { + Cat::class => assertType('string', $a->value()), + Dog::class => assertType('never', $a->value()), + }; +} + +/** @param Cat|Dog $a */ +function ifElseClassPreservesGeneric(Animal $a): void { + if ($a::class === Cat::class) { + assertType('int', $a->value()); + } else { + assertType('int', $a->value()); + } +} + +/** @param Animal $a */ +function singleGenericClassMatch(Animal $a): void { + if ($a::class === Cat::class) { + assertType('string', $a->value()); + } +} + +/** @template T */ +final class Box { + /** @param T $val */ + public function __construct(private mixed $val) {} + /** @return T */ + public function get(): mixed { return $this->val; } +} + +/** @param Box $box */ +function finalGenericClassMatch(Box $box): void { + if ($box::class === Box::class) { + assertType('string', $box->get()); + } +} + +/** @param Cat|Dog $a */ +function mirrorCasePreservesGeneric(Animal $a): void { + if (Cat::class === $a::class) { + assertType('float', $a->value()); + } +} + +/** @param Cat>|Dog> $a */ +function matchWithMethodCall(Animal $a): void { + $result = match ($a::class) { + Cat::class => $a->value(), + Dog::class => [], + }; + assertType('array', $result); +} + +/** @param Cat|Dog $a */ +function nonMatchingClass(Animal $a): void { + if ($a::class === \stdClass::class) { + assertType('*NEVER*', $a); + } else { + assertType('ClassStringMatchPreservesGenerics\Cat|ClassStringMatchPreservesGenerics\Dog', $a); + } +} + +/** + * @template T + * @phpstan-sealed GenCat|GenDog + */ +abstract class GenAnimal { + /** @return T */ + abstract public function value(): mixed; +} + +/** + * @template T + * @extends GenAnimal + */ +class GenCat extends GenAnimal { + /** @param T $val */ + public function __construct(private mixed $val) {} + /** @return T */ + public function value(): mixed { return $this->val; } +} + +/** + * @template T + * @extends GenAnimal + */ +class GenDog extends GenAnimal { + /** @return never */ + public function value(): never { throw new \RuntimeException(); } +} + +/** @param GenAnimal $a */ +function sealedGenericMatch(GenAnimal $a): void { + $result = match ($a::class) { + GenCat::class => $a->value(), + GenDog::class => 'fallback', + }; + assertType('string', $result); +} diff --git a/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php b/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php new file mode 100644 index 0000000000..f7869ad509 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php @@ -0,0 +1,154 @@ += 8.0 + +declare(strict_types = 1); + +namespace SealedClassStringMatch; + +use function PHPStan\Testing\assertType; + +/** @phpstan-sealed Bar|Baz */ +abstract class Foo {} +class Bar extends Foo {} +class Baz extends Foo {} + +function originalIssue(Foo $foo): void { + $class = $foo::class; + assertType('class-string&literal-string', $class); + + if ($class === Bar::class) { + assertType("'SealedClassStringMatch\\\\Bar'", $class); + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === Baz::class) { + assertType("'SealedClassStringMatch\\\\Baz'", $class); + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed FinalA|FinalB */ +abstract class SealedFinal {} +final class FinalA extends SealedFinal {} +final class FinalB extends SealedFinal {} + +function finalSubtypes(SealedFinal $s): void { + $class = $s::class; + + if ($class === FinalA::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === FinalB::class) { + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed X|Y|Z */ +abstract class ThreeWay {} +final class X extends ThreeWay {} +final class Y extends ThreeWay {} +final class Z extends ThreeWay {} + +function threeSubtypes(ThreeWay $t): void { + $class = $t::class; + + if ($class === X::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === Y::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === Z::class) { + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed ConcreteChild */ +class ConcreteParent {} +final class ConcreteChild extends ConcreteParent {} + +function concreteParent(ConcreteParent $c): void { + $class = $c::class; + + if ($class === ConcreteParent::class) { + return; + } + + assertType('class-string&literal-string', $class); + + if ($class === ConcreteChild::class) { + return; + } + + assertType('*NEVER*', $class); +} + +/** @phpstan-sealed ImplA|ImplB */ +interface SealedInterface {} +final class ImplA implements SealedInterface {} +final class ImplB implements SealedInterface {} + +function sealedInterface(SealedInterface $i): void { + $class = $i::class; + + if ($class === ImplA::class) { + return; + } + + if ($class === ImplB::class) { + return; + } + + assertType('*NEVER*', $class); +} + +function nonAllowedRemoval(Foo $foo): void { + $class = $foo::class; + + if ($class === X::class) { + return; + } + + assertType('class-string&literal-string', $class); +} + +function falsyBranch(Foo $foo): void { + $class = $foo::class; + + if ($class !== Bar::class) { + assertType('class-string&literal-string', $class); + } +} + +function matchDirect(Foo $foo): void { + $result = match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; + assertType("'Bar'|'Baz'", $result); +} + +function matchViaVariable(Foo $foo): void { + $class = $foo::class; + $result = match ($class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; + assertType("'Bar'|'Baz'", $result); +} diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 09330a874b..d7b061d44e 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -447,6 +447,17 @@ public function testBug9534(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testSealedClassStringMatch(): void + { + $this->analyse([__DIR__ . '/data/match-sealed-class-string.php'], [ + [ + 'Match expression does not handle remaining value: class-string&literal-string', + 32, + ], + ]); + } + #[RequiresPhp('>= 8.0')] public function testBug13029(): void { diff --git a/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php b/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php new file mode 100644 index 0000000000..33e734af20 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php @@ -0,0 +1,96 @@ += 8.0 + +declare(strict_types = 1); + +namespace MatchSealedClassString; + +/** @phpstan-sealed Bar|Baz */ +abstract class Foo {} +class Bar extends Foo {} +class Baz extends Foo {} + +function originalIssue(Foo $foo): string { + return match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; +} + +/** @phpstan-sealed FinalA|FinalB */ +abstract class SealedFinal {} +final class FinalA extends SealedFinal {} +final class FinalB extends SealedFinal {} + +function exhaustiveFinal(SealedFinal $s): string { + return match ($s::class) { + FinalA::class => 'A', + FinalB::class => 'B', + }; +} + +function partialMatch(Foo $foo): string { + return match ($foo::class) { // error + Bar::class => 'Bar', + }; +} + +/** @phpstan-sealed X|Y|Z */ +abstract class ThreeWay {} +final class X extends ThreeWay {} +final class Y extends ThreeWay {} +final class Z extends ThreeWay {} + +function exhaustiveThreeWay(ThreeWay $t): string { + return match ($t::class) { + X::class => 'X', + Y::class => 'Y', + Z::class => 'Z', + }; +} + +/** @phpstan-sealed ImplA|ImplB */ +interface SealedInterface {} +final class ImplA implements SealedInterface {} +final class ImplB implements SealedInterface {} + +function sealedInterfaceExhaustive(SealedInterface $i): string { + return match ($i::class) { + ImplA::class => 'A', + ImplB::class => 'B', + }; +} + +/** + * @template T + * @phpstan-sealed GenFoo|GenBar + */ +abstract class GenericSealed { + /** @return T */ + abstract public function value(): mixed; +} + +/** + * @template T + * @extends GenericSealed + */ +class GenFoo extends GenericSealed { + /** @return T */ + public function value(): mixed { throw new \RuntimeException(); } +} + +/** + * @template T + * @extends GenericSealed + */ +class GenBar extends GenericSealed { + /** @return T */ + public function value(): mixed { throw new \RuntimeException(); } +} + +/** @param GenericSealed $s */ +function sealedGenericExhaustive(GenericSealed $s): string { + return match ($s::class) { + GenFoo::class => 'foo', + GenBar::class => 'bar', + }; +}