From 643316a33dc6ef6341c45071416cb796449da651 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet <ceesjank@gmail.com> Date: Thu, 24 Mar 2022 08:03:54 +0100 Subject: [PATCH] Add template annotations These annotations will aid static analyses like PHPStan and Psalm to enhance type-safety for this project and projects depending on it These changes make the following example understandable by PHPStan: ```php final readonly class User { public function __construct( public string $name, ) } /** * \React\Promise\PromiseInterface<User> */ function getCurrentUserFromDatabase(): \React\Promise\PromiseInterface { // The following line would do the database query and fetch the result from it // but keeping it simple for the sake of the example. return \React\Promise\resolve(new User('WyriHaximus')); } // For the sake of this example we're going to assume the following code runs // in \React\Async\async call echo await(getCurrentUserFromDatabase())->name; // This echos: WyriHaximus ``` --- README.md | 12 ++++---- src/FiberMap.php | 19 ++++++++++--- src/functions.php | 56 ++++++++++++++++++++++++------------ tests/AwaitTest.php | 2 +- tests/CoroutineTest.php | 10 +++---- tests/ParallelTest.php | 3 ++ tests/SeriesTest.php | 6 ++++ tests/WaterfallTest.php | 6 ++++ tests/types/async.php | 17 +++++++++++ tests/types/await.php | 23 +++++++++++++++ tests/types/coroutine.php | 60 +++++++++++++++++++++++++++++++++++++++ tests/types/parallel.php | 33 +++++++++++++++++++++ tests/types/series.php | 33 +++++++++++++++++++++ tests/types/waterfall.php | 42 +++++++++++++++++++++++++++ 14 files changed, 288 insertions(+), 34 deletions(-) create mode 100644 tests/types/async.php create mode 100644 tests/types/await.php create mode 100644 tests/types/coroutine.php create mode 100644 tests/types/parallel.php create mode 100644 tests/types/series.php create mode 100644 tests/types/waterfall.php diff --git a/README.md b/README.md index 6e71f49..d8dd55c 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Async\await(…); ### async() -The `async(callable $function): callable` function can be used to +The `async(callable():(PromiseInterface<T>|T) $function): (callable():PromiseInterface<T>)` function can be used to return an async function for a function that uses [`await()`](#await) internally. This function is specifically designed to complement the [`await()` function](#await). @@ -226,7 +226,7 @@ await($promise); ### await() -The `await(PromiseInterface $promise): mixed` function can be used to +The `await(PromiseInterface<T> $promise): T` function can be used to block waiting for the given `$promise` to be fulfilled. ```php @@ -278,7 +278,7 @@ try { ### coroutine() -The `coroutine(callable $function, mixed ...$args): PromiseInterface<mixed>` function can be used to +The `coroutine(callable(mixed ...$args):(\Generator|PromiseInterface<T>|T) $function, mixed ...$args): PromiseInterface<T>` function can be used to execute a Generator-based coroutine to "await" promises. ```php @@ -498,7 +498,7 @@ Loop::addTimer(2.0, function () use ($promise): void { ### parallel() -The `parallel(iterable<callable():PromiseInterface<mixed>> $tasks): PromiseInterface<array<mixed>>` function can be used +The `parallel(iterable<callable():PromiseInterface<T>> $tasks): PromiseInterface<array<T>>` function can be used like this: ```php @@ -540,7 +540,7 @@ React\Async\parallel([ ### series() -The `series(iterable<callable():PromiseInterface<mixed>> $tasks): PromiseInterface<array<mixed>>` function can be used +The `series(iterable<callable():PromiseInterface<T>> $tasks): PromiseInterface<array<T>>` function can be used like this: ```php @@ -582,7 +582,7 @@ React\Async\series([ ### waterfall() -The `waterfall(iterable<callable(mixed=):PromiseInterface<mixed>> $tasks): PromiseInterface<mixed>` function can be used +The `waterfall(iterable<callable(mixed=):PromiseInterface<T>> $tasks): PromiseInterface<T>` function can be used like this: ```php diff --git a/src/FiberMap.php b/src/FiberMap.php index 0648788..f843a2d 100644 --- a/src/FiberMap.php +++ b/src/FiberMap.php @@ -6,13 +6,15 @@ /** * @internal + * + * @template T */ final class FiberMap { /** @var array<int,bool> */ private static array $status = []; - /** @var array<int,PromiseInterface> */ + /** @var array<int,PromiseInterface<T>> */ private static array $map = []; /** @param \Fiber<mixed,mixed,mixed,mixed> $fiber */ @@ -27,19 +29,28 @@ public static function cancel(\Fiber $fiber): void self::$status[\spl_object_id($fiber)] = true; } - /** @param \Fiber<mixed,mixed,mixed,mixed> $fiber */ + /** + * @param \Fiber<mixed,mixed,mixed,mixed> $fiber + * @param PromiseInterface<T> $promise + */ public static function setPromise(\Fiber $fiber, PromiseInterface $promise): void { self::$map[\spl_object_id($fiber)] = $promise; } - /** @param \Fiber<mixed,mixed,mixed,mixed> $fiber */ + /** + * @param \Fiber<mixed,mixed,mixed,mixed> $fiber + * @param PromiseInterface<T> $promise + */ public static function unsetPromise(\Fiber $fiber, PromiseInterface $promise): void { unset(self::$map[\spl_object_id($fiber)]); } - /** @param \Fiber<mixed,mixed,mixed,mixed> $fiber */ + /** + * @param \Fiber<mixed,mixed,mixed,mixed> $fiber + * @return ?PromiseInterface<T> + */ public static function getPromise(\Fiber $fiber): ?PromiseInterface { return self::$map[\spl_object_id($fiber)] ?? null; diff --git a/src/functions.php b/src/functions.php index 5a02406..6c27936 100644 --- a/src/functions.php +++ b/src/functions.php @@ -176,8 +176,14 @@ * await($promise); * ``` * - * @param callable $function - * @return callable(mixed ...): PromiseInterface<mixed> + * @template T + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1,A2,A3,A4,A5): (PromiseInterface<T>|T) $function + * @return callable(A1=,A2=,A3=,A4=,A5=): PromiseInterface<T> * @since 4.0.0 * @see coroutine() */ @@ -268,8 +274,9 @@ function async(callable $function): callable * } * ``` * - * @param PromiseInterface $promise - * @return mixed returns whatever the promise resolves to + * @template T + * @param PromiseInterface<T> $promise + * @return T returns whatever the promise resolves to * @throws \Exception when the promise is rejected with an `Exception` * @throws \Throwable when the promise is rejected with a `Throwable` * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) @@ -279,6 +286,8 @@ function await(PromiseInterface $promise): mixed $fiber = null; $resolved = false; $rejected = false; + + /** @var T $resolvedValue */ $resolvedValue = null; $rejectedThrowable = null; $lowLevelFiber = \Fiber::getCurrent(); @@ -292,6 +301,7 @@ function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFibe /** @var ?\Fiber<mixed,mixed,mixed,mixed> $fiber */ if ($fiber === null) { $resolved = true; + /** @var T $resolvedValue */ $resolvedValue = $value; return; } @@ -305,7 +315,7 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL if (!$throwable instanceof \Throwable) { $throwable = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) + 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) /** @phpstan-ignore-line */ ); // avoid garbage references by replacing all closures in call stack. @@ -592,9 +602,16 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(mixed ...$args):(\Generator<mixed,PromiseInterface,mixed,mixed>|mixed) $function + * @template T + * @template TYield + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1, A2, A3, A4, A5):(\Generator<mixed, PromiseInterface<TYield>, TYield, PromiseInterface<T>|T>|PromiseInterface<T>|T) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is - * @return PromiseInterface<mixed> + * @return PromiseInterface<T> * @since 3.0.0 */ function coroutine(callable $function, mixed ...$args): PromiseInterface @@ -611,7 +628,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { - /** @var ?PromiseInterface $promise */ + /** @var ?PromiseInterface<T> $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } @@ -632,7 +649,6 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface return; } - /** @var mixed $promise */ $promise = $generator->current(); if (!$promise instanceof PromiseInterface) { $next = null; @@ -642,6 +658,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface return; } + /** @var PromiseInterface<TYield> $promise */ assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); @@ -660,12 +677,13 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface } /** - * @param iterable<callable():PromiseInterface<mixed>> $tasks - * @return PromiseInterface<array<mixed>> + * @template T + * @param iterable<callable():(PromiseInterface<T>|T)> $tasks + * @return PromiseInterface<array<T>> */ function parallel(iterable $tasks): PromiseInterface { - /** @var array<int,PromiseInterface> $pending */ + /** @var array<int,PromiseInterface<T>> $pending */ $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { @@ -720,14 +738,15 @@ function parallel(iterable $tasks): PromiseInterface } /** - * @param iterable<callable():PromiseInterface<mixed>> $tasks - * @return PromiseInterface<array<mixed>> + * @template T + * @param iterable<callable():(PromiseInterface<T>|T)> $tasks + * @return PromiseInterface<array<T>> */ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface<T> $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -774,14 +793,15 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable<(callable():PromiseInterface<mixed>)|(callable(mixed):PromiseInterface<mixed>)> $tasks - * @return PromiseInterface<mixed> + * @template T + * @param iterable<(callable():(PromiseInterface<T>|T))|(callable(mixed):(PromiseInterface<T>|T))> $tasks + * @return PromiseInterface<($tasks is non-empty-array|\Traversable ? T : null)> */ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface<T> $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 3158a1b..25e269b 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -413,7 +413,7 @@ public function testRejectedPromisesShouldBeDetached(callable $await): void })()); } - /** @return iterable<string,list<callable(PromiseInterface): mixed>> */ + /** @return iterable<string,list<callable(PromiseInterface<mixed>): mixed>> */ public function provideAwaiters(): iterable { yield 'await' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await($promise)]; diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 2c674c5..1df4cdc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -22,7 +22,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -53,7 +53,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } throw new \RuntimeException('Foo'); }); @@ -99,7 +99,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); @@ -169,7 +169,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -249,7 +249,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie gc_collect_cycles(); - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 37b1e10..ad24589 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -12,6 +12,9 @@ class ParallelTest extends TestCase { public function testParallelWithoutTasks(): void { + /** + * @var array<callable(): React\Promise\PromiseInterface<mixed>> $tasks + */ $tasks = array(); $promise = React\Async\parallel($tasks); diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 9b20815..69cafd5 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -12,6 +12,9 @@ class SeriesTest extends TestCase { public function testSeriesWithoutTasks(): void { + /** + * @var array<callable(): React\Promise\PromiseInterface<mixed>> $tasks + */ $tasks = array(); $promise = React\Async\series($tasks); @@ -151,6 +154,9 @@ public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRe $tasks = new class() implements \IteratorAggregate { public int $called = 0; + /** + * @return \Iterator<callable(): React\Promise\PromiseInterface<mixed>> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 2b274b2..be174a9 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -12,6 +12,9 @@ class WaterfallTest extends TestCase { public function testWaterfallWithoutTasks(): void { + /** + * @var array<callable(): React\Promise\PromiseInterface<mixed>> $tasks + */ $tasks = array(); $promise = React\Async\waterfall($tasks); @@ -165,6 +168,9 @@ public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromis $tasks = new class() implements \IteratorAggregate { public int $called = 0; + /** + * @return \Iterator<callable(): React\Promise\PromiseInterface<mixed>> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/types/async.php b/tests/types/async.php new file mode 100644 index 0000000..b5ba8fe --- /dev/null +++ b/tests/types/async.php @@ -0,0 +1,17 @@ +<?php + +use React\Promise\PromiseInterface; +use function PHPStan\Testing\assertType; +use function React\Async\async; +use function React\Async\await; +use function React\Promise\resolve; + +assertType('React\Promise\PromiseInterface<bool>', async(static fn (): bool => true)()); +assertType('React\Promise\PromiseInterface<bool>', async(static fn (): PromiseInterface => resolve(true))()); +assertType('React\Promise\PromiseInterface<bool>', async(static fn (): bool => await(resolve(true)))()); + +assertType('React\Promise\PromiseInterface<int>', async(static fn (int $a): int => $a)(42)); +assertType('React\Promise\PromiseInterface<int>', async(static fn (int $a, int $b): int => $a + $b)(10, 32)); +assertType('React\Promise\PromiseInterface<int>', async(static fn (int $a, int $b, int $c): int => $a + $b + $c)(10, 22, 10)); +assertType('React\Promise\PromiseInterface<int>', async(static fn (int $a, int $b, int $c, int $d): int => $a + $b + $c + $d)(10, 22, 5, 5)); +assertType('React\Promise\PromiseInterface<int>', async(static fn (int $a, int $b, int $c, int $d, int $e): int => $a + $b + $c + $d + $e)(10, 12, 10, 5, 5)); diff --git a/tests/types/await.php b/tests/types/await.php new file mode 100644 index 0000000..07d51b6 --- /dev/null +++ b/tests/types/await.php @@ -0,0 +1,23 @@ +<?php + +use React\Promise\PromiseInterface; +use function PHPStan\Testing\assertType; +use function React\Async\async; +use function React\Async\await; +use function React\Promise\resolve; + +assertType('bool', await(resolve(true))); +assertType('bool', await(async(static fn (): bool => true)())); +assertType('bool', await(async(static fn (): PromiseInterface => resolve(true))())); +assertType('bool', await(async(static fn (): bool => await(resolve(true)))())); + +final class AwaitExampleUser +{ + public string $name; + + public function __construct(string $name) { + $this->name = $name; + } +} + +assertType('string', await(resolve(new AwaitExampleUser('WyriHaximus')))->name); diff --git a/tests/types/coroutine.php b/tests/types/coroutine.php new file mode 100644 index 0000000..4c0f84c --- /dev/null +++ b/tests/types/coroutine.php @@ -0,0 +1,60 @@ +<?php + +use function PHPStan\Testing\assertType; +use function React\Async\await; +use function React\Async\coroutine; +use function React\Promise\resolve; + +assertType('React\Promise\PromiseInterface<bool>', coroutine(static function () { + return true; +})); + +assertType('React\Promise\PromiseInterface<bool>', coroutine(static function () { + return resolve(true); +})); + +// assertType('React\Promise\PromiseInterface<bool>', coroutine(static function () { +// return (yield resolve(true)); +// })); + +assertType('React\Promise\PromiseInterface<int>', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + + return time(); +})); + +// assertType('React\Promise\PromiseInterface<bool>', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + +// return $bool; +// })); + +assertType('React\Promise\PromiseInterface<bool>', coroutine(static function () { + yield resolve(time()); + + return true; +})); + +assertType('React\Promise\PromiseInterface<bool>', coroutine(static function () { + for ($i = 0; $i <= 10; $i++) { + yield resolve($i); + } + + return true; +})); + +assertType('React\Promise\PromiseInterface<int>', coroutine(static fn(int $a): int => $a, 42)); +assertType('React\Promise\PromiseInterface<int>', coroutine(static fn(int $a, int $b): int => $a + $b, 10, 32)); +assertType('React\Promise\PromiseInterface<int>', coroutine(static fn(int $a, int $b, int $c): int => $a + $b + $c, 10, 22, 10)); +assertType('React\Promise\PromiseInterface<int>', coroutine(static fn(int $a, int $b, int $c, int $d): int => $a + $b + $c + $d, 10, 22, 5, 5)); +assertType('React\Promise\PromiseInterface<int>', coroutine(static fn(int $a, int $b, int $c, int $d, int $e): int => $a + $b + $c + $d + $e, 10, 12, 10, 5, 5)); + +assertType('bool', await(coroutine(static function () { + return true; +}))); + +// assertType('bool', await(coroutine(static function () { +// return (yield resolve(true)); +// }))); diff --git a/tests/types/parallel.php b/tests/types/parallel.php new file mode 100644 index 0000000..dacd024 --- /dev/null +++ b/tests/types/parallel.php @@ -0,0 +1,33 @@ +<?php + +use React\Promise\PromiseInterface; +use function PHPStan\Testing\assertType; +use function React\Async\await; +use function React\Async\parallel; +use function React\Promise\resolve; + +assertType('React\Promise\PromiseInterface<array>', parallel([])); + +assertType('React\Promise\PromiseInterface<array<bool|float|int>>', parallel([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface<array<bool|float|int>>', parallel([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +])); + +assertType('array<bool|float|int>', await(parallel([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +assertType('array<bool|float|int>', await(parallel([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +]))); diff --git a/tests/types/series.php b/tests/types/series.php new file mode 100644 index 0000000..9a233e3 --- /dev/null +++ b/tests/types/series.php @@ -0,0 +1,33 @@ +<?php + +use React\Promise\PromiseInterface; +use function PHPStan\Testing\assertType; +use function React\Async\await; +use function React\Async\series; +use function React\Promise\resolve; + +assertType('React\Promise\PromiseInterface<array>', series([])); + +assertType('React\Promise\PromiseInterface<array<bool|float|int>>', series([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface<array<bool|float|int>>', series([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +])); + +assertType('array<bool|float|int>', await(series([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +assertType('array<bool|float|int>', await(series([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +]))); diff --git a/tests/types/waterfall.php b/tests/types/waterfall.php new file mode 100644 index 0000000..1470785 --- /dev/null +++ b/tests/types/waterfall.php @@ -0,0 +1,42 @@ +<?php + +use React\Promise\PromiseInterface; +use function PHPStan\Testing\assertType; +use function React\Async\await; +use function React\Async\waterfall; +use function React\Promise\resolve; + +assertType('React\Promise\PromiseInterface<null>', waterfall([])); + +assertType('React\Promise\PromiseInterface<float>', waterfall([ + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface<float>', waterfall([ + static fn (): float => microtime(true), +])); + +// Desired, but currently unsupported with the current set of templates +//assertType('React\Promise\PromiseInterface<float>', waterfall([ +// static fn (): PromiseInterface => resolve(true), +// static fn (bool $bool): PromiseInterface => resolve(time()), +// static fn (int $int): PromiseInterface => resolve(microtime(true)), +//])); + +assertType('float', await(waterfall([ + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +// Desired, but currently unsupported with the current set of templates +//assertType('float', await(waterfall([ +// static fn (): PromiseInterface => resolve(true), +// static fn (bool $bool): PromiseInterface => resolve(time()), +// static fn (int $int): PromiseInterface => resolve(microtime(true)), +//]))); + +// assertType('React\Promise\PromiseInterface<null>', waterfall(new EmptyIterator())); + +$iterator = new ArrayIterator([ + static fn (): PromiseInterface => resolve(true), +]); +assertType('React\Promise\PromiseInterface<bool>', waterfall($iterator));