From 41385458416065a0630e827a920f1418790e2511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Wed, 26 Aug 2026 11:08:42 +0200 Subject: [PATCH] Make BackedEnum generic `BackedEnum` now carries the backing type: `@template-covariant T of int|string`. `BackedEnum::$value` is `string` instead of `int|string`, and `value-of` for `@template T of BackedEnum` resolves to `string`. `from()` and `tryFrom()` take a method-level `@template TValue of T` instead of `@param T`. `T` stays out of contravariant position, which the variance check forbids for a covariant type parameter, while `BackedEnum::from('a')` is still reported. `@throws` tags from phpstorm-stubs are preserved so that the stub does not silence result-unused and dead-catch analysis around `from()`. Three things keep this backwards compatible: * `T` defaults to `int|string`, so a bare `BackedEnum` in PHPDoc keeps its old meaning and is not reported as missing type arguments on level 6 and above. * `T` is covariant, so every backed enum stays assignable to a bare `BackedEnum`. * PHP implements `BackedEnum` implicitly, so there is no `@implements` tag that could carry the type argument. `ClassReflection::getImmediateInterfaces()` fills it in from the enum backing type instead. Two supporting changes were needed for that last point: * `ClassReflection::getInterfaces()` and `ObjectType::getAncestorWithClassName()` now prefer the interface as resolved on the class itself over the same interface reached through one of its ancestors. Without this, an enum that implements an interface which extends `BackedEnum` resolved to the ancestor type arguments of that interface. * A generic ancestor with no `@implements` or `@extends` tag now uses the declared template defaults instead of error types, when every template type has a default. This is what makes `interface Foo extends BackedEnum` resolve to `BackedEnum` rather than `BackedEnum<*ERROR*>`. Because the interface is implemented implicitly, `EnumAncestorsRule` now accepts an `@implements BackedEnum` tag on a backed enum instead of reporting that the enum "does not implement any interface", and reports `enum.implementsBackingType` when the tag contradicts the actual backing type. This gives downstream code that annotated backed enums this way (e.g. via shipmonk/phpstan-rules' BackedEnumGenericsRule) a working migration path. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01QCdFyyKvMdEaU4bywFUist --- conf/config.neon | 1 + src/Reflection/ClassReflection.php | 43 ++++++- src/Rules/Generics/EnumAncestorsRule.php | 37 +++++- src/Type/ObjectType.php | 10 ++ src/Type/ValueOfType.php | 28 +++++ stubs/BackedEnum.stub | 28 +++++ .../Analyser/nsrt/generic-backed-enum.php | 119 ++++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 15 +++ ...ssingFunctionParameterTypehintRuleTest.php | 7 ++ .../data/generic-backed-enum-acceptance.php | 64 ++++++++++ .../data/generic-backed-enum-typehints.php | 21 ++++ .../Rules/Generics/EnumAncestorsRuleTest.php | 8 ++ .../Rules/Generics/data/enum-ancestors.php | 24 ++++ .../Methods/CallStaticMethodsRuleTest.php | 20 +++ .../Methods/data/generic-backed-enum.php | 44 +++++++ 15 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 stubs/BackedEnum.stub create mode 100644 tests/PHPStan/Analyser/nsrt/generic-backed-enum.php create mode 100644 tests/PHPStan/Rules/Functions/data/generic-backed-enum-acceptance.php create mode 100644 tests/PHPStan/Rules/Functions/data/generic-backed-enum-typehints.php create mode 100644 tests/PHPStan/Rules/Methods/data/generic-backed-enum.php diff --git a/conf/config.neon b/conf/config.neon index 74db379e5b7..86346f0d883 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -155,6 +155,7 @@ parameters: - ../stubs/ArrayObject.stub - ../stubs/WeakReference.stub - ../stubs/SensitiveParameterValue.stub + - ../stubs/BackedEnum.stub - ../stubs/ext-ds.stub - ../stubs/ImagickPixel.stub - ../stubs/PDOStatement.stub diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 624a054cc49..80ee71e4237 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1121,6 +1121,12 @@ public function getInterfaces(): array } } + // An interface resolved on this class itself is more specific than the same + // interface reached through one of its ancestors. + foreach ($immediateInterfaces as $name => $immediateInterface) { + $interfaces[$name] = $immediateInterface; + } + $this->cachedInterfaces = $interfaces; return $interfaces; @@ -1207,7 +1213,7 @@ public function getImmediateInterfaces(): array if ($immediateInterface->isGeneric()) { $immediateInterfaces[$immediateInterface->getName()] = $immediateInterface->withTypes( - array_values($immediateInterface->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes()), + $this->getUnspecifiedAncestorTypes($immediateInterface), ); continue; } @@ -1215,9 +1221,44 @@ public function getImmediateInterfaces(): array $immediateInterfaces[$immediateInterface->getName()] = $immediateInterface; } + // PHP implicitly implements BackedEnum on backed enums, so there is no + // @implements tag that could carry the backing type. + $backedEnumType = $this->getBackedEnumType(); + if ($backedEnumType !== null && $this->reflectionProvider->hasClass('BackedEnum')) { + $immediateInterfaces['BackedEnum'] = $this->reflectionProvider->getClass('BackedEnum') + ->withTypes([$backedEnumType]); + } + return $immediateInterfaces; } + /** + * Type arguments for a generic ancestor that is not described by an @implements + * or @extends tag. Declared template defaults are used when the ancestor has + * one for every template type, otherwise the type arguments stay erroneous. + * + * @return list + */ + private function getUnspecifiedAncestorTypes(ClassReflection $ancestor): array + { + $defaults = []; + foreach ($ancestor->getTemplateTags() as $templateTag) { + $default = $templateTag->getDefault(); + if ($default === null) { + $defaults = null; + break; + } + + $defaults[] = $default; + } + + if ($defaults !== null) { + return $defaults; + } + + return array_values($ancestor->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes()); + } + /** * @return array */ diff --git a/src/Rules/Generics/EnumAncestorsRule.php b/src/Rules/Generics/EnumAncestorsRule.php index d85b8c51721..0651f360405 100644 --- a/src/Rules/Generics/EnumAncestorsRule.php +++ b/src/Rules/Generics/EnumAncestorsRule.php @@ -11,7 +11,11 @@ use PHPStan\PhpDoc\Tag\ExtendsTag; use PHPStan\PhpDoc\Tag\ImplementsTag; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\VerbosityLevel; use function array_map; use function array_merge; use function sprintf; @@ -64,8 +68,15 @@ public function processNode(Node $node, Scope $scope): array '', ); + $implementsNames = $originalNode->implements; + if ($classReflection->isBackedEnum()) { + // PHP implicitly implements BackedEnum on backed enums, so an + // @implements tag for it has no counterpart in the declared list. + $implementsNames[] = new Node\Name\FullyQualified('BackedEnum'); + } + $implementsErrors = $this->genericAncestorsCheck->check( - $originalNode->implements, + $implementsNames, array_map(static fn (ImplementsTag $tag): Type => $tag->getType(), $classReflection->getImplementsTags()), sprintf('Enum %s @implements tag contains incompatible type %%s.', $escapedEnumName), sprintf('Enum %s @implements tag contains unresolvable type.', $enumName), @@ -81,6 +92,30 @@ public function processNode(Node $node, Scope $scope): array sprintf('in implemented type %%s of enum %s', $escapedEnumName), ); + $backedEnumType = $classReflection->getBackedEnumType(); + if ($backedEnumType !== null) { + $expectedTagType = new GenericObjectType('BackedEnum', [$backedEnumType]); + $bareTagType = new ObjectType('BackedEnum'); + foreach ($classReflection->getImplementsTags() as $implementsTag) { + $implementsTagType = $implementsTag->getType(); + if ($implementsTagType->getObjectClassNames() !== ['BackedEnum']) { + continue; + } + if ($implementsTagType->equals($bareTagType) || $implementsTagType->equals($expectedTagType)) { + continue; + } + + $implementsErrors[] = RuleErrorBuilder::message(sprintf( + 'The @implements tag of enum %s specifies %s but the enum is backed by %s.', + $enumName, + $implementsTagType->describe(VerbosityLevel::typeOnly()), + $backedEnumType->describe(VerbosityLevel::typeOnly()), + )) + ->identifier('enum.implementsBackingType') + ->build(); + } + } + foreach ($this->crossCheckInterfacesHelper->check($classReflection) as $error) { $implementsErrors[] = $error; } diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index b634ca556cc..27342c65560 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1871,6 +1871,16 @@ public function getAncestorWithClassName(string $className): ?self return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $this; } + // An interface resolved on this class itself is more specific than the same + // interface reached through one of its ancestors. + foreach ($this->getInterfaces() as $interface) { + if ($interface->getClassName() !== $className) { + continue; + } + + return self::$ancestors[$description][$className] = $this->currentAncestors[$className] = $interface; + } + foreach ($this->getInterfaces() as $interface) { $ancestor = $interface->getAncestorWithClassName($className); if ($ancestor !== null) { diff --git a/src/Type/ValueOfType.php b/src/Type/ValueOfType.php index e2d3f0516c2..8634cfed2a0 100644 --- a/src/Type/ValueOfType.php +++ b/src/Type/ValueOfType.php @@ -9,6 +9,7 @@ use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Traits\LateResolvableTypeTrait; use PHPStan\Type\Traits\NonGeneralizableTypeTrait; +use function array_values; use function count; use function sprintf; @@ -58,6 +59,33 @@ protected function getResult(): Type && $this->type instanceof TemplateType && (new ObjectType('BackedEnum'))->isSuperTypeOf($this->type->getBound())->yes() ) { + $backingTypes = []; + foreach ($this->type->getBound()->getObjectClassReflections() as $classReflection) { + $ancestor = $classReflection->getAncestorWithClassName('BackedEnum'); + if ($ancestor === null) { + $backingTypes = []; + break; + } + + $ancestorTypes = $ancestor->getActiveTemplateTypeMap()->getTypes(); + if (count($ancestorTypes) !== 1) { + $backingTypes = []; + break; + } + + $backingType = array_values($ancestorTypes)[0]; + if ($backingType instanceof ErrorType) { + $backingTypes = []; + break; + } + + $backingTypes[] = $backingType; + } + + if ($backingTypes !== []) { + return TypeCombinator::union(...$backingTypes); + } + return new UnionType([new IntegerType(), new StringType()]); } diff --git a/stubs/BackedEnum.stub b/stubs/BackedEnum.stub new file mode 100644 index 00000000000..99b156fac15 --- /dev/null +++ b/stubs/BackedEnum.stub @@ -0,0 +1,28 @@ += 8.1 + +declare(strict_types = 1); + +namespace GenericBackedEnum; + +use BackedEnum; +use UnitEnum; +use function PHPStan\Testing\assertType; + +enum StringEnum: string +{ + + case A = 'a'; + case B = 'b'; + +} + +enum IntEnum: int +{ + + case One = 1; + +} + +enum PureEnum +{ + + case X; + +} + +interface HasLabel extends BackedEnum +{ + +} + +/** + * @extends BackedEnum + */ +interface StringBackedInterface extends BackedEnum +{ + +} + +enum ViaInterface: string implements HasLabel +{ + + case A = 'a'; + +} + +enum ViaStringInterface: string implements StringBackedInterface +{ + + case A = 'a'; + +} + +function bare(BackedEnum $e): void +{ + assertType('int|string', $e->value); + assertType('non-decimal-int-string&non-falsy-string', $e->name); +} + +/** + * @param BackedEnum $e + * @param BackedEnum $i + */ +function withTypes(BackedEnum $e, BackedEnum $i): void +{ + assertType('string', $e->value); + assertType('int', $i->value); + assertType('BackedEnum', $e::from('a')); + assertType('BackedEnum|null', $e::tryFrom('a')); +} + +function unresolvedInterface(HasLabel $e): void +{ + assertType('int|string', $e->value); +} + +function resolvedInterface(StringBackedInterface $e): void +{ + assertType('string', $e->value); +} + +/** + * @template T of BackedEnum + * @param T $e + * @return value-of + */ +function valueOfAny(BackedEnum $e) +{ + return $e->value; +} + +/** + * @template T of BackedEnum + * @param class-string $className + * @return T + */ +function fromString(string $className, string $value): BackedEnum +{ + return $className::from($value); +} + +function templates(): void +{ + assertType("'a'", valueOfAny(StringEnum::A)); + assertType('1', valueOfAny(IntEnum::One)); + assertType('GenericBackedEnum\StringEnum', fromString(StringEnum::class, 'a')); +} + +function unitEnumIsNotGeneric(UnitEnum $e, PureEnum $p): void +{ + assertType('non-decimal-int-string&non-falsy-string', $e->name); + assertType("'X'", $p->name); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index cad2f9222eb..054fec51eb2 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3099,4 +3099,19 @@ public function testBug13114(bool $checkExplicitMixed, bool $checkImplicitMixed) ]); } + #[RequiresPhp('>= 8.1.0')] + public function testGenericBackedEnumAcceptance(): void + { + $this->analyse([__DIR__ . '/data/generic-backed-enum-acceptance.php'], [ + [ + 'Parameter #1 $e of function GenericBackedEnumAcceptance\acceptsStringBacked expects BackedEnum, GenericBackedEnumAcceptance\IntEnum given.', + 62, + ], + [ + 'Parameter #1 $e of function GenericBackedEnumAcceptance\acceptsStringBacked expects BackedEnum, GenericBackedEnumAcceptance\HasLabel given.', + 63, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/MissingFunctionParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Functions/MissingFunctionParameterTypehintRuleTest.php index 7d43d80c8de..13cd363668a 100644 --- a/tests/PHPStan/Rules/Functions/MissingFunctionParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Functions/MissingFunctionParameterTypehintRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\MissingTypehintCheck; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPUnit\Framework\Attributes\RequiresPhp; /** * @extends RuleTestCase @@ -96,4 +97,10 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testGenericBackedEnum(): void + { + $this->analyse([__DIR__ . '/data/generic-backed-enum-typehints.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/generic-backed-enum-acceptance.php b/tests/PHPStan/Rules/Functions/data/generic-backed-enum-acceptance.php new file mode 100644 index 00000000000..1c55b680609 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/generic-backed-enum-acceptance.php @@ -0,0 +1,64 @@ += 8.1 + +declare(strict_types = 1); + +namespace GenericBackedEnumAcceptance; + +use BackedEnum; + +enum StringEnum: string +{ + + case A = 'a'; + +} + +enum IntEnum: int +{ + + case One = 1; + +} + +interface HasLabel extends BackedEnum +{ + +} + +/** + * @extends BackedEnum + */ +interface StringBackedInterface extends BackedEnum +{ + +} + +enum ViaInterface: string implements HasLabel +{ + + case A = 'a'; + +} + +enum ViaStringInterface: string implements StringBackedInterface +{ + + case A = 'a'; + +} + +/** + * @param BackedEnum $e + */ +function acceptsStringBacked(BackedEnum $e): void +{ +} + +function test(StringEnum $a, ViaInterface $b, ViaStringInterface $c, IntEnum $d, HasLabel $e): void +{ + acceptsStringBacked($a); + acceptsStringBacked($b); + acceptsStringBacked($c); + acceptsStringBacked($d); + acceptsStringBacked($e); +} diff --git a/tests/PHPStan/Rules/Functions/data/generic-backed-enum-typehints.php b/tests/PHPStan/Rules/Functions/data/generic-backed-enum-typehints.php new file mode 100644 index 00000000000..3571e7eca5f --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/generic-backed-enum-typehints.php @@ -0,0 +1,21 @@ += 8.1 + +declare(strict_types = 1); + +namespace GenericBackedEnumTypehints; + +use BackedEnum; + +function bareIsNotMissingTypes(BackedEnum $e): BackedEnum +{ + return $e; +} + +/** + * @param BackedEnum $e + * @return BackedEnum + */ +function withTypes(BackedEnum $e): BackedEnum +{ + return $e; +} diff --git a/tests/PHPStan/Rules/Generics/EnumAncestorsRuleTest.php b/tests/PHPStan/Rules/Generics/EnumAncestorsRuleTest.php index 101bca4eb3c..d2b5e9075fa 100644 --- a/tests/PHPStan/Rules/Generics/EnumAncestorsRuleTest.php +++ b/tests/PHPStan/Rules/Generics/EnumAncestorsRuleTest.php @@ -56,6 +56,14 @@ public function testRule(): void 'Call-site variance annotation of covariant EnumGenericAncestors\NonGeneric in generic type EnumGenericAncestors\Generic in PHPDoc tag @implements is not allowed.', 93, ], + [ + 'The @implements tag of enum EnumGenericAncestors\BackedEnumWrongTag specifies BackedEnum but the enum is backed by int.', + 122, + ], + [ + 'Enum EnumGenericAncestors\BackedEnumTagOnPureEnum has @implements tag, but does not implement any interface.', + 130, + ], ]); } diff --git a/tests/PHPStan/Rules/Generics/data/enum-ancestors.php b/tests/PHPStan/Rules/Generics/data/enum-ancestors.php index 1cda1bcbcd4..11df5ad5a57 100644 --- a/tests/PHPStan/Rules/Generics/data/enum-ancestors.php +++ b/tests/PHPStan/Rules/Generics/data/enum-ancestors.php @@ -107,3 +107,27 @@ enum Foo9 implements GenericDefault { } + +/** + * @implements \BackedEnum + */ +enum BackedEnumRightTag: string +{ + +} + +/** + * @implements \BackedEnum + */ +enum BackedEnumWrongTag: int +{ + +} + +/** + * @implements \BackedEnum + */ +enum BackedEnumTagOnPureEnum +{ + +} diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index f4d3868b15e..a2fc77801c4 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1066,4 +1066,24 @@ public function testBug15002(): void $this->analyse([__DIR__ . '/data/bug-15002.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testGenericBackedEnum(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/generic-backed-enum.php'], [ + [ + 'Parameter #1 $value of static method BackedEnum::from() expects TValue of int, string given.', + 23, + ], + [ + 'Parameter #1 $value of static method BackedEnum::tryFrom() expects TValue of string, int given.', + 24, + ], + [ + 'Parameter #1 $value of static method BackedEnum::from() expects TValue of string, int given.', + 36, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/generic-backed-enum.php b/tests/PHPStan/Rules/Methods/data/generic-backed-enum.php new file mode 100644 index 00000000000..d9e595dbcd2 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/generic-backed-enum.php @@ -0,0 +1,44 @@ += 8.1 + +declare(strict_types = 1); + +namespace GenericBackedEnumStaticCall; + +use BackedEnum; + +enum StringEnum: string +{ + + case A = 'a'; + +} + +/** + * @param BackedEnum $intBacked + * @param BackedEnum $stringBacked + */ +function backedEnumInterface(BackedEnum $intBacked, BackedEnum $stringBacked, BackedEnum $bare, int $i, string $s): void +{ + $intBacked::from($i); + $intBacked::from($s); + $stringBacked::tryFrom($i); + $stringBacked::tryFrom($s); + $bare::from($i); + $bare::from($s); +} + +/** + * @template T of BackedEnum + * @param class-string $className + */ +function stringBackedClassString(string $className, int $i, string $s): void +{ + $className::from($i); + $className::from($s); +} + +function concreteEnum(string $s): void +{ + StringEnum::from($s); + StringEnum::tryFrom($s); +}