Skip to content
Merged
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/UnhandledFitException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace empaphy\usephul;

use Closure;
use Exception;
use Stringable;

/**
* An UnhandledFitException is thrown when the subject passed to the fit()
* function is not a fit for any callback argument.
*/
class UnhandledFitException extends Exception
{
/**
* @param mixed $subject
* Subject that was deemed unfit.
*
* @param array<Closure(mixed $arg, mixed ...$args): mixed> $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");
}
}
2 changes: 1 addition & 1 deletion src/Var/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

/**
* @noinspection DuplicatedCode
* @noinspection SuspiciousBinaryOperationInspection
*/

declare(strict_types=1);

namespace empaphy\usephul;

use Closure;
use InvalidArgumentException;
use ReflectionException;
use ReflectionFunction;
use ReflectionIntersectionType;
use ReflectionType;
use ReflectionUnionType;

use function assert;
use function count;
use function get_debug_type;
use function is_string;
use function sprintf;

/**
* Returns the result of the first callback with a parameter type that fits
* the type of the given value.
*
* This function functions similar to a `match` or `switch` statement but uses
* type checks to gauge which expression to evaluate for __subject__.
*
* use empaphy\usephul\Fallback;
* use function empaphy\usephul\fit;
*
* $result = fit(
* $example,
* fn(string $v): string => "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]);
}
7 changes: 7 additions & 0 deletions tests/Unit/Fit/Bar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Fit;

class Bar implements BarInterface {}
7 changes: 7 additions & 0 deletions tests/Unit/Fit/BarInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Fit;

interface BarInterface {}
7 changes: 7 additions & 0 deletions tests/Unit/Fit/Foo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Fit;

class Foo implements FooInterface {}
7 changes: 7 additions & 0 deletions tests/Unit/Fit/FooBar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Fit;

class FooBar implements FooInterface, BarInterface {}
7 changes: 7 additions & 0 deletions tests/Unit/Fit/FooInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Fit;

interface FooInterface {}
117 changes: 117 additions & 0 deletions tests/Unit/FitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/**
* @noinspection PhpDocMissingThrowsInspection
* @noinspection PhpParamsInspection
* @noinspection PhpUnhandledExceptionInspection
*/

declare(strict_types=1);

namespace Tests\Unit;

use ArgumentCountError;
use Closure;
use empaphy\usephul\UnhandledFitException;
use Exception;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCase;
use Tests\Unit\Fit\Bar;
use Tests\Unit\Fit\BarInterface;
use Tests\Unit\Fit\Foo;
use Tests\Unit\Fit\FooBar;
use Tests\Unit\Fit\FooInterface;

use function empaphy\usephul\fit;

#[CoversFunction('empaphy\usephul\fit')]
#[UsesClass(UnhandledFitException::class)]
class FitTest extends TestCase
{
public static function fitArgumentsProvider(): array
{
$myFoo = new class implements FooInterface {};
$foo = new Foo();
$bar = new Bar();
$fooBar = new FooBar();

return [
['foo', [fn(string $v) => $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<Closure(TFit $arg, TFit ...$args): TResult> $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<Closure(mixed $arg, mixed ...$args): mixed> $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');
}
}
Loading