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
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion src/Reflection/ClassReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1207,17 +1213,52 @@ 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;
}

$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<Type>
*/
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<string, ClassReflection>
*/
Expand Down
37 changes: 36 additions & 1 deletion src/Rules/Generics/EnumAncestorsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions src/Type/ObjectType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions src/Type/ValueOfType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()]);
}

Expand Down
28 changes: 28 additions & 0 deletions stubs/BackedEnum.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/**
* @template-covariant T of int|string = int|string
*/
interface BackedEnum extends UnitEnum
{

/** @var T */
public readonly int|string $value;

/**
* @template TValue of T
* @param TValue $value
* @return static
* @throws \ValueError
* @throws \TypeError
*/
public static function from(int|string $value): static;

/**
* @template TValue of T
* @param TValue $value
* @return static|null
*/
public static function tryFrom(int|string $value): ?static;

}
119 changes: 119 additions & 0 deletions tests/PHPStan/Analyser/nsrt/generic-backed-enum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php // lint >= 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<string>
*/
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<string> $e
* @param BackedEnum<int> $i
*/
function withTypes(BackedEnum $e, BackedEnum $i): void
{
assertType('string', $e->value);
assertType('int', $i->value);
assertType('BackedEnum<string>', $e::from('a'));
assertType('BackedEnum<string>|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<T>
*/
function valueOfAny(BackedEnum $e)
{
return $e->value;
}

/**
* @template T of BackedEnum<string>
* @param class-string<T> $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);
}
15 changes: 15 additions & 0 deletions tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>, GenericBackedEnumAcceptance\IntEnum given.',
62,
],
[
'Parameter #1 $e of function GenericBackedEnumAcceptance\acceptsStringBacked expects BackedEnum<string>, GenericBackedEnumAcceptance\HasLabel given.',
63,
],
]);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPStan\Rules\MissingTypehintCheck;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<MissingFunctionParameterTypehintRule>
Expand Down Expand Up @@ -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'], []);
}

}
Loading
Loading