diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 8e850570012..a2e46ce4d61 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -74,6 +74,7 @@ use Psalm\Storage\FunctionLikeParameter; use Psalm\Storage\MethodStorage; use Psalm\Type; +use Psalm\Type\Atomic; use Psalm\Type\Atomic\TGenericObject; use Psalm\Type\Atomic\TMixed; use Psalm\Type\Atomic\TNamedObject; @@ -84,6 +85,7 @@ use UnexpectedValueException; use function array_filter; +use function array_key_first; use function array_keys; use function array_map; use function array_merge; @@ -99,9 +101,11 @@ use function preg_match; use function preg_replace; use function reset; +use function sprintf; use function str_replace; use function strtolower; use function substr; +use function trim; /** * @internal @@ -2193,6 +2197,18 @@ private function checkImplementedInterfaces( ); } + if ($fq_interface_name_lc === 'backedenum' && + $codebase->analysis_php_version_id >= 8_01_00 + ) { + $this->checkTemplateParams( + $codebase, + $storage, + $interface_storage, + $code_location, + $storage->template_type_implements_count[$fq_interface_name_lc] ?? 0, + ); + } + if (($fq_interface_name_lc === 'unitenum' || $fq_interface_name_lc === 'backedenum') && !$storage->is_enum @@ -2521,6 +2537,25 @@ private function checkEnum(): void { $storage = $this->storage; + /** @var Atomic|null $enum_implemented_type */ + $enum_implemented_type = null; + + foreach ($storage->template_extended_params ?? [] as $template_extended_params_map) { + foreach ($template_extended_params_map as $template) { + if (count($template_extended_params_map) > 1) { + throw new LogicException('BackedEnum should only have 1 template parameter in its stub'); + } + + $enum_implemented_types = $template->getAtomicTypes(); + + if (count($enum_implemented_types) === 1) { + $enum_implemented_type = $enum_implemented_types[array_key_first($enum_implemented_types)] ?? null; + + break 2; + } + } + } + $seen_values = []; foreach ($storage->enum_cases as $case_storage) { $case_value = $case_storage->getValue($this->getCodebase()->classlikes); @@ -2541,8 +2576,11 @@ private function checkEnum(): void ), ); } elseif ($case_value !== null) { - if ((is_int($case_value) && $storage->enum_type === 'string') - || (is_string($case_value) && $storage->enum_type === 'int') + $is_int_case_value = is_int($case_value); + $is_string_case_value = is_string($case_value); + + if (($is_int_case_value && $storage->enum_type === 'string') || + ($is_string_case_value && $storage->enum_type === 'int') ) { IssueBuffer::maybeAdd( new InvalidEnumCaseValue( @@ -2552,6 +2590,102 @@ private function checkEnum(): void ), ); } + + if ($is_string_case_value && $storage->enum_type === 'string') { + $case_value = (string) $case_value; + + if ($enum_implemented_type instanceof Type\Atomic\TNonEmptyString) { + if (trim($case_value) === '') { + IssueBuffer::maybeAdd( + new InvalidEnumCaseValue( + sprintf( + 'Enum case value type should be %s, got `%s`', + $enum_implemented_type->getId(), + $case_value, + ), + $case_storage->stmt_location, + $storage->name, + ), + ); + } + } + } + + if ($is_int_case_value && $storage->enum_type === 'int') { + $case_value = (int) $case_value; + + if ($enum_implemented_type instanceof Type\Atomic\TIntRange) { + if ($enum_implemented_type->isPositive() && $case_value < 1) { + IssueBuffer::maybeAdd( + new InvalidEnumCaseValue( + sprintf( + 'Enum case value type should be %s, got `%d`', + $enum_implemented_type->getId(), + $case_value, + ), + $case_storage->stmt_location, + $storage->name, + ), + ); + } + + if ($enum_implemented_type->isPositiveOrZero() && $case_value < 0) { + IssueBuffer::maybeAdd( + new InvalidEnumCaseValue( + sprintf( + 'Enum case value type should be %s, got `%d`', + $enum_implemented_type->getId(), + $case_value, + ), + $case_storage->stmt_location, + $storage->name, + ), + ); + } + + if ($enum_implemented_type->isNegative() && $case_value >= 0) { + IssueBuffer::maybeAdd( + new InvalidEnumCaseValue( + sprintf( + 'Enum case value type should be %s, got `%d`', + $enum_implemented_type->getId(), + $case_value, + ), + $case_storage->stmt_location, + $storage->name, + ), + ); + } + + if ($enum_implemented_type->isNegativeOrZero() && $case_value > 0) { + IssueBuffer::maybeAdd( + new InvalidEnumCaseValue( + sprintf( + 'Enum case value type should be %s, got `%d`', + $enum_implemented_type->getId(), + $case_value, + ), + $case_storage->stmt_location, + $storage->name, + ), + ); + } + + if ($enum_implemented_type->contains($case_value) === false) { + IssueBuffer::maybeAdd( + new InvalidEnumCaseValue( + sprintf( + 'Enum case value type should be %s, got `%d`', + $enum_implemented_type->getId(), + $case_value, + ), + $case_storage->stmt_location, + $storage->name, + ), + ); + } + } + } } if ($case_value !== null) { diff --git a/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php index bc20c08f8ae..d13f06bae02 100644 --- a/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php @@ -32,6 +32,7 @@ use Psalm\Type\Union; use UnexpectedValueException; +use function array_filter; use function array_keys; use function array_pop; use function array_search; @@ -636,10 +637,38 @@ protected function checkTemplateParams( CodeLocation $code_location, int $given_param_count ): void { + $enum_type = $storage->enum_type; + $parent_storage_class_template_types = $parent_storage->getClassTemplateTypes(); + + $is_backed_enum_like = $storage->is_enum && + in_array( + $enum_type, + array_keys( + count($parent_storage_class_template_types) ? + $parent_storage_class_template_types[0]->getAtomicTypes() + : [], + true, + ), + ); + $expected_param_count = $parent_storage->template_types === null ? 0 : count($parent_storage->template_types); + /** + * 1) BackedEnum do not always need a template to infer the type as it must be specified by default when + * it is declared, the template is only needed for more specific types such as non-empty-string + * 2) BackedEnum can only be extended by interfaces and cannot be implemented by classes + */ + if (($is_backed_enum_like && $given_param_count === 0) || + ( + $is_backed_enum_like === false && + $parent_storage->name === 'BackedEnum' + ) + ) { + $expected_param_count = 0; + } + if ($expected_param_count > $given_param_count) { IssueBuffer::maybeAdd( new MissingTemplateParam( @@ -702,7 +731,13 @@ protected function checkTemplateParams( if (isset($parent_storage->template_covariants[$i]) && !$parent_storage->template_covariants[$i] ) { - foreach ($extended_type->getAtomicTypes() as $t) { + $extended_type_atomic_types = $extended_type->getAtomicTypes(); + $extended_type_atomic_types_int_string_diff = array_filter( + $extended_type_atomic_types, + fn($at) => !$at instanceof Type\Atomic\TInt && !$at instanceof Type\Atomic\TString, + ); + + foreach ($extended_type_atomic_types as $t) { if ($t instanceof TTemplateParam && $storage->template_types && $storage->template_covariants @@ -720,6 +755,30 @@ protected function checkTemplateParams( $storage->suppressed_issues + $this->getSuppressedIssues(), ); } + + if ($is_backed_enum_like && $extended_type_atomic_types_int_string_diff === []) { + if ($t instanceof Type\Atomic\TInt && $enum_type === 'string') { + IssueBuffer::maybeAdd( + new InvalidTemplateParam( + 'Extended template param ' . $template_name + . ' expects type ' . $enum_type + . ', type ' . $extended_type->getId() . ' given', + $code_location, + ), + ); + } + + if ($t instanceof Type\Atomic\TString && $enum_type === 'int') { + IssueBuffer::maybeAdd( + new InvalidTemplateParam( + 'Extended template param ' . $template_name + . ' expects type ' . $enum_type + . ', type ' . $extended_type->getId() . ' given', + $code_location, + ), + ); + } + } } } diff --git a/src/Psalm/Internal/Codebase/Populator.php b/src/Psalm/Internal/Codebase/Populator.php index 864a27e7c91..950947a64d8 100644 --- a/src/Psalm/Internal/Codebase/Populator.php +++ b/src/Psalm/Internal/Codebase/Populator.php @@ -451,7 +451,7 @@ private function populateDataFromTrait( $storage->pseudo_property_set_types += $trait_storage->pseudo_property_set_types; $storage->pseudo_static_methods += $trait_storage->pseudo_static_methods; - + $storage->pseudo_methods += $trait_storage->pseudo_methods; $storage->declaring_pseudo_method_ids += $trait_storage->declaring_pseudo_method_ids; } @@ -645,11 +645,39 @@ private static function extendTemplateParams( } } } + + $defaultTemplate = self::getDefaultTemplateForInterfaceImplementingBackedEnum($storage, $parent_storage) + ?? self::getDefaultTemplateForBackedEnum($storage); + + if ($defaultTemplate !== null) { + /** + * @psalm-suppress TypeDoesNotContainNull It contains it according to the code + */ + if ($storage->template_extended_offsets === null) { + $storage->template_extended_offsets = []; + } + + $storage->template_extended_offsets['BackedEnum'] = $defaultTemplate; + } } else { - foreach ($parent_storage->template_types as $template_name => $template_type_map) { - foreach ($template_type_map as $template_type) { - $default_param = $template_type->setProperties(['from_docblock' => false]); - $storage->template_extended_params[$parent_storage->name][$template_name] = $default_param; + $defaultTemplate = self::getDefaultTemplateForInterfaceImplementingBackedEnum($storage, $parent_storage) + ?? self::getDefaultTemplateForBackedEnum($storage); + + if ($defaultTemplate !== null) { + /** + * @psalm-suppress DocblockTypeContradiction It contains it according to the code + */ + if ($storage->template_extended_params === null) { + $storage->template_extended_params = []; + } + + $storage->template_extended_params['BackedEnum'] = $defaultTemplate; + } else { + foreach ($parent_storage->template_types as $template_name => $template_type_map) { + foreach ($template_type_map as $template_type) { + $default_param = $template_type->setProperties(['from_docblock' => false]); + $storage->template_extended_params[$parent_storage->name][$template_name] = $default_param; + } } } @@ -670,6 +698,55 @@ private static function extendTemplateParams( } } + /** + * @return array{T: Union}|null + */ + private static function getDefaultTemplateForInterfaceImplementingBackedEnum( + ClassLikeStorage $storage, + ClassLikeStorage $parent_storage + ): ?array { + $is_interface = $storage->is_interface; + + if ($is_interface === false || $parent_storage->name !== "BackedEnum") { + return null; + } + + // it comes from the BackedEnum stub + $mapped_name = 'T'; + + $t_template_param = new TTemplateParam( + $mapped_name, + new Union(['int' => new TInt(), 'string' => new TString()]), + $storage->name, + ); + + return [$mapped_name => new Union(['types' => $t_template_param])]; + } + + /** + * This allows a BackedEnum to not implement any template via docblock as the default type is inferred + * by the backed type, unless the user wants to define a more specific type for the backed enum. + * + * @return array{T: Union}|null + */ + private static function getDefaultTemplateForBackedEnum(ClassLikeStorage $storage): ?array + { + $enum_type = $storage->enum_type; + + if ($enum_type === null || $storage->template_type_implements_count !== null) { + return null; + } + + // it comes from the BackedEnum stub + $mapped_name = 'T'; + + if ($enum_type === 'string') { + return [$mapped_name => new Union(['string' => new TString()])]; + } + + return [$mapped_name => new Union(['int' => new TInt()])]; + } + private function populateInterfaceDataFromParentInterface( ClassLikeStorage $storage, ClassLikeStorageProvider $storage_provider, diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index 6aec220047d..3af6a9572e3 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -354,10 +354,10 @@ final class ClassLikeStorage implements HasAttributesInterface /** * A map of which generic classlikes are extended or implemented by this class or interface. * - * This is only used in the populator, which poulates the $template_extended_params property below. + * This is only used in the populator, which populates the $template_extended_params property below. * * @internal - * @var array>|null + * @var array>|null */ public $template_extended_offsets; diff --git a/stubs/Php81.phpstub b/stubs/Php81.phpstub index 4c81bf3e209..4aa96cc3197 100644 --- a/stubs/Php81.phpstub +++ b/stubs/Php81.phpstub @@ -11,19 +11,25 @@ namespace { public static function cases(): array; } + /** @template T of int|string */ interface BackedEnum extends UnitEnum { /** @var non-empty-string $name */ public readonly string $name; + /** @var T $value */ public readonly int|string $value; /** + * @template T of int|string * @psalm-pure + * @param T $value */ public static function from(string|int $value): static; /** + * @template T of int|string * @psalm-pure + * @param T $value */ public static function tryFrom(string|int $value): ?static; diff --git a/tests/EnumTest.php b/tests/EnumTest.php index f66cdae3889..d2149924a0f 100644 --- a/tests/EnumTest.php +++ b/tests/EnumTest.php @@ -713,6 +713,98 @@ enum Foo: string { 'ignored_issues' => [], 'php_version' => '8.1', ], + 'positiveIntTemplateType' => [ + 'code' => ' + */ + enum PositiveNumber: int { + case One = 1; + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nonNegativeIntTemplateType' => [ + 'code' => ' + */ + enum NonNegativeNumber: int { + case Zero = 0; + case One = 1; + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'negativeIntTemplateType' => [ + 'code' => ' + */ + enum NegativeNumber: int { + case MinusOne = -1; + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'intRangeTemplateType' => [ + 'code' => '> + */ + enum IntRangeNumber: int { + case Zero = 0; + case One = 1; + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nonEmptyStringTemplateType' => [ + 'code' => ' + */ + enum StringEnum: string { + case Zero = "0"; + case One = "1"; + }', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'validTemplateParameterTypePassedToFromMethod' => [ + 'code' => ' + */ + enum NumberEnum: int { + case Zero = 0; + case One = 1; + }; + + $nonNegativeNumber = NumberEnum::from(0);', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'validTemplateParameterTypePassedToTryFromMethod' => [ + 'code' => ' + */ + enum NumberEnum: int { + case One = 1; + }; + + $negativeNumber = NumberEnum::tryFrom(1);', + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], ]; } @@ -1207,6 +1299,157 @@ enum Bar: int 'ignored_issues' => [], 'php_version' => '8.1', ], + 'positiveIntTemplateTypeDoesNotMatchEnumValue' => [ + 'code' => ' + */ + enum PositiveNumber: int { + case Zero = 0; + case One = 1; + }', + 'error_message' => 'InvalidEnumCaseValue', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nonNegativeIntTemplateTypeDoesNotMatchEnumValue' => [ + 'code' => ' + */ + enum NonNegativeNumber: int { + case MinusOne = -1; + case Zero = 0; + case One = 1; + }', + 'error_message' => 'InvalidEnumCaseValue', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'negativeIntTemplateTypeDoesNotMatchEnumValue' => [ + 'code' => ' + */ + enum NegativeNumber: int { + case MinusOne = -1; + case Zero = 0; + case One = 1; + }', + 'error_message' => 'InvalidEnumCaseValue', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'intRangeTemplateTypeDoesNotMatchEnumValue' => [ + 'code' => '> + */ + enum IntRangeNumber: int { + case TwoHundred = 200; + case Zero = 0; + case One = 1; + case OneHundred = 100; + }', + 'error_message' => 'InvalidEnumCaseValue', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nonEmptyStringTemplateTypeDoesNotMatchEnumValue' => [ + 'code' => ' + */ + enum StringEnum: string { + case Zero = "0"; + case One = "1"; + case Empty = ""; + }', + 'error_message' => 'InvalidEnumCaseValue', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'stringTemplateTypeDoesNotMatchBackingType' => [ + 'code' => ' + */ + enum Number: int { + case Zero = 0; + case One = 1; + }', + 'error_message' => 'InvalidTemplateParam', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'intTemplateTypeDoesNotMatchBackingType' => [ + 'code' => ' + */ + enum Number: string { + case Zero = "0"; + case One = "1"; + }', + 'error_message' => 'InvalidTemplateParam', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nonAllowedTemplateType' => [ + 'code' => ' + */ + enum NumberEnum: int { + case Zero = 0; + case One = 1; + }', + 'error_message' => 'InvalidTemplateParam', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'nonAllowedTemplateUnionType' => [ + 'code' => ' + */ + enum NumberEnum: int { + case Zero = 0; + case One = 1; + }', + 'error_message' => 'InvalidTemplateParam', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'invalidTemplateParameterTypePassedToFromMethod' => [ + 'code' => ' + */ + enum NumberEnum: int { + case Zero = 0; + case One = 1; + }; + + $negativeNumber = NumberEnum::from(-10);', + 'error_message' => 'InvalidArgument', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'invalidTemplateParameterTypePassedToTryFromMethod' => [ + 'code' => ' + */ + enum NumberEnum: int { + case One = 1; + }; + + $nonPositiveNumber = NumberEnum::tryFrom(0);', + 'error_message' => 'InvalidArgument', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], ]; } }