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..d727f724 --- /dev/null +++ b/src/Psl/Type/Internal/BackedEnumValueType.php @@ -0,0 +1,105 @@ +> + * + * @internal + */ +final readonly class BackedEnumValueType extends Type +{ + private bool $isStringBacked; + + /** + * @psalm-mutation-free + * + * @param class-string $enum + */ + public function __construct( + private string $enum + ) { + $this->isStringBacked = is_string($this->enum::cases()[0]->value); + } + + /** + * @psalm-assert-if-true value-of $value + */ + public function matches(mixed $value): bool + { + if (! is_string($value) && ! is_int($value)) { + return false; + } + + $values = array_map( + static fn (BackedEnum $enum): string|int => $enum->value, + $this->enum::cases(), + ); + + return in_array($value, $values, true); + } + + /** + * @throws CoercionException + * + * @return value-of + */ + public function coerce(mixed $value): string|int + { + if ($this->isStringBacked) { + try { + $str_value = string()->coerce($value); + if ($this->matches($str_value)) { + return $str_value; + } + } catch (CoercionException) { + } + } else { + try { + $int_value = int()->coerce($value); + if ($this->matches($int_value)) { + return $int_value; + } + } catch (CoercionException) { + } + } + + throw CoercionException::withValue($value, $this->toString()); + } + + /** + * @throws AssertException + * + * @return value-of + * + * @psalm-assert value-of $value + */ + 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)]; + } +}