diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index cceef0e7c0..6b1f44ca27 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,48 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty return $specifiedTypes; } + private function narrowTypePreservingGenerics(ObjectType $narrowedType, Type $currentVarType): Type + { + // Intersect with current type — for unions (e.g., Cat|Dog), + // TypeCombinator distributes over members and preserves generic parameters + $intersected = TypeCombinator::intersect($narrowedType, $currentVarType); + if (!$intersected instanceof NeverType && !$intersected->equals($narrowedType)) { + return $intersected; + } + + // For generic parent types (e.g., Animal), resolve child's + // template parameters through the class hierarchy + $currentClassReflections = $currentVarType->getObjectClassReflections(); + if ( + count($currentClassReflections) === 1 + && count($currentClassReflections[0]->getTemplateTypeMap()->getTypes()) > 0 + ) { + $className = $narrowedType->getClassName(); + if ($this->reflectionProvider->hasClass($className)) { + $childReflection = $this->reflectionProvider->getClass($className); + $childTemplateTypes = $childReflection->getTemplateTypeMap()->getTypes(); + if (count($childTemplateTypes) > 0) { + $freshChildType = new GenericObjectType($className, array_values($childTemplateTypes)); + $currentClassNames = $currentVarType->getObjectClassNames(); + $ancestorAsParent = count($currentClassNames) === 1 + ? $freshChildType->getAncestorWithClassName($currentClassNames[0]) + : null; + if ($ancestorAsParent !== null) { + $inferredMap = $ancestorAsParent->inferTemplateTypes($currentVarType); + $resolvedTypes = []; + foreach ($childTemplateTypes as $name => $templateType) { + $resolved = $inferredMap->getType($name); + $resolvedTypes[] = $resolved ?? $templateType; + } + return new GenericObjectType($className, $resolvedTypes); + } + } + } + } + + return $narrowedType; + } + private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; @@ -2879,9 +2923,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope strtolower($unwrappedLeftExpr->name->toString()) === 'class' ) { if ($this->reflectionProvider->hasClass($rightType->getValue())) { + $className = $rightType->getValue(); + $classReflection = $this->reflectionProvider->getClass($className); + $narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal()); + + $currentVarType = $scope->getType($unwrappedLeftExpr->class); + $narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType); + return $this->create( $unwrappedLeftExpr->class, - new ObjectType($rightType->getValue(), classReflection: $this->reflectionProvider->getClass($rightType->getValue())->asFinal()), + $narrowedType, $context, $scope, )->unionWith($this->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); @@ -2910,9 +2961,16 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope strtolower($unwrappedRightExpr->name->toString()) === 'class' ) { if ($this->reflectionProvider->hasClass($leftType->getValue())) { + $className = $leftType->getValue(); + $classReflection = $this->reflectionProvider->getClass($className); + $narrowedType = new ObjectType($className, classReflection: $classReflection->asFinal()); + + $currentVarType = $scope->getType($unwrappedRightExpr->class); + $narrowedType = $this->narrowTypePreservingGenerics($narrowedType, $currentVarType); + return $this->create( $unwrappedRightExpr->class, - new ObjectType($leftType->getValue(), classReflection: $this->reflectionProvider->getClass($leftType->getValue())->asFinal()), + $narrowedType, $context, $scope, )->unionWith($this->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index b4ac3eff88..7b60b3f851 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,73 @@ 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(); + + // Build O(1) lookup of previously subtracted class names + $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; + } + } + } + } + + // Single pass: verify classToRemove is allowed and collect remaining + $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(); + } + + // When removing the sealed class itself (concrete non-abstract parent), + // narrow to remaining subtypes directly. ObjectType subtraction would + // create a self-referential type (e.g., Foo~Foo) that incorrectly + // excludes child class-strings due to covariant subtraction semantics. + if ($genericObjectClassNames[0] === $classToRemove) { + if (count($remainingAllowedSubTypes) === 1) { + return new self($remainingAllowedSubTypes[0]); + } + + return new self(TypeCombinator::union(...$remainingAllowedSubTypes)); + } + + // Build subtracted type: previous + new removal + $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..b05136ce8e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-string-match-preserves-generics.php @@ -0,0 +1,116 @@ += 8.0 + +declare(strict_types = 1); + +namespace ClassStringMatchPreservesGenerics; + +use function PHPStan\Testing\assertType; + +// === Setup: generic parent with subtypes === + +/** @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(); } +} + +// === 1. Union type: ::class match preserves generic parameter === + +/** @param Cat|Dog $a */ +function unionMatchPreservesGeneric(Animal $a): void { + match ($a::class) { + Cat::class => assertType('string', $a->value()), + Dog::class => assertType('never', $a->value()), + }; +} + +// === 2. If-else with ::class preserves generic parameter === + +/** @param Cat|Dog $a */ +function ifElseClassPreservesGeneric(Animal $a): void { + if ($a::class === Cat::class) { + assertType('int', $a->value()); + } else { + // The falsey branch uses the general === handler (not the ::class special path), + // so the object variable is not narrowed with generics — only the class-string is. + assertType('int', $a->value()); + } +} + +// === 3. Single generic type (not union): ::class narrows and preserves generic === + +/** @param Animal $a */ +function singleGenericClassMatch(Animal $a): void { + if ($a::class === Cat::class) { + assertType('string', $a->value()); + } +} + +// === 4. Final generic class: ::class preserves generic === + +/** @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()); + } +} + +// === 5. Mirror case: 'Foo' === $a::class also preserves generics === + +/** @param Cat|Dog $a */ +function mirrorCasePreservesGeneric(Animal $a): void { + if (Cat::class === $a::class) { + assertType('float', $a->value()); + } +} + +// === 6. Match with multiple arms and method calls === + +/** @param Cat>|Dog> $a */ +function matchWithMethodCall(Animal $a): void { + $result = match ($a::class) { + Cat::class => $a->value(), + Dog::class => [], + }; + assertType('array', $result); +} + +// === 7. Non-matching class: no narrowing side-effects === + +/** @param Cat|Dog $a */ +function nonMatchingClass(Animal $a): void { + if ($a::class === \stdClass::class) { + // Unreachable: stdClass is not in Cat|Dog union, so the intersection is never + assertType('*NEVER*', $a); + } else { + assertType('ClassStringMatchPreservesGenerics\Cat|ClassStringMatchPreservesGenerics\Dog', $a); + } +} 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..1ad739bd2e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/sealed-class-string-match.php @@ -0,0 +1,174 @@ += 8.0 + +declare(strict_types = 1); + +namespace SealedClassStringMatch; + +use function PHPStan\Testing\assertType; + +// === Original issue example (phpstan/phpstan#12241) === +// Non-final subtypes — sealed declaration is trusted + +/** @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); +} + +// === Final subtypes === + +/** @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); +} + +// === Three subtypes — progressive narrowing === + +/** @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); +} + +// === Concrete sealed parent (non-abstract — parent itself is an allowed subtype) === + +/** @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); +} + +// === Sealed interface === + +/** @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); +} + +// === Removing a non-allowed class — no narrowing === + +function nonAllowedRemoval(Foo $foo): void { + $class = $foo::class; + + if ($class === X::class) { + return; + } + + // X is not an allowed subtype of Foo, so no narrowing + assertType('class-string&literal-string', $class); +} + +// === Falsy branch — !== narrows via subtraction === + +function falsyBranch(Foo $foo): void { + $class = $foo::class; + + if ($class !== Bar::class) { + assertType('class-string&literal-string', $class); + } +} + +// === Match expression directly on $foo::class === + +function matchDirect(Foo $foo): void { + $result = match ($foo::class) { + Bar::class => 'Bar', + Baz::class => 'Baz', + }; + assertType("'Bar'|'Baz'", $result); +} + +// === Match with variable assignment === + +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..56d552455e 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', + 38, + ], + ]); + } + #[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..1eb506b514 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-sealed-class-string.php @@ -0,0 +1,71 @@ += 8.0 + +declare(strict_types = 1); + +namespace MatchSealedClassString; + +// === Original issue (phpstan/phpstan#12241) — non-final subtypes === + +/** @phpstan-sealed Bar|Baz */ +abstract class Foo {} +class Bar extends Foo {} +class Baz extends Foo {} + +function originalIssue(Foo $foo): string { + return match ($foo::class) { // no error - exhaustive + Bar::class => 'Bar', + Baz::class => 'Baz', + }; +} + +// === All final subtypes === + +/** @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) { // no error - exhaustive + FinalA::class => 'A', + FinalB::class => 'B', + }; +} + +// === Partial match — missing subtype === + +function partialMatch(Foo $foo): string { + return match ($foo::class) { // error: does not handle remaining value + Bar::class => 'Bar', + }; +} + +// === Three subtypes — exhaustive === + +/** @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) { // no error - exhaustive + X::class => 'X', + Y::class => 'Y', + Z::class => 'Z', + }; +} + +// === Sealed interface === + +/** @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) { // no error - exhaustive + ImplA::class => 'A', + ImplB::class => 'B', + }; +}