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
10 changes: 8 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
],
"require": {
"php": "^8.2 || ^8.3 || ^8.4 || ^8.5",
"phunkie/phunkie": "^1.1.0"
"phunkie/phunkie": "^1.2.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
Expand All @@ -22,12 +22,18 @@
"suggest": {
"ext-parallel": "Required for parallel execution using threads. PHP must be compiled with ZTS support."
},
"extra": {
"phunkiec": {
"macros": "macros/"
}
},
"autoload": {
"psr-4": {
"Phunkie\\Effect\\": "src/"
},
"files": [
"src/Functions/common.php"
"src/Functions/common.php",
"src/PatternMatching/Referenced/io.php"
]
},
"autoload-dev": {
Expand Down
23 changes: 23 additions & 0 deletions docs/io.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,29 @@ $getUser = io(fn() => $db->findUser($id))
->handleError(fn($e) => null); // Return null if not found
```

### ensure() - Fail When a Value Is Not Acceptable

`ensure` raises an error when the produced value does not satisfy a predicate. It
stays lazy — the check runs when the IO is run — and the raised error is
recoverable through `attempt()` / `handleError()`.

```php
$positive = io(fn() => readNumber())
->ensure(fn($n) => $n > 0, new \RuntimeException('must be positive'));

// ensureOr builds the error from the offending value
$positive = io(fn() => readNumber())
->ensureOr(fn($n) => $n > 0, fn($n) => new \RuntimeException("not positive: $n"));
```

`ensure` is how you express a conditional failure over an IO. A for-comprehension
**guard** — `for { $x <- io if $x > 0 } yield $x` — is deliberately not supported
for IO: a lazy `IO<A>` has no empty value to fall through to, so there is nothing
for a failed guard to become. This mirrors cats-effect, where filtering an IO is
done with `ensure`/`ensureOr` rather than with for-comprehension guard syntax. A
plain `for { $x <- io } yield ...` (no guard) works as usual, desugaring to
`flatMap`/`map`.

### Combining Error Handling

```php
Expand Down
19 changes: 19 additions & 0 deletions macros/pattern-matching.syn
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Pattern matching for effect's types.
#
# Shipped by phunkie/effect and discovered by phunkiec through the
# `extra.phunkiec.macros` entry in this package's composer.json. These rules are
# loaded before phunkiec's bundled rules, so where a name collides — `IO` is both
# phunkie's Cats\IO and effect's own IO — effect's rule wins when effect is
# installed.
#
# The rules are anchored on `$on(`, which only phunkiec's `match { }` desugaring
# ever emits, so an IO constructed anywhere else in the file is left alone. The
# bound part is captured as `$(T_VARIABLE ...)`, so `IO(_)` (wildcard) and a
# by-value `IO(...)` are left untouched.

# effect IO — binds the wrapped thunk
$(macro) {
$on( IO( $(T_VARIABLE as thunk) ) )
} >> {
$on(\Phunkie\Effect\PatternMatching\Referenced\IO($(thunk)))
}
2 changes: 2 additions & 0 deletions src/IO/IO.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Phunkie\Effect\Concurrent\FiberExecutionContext;
use Phunkie\Effect\Ops\IO\ApplicativeOps;
use Phunkie\Effect\Ops\IO\FunctorOps;
use Phunkie\Effect\Ops\IO\MonadErrorOps;
use Phunkie\Effect\Ops\IO\MonadOps;
use Phunkie\Effect\Ops\IO\ParallelOps;
use Phunkie\Types\Kind;
Expand All @@ -45,6 +46,7 @@ class IO implements Functor, Applicative, Monad, Parallel, Kind
use FunctorOps;
use ApplicativeOps;
use MonadOps;
use MonadErrorOps;
use ParallelOps;

private $unsafeRun;
Expand Down
73 changes: 73 additions & 0 deletions src/Ops/IO/MonadErrorOps.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/*
* This file is part of Phunkie Effect, A functional effect system for PHP inspired by Cats Effect.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Phunkie\Effect\Ops\IO;

use Phunkie\Effect\IO\IO;
use Throwable;

/**
* MonadError combinators for IO.
*
* A lazy IO<A> has no empty value to filter into, so it deliberately has no
* withFilter — a for-comprehension guard has nothing to fall through to. The
* intent behind a guard is instead written explicitly here, through the error
* channel: ensure raises when a predicate fails, recoverable via attempt().
* This mirrors cats-effect, where filtering an IO is done with ensure/ensureOr
* rather than with for-comprehension guard syntax.
*
* @template A
*/
trait MonadErrorOps
{
/**
* Raises $error when the produced value does not satisfy $predicate.
*
* Stays lazy: the check runs when the returned IO is run, and the raised
* error is recoverable through attempt() / handleError().
*
* @param callable(A):bool $predicate The condition the value must satisfy
* @param Throwable $error The error to raise when it does not
* @return IO<A>
*/
public function ensure(callable $predicate, Throwable $error): IO
{
return new IO(function () use ($predicate, $error) {
$a = ($this->unsafeRun)();

if (! $predicate($a)) {
throw $error;
}

return $a;
});
}

/**
* Like ensure, but the error is computed from the offending value.
*
* @param callable(A):bool $predicate The condition the value must satisfy
* @param callable(A):Throwable $error Builds the error from the value that failed
* @return IO<A>
*/
public function ensureOr(callable $predicate, callable $error): IO
{
return new IO(function () use ($predicate, $error) {
$a = ($this->unsafeRun)();

if (! $predicate($a)) {
throw $error($a);
}

return $a;
});
}
}
38 changes: 38 additions & 0 deletions src/PatternMatching/Referenced/io.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of Phunkie Effect, A functional effect system for PHP inspired by Cats Effect.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Phunkie\Effect\PatternMatching\Referenced;

use Phunkie\Effect\IO\IO as IOType;
use Phunkie\PatternMatching\Referenced\GenericReferenced;

/**
* Creates a pattern that matches an effect IO and binds the thunk it wraps.
*
* This is effect's own IO pattern, distinct from phunkie's Referenced\IO, which
* targets Phunkie\Cats\IO. It builds on phunkie's GenericReferenced, so it needs
* phunkie 1.2.
*
* Example:
* ```php
* $on = pmatch(new IO(fn () => 42));
* $result = match (true) {
* $on(IO($thunk)) => $thunk() // $thunk is the wrapped effect, so 42
* };
* ```
*
* @param mixed $thunk Variable that receives the thunk wrapped by the IO
* @return GenericReferenced Pattern matching an effect IO
*/
function IO(&$thunk): GenericReferenced
{
return new GenericReferenced(IOType::class, $thunk);
}
82 changes: 82 additions & 0 deletions tests/Unit/Ops/IO/MonadErrorOpsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

/*
* This file is part of Phunkie Effect, A functional effect system for PHP inspired by Cats Effect.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Tests\Unit\Phunkie\Effect\Ops\IO;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Phunkie\Effect\IO\IO;
use RuntimeException;

class MonadErrorOpsTest extends TestCase
{
#[Test]
public function it_passes_the_value_through_when_the_predicate_holds()
{
$io = new IO(fn () => 42);

$ensured = $io->ensure(fn ($x) => $x > 0, new RuntimeException("must be positive"));

$this->assertEquals(42, $ensured->unsafeRun());
}

#[Test]
public function it_raises_the_error_when_the_predicate_fails()
{
$io = new IO(fn () => -1);

$ensured = $io->ensure(fn ($x) => $x > 0, new RuntimeException("must be positive"));

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("must be positive");
$ensured->unsafeRun();
}

#[Test]
public function it_does_not_run_the_effect_until_the_ensured_io_is_run()
{
$ran = false;
$io = new IO(function () use (&$ran) {
$ran = true;

return -1;
});

$io->ensure(fn ($x) => $x > 0, new RuntimeException("boom"));

$this->assertFalse($ran, "ensure must stay lazy and not run the effect");
}

#[Test]
public function it_is_recoverable_through_attempt()
{
$io = new IO(fn () => -1);

$recovered = $io
->ensure(fn ($x) => $x > 0, new RuntimeException("must be positive"))
->attempt()
->map(fn ($validation) => $validation->fold(fn ($e) => $e->getMessage())(fn ($a) => "ok: $a"));

$this->assertEquals("must be positive", $recovered->unsafeRun());
}

#[Test]
public function it_builds_the_error_from_the_offending_value_with_ensure_or()
{
$io = new IO(fn () => -5);

$ensured = $io->ensureOr(fn ($x) => $x > 0, fn ($x) => new RuntimeException("bad value: $x"));

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("bad value: -5");
$ensured->unsafeRun();
}
}
46 changes: 46 additions & 0 deletions tests/Unit/PatternMatching/ReferencedIOTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of Phunkie Effect, A functional effect system for PHP inspired by Cats Effect.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Tests\Unit\Phunkie\Effect\PatternMatching;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Phunkie\Effect\IO\IO;

use function Phunkie\Effect\PatternMatching\Referenced\IO as IOPattern;

class ReferencedIOTest extends TestCase
{
#[Test]
public function it_binds_the_thunk_when_matching_an_io()
{
$on = pmatch(new IO(fn () => 42));

$result = match (true) {
$on(IOPattern($thunk)) => $thunk()
};

$this->assertEquals(42, $result);
}

#[Test]
public function it_does_not_match_a_value_that_is_not_an_io()
{
$on = pmatch(42);

$result = match (true) {
$on(IOPattern($thunk)) => "io",
$on(_) => "not an io"
};

$this->assertEquals("not an io", $result);
}
}
Loading