diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php index b0ffb87d273..0011896ea2e 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/AtomicPropertyFetchAnalyzer.php @@ -49,6 +49,7 @@ use Psalm\Type\Atomic\TEnumCase; use Psalm\Type\Atomic\TFalse; use Psalm\Type\Atomic\TGenericObject; +use Psalm\Type\Atomic\TInt; use Psalm\Type\Atomic\TLiteralInt; use Psalm\Type\Atomic\TLiteralString; use Psalm\Type\Atomic\TMixed; @@ -56,6 +57,7 @@ use Psalm\Type\Atomic\TNull; use Psalm\Type\Atomic\TObject; use Psalm\Type\Atomic\TObjectWithProperties; +use Psalm\Type\Atomic\TString; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; @@ -184,35 +186,19 @@ public static function analyze( $property_id = $fq_class_name . '::$' . $prop_name; - if ($class_storage->is_enum) { - if ($prop_name === 'value' && $class_storage->enum_type !== null && $class_storage->enum_cases) { - $case_values = []; - - foreach ($class_storage->enum_cases as $enum_case) { - if (is_string($enum_case->value)) { - $case_values[] = new TLiteralString($enum_case->value); - } elseif (is_int($enum_case->value)) { - $case_values[] = new TLiteralInt($enum_case->value); - } else { - // this should never happen - $case_values[] = new TMixed(); - } - } - - // todo: this is suboptimal when we reference enum directly, e.g. Status::Open->value + if ($class_storage->is_enum || in_array('UnitEnum', $codebase->getParentInterfaces($fq_class_name))) { + if ($prop_name === 'value' && !$class_storage->is_enum) { $statements_analyzer->node_data->setType( $stmt, - new Union($case_values) + new Union([ + new TString(), + new TInt() + ]) ); + } elseif ($prop_name === 'value' && $class_storage->enum_type !== null && $class_storage->enum_cases) { + self::handleEnumValue($statements_analyzer, $stmt, $class_storage); } elseif ($prop_name === 'name') { - if ($lhs_type_part instanceof TEnumCase) { - $statements_analyzer->node_data->setType( - $stmt, - new Union([new TLiteralString($lhs_type_part->case_name)]) - ); - } else { - $statements_analyzer->node_data->setType($stmt, Type::getNonEmptyString()); - } + self::handleEnumName($statements_analyzer, $stmt, $lhs_type_part); } else { self::handleNonExistentProperty( $statements_analyzer, @@ -908,6 +894,47 @@ public static function processTaints( } } + private static function handleEnumName( + StatementsAnalyzer $statements_analyzer, + PropertyFetch $stmt, + Atomic $lhs_type_part + ): void { + if ($lhs_type_part instanceof TEnumCase) { + $statements_analyzer->node_data->setType( + $stmt, + new Union([new TLiteralString($lhs_type_part->case_name)]) + ); + } else { + $statements_analyzer->node_data->setType($stmt, Type::getNonEmptyString()); + } + } + + private static function handleEnumValue( + StatementsAnalyzer $statements_analyzer, + PropertyFetch $stmt, + ClassLikeStorage $class_storage + ): void { + $case_values = []; + + foreach ($class_storage->enum_cases as $enum_case) { + if (is_string($enum_case->value)) { + $case_values[] = new TLiteralString($enum_case->value); + } elseif (is_int($enum_case->value)) { + $case_values[] = new TLiteralInt($enum_case->value); + } else { + // this should never happen + $case_values[] = new TMixed(); + } + } + + // todo: this is suboptimal when we reference enum directly, e.g. Status::Open->value + /** @psalm-suppress ArgumentTypeCoercion */ + $statements_analyzer->node_data->setType( + $stmt, + new Union($case_values) + ); + } + private static function handleUndefinedProperty( Context $context, StatementsAnalyzer $statements_analyzer, @@ -1009,7 +1036,8 @@ private static function handleNonExistentClass( if (!$class_exists && //interfaces can't have properties. Except when they do... In PHP Core, they can - !in_array($fq_class_name, ['UnitEnum', 'BackedEnum'], true) + !in_array($fq_class_name, ['UnitEnum', 'BackedEnum'], true) && + !in_array('UnitEnum', $codebase->getParentInterfaces($fq_class_name)) ) { if (IssueBuffer::accepts( new NoInterfaceProperties( diff --git a/stubs/Php81.phpstub b/stubs/Php81.phpstub index d3398388de4..694e4e9ce72 100644 --- a/stubs/Php81.phpstub +++ b/stubs/Php81.phpstub @@ -11,7 +11,7 @@ namespace { public static function cases(): array; } - interface BackedEnum + interface BackedEnum extends UnitEnum { public readonly int|string $value; diff --git a/tests/EnumTest.php b/tests/EnumTest.php index 9cfe0c93861..7b4aa17f32d 100644 --- a/tests/EnumTest.php +++ b/tests/EnumTest.php @@ -377,6 +377,12 @@ enum Status: int { static fn (\UnitEnum $tag): string => $tag->name; static fn (\BackedEnum $tag): string|int => $tag->value; + + interface ExtendedUnitEnum extends \UnitEnum {} + static fn (ExtendedUnitEnum $tag): string => $tag->name; + + interface ExtendedBackedEnum extends \BackedEnum {} + static fn (ExtendedBackedEnum $tag): string|int => $tag->value; ', 'assertions' => [], [], diff --git a/tests/Traits/ValidCodeAnalysisTestTrait.php b/tests/Traits/ValidCodeAnalysisTestTrait.php index 46a57ac4a22..222b184c5f6 100644 --- a/tests/Traits/ValidCodeAnalysisTestTrait.php +++ b/tests/Traits/ValidCodeAnalysisTestTrait.php @@ -55,6 +55,10 @@ public function testValidCode( if (version_compare(PHP_VERSION, '8.0.0', '<')) { $this->markTestSkipped('Test case requires PHP 8.0.'); } + } elseif (strpos($test_name, 'PHP81-') !== false) { + if (version_compare(PHP_VERSION, '8.1.0', '<')) { + $this->markTestSkipped('Test case requires PHP 8.1.'); + } } elseif (strpos($test_name, 'SKIPPED-') !== false) { $this->markTestSkipped('Skipped due to a bug.'); }