From 806db287d282a4206047d5763824a5364cd3ee22 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 28 Nov 2021 09:37:57 +0200 Subject: [PATCH] Infer `::from()` and `::tryFrom()` return types on backed enums Fixes vimeo/psalm#6429 --- src/Psalm/Internal/Analyzer/ClassAnalyzer.php | 11 +- src/Psalm/Internal/Codebase/Methods.php | 66 +++++++++-- .../Reflector/ClassLikeNodeScanner.php | 12 +- stubs/Php81.phpstub | 9 +- tests/EnumTest.php | 103 ++++++++++++++++++ 5 files changed, 185 insertions(+), 16 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 4f933239c07..d77fa1a7d56 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -2354,8 +2354,15 @@ private function checkImplementedInterfaces( ); } - if ($storage->is_enum && $interface_method_name_lc === 'cases') { - continue; + if ($storage->is_enum) { + if ($interface_method_name_lc === 'cases') { + continue; + } + if ($storage->enum_type + && in_array($interface_method_name_lc, ['from', 'tryfrom'], true) + ) { + continue; + } } if (!$implementer_method_storage) { diff --git a/src/Psalm/Internal/Codebase/Methods.php b/src/Psalm/Internal/Codebase/Methods.php index 0cf0c263445..c2d734eb9cd 100644 --- a/src/Psalm/Internal/Codebase/Methods.php +++ b/src/Psalm/Internal/Codebase/Methods.php @@ -121,8 +121,16 @@ public function methodExists( return false; } - if ($class_storage->is_enum && $method_name === 'cases') { - return true; + if ($class_storage->is_enum) { + if ($method_name === 'cases') { + return true; + } + + if ($class_storage->enum_type + && \in_array($method_name, ['from', 'tryFrom'], true) + ) { + return true; + } } $source_file_path = $source ? $source->getFilePath() : $source_file_path; @@ -686,21 +694,55 @@ public function getMethodReturnType( $appearing_fq_class_storage = $this->classlike_storage_provider->get($appearing_fq_class_name); if ($appearing_fq_class_name === 'UnitEnum' - && $original_method_name === 'cases' && $original_class_storage->is_enum - && $original_class_storage->enum_cases ) { - $types = []; + if ($original_method_name === 'cases') { + if ($original_class_storage->enum_cases === []) { + return Type::getEmptyArray(); + } + $types = []; - foreach ($original_class_storage->enum_cases as $case_name => $_) { - $types[] = new Type\Union([new Type\Atomic\TEnumCase($original_fq_class_name, $case_name)]); - } + foreach ($original_class_storage->enum_cases as $case_name => $_) { + $types[] = new Type\Union([new Type\Atomic\TEnumCase($original_fq_class_name, $case_name)]); + } - $list = new Type\Atomic\TKeyedArray($types); - $list->is_list = true; - $list->sealed = true; + $list = new Type\Atomic\TKeyedArray($types); + $list->is_list = true; + $list->sealed = true; + return new Type\Union([$list]); + } + } - return new Type\Union([$list]); + if ($appearing_fq_class_name === 'BackedEnum' + && $original_class_storage->is_enum + && $original_class_storage->enum_type + ) { + if (($original_method_name === 'from' + || $original_method_name === 'tryfrom' + ) && $source_analyzer + && isset($args[0]) + && ($first_arg_type = $source_analyzer->getNodeTypeProvider()->getType($args[0]->value)) + ) { + $types = []; + foreach ($original_class_storage->enum_cases as $case_name => $case_storage) { + if (UnionTypeComparator::isContainedBy( + $source_analyzer->getCodebase(), + \is_int($case_storage->value) ? + Type::getInt(false, $case_storage->value) : + Type::getString($case_storage->value), + $first_arg_type + )) { + $types[] = new Type\Atomic\TEnumCase($original_fq_class_name, $case_name); + } + } + if ($types) { + if ($original_method_name === 'tryfrom') { + $types[] = new Type\Atomic\TNull(); + } + return new Type\Union($types); + } + return $original_method_name === 'tryfrom' ? Type::getNull() : Type::getNever(); + } } if (!$appearing_fq_class_storage->user_defined diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index bb39e3af390..047c3365462 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -300,7 +300,17 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool $this->file_storage->has_visitor_issues = true; $storage->has_visitor_issues = true; } - // todo: $this->codebase->scanner->queueClassLikeForScanning('BackedEnum'); + $storage->class_implements['backedenum'] = 'BackedEnum'; + $storage->direct_class_interfaces['backedenum'] = 'BackedEnum'; + $this->file_storage->required_interfaces['backedenum'] = 'BackedEnum'; + $this->codebase->scanner->queueClassLikeForScanning('BackedEnum'); + $storage->declaring_method_ids['from'] = new \Psalm\Internal\MethodIdentifier('BackedEnum', 'from'); + $storage->appearing_method_ids['from'] = $storage->declaring_method_ids['from']; + $storage->declaring_method_ids['tryfrom'] = new \Psalm\Internal\MethodIdentifier( + 'BackedEnum', + 'tryfrom' + ); + $storage->appearing_method_ids['tryfrom'] = $storage->declaring_method_ids['tryfrom']; } $this->codebase->scanner->queueClassLikeForScanning('UnitEnum'); diff --git a/stubs/Php81.phpstub b/stubs/Php81.phpstub index 0762baef4d9..3a1937ae81f 100644 --- a/stubs/Php81.phpstub +++ b/stubs/Php81.phpstub @@ -3,10 +3,17 @@ namespace { interface UnitEnum { /** @var non-empty-string $name */ public readonly string $name; - + /** @return non-empty-list */ public static function cases(): array; } + + interface BackedEnum + { + public readonly int|string $value; + public static function from(string|int $value): static; + public static function tryFrom(string|int $value): ?static; + } } namespace FTP { diff --git a/tests/EnumTest.php b/tests/EnumTest.php index 7f5b10dd8b2..98f7d540dcc 100644 --- a/tests/EnumTest.php +++ b/tests/EnumTest.php @@ -261,6 +261,109 @@ enum ParamType { [], '8.1', ], + 'casesOnEnumWithNoCasesReturnEmptyArray' => [ + ' [ + '$_z===' => 'array', + ], + [], + '8.1', + ], + 'backedEnumFromReturnsInstanceOfThatEnum' => [ + ' [], + [], + '8.1', + ], + 'backedEnumTryFromReturnsInstanceOfThatEnum' => [ + ' [], + [], + '8.1', + ], + 'backedEnumFromReturnsSpecificCase' => [ + ' [ + '$_z===' => 'enum(Status::Closed)', + ], + [], + '8.1', + ], + 'backedEnumTryFromReturnsSpecificCase' => [ + ' [ + '$_z===' => 'enum(Status::Closed)|null', + ], + [], + '8.1', + ], + 'backedEnumFromReturnsUnionOfCases' => [ + ' [ + '$_z===' => 'enum(Status::Closed)|enum(Status::Open)', + ], + [], + '8.1', + ], + 'backedEnumTryFromReturnsUnionOfCases' => [ + ' [ + '$_z===' => 'enum(Status::Closed)|enum(Status::Open)|null', + ], + [], + '8.1', + ], ]; }