diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f1c58..e206ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,57 @@ name: CI on: [push, pull_request] jobs: + blackbox: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macOS-latest] + php-version: ['8.2', '8.3'] + dependency-versions: ['lowest', 'highest'] + name: 'BlackBox' + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + coverage: none + - name: Composer + uses: "ramsey/composer-install@v2" + with: + dependency-versions: ${{ matrix.dependencies }} + - name: BlackBox + run: php blackbox.php + coverage: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macOS-latest] + php-version: ['8.2', '8.3'] + dependency-versions: ['lowest', 'highest'] + name: 'Coverage' + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + coverage: xdebug + - name: Composer + uses: "ramsey/composer-install@v2" + with: + dependency-versions: ${{ matrix.dependencies }} + - name: BlackBox + run: php blackbox.php + env: + ENABLE_COVERAGE: 'true' + - uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} psalm: runs-on: ubuntu-latest strategy: diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 1431d66..021fd34 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,6 +1,6 @@ when( + \getenv('ENABLE_COVERAGE') !== false, + static fn(Application $app) => $app + ->codeCoverage( + CodeCoverage::of( + __DIR__.'/src/', + __DIR__.'/proofs/', + ) + ->dumpTo('coverage.clover') + ->enableWhen(true), + ) + ->scenariiPerProof(1), + ) + ->tryToProve(Load::everythingIn(__DIR__.'/proofs/')) + ->exit(); diff --git a/proofs/functional.php b/proofs/functional.php new file mode 100644 index 0000000..759270e --- /dev/null +++ b/proofs/functional.php @@ -0,0 +1,179 @@ +time(static function() { + Forerunner::of(Factory::build())(null, Predetermined::of( + static fn($os) => $os->process()->halt(Second::of(1)), + static fn($os) => $os->process()->halt(Second::of(1)), + static fn($os) => $os->process()->halt(Second::of(1)), + )); + }); + $expect + ->inLessThan() + ->seconds(2); + $expect + ->inMoreThan() + ->seconds(1); + }, + ); + + yield proof( + 'Carry value via the source', + given( + Set\Type::any(), + Set\Type::any(), + ), + static function($assert, $initial, $modified) { + $returned = Forerunner::of(Factory::build())( + $initial, + static fn($_, $__, $continuation) => $continuation->terminate(), + ); + $assert->same($initial, $returned); + + $returned = Forerunner::of(Factory::build())( + $initial, + static fn($carry, $__, $continuation) => $continuation + ->carryWith($initial) + ->terminate(), + ); + $assert->same($initial, $returned); + + $returned = Forerunner::of(Factory::build())( + $initial, + static fn($carry, $__, $continuation) => $continuation + ->carryWith($modified) + ->terminate(), + ); + $assert->same($modified, $returned); + }, + ); + + yield test( + 'The source is run asynchronously', + static function($assert) { + $expect = $assert->time(static function() { + Forerunner::of(Factory::build())( + false, + static fn($started, $os, $continuation, $results) => match ([$started, $results->size()]) { + [false, 0] => $continuation + ->launch(Sequence::of( + Task::of(static function($os) { + $os->process()->halt(Second::of(1)); + $os->process()->halt(Second::of(1)); + }), + Task::of(static function($os) { + $os->process()->halt(Second::of(1)); + $os->process()->halt(Second::of(1)); + }), + Task::of(static function($os) { + $os->process()->halt(Second::of(1)); + $os->process()->halt(Second::of(1)); + }), + )) + ->carryWith(true), + [true, 0] => (static function($os, $continuation) { + // this halt is executed at the same time at the + // second one in each task + $os->process()->halt(Second::of(1)); + + return $continuation; + })($os, $continuation), + default => $continuation->terminate(), + }, + ); + }); + $expect + ->inLessThan() + ->seconds(3); + $expect + ->inMoreThan() + ->seconds(2); + }, + ); + + yield test( + 'Streams are handled asynchronously', + static function($assert) { + $lines = []; + Forerunner::of(Factory::build())(null, Predetermined::of( + static function($os) use ($assert, &$lines) { + $file = $os + ->filesystem() + ->mount(Path::of('./')) + ->get(Name::of('composer.json')) + ->match( + static fn($file) => $file, + static fn() => null, + ); + $assert->not()->null($file); + $lines[] = $file + ->content() + ->lines() + ->first() + ->match( + static fn($line) => $line->toString(), + static fn() => null, + ); + $lines[] = $file + ->content() + ->lines() + ->filter(static fn($line) => !$line->str()->empty()) + ->last() + ->match( + static fn($line) => $line->toString(), + static fn() => null, + ); + }, + static function($os) use ($assert, &$lines) { + $file = $os + ->filesystem() + ->mount(Path::of('./')) + ->get(Name::of('LICENSE')) + ->match( + static fn($file) => $file, + static fn() => null, + ); + $assert->not()->null($file); + $lines[] = $file + ->content() + ->lines() + ->first() + ->match( + static fn($line) => $line->toString(), + static fn() => null, + ); + $lines[] = $file + ->content() + ->lines() + ->filter(static fn($line) => !$line->str()->empty()) + ->last() + ->match( + static fn($line) => $line->toString(), + static fn() => null, + ); + }, + )); + $assert->same( + ['{', 'MIT License', 'SOFTWARE.', '}'], + $lines, + ); + }, + )->tag(Tag::wip); +}; diff --git a/src/Source/Context.php b/src/Source/Context.php index 42d4e5d..329de47 100644 --- a/src/Source/Context.php +++ b/src/Source/Context.php @@ -70,12 +70,32 @@ public static function of(Source $source, mixed $carry): self /** * @param Sequence $results + * + * @return self */ public function withResults(Sequence $results): self { return new self($this->source, $this->continuation, $results); } + /** + * @template R1 + * @template R2 + * + * @param callable(Source, C): R1 $resume + * @param callable(C): R2 $terminate + * + * @return R1|R2 + */ + public function match(callable $resume, callable $terminate): mixed + { + /** @psalm-suppress MixedArgument */ + return $this->continuation->match( + fn($carry) => $resume($this->source, $carry), + static fn($carry) => $terminate($carry), + ); + } + /** * @return Continuation */ diff --git a/src/Tasks.php b/src/Tasks.php index 55db98a..4cc85e1 100644 --- a/src/Tasks.php +++ b/src/Tasks.php @@ -112,9 +112,8 @@ public function continue(OperatingSystem $os): self $source = $source->flatMap(static fn($task) => match (true) { $task instanceof Task\Terminated && $task->returned() instanceof Context => $task ->returned() - ->continuation() ->match( - static fn() => Either::right($task->returned()), + static fn($source, $carry) => Either::right(Context::of($source, $carry)), static fn($carry) => Either::left($carry), ), default => Either::right($task), diff --git a/src/Wait.php b/src/Wait.php index 53344f5..7955588 100644 --- a/src/Wait.php +++ b/src/Wait.php @@ -116,12 +116,15 @@ public function __invoke(): array static fn() => null, ); /** @psalm-suppress InvalidArgument */ - $watch = $this - ->os - ->sockets() - ->watch($timeout) - ->forRead(...$forRead->toList()) - ->forWrite(...$forWrite->toList()); + $watch = $this->os->sockets()->watch($timeout); + $watch = $forRead->sort(fn($a, $b) => 0)->match( + static fn($read, $rest) => $watch->forRead($read, ...$rest->toList()), + static fn() => $watch, + ); + $watch = $forWrite->sort(fn($a, $b) => 0)->match( + static fn($write, $rest) => $watch->forWrite($write, ...$rest->toList()), + static fn() => $watch, + ); $ready = $watch(); $took = $this->os->clock()->now()->elapsedSince($started);