diff --git a/composer.json b/composer.json index 602f289..91bbd53 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "src/array.php", "src/constants.php", "src/debug.php", + "src/functions.php", "src/generators.php", "src/Math/functions.php", "src/other.php", diff --git a/src/UnhandledFitException.php b/src/UnhandledFitException.php new file mode 100644 index 0000000..46b1c17 --- /dev/null +++ b/src/UnhandledFitException.php @@ -0,0 +1,33 @@ + $callbacks + * List of callbacks against which the subject was gauged. + */ + public function __construct( + public readonly mixed $subject, + public readonly array $callbacks, + ) { + $value = is_scalar($subject) || $subject instanceof Stringable + ? $subject + : print_r($subject, true); + parent::__construct("Unhandled fit value: $value"); + } +} diff --git a/src/Var/Type.php b/src/Var/Type.php index 0ca4f0c..63faaa8 100644 --- a/src/Var/Type.php +++ b/src/Var/Type.php @@ -222,7 +222,7 @@ public static function tryOf(// @phpstan-ignore return.unusedType /** * Checks whether the type of the provided value matches this Type. * - * @param mixed $value + * @param mixed $value * The value to check. * * @return bool diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..45b5254 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,162 @@ + "value '$v' is a string", + * fn(int|float $v) => "value is an integer or float", + * fn(array $v, object $w) => "value is an array or an object", + * fn(Foo&(Bar|(Baz&Qux)) $v) => "both `Foo` & `Bar` or `Baz` & `Qux`", + * fn(Fallback $default) => "value is of some other type", + * ); + * + * This function supports {@see \empaphy\usephul\Fallback} as callback argument + * type to indicate a default case. Alternatively, you can use `mixed`. + * + * {@see fit()} is optimized for performance and makes no recursive calls, or + * calls to any other helper functions; it's completely self-contained. + * + * @template TFit + * @template TResult + * @template TSubject of TFit + * + * @param TSubject $subject + * The value to gauge. + * + * @param Closure(TFit $arg, TFit ...$args): TResult $callback + * A callback function. The first callback argument with a parameter type + * that fits __subject__ will be called and its result returned. + * + * @param Closure(TFit $arg, TFit ...$args): TResult ...$callbacks + * Additional callback functions. The first callback function with a parameter + * type that fits __subject__ will be called and its result returned. + * + * @return TResult + * The result of the first of the __callbacks__ that fits __subject__. + * + * @throws (TFit is empty ? InvalidArgumentException : never) + * Thrown if a callback has no parameters, or if any callback parameter is + * missing a type declaration. + * + * @throws ($subject is TFit ? never : UnhandledFitException) + * Thrown when no callback function can fit the subject. + */ +function fit(mixed $subject, Closure $callback, Closure ...$callbacks): mixed +{ + $subjectType = get_debug_type($subject); + + $argumentPosition = 2; + foreach ([$callback, ...$callbacks] as $key => $fn) { + try { + $reflectionFunction = new ReflectionFunction($fn); + } catch (ReflectionException) { // @codeCoverageIgnore + continue; // @codeCoverageIgnore + } + + if ($reflectionFunction->getNumberOfParameters() === 0) { + throw new InvalidArgumentException( + sprintf( + 'Argument #%d (...$callbacks[%s])' + . ' must have at least one parameter', + $argumentPosition, + is_string($key) ? "'$key'" : $key, + ), + ); + } + + $types = [[]]; + foreach ($reflectionFunction->getParameters() as $parameter) { + $types[0][] = $parameter->getType(); + } + + $modes = [true]; + $inits = [false]; + $nums = [count($types[0])]; + $pos = [0]; + $fit = false; + + for ($l = 0; $l > -1;) { + $type = $types[$l][$pos[$l]++]; + $mode = $type instanceof ReflectionUnionType; + $init = $type instanceof ReflectionIntersectionType; + + if ($mode || $init) { + $l++; + $types[$l] = $type->getTypes(); + $inits[$l] = $init; + $modes[$l] = $mode; + $nums[$l] = count($types[$l]); + $pos[$l] = 0; + + continue; + } + + if ($type === null) { + throw new InvalidArgumentException( + sprintf( + 'Argument #%d (...$callbacks[%s])' + . ' must have a type declaration', + $argumentPosition, + is_string($key) ? "'$key'" : $key, + ), + ); + } + + assert($type instanceof ReflectionType); + + $typeName = (string) $type; + $fit = $subjectType === $typeName + || $subject instanceof $typeName + || 'mixed' === $typeName; + + while ($l > -1 && ($fit === $modes[$l] || $pos[$l] === $nums[$l])) { + if ($pos[$l] === $nums[$l] && $fit !== $modes[$l]) { + $fit = $inits[$l]; + } + + unset($types[$l], $modes[$l], $nums[$l], $pos[$l], $inits[$l]); + $l--; + } + } + + if ($fit) { + return $fn($subject); + } + + $argumentPosition++; + } + + throw new UnhandledFitException($subject, [$callback, ...$callbacks]); +} diff --git a/tests/Unit/Fit/Bar.php b/tests/Unit/Fit/Bar.php new file mode 100644 index 0000000..db86e80 --- /dev/null +++ b/tests/Unit/Fit/Bar.php @@ -0,0 +1,7 @@ + $v], 'foo'], + ['foo', [fn(string|int $v) => $v], 'foo'], + [0, [fn(string|int $v) => $v], 0], + [0, [fn(string $s) => $s, fn(int $i) => $i], 0], + [$myFoo, [fn(FooInterface $v) => $v], $myFoo], + [$foo, [fn(Foo|Bar $v) => $v], $foo], + [$bar, [fn(Foo|Bar $v) => $v], $bar], + [$fooBar, [fn(FooInterface&BarInterface $v) => $v], $fooBar], + ['foo', [fn(Foo $v) => throw new Exception(), fn(mixed $v) => 'bar'], 'bar'], + ['foo', [fn(string $v) => $v, fn(string $v) => $v], 'foo'], + ]; + } + + /** + * @template TResult + * @template TSubject + * @template TFit of TSubject + * + * @param TSubject $subject + * @param array $callbacks + * @param TResult $expected + */ + #[DataProvider('fitArgumentsProvider')] + public function testFit(mixed $subject, array $callbacks, mixed $expected): void + { + $actual = fit($subject, ...$callbacks); + $this->assertSame($expected, $actual); + } + + public static function unfitArgumentsProvider(): array + { + $myFoo = new class implements FooInterface {}; + $foo = new Foo(); + $bar = new Bar(); + + return [ + ['foo', [fn(int $v) => $v]], + [$foo, [fn(string|int $v) => $v]], + [0, [fn(string $v) => $v]], + [0, [fn(string $s) => $s, fn(string $i) => $i]], + [$myFoo, [fn(BarInterface $v) => $v]], + [$foo, [fn(Bar $v) => $v]], + [$bar, [fn(Foo $v) => $v]], + [$foo, [fn(FooInterface&BarInterface $v) => $v]], + ['foo', [fn(Foo $v) => throw new Exception()]], + ]; + } + + /** + * @param array $callbacks + */ + #[DataProvider('unfitArgumentsProvider')] + public function testFitThrowsExceptionWhenSubjectIsUnfit(mixed $subject, array $callbacks): void + { + $this->expectException(UnhandledFitException::class); + fit($subject, ...$callbacks); + } + + public function testFitThrowsExceptionWhenTypeDeclarationIsMissing(): void + { + $this->expectException(InvalidArgumentException::class); + fit('foo', fn($v) => $v); + } + + public function testFitThrowsExceptionWhenCallbackMissing(): void + { + $this->expectException(ArgumentCountError::class); + fit('foo'); // @phpstan-ignore arguments.count,argument.templateType + } + + public function testFitThrowsExceptionWhenCallbackHasNoParameters(): void + { + $this->expectException(InvalidArgumentException::class); + fit('foo', fn() => 'bar'); + } +} diff --git a/tests/Unit/UnhandledFitExceptionTest.php b/tests/Unit/UnhandledFitExceptionTest.php new file mode 100644 index 0000000..97d9158 --- /dev/null +++ b/tests/Unit/UnhandledFitExceptionTest.php @@ -0,0 +1,39 @@ + $v], "Unhandled fit value: class@anonymous Object\n" + . "(\n [qux] => QUX\n [baz] => BAZ\n)\n"], + [$foo, [fn(int $v) => $v], 'Unhandled fit value: foo'], + ['foo', [fn(int $v) => $v], 'Unhandled fit value: foo'], + [0, [fn(string $v) => $v], 'Unhandled fit value: 0'], + ]; // @formatter:on + } + + #[DataProvider('dataProvider')] + public function testUnhandledFitException(mixed $subject, array $callbacks, string $expectedMessage): void + { + $unhandledFitException = new UnhandledFitException($subject, $callbacks); + $this->assertEquals($expectedMessage, $unhandledFitException->getMessage()); + } +} diff --git a/tests/Unit/Var/TypeData.php b/tests/Unit/Var/TypeData.php index 6788801..ec84e71 100644 --- a/tests/Unit/Var/TypeData.php +++ b/tests/Unit/Var/TypeData.php @@ -12,6 +12,16 @@ use function fopen; use function is_resource; +use const empaphy\usephul\ZEND_STR_ARRAY; +use const empaphy\usephul\ZEND_STR_BOOLEAN; +use const empaphy\usephul\ZEND_STR_CLOSED_RESOURCE; +use const empaphy\usephul\ZEND_STR_DOUBLE; +use const empaphy\usephul\ZEND_STR_INTEGER; +use const empaphy\usephul\ZEND_STR_NULL; +use const empaphy\usephul\ZEND_STR_OBJECT; +use const empaphy\usephul\ZEND_STR_RESOURCE; +use const empaphy\usephul\ZEND_STR_STRING; + class TypeData { public static function casesProvider(): array @@ -59,15 +69,15 @@ public static function failCasesProvider(): array public static function valuesProvider(): array { return [ //@formatter:off - [Type::Null, 'NULL'], - [Type::Boolean, 'boolean'], - [Type::Integer, 'integer'], - [Type::Float, 'double'], - [Type::String, 'string'], - [Type::Array, 'array'], - [Type::Object, 'object'], - [Type::Resource, 'resource'], - [Type::ClosedResource, 'resource (closed)'], + [Type::Null, ZEND_STR_NULL], + [Type::Boolean, ZEND_STR_BOOLEAN], + [Type::Integer, ZEND_STR_INTEGER], + [Type::Float, ZEND_STR_DOUBLE], + [Type::String, ZEND_STR_STRING], + [Type::Array, ZEND_STR_ARRAY], + [Type::Object, ZEND_STR_OBJECT], + [Type::Resource, ZEND_STR_RESOURCE], + [Type::ClosedResource, ZEND_STR_CLOSED_RESOURCE], [Type::Unknown, 'unknown type'], ]; //@formatter:on }