diff --git a/src/Psl/Option/Option.php b/src/Psl/Option/Option.php index 17b17c07..625e33dc 100644 --- a/src/Psl/Option/Option.php +++ b/src/Psl/Option/Option.php @@ -305,4 +305,77 @@ 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`. + * + * @note: If an element is `None`, the corresponding element in the resulting tuple will be `None`. + * + * @template U + * + * @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 U + * @template Tv + * + * @param Option $other The Option to zip with. + * @param (Closure(T, U): Tv) $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 U $b */ + static fn ($b) => $closure($a, $b) + ); + } + ); + } + + /** + * @template OValue1 + * @template OValue2 + * + * @psalm-if-this-is Option + * + * @return array{Option, Option} + */ + public function unzip(): array + { + if ($this->option === null) { + return [none(), none()]; + } + + /** @psalm-suppress DocblockTypeContradiction we want this check in runtime */ + if (!is_array($this->option[0])) { + return [none(), none()]; + } + + if (!array_key_exists(0, $this->option[0]) || !array_key_exists(1, $this->option[0])) { + return [none(), none()]; + } + + [$a, $b] = $this->option[0]; + + return [some($a), some($b)]; + } } diff --git a/tests/fixture/Point.php b/tests/fixture/Point.php new file mode 100644 index 00000000..762d34f3 --- /dev/null +++ b/tests/fixture/Point.php @@ -0,0 +1,14 @@ +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\some(1)]; + yield [Option\some([])]; + yield [Option\some(['foo'])]; + 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..5e315f1b 100644 --- a/tests/unit/Option/SomeTest.php +++ b/tests/unit/Option/SomeTest.php @@ -9,6 +9,7 @@ use Psl\Comparison\Equable; use Psl\Comparison\Order; use Psl\Option; +use Psl\Tests\Fixture; final class SomeTest extends TestCase { @@ -129,4 +130,41 @@ 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.5); + $y = Option\some(42.7); + + $point = $x->zipWith($y, static fn($a, $b) => new Fixture\Point($a, $b)); + + static::assertTrue(Option\some(new Fixture\Point(17.5, 42.7))->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]; + } }