diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c43..3d1d903fa6 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -58,6 +58,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; @@ -87,6 +88,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; @@ -2665,6 +2667,74 @@ 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()); + + // Infer generic type parameters from the current type when narrowing to a child class. + // For union types (e.g. Cat|Dog), scope application via + // addTypeToExpression already preserves generics through TypeCombinator::intersect. + // This inference handles the parent-to-child case (e.g. Animal → Cat). + $currentVarType = $scope->getType($classExpr->class); + $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; @@ -2966,22 +3036,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); @@ -2997,23 +3059,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/tests/PHPStan/Analyser/nsrt/class-string-generic-narrowing.php b/tests/PHPStan/Analyser/nsrt/class-string-generic-narrowing.php new file mode 100644 index 0000000000..46d9069772 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-generic-narrowing.php @@ -0,0 +1,75 @@ += 8.0 + +declare(strict_types = 1); + +namespace ClassStringGenericNarrowing; + +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 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('ClassStringGenericNarrowing\Cat|ClassStringGenericNarrowing\Dog', $a); + } +}