Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add backed enum type annotation #10907

Open
wants to merge 14 commits into
base: 5.x
Choose a base branch
from
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
138 changes: 136 additions & 2 deletions src/Psalm/Internal/Analyzer/ClassAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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) {
Expand Down
61 changes: 60 additions & 1 deletion src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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,
),
);
}
}
}
}

Expand Down
87 changes: 82 additions & 5 deletions src/Psalm/Internal/Codebase/Populator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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,
Expand Down
Loading
Loading