Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 84 additions & 31 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>|Dog<string>), scope application via
// addTypeToExpression already preserves generics through TypeCombinator::intersect.
// This inference handles the parent-to-child case (e.g. Animal<string> → Cat<string>).
$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;
Expand Down Expand Up @@ -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);
Expand All @@ -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()) {
Expand Down
75 changes: 75 additions & 0 deletions tests/PHPStan/Analyser/nsrt/class-string-generic-narrowing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php // lint >= 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<T>
*/
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<T>
*/
class Dog extends Animal {
/** @return never */
public function value(): never { throw new \RuntimeException(); }
}

/** @param Cat<string>|Dog<string> $a */
function unionMatchPreservesGeneric(Animal $a): void {
match ($a::class) {
Cat::class => assertType('string', $a->value()),
Dog::class => assertType('never', $a->value()),
};
}

/** @param Cat<int>|Dog<int> $a */
function ifElseClassPreservesGeneric(Animal $a): void {
if ($a::class === Cat::class) {
assertType('int', $a->value());
} else {
assertType('int', $a->value());
}
}

/** @param Cat<float>|Dog<float> $a */
function mirrorCasePreservesGeneric(Animal $a): void {
if (Cat::class === $a::class) {
assertType('float', $a->value());
}
}

/** @param Cat<array<string>>|Dog<array<string>> $a */
function matchWithMethodCall(Animal $a): void {
$result = match ($a::class) {
Cat::class => $a->value(),
Dog::class => [],
};
assertType('array<string>', $result);
}

/** @param Cat<string>|Dog<string> $a */
function nonMatchingClass(Animal $a): void {
if ($a::class === \stdClass::class) {
assertType('*NEVER*', $a);
} else {
assertType('ClassStringGenericNarrowing\Cat<string>|ClassStringGenericNarrowing\Dog<string>', $a);
}
}
Loading