diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 5b365c44..853f58d2 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -387,6 +387,7 @@ final class Loader 'Psl\\Type\\is_nan' => 'Psl/Type/is_nan.php', 'Psl\\Type\\literal_scalar' => 'Psl/Type/literal_scalar.php', 'Psl\\Type\\backed_enum' => 'Psl/Type/backed_enum.php', + 'Psl\\Type\\backed_enum_value' => 'Psl/Type/backed_enum_value.php', 'Psl\\Type\\unit_enum' => 'Psl/Type/unit_enum.php', 'Psl\\Type\\converted' => 'Psl/Type/converted.php', 'Psl\\Json\\encode' => 'Psl/Json/encode.php', diff --git a/src/Psl/Type/Internal/BackedEnumValueType.php b/src/Psl/Type/Internal/BackedEnumValueType.php new file mode 100644 index 00000000..a63ee117 --- /dev/null +++ b/src/Psl/Type/Internal/BackedEnumValueType.php @@ -0,0 +1,102 @@ +> + * + * @internal + */ +final readonly class BackedEnumValueType extends Type +{ + private bool $isStringBacked; + + /** + * @psalm-mutation-free + * + * @param class-string $enum + */ + public function __construct( + private string $enum + ) { + invariant(is_a($this->enum, BackedEnum::class, true), 'Backed enum class-string required'); + $reflection = new ReflectionEnum($this->enum); + $this->isStringBacked = $reflection->getBackingType()->getName() === 'string'; + } + + /** + * @psalm-assert-if-true value-of $value + */ + public function matches(mixed $value): bool + { + return match ($this->isStringBacked) { + true => is_string($value) && $this->enum::tryFrom($value) !== null, + false => is_int($value) && $this->enum::tryFrom($value) !== null, + }; + } + + /** + * @throws CoercionException + * + * @return value-of + * + * @psalm-suppress MismatchingDocblockReturnType,DocblockTypeContradiction + * Psalm has issues with value-of when used with an enum + */ + public function coerce(mixed $value): string|int + { + try { + $case = $this->isStringBacked + ? string()->coerce($value) + : int()->coerce($value); + + if ($this->matches($case)) { + return $case; + } + } catch (CoercionException) { + } + + throw CoercionException::withValue($value, $this->toString()); + } + + /** + * @throws AssertException + * + * @return value-of + * + * @psalm-assert value-of $value + * + * @psalm-suppress MismatchingDocblockReturnType + * Psalm has issues with value-of when used with an enum + */ + public function assert(mixed $value): string|int + { + if ($this->matches($value)) { + return $value; + } + + throw AssertException::withValue($value, $this->toString()); + } + + public function toString(): string + { + return 'value-of<' . $this->enum . '>'; + } +} diff --git a/src/Psl/Type/README.md b/src/Psl/Type/README.md index a33500e0..58113b32 100644 --- a/src/Psl/Type/README.md +++ b/src/Psl/Type/README.md @@ -77,7 +77,24 @@ Provides a type that can parse backed-enums. Can coerce from: * `string` when `T` is a string-backed enum. -* `int` when `T` is a string-backed enum. +* `int` when `T` is an integer-backed enum. + +--- + +#### [backed_enum_value](backed_enum_value.php) + +```hack +@pure +@template T of BackedEnum +Type\backed_enum_value(class-string $enum): TypeInterface> +``` + +Provides a type that can verify a value matches a backed enum value. + +Can coerce from: + +* `string|int` when `T` is a string-backed enum. +* `int|numeric-string` when `T` is an integer-backed enum. --- diff --git a/src/Psl/Type/backed_enum_value.php b/src/Psl/Type/backed_enum_value.php new file mode 100644 index 00000000..94cb6b8a --- /dev/null +++ b/src/Psl/Type/backed_enum_value.php @@ -0,0 +1,21 @@ + $enum + * + * @return TypeInterface> + */ +function backed_enum_value(string $enum): TypeInterface +{ + return new Internal\BackedEnumValueType($enum); +} diff --git a/tests/unit/Type/IntegerBackedEnumValueTypeTest.php b/tests/unit/Type/IntegerBackedEnumValueTypeTest.php new file mode 100644 index 00000000..8b9f2996 --- /dev/null +++ b/tests/unit/Type/IntegerBackedEnumValueTypeTest.php @@ -0,0 +1,53 @@ +> + */ +final class IntegerBackedEnumValueTypeTest extends TypeTest +{ + public function getType(): Type\TypeInterface + { + return Type\backed_enum_value(IntegerEnum::class); + } + + public function getValidCoercions(): iterable + { + yield [$this->stringable('1'), IntegerEnum::Foo->value]; + yield [1, IntegerEnum::Foo->value]; + yield ['1', IntegerEnum::Foo->value]; + yield ['2', IntegerEnum::Bar->value]; + yield [2, IntegerEnum::Bar->value]; + } + + /** + * @return iterable + */ + public function getInvalidCoercions(): iterable + { + yield [99]; + yield [null]; + yield [STDIN]; + yield ['hello']; + yield [$this->stringable('bar')]; + yield [new class { + }]; + } + + /** + * @return iterable, 1: string}> + */ + public function getToStringExamples(): iterable + { + yield [Type\backed_enum_value(IntegerEnum::class), Str\format('value-of<%s>', IntegerEnum::class)]; + } +} diff --git a/tests/unit/Type/StringBackedEnumValueTypeTest.php b/tests/unit/Type/StringBackedEnumValueTypeTest.php new file mode 100644 index 00000000..ea936ac1 --- /dev/null +++ b/tests/unit/Type/StringBackedEnumValueTypeTest.php @@ -0,0 +1,51 @@ +> + */ +final class StringBackedEnumValueTypeTest extends TypeTest +{ + public function getType(): Type\TypeInterface + { + return Type\backed_enum_value(StringEnum::class); + } + + public function getValidCoercions(): iterable + { + yield [1, StringEnum::Bar->value]; + yield [$this->stringable('foo'), StringEnum::Foo->value]; + yield ['foo', StringEnum::Foo->value]; + yield ['1', StringEnum::Bar->value]; + } + + /** + * @return iterable + */ + public function getInvalidCoercions(): iterable + { + yield [null]; + yield [STDIN]; + yield ['hello']; + yield [$this->stringable('bar')]; + yield [new class { + }]; + } + + /** + * @return iterable>, 1: string}> + */ + public function getToStringExamples(): iterable + { + yield [Type\backed_enum_value(StringEnum::class), Str\format('value-of<%s>', StringEnum::class)]; + } +}