From 22bcd576d339ccce6bf1a85f5bbdeecc844fc676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Fri, 25 Aug 2023 23:53:58 +0200 Subject: [PATCH 01/12] qa: add test to verify backed enums are no literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- tests/EnumTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/EnumTest.php b/tests/EnumTest.php index 1ac88b1c2..77df3b8d6 100644 --- a/tests/EnumTest.php +++ b/tests/EnumTest.php @@ -1015,6 +1015,21 @@ class EnumTest extends TestCase 'ignored_issues' => [], 'php_version' => '8.1', ], + 'backedEnumDoesNotPassNativeType' => [ + 'code' => ' 'InvalidArgument', + 'ignored_issues' => [], + 'php_version' => '8.1', + ], ]; } } From 31eaf83c49469a16522b0974df9b53c91e7a58ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Fri, 25 Aug 2023 23:54:31 +0200 Subject: [PATCH 02/12] bugfix: prevent enums from being detected as literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- .../Type/Comparator/AtomicTypeComparator.php | 36 ++----------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php b/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php index e01ee2d2b..553d796c0 100644 --- a/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php @@ -4,7 +4,6 @@ namespace Psalm\Internal\Type\Comparator; use Psalm\Codebase; use Psalm\Internal\MethodIdentifier; -use Psalm\Type; use Psalm\Type\Atomic; use Psalm\Type\Atomic\Scalar; use Psalm\Type\Atomic\TArray; @@ -18,12 +17,10 @@ use Psalm\Type\Atomic\TConditional; use Psalm\Type\Atomic\TEmptyMixed; use Psalm\Type\Atomic\TEnumCase; use Psalm\Type\Atomic\TGenericObject; -use Psalm\Type\Atomic\TInt; use Psalm\Type\Atomic\TIterable; use Psalm\Type\Atomic\TKeyOf; use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TList; -use Psalm\Type\Atomic\TLiteralInt; use Psalm\Type\Atomic\TLiteralString; use Psalm\Type\Atomic\TMixed; use Psalm\Type\Atomic\TNamedObject; @@ -46,7 +43,6 @@ use function array_values; use function assert; use function count; use function get_class; -use function is_int; use function strtolower; /** @@ -636,37 +632,9 @@ class AtomicTypeComparator } if ($input_type_part instanceof TEnumCase - && $codebase->classlike_storage_provider->has($input_type_part->value) + && !$container_type_part instanceof TEnumCase ) { - if ($container_type_part instanceof TString || $container_type_part instanceof TInt) { - $input_type_classlike_storage = $codebase->classlike_storage_provider->get($input_type_part->value); - if ($input_type_classlike_storage->enum_type === null - || !isset($input_type_classlike_storage->enum_cases[$input_type_part->case_name]) - ) { - // Not a backed enum or non-existent enum case - return false; - } - - $input_type_enum_case_storage = $input_type_classlike_storage->enum_cases[$input_type_part->case_name]; - assert( - $input_type_enum_case_storage->value !== null, - 'Backed enums cannot have values without a value.', - ); - - if (is_int($input_type_enum_case_storage->value)) { - return self::isContainedBy( - $codebase, - new TLiteralInt($input_type_enum_case_storage->value), - $container_type_part, - ); - } - - return self::isContainedBy( - $codebase, - Type::getAtomicStringFromLiteral($input_type_enum_case_storage->value), - $container_type_part, - ); - } + return false; } if ($container_type_part instanceof TString || $container_type_part instanceof TScalar) { From 5948559a31d8c0e2bcb914386ea08f1ff3b151a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Fri, 25 Aug 2023 23:58:53 +0200 Subject: [PATCH 03/12] feature: introducing proper handling of `value-of` in combination with backed enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This introduces both: - a bugfix for a regression introduced by `31eaf83c4` which prevents backed enums are incorrectly identified as literals - an additional feature so that `value-of` can be used with backed enums to assert any of the enum cases values Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- .../Type/SimpleAssertionReconciler.php | 58 ++++++++++++++++++- src/Psalm/Type.php | 14 +++++ tests/AssertAnnotationTest.php | 12 +++- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 0602fff82..7d17e9f1c 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -83,6 +83,7 @@ use function assert; use function count; use function explode; use function get_class; +use function in_array; use function is_int; use function min; use function strlen; @@ -533,7 +534,10 @@ class SimpleAssertionReconciler extends Reconciler } if ($assertion_type instanceof TValueOf) { - return $assertion_type->type; + return self::reconcileValueOf( + $codebase, + $assertion_type + ); } return null; @@ -2951,6 +2955,58 @@ class SimpleAssertionReconciler extends Reconciler return TypeCombiner::combine(array_values($matched_class_constant_types), $codebase); } + private static function reconcileValueOf( + Codebase $codebase, + TValueOf $assertion_type + ): ?Union { + $reconciled_types = []; + + // For now, only enums are supported here + foreach ($assertion_type->type->getAtomicTypes() as $atomic_type) { + $class_name = null; + $enum_case_to_assert = null; + if ($atomic_type instanceof TClassConstant) { + $class_name = $atomic_type->fq_classlike_name; + $enum_case_to_assert = $atomic_type->const_name; + } elseif ($atomic_type instanceof TNamedObject) { + $class_name = $atomic_type->value; + } else { + return null; + } + + if (!$codebase->classOrInterfaceOrEnumExists($class_name)) { + return null; + } + + $class_storage = $codebase->classlike_storage_provider->get($class_name); + if (!$class_storage->is_enum) { + return null; + } + + if (!in_array($class_storage->enum_type, ['string', 'int'], true)) { + return null; + } + + // For value-of, the assertion is meant to return *ANY* value of *ANY* enum case + if ($enum_case_to_assert === null) { + foreach ($class_storage->enum_cases as $enum_case) { + $reconciled_types[] = Type::getLiteral($enum_case->value); + } + + continue; + } + + $enum_case = $class_storage->enum_cases[$atomic_type->const_name] ?? null; + if ($enum_case === null) { + return null; + } + + $reconciled_types[] = Type::getLiteral($enum_case->value); + } + + return new Union($reconciled_types); + } + /** * @psalm-assert-if-true TCallableObject|TObjectWithProperties|TNamedObject $type */ diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index fbc3e7e78..23399f7f6 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -58,6 +58,7 @@ use function array_values; use function explode; use function get_class; use function implode; +use function is_int; use function preg_quote; use function preg_replace; use function stripos; @@ -258,6 +259,19 @@ abstract class Type return new Union([$type]); } + /** + * @param int|string $value + * @return TLiteralString|TLiteralInt + */ + public static function getLiteral($value) + { + if (is_int($value)) { + return new TLiteralInt($value); + } + + return new TLiteralString($value); + } + public static function getString(?string $value = null): Union { return new Union([$value === null ? new TString() : self::getAtomicStringFromLiteral($value)]); diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index fba322b7d..16cdf6d52 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2194,6 +2194,10 @@ class AssertAnnotationTest extends TestCase function assertSomeInt(int $foo): void {} + /** @psalm-assert value-of $foo */ + function assertAnyEnumValue(string|int $foo): void + {} + /** @param "foo"|"bar" $foo */ function takesSomeStringFromEnum(string $foo): StringEnum { @@ -2216,8 +2220,14 @@ class AssertAnnotationTest extends TestCase assertSomeInt($int); takesSomeIntFromEnum($int); + + /** @var string|int $potentialEnumValue */ + $potentialEnumValue = null; + assertAnyEnumValue($potentialEnumValue); ', - 'assertions' => [], + 'assertions' => [ + '$potentialEnumValue===' => "'bar'|'baz'|'foo'|1|2|3", + ], 'ignored_issues' => [], 'php_version' => '8.1', ], From 6c1899df13727e28e957a662c18196640f282ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:08:14 +0200 Subject: [PATCH 04/12] qa: apply coding standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- src/Psalm/Internal/Type/SimpleAssertionReconciler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 7d17e9f1c..5b48139c8 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -536,7 +536,7 @@ class SimpleAssertionReconciler extends Reconciler if ($assertion_type instanceof TValueOf) { return self::reconcileValueOf( $codebase, - $assertion_type + $assertion_type, ); } From cd3e294bfddfbd2c49dd710d6994dbb1700ff23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:15:53 +0200 Subject: [PATCH 05/12] bugfix: remove atomic type comparator regarding `TEnumCase` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php b/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php index 553d796c0..26516c0a9 100644 --- a/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php @@ -631,12 +631,6 @@ class AtomicTypeComparator } } - if ($input_type_part instanceof TEnumCase - && !$container_type_part instanceof TEnumCase - ) { - return false; - } - if ($container_type_part instanceof TString || $container_type_part instanceof TScalar) { if ($input_type_part instanceof TNamedObject) { // check whether the object has a __toString method From f93ac70a0a72e4bd070debb9eebd41eed3358d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:24:36 +0200 Subject: [PATCH 06/12] qa: resolving psalm issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove unused variable - add assertion to reduce possible types from `TEnumCase` - use `TypeCombiner` to provide proper `Union` containing all literals Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- src/Psalm/Internal/Type/SimpleAssertionReconciler.php | 5 +++-- src/Psalm/Type.php | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 5b48139c8..a1ac4bff2 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -2963,7 +2963,6 @@ class SimpleAssertionReconciler extends Reconciler // For now, only enums are supported here foreach ($assertion_type->type->getAtomicTypes() as $atomic_type) { - $class_name = null; $enum_case_to_assert = null; if ($atomic_type instanceof TClassConstant) { $class_name = $atomic_type->fq_classlike_name; @@ -2990,6 +2989,7 @@ class SimpleAssertionReconciler extends Reconciler // For value-of, the assertion is meant to return *ANY* value of *ANY* enum case if ($enum_case_to_assert === null) { foreach ($class_storage->enum_cases as $enum_case) { + assert($enum_case->value !== null, 'Verified enum type above, value can not contain `null` anymore.'); $reconciled_types[] = Type::getLiteral($enum_case->value); } @@ -3001,10 +3001,11 @@ class SimpleAssertionReconciler extends Reconciler return null; } + assert($enum_case->value !== null, 'Verified enum type above, value can not contain `null` anymore.'); $reconciled_types[] = Type::getLiteral($enum_case->value); } - return new Union($reconciled_types); + return TypeCombiner::combine($reconciled_types, $codebase, false, false); } /** diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 23399f7f6..5a34e81a1 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -269,7 +269,7 @@ abstract class Type return new TLiteralInt($value); } - return new TLiteralString($value); + return TLiteralString::make($value); } public static function getString(?string $value = null): Union From a8bb8d38eaed64d50d8b41c8c38dd00859356dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:41:06 +0200 Subject: [PATCH 07/12] qa: ensure that we do have at least one enum case extracted from the `value-of` usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- src/Psalm/Internal/Type/SimpleAssertionReconciler.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index a1ac4bff2..10610d311 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -537,6 +537,7 @@ class SimpleAssertionReconciler extends Reconciler return self::reconcileValueOf( $codebase, $assertion_type, + $failed_reconciliation, ); } @@ -2957,7 +2958,8 @@ class SimpleAssertionReconciler extends Reconciler private static function reconcileValueOf( Codebase $codebase, - TValueOf $assertion_type + TValueOf $assertion_type, + int &$failed_reconciliation ): ?Union { $reconciled_types = []; @@ -3005,6 +3007,11 @@ class SimpleAssertionReconciler extends Reconciler $reconciled_types[] = Type::getLiteral($enum_case->value); } + if ($reconciled_types === []) { + $failed_reconciliation = Reconciler::RECONCILIATION_EMPTY; + return Type::getNever(); + } + return TypeCombiner::combine($reconciled_types, $codebase, false, false); } From a6b646f0e39ea4bb0ae33621300f526a8554cb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:42:33 +0200 Subject: [PATCH 08/12] qa: re-arrange code to avoid line-length limit of 120 chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- src/Psalm/Internal/Type/SimpleAssertionReconciler.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 10610d311..e8a031355 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -2991,7 +2991,10 @@ class SimpleAssertionReconciler extends Reconciler // For value-of, the assertion is meant to return *ANY* value of *ANY* enum case if ($enum_case_to_assert === null) { foreach ($class_storage->enum_cases as $enum_case) { - assert($enum_case->value !== null, 'Verified enum type above, value can not contain `null` anymore.'); + assert( + $enum_case->value !== null, + 'Verified enum type above, value can not contain `null` anymore.', + ); $reconciled_types[] = Type::getLiteral($enum_case->value); } From 5aac98b1739a71f04b7e5d3053d959a47da8656c Mon Sep 17 00:00:00 2001 From: Lens0021 / Leslie Date: Sat, 26 Aug 2023 02:36:37 +0900 Subject: [PATCH 09/12] A semicolon expected --- docs/annotating_code/type_syntax/array_types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/annotating_code/type_syntax/array_types.md b/docs/annotating_code/type_syntax/array_types.md index 2846a9a22..1021ab1fc 100644 --- a/docs/annotating_code/type_syntax/array_types.md +++ b/docs/annotating_code/type_syntax/array_types.md @@ -14,7 +14,7 @@ $a = [1, 2, 3, 4, 5]; ```php 'hello', 5 => 'goodbye']; -$b = ['a' => 'AA', 'b' => 'BB', 'c' => 'CC'] +$b = ['a' => 'AA', 'b' => 'BB', 'c' => 'CC']; ``` Makeshift [Structs](https://en.wikipedia.org/wiki/Struct_(C_programming_language)): From e985e8aadb12f1dd25b313641c01b3d8149909e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20B=C3=B6sing?= <2189546+boesing@users.noreply.github.com> Date: Sat, 26 Aug 2023 00:49:02 +0200 Subject: [PATCH 10/12] qa: add proper parameter type-hint to suit psalm analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Bösing <2189546+boesing@users.noreply.github.com> --- src/Psalm/Internal/Type/SimpleAssertionReconciler.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index e8a031355..d08e61561 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -2956,6 +2956,9 @@ class SimpleAssertionReconciler extends Reconciler return TypeCombiner::combine(array_values($matched_class_constant_types), $codebase); } + /** + * @param Reconciler::RECONCILIATION_* $failed_reconciliation + */ private static function reconcileValueOf( Codebase $codebase, TValueOf $assertion_type, From ad0a132d7f5d2cac317416a917fb44bd704c34cb Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Fri, 25 Aug 2023 19:30:16 -0400 Subject: [PATCH 11/12] Update src/Psalm/Type.php --- src/Psalm/Type.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 5a34e81a1..6903c9409 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -263,7 +263,7 @@ abstract class Type * @param int|string $value * @return TLiteralString|TLiteralInt */ - public static function getLiteral($value) + public static function getLiteral($value): Atomic { if (is_int($value)) { return new TLiteralInt($value); From b6ed045746d5bcc1f3f9bc0f1b835c8f4940bc3d Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sat, 26 Aug 2023 01:36:05 +0200 Subject: [PATCH 12/12] `strrchr()`: `$before_needle` parameter Refs: php/php-src#11430 --- dictionaries/CallMap.php | 2 +- dictionaries/CallMap_83_delta.php | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index bbd5ab345..8c77bba52 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -12987,7 +12987,7 @@ return [ 'strpbrk' => ['string|false', 'string'=>'string', 'characters'=>'string'], 'strpos' => ['int|false', 'haystack'=>'string', 'needle'=>'string', 'offset='=>'int'], 'strptime' => ['array|false', 'timestamp'=>'string', 'format'=>'string'], -'strrchr' => ['string|false', 'haystack'=>'string', 'needle'=>'string'], +'strrchr' => ['string|false', 'haystack'=>'string', 'needle'=>'string', 'before_needle='=>'bool'], 'strrev' => ['string', 'string'=>'string'], 'strripos' => ['int|false', 'haystack'=>'string', 'needle'=>'string', 'offset='=>'int'], 'strrpos' => ['int|false', 'haystack'=>'string', 'needle'=>'string', 'offset='=>'int'], diff --git a/dictionaries/CallMap_83_delta.php b/dictionaries/CallMap_83_delta.php index 75266c16c..8a4a76077 100644 --- a/dictionaries/CallMap_83_delta.php +++ b/dictionaries/CallMap_83_delta.php @@ -113,6 +113,10 @@ return [ 'old' => ['?bool', 'text'=>'string'], 'new' => ['bool', 'text'=>'string'], ], + 'strrchr' => [ + 'old' => ['string|false', 'haystack'=>'string', 'needle'=>'string'], + 'new' => ['string|false', 'haystack'=>'string', 'needle'=>'string', 'before_needle='=>'bool'], + ], ], 'removed' => [