diff --git a/docs/component/option.md b/docs/component/option.md index 439dba7a..03e95348 100644 --- a/docs/component/option.md +++ b/docs/component/option.md @@ -18,6 +18,6 @@ #### `Classes` -- [Option](./../../src/Psl/Option/Option.php#L16) +- [Option](./../../src/Psl/Option/Option.php#L17) diff --git a/src/Psl/Option/Option.php b/src/Psl/Option/Option.php index 17b17c07..1d99e64c 100644 --- a/src/Psl/Option/Option.php +++ b/src/Psl/Option/Option.php @@ -6,6 +6,7 @@ use Closure; use Psl\Comparison; +use Psl\Type; /** * @template T @@ -305,4 +306,72 @@ public function equals(mixed $other): bool { return Comparison\equal($this, $other); } + + /** + * Combines two `Option` values into a single `Option` containing a tuple of the two inner values. + * If either of the `Option`s is `None`, the resulting `Option` will also be `None`. + * + * @template Tu + * + * @param Option $other The other `Option` to zip with. + * + * @return Option The resulting `Option` containing the combined tuple or `None`. + */ + public function zip(Option $other): Option + { + return $this->andThen(static function ($a) use ($other) { + return $other->map(static fn($b) => [$a, $b]); + }); + } + + /** + * Applies the provided closure to the value contained in this `Option` and the value contained in the $other `Option`, + * and returns a new `Option` containing the result of the closure. + * + * @template Tu + * @template Tr + * + * @param Option $other The Option to zip with. + * @param (Closure(T, Tu): Tr) $closure The closure to apply to the values. + * + * @return Option The new `Option` containing the result of applying the closure to the values, + * or `None` if either this or the $other `Option is `None`. + */ + public function zipWith(Option $other, Closure $closure): Option + { + return $this->andThen( + /** @param T $a */ + static function ($a) use ($other, $closure) { + return $other->map( + /** @param Tu $b */ + static fn ($b) => $closure($a, $b) + ); + } + ); + } + + /** + * @template Tv + * @template Tr + * + * @psalm-if-this-is Option + * + * @throws Type\Exception\AssertException + * + * @return array{Option, Option} + */ + public function unzip(): array + { + if ($this->option === null) { + return [none(), none()]; + } + + // Assertion done in a separate variable to avoid Psalm inferring the type of $this->option as mixed + $option = $this->option[0]; + Type\shape([Type\mixed(), Type\mixed()])->assert($option); + + [$a, $b] = $option; + + return [some($a), some($b)]; + } } diff --git a/tests/fixture/Point.php b/tests/fixture/Point.php new file mode 100644 index 00000000..edbc5822 --- /dev/null +++ b/tests/fixture/Point.php @@ -0,0 +1,14 @@ + + */ +function test_partial_none_tuple_1(): Option\Option +{ + return Option\none()->zip(Option\some(1)); +} + +/** + * @return Option\Option + */ +function test_partial_none_tuple_2(): Option\Option +{ + return Option\some(1)->zip(Option\none()); +} + +/** + * @throws Type\Exception\AssertException + * + * @return array{Option\Option, Option\Option} + */ +function test_partial_none_unzip_1(): array +{ + return test_partial_none_tuple_1()->unzip(); +} + +/** + * @return Option\Option + */ +function test_some_zip(): Option\Option +{ + return Option\some(1)->zip(Option\some('2')); +} + +/** + * @throws Type\Exception\AssertException + * + * @return array{Option\Option, Option\Option} + */ +function test_partial_none_unzip_2(): array +{ + return test_partial_none_tuple_2()->unzip(); +} + +/** + * @throws Type\Exception\AssertException + * + * @return array{Option\Option, Option\Option} + */ +function test_some_unzip(): array +{ + return test_some_zip()->unzip(); +} + +/** + * @return Option\Option + */ +function test_some_zip_with() +{ + return Option\some(1)->zipWith(Option\some('2'), static fn($a, $b) => $a + (int) $b); +} + +/** + * @return Option\Option + */ +function test_some_zip_with_2() +{ + return Option\some(1)->zipWith(Option\some('2'), static fn($a, $b) => $b); +} diff --git a/tests/unit/Option/NoneTest.php b/tests/unit/Option/NoneTest.php index 33c4a47a..7ccc6cdb 100644 --- a/tests/unit/Option/NoneTest.php +++ b/tests/unit/Option/NoneTest.php @@ -130,4 +130,41 @@ public function testEquality(): void static::assertTrue($a->equals(Option\none())); static::assertFalse($a->equals(Option\some('other'))); } + + public function testZip(): void + { + $x = Option\some(1); + $y = Option\none(); + + static::assertTrue($x->zip($y)->isNone()); + static::assertTrue($y->zip($x)->isNone()); + } + + public function testZipWith(): void + { + $x = Option\some(1); + $y = Option\none(); + + static::assertTrue($x->zipWith($y, static fn($a, $b) => $a + $b)->isNone()); + static::assertTrue($y->zipWith($x, static fn($a, $b) => $a + $b)->isNone()); + } + + /** + * @dataProvider provideTestUnzip + */ + public function testUnzip(Option\Option $option): void + { + [$x, $y] = $option->unzip(); + + static::assertTrue($x->isNone()); + static::assertTrue($y->isNone()); + } + + private function provideTestUnzip(): iterable + { + yield [Option\none()]; + yield [Option\none()->zip(Option\none())]; + yield [Option\none()->zip(Option\some(1))]; + yield [Option\some(1)->zip(Option\none())]; + } } diff --git a/tests/unit/Option/SomeTest.php b/tests/unit/Option/SomeTest.php index 7ce1a40f..4b4b3dca 100644 --- a/tests/unit/Option/SomeTest.php +++ b/tests/unit/Option/SomeTest.php @@ -9,6 +9,8 @@ use Psl\Comparison\Equable; use Psl\Comparison\Order; use Psl\Option; +use Psl\Tests\Fixture; +use Psl\Type; final class SomeTest extends TestCase { @@ -129,4 +131,57 @@ public function testEquality() static::assertFalse($a->equals(Option\some('other'))); static::assertTrue($a->equals(Option\some('a'))); } + + public function testZip(): void + { + $x = Option\some(1); + $y = Option\some("hi"); + + static::assertTrue(Option\some([1, 'hi'])->equals($x->zip($y))); + static::assertTrue(Option\some(['hi', 1])->equals($y->zip($x))); + } + + public function testZipWith(): void + { + $x = Option\some(17); + $y = Option\some(42); + + $point = $x->zipWith($y, static fn($a, $b) => new Fixture\Point($a, $b)); + + static::assertTrue(Option\some(new Fixture\Point(17, 42))->equals($point)); + } + + /** + * @dataProvider provideTestUnzip + */ + public function testUnzip(Option\Option $option, mixed $expectedX, mixed $expectedY): void + { + [$x, $y] = $option->unzip(); + + static::assertSame($expectedX, $x->unwrap()); + static::assertSame($expectedY, $y->unwrap()); + } + + private function provideTestUnzip(): iterable + { + yield [Option\some(null)->zip(Option\some('hi')), null, 'hi']; + yield [Option\some(1)->zip(Option\some('hi')), 1, 'hi']; + yield [Option\some([true, false]), true, false]; + } + + /** + * @dataProvider provideTestUnzipAssertionException + */ + public function testUnzipAssertionException(Option\Option $option): void + { + static::expectException(Type\Exception\AssertException::class); + $option->unzip(); + } + + private function provideTestUnzipAssertionException(): iterable + { + yield [Option\some(null)]; + yield [Option\some(1)]; + yield [Option\some([true])]; + } }