From f7dd33b62909faefab63d299936bcf616b255b16 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 2 Aug 2024 23:43:19 +0800 Subject: [PATCH] Implement the Nexus Option library --- composer.json | 8 +- phpstan-baseline.php | 11 ++ phpstan.dist.neon | 5 +- src/Nexus/Option/Choice.php | 43 ++++++ src/Nexus/Option/LICENSE | 21 +++ src/Nexus/Option/None.php | 103 ++++++++++++++ src/Nexus/Option/NoneException.php | 25 ++++ src/Nexus/Option/Option.php | 217 +++++++++++++++++++++++++++++ src/Nexus/Option/README.md | 92 ++++++++++++ src/Nexus/Option/Some.php | 112 +++++++++++++++ src/Nexus/Option/composer.json | 37 +++++ src/Nexus/Option/functions.php | 28 ++++ tests/Option/ChoiceTest.php | 47 +++++++ tests/Option/NoneExceptionTest.php | 33 +++++ tests/Option/OptionTest.php | 196 ++++++++++++++++++++++++++ 15 files changed, 975 insertions(+), 3 deletions(-) create mode 100644 phpstan-baseline.php create mode 100644 src/Nexus/Option/Choice.php create mode 100644 src/Nexus/Option/LICENSE create mode 100644 src/Nexus/Option/None.php create mode 100644 src/Nexus/Option/NoneException.php create mode 100644 src/Nexus/Option/Option.php create mode 100644 src/Nexus/Option/README.md create mode 100644 src/Nexus/Option/Some.php create mode 100644 src/Nexus/Option/composer.json create mode 100644 src/Nexus/Option/functions.php create mode 100644 tests/Option/ChoiceTest.php create mode 100644 tests/Option/NoneExceptionTest.php create mode 100644 tests/Option/OptionTest.php diff --git a/composer.json b/composer.json index 13a062a..7ba246b 100644 --- a/composer.json +++ b/composer.json @@ -28,12 +28,18 @@ "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "^11.2" }, + "replace": { + "nexusphp/option": "self.version" + }, "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "Nexus\\": "src/Nexus/" - } + }, + "files": [ + "src/Nexus/Option/functions.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/phpstan-baseline.php b/phpstan-baseline.php new file mode 100644 index 0000000..c3212fb --- /dev/null +++ b/phpstan-baseline.php @@ -0,0 +1,11 @@ + '#^Method Nexus\\\\Option\\\\Choice\\:\\:from\\(\\) never returns Nexus\\\\Option\\\\Some\\ so it can be removed from the return type\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Nexus/Option/Choice.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 4809c8a..6b79b9f 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,9 +1,10 @@ includes: - vendor/phpstan/phpstan/conf/bleedingEdge.neon + - phpstan-baseline.php parameters: phpVersion: 80200 - level: 8 + level: 9 tmpDir: build/phpstan paths: - src @@ -16,7 +17,6 @@ parameters: - vendor/autoload.php exceptions: check: - missingCheckedExceptionInThrows: true tooWideThrowType: true checkTooWideReturnTypesInProtectedAndPublicMethods: true checkUninitializedProperties: true @@ -26,3 +26,4 @@ parameters: reportAlwaysTrueInLastCondition: true reportAnyTypeWideningInVarTag: true checkMissingCallableSignature: true + treatPhpDocTypesAsCertain: false diff --git a/src/Nexus/Option/Choice.php b/src/Nexus/Option/Choice.php new file mode 100644 index 0000000..d4b4ea5 --- /dev/null +++ b/src/Nexus/Option/Choice.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Option; + +/** + * @internal + */ +final class Choice +{ + /** + * Creates an option from the given `$value`. + * + * The value of a **None** option can be defined by assigning a `$none` value. + * By default, this is equal to `null` but can be another value. + * + * @template T + * @template S + * + * @param T $value + * @param S $none + * + * @return (T is S ? None : Some) + */ + public static function from(mixed $value, mixed $none = null): Option + { + if ($value === $none) { + return new None(); + } + + return new Some($value); + } +} diff --git a/src/Nexus/Option/LICENSE b/src/Nexus/Option/LICENSE new file mode 100644 index 0000000..ce701d4 --- /dev/null +++ b/src/Nexus/Option/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 John Paul E. Balandan, CPA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Nexus/Option/None.php b/src/Nexus/Option/None.php new file mode 100644 index 0000000..be40a3a --- /dev/null +++ b/src/Nexus/Option/None.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Option; + +/** + * @implements Option + */ +final readonly class None implements Option +{ + public function isSome(): bool + { + return false; + } + + public function isSomeAnd(\Closure $predicate): bool + { + return false; + } + + public function isNone(): bool + { + return true; + } + + public function unwrap(): mixed + { + throw new NoneException(); + } + + public function unwrapOr(mixed $default): mixed + { + return $default; + } + + public function unwrapOrElse(\Closure $default): mixed + { + return $default(); + } + + public function map(\Closure $predicate): Option + { + return clone $this; + } + + public function mapOr(mixed $default, \Closure $predicate): mixed + { + return $default; + } + + public function mapOrElse(\Closure $default, \Closure $predicate): mixed + { + return $default(); + } + + public function and(Option $other): Option + { + return clone $this; + } + + public function andThen(\Closure $predicate): Option + { + return clone $this; + } + + public function filter(\Closure $predicate): Option + { + return clone $this; + } + + public function or(Option $other): Option + { + return $other; + } + + public function orElse(\Closure $other): Option + { + return $other(); + } + + public function xor(Option $other): Option + { + return $other->isSome() ? $other : clone $this; + } + + /** + * @return \EmptyIterator + */ + public function getIterator(): \Traversable + { + return new \EmptyIterator(); + } +} diff --git a/src/Nexus/Option/NoneException.php b/src/Nexus/Option/NoneException.php new file mode 100644 index 0000000..adfdeb6 --- /dev/null +++ b/src/Nexus/Option/NoneException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Option; + +/** + * Exception thrown when accessing the value of a `None` option. + */ +final class NoneException extends \UnderflowException +{ + public function __construct() + { + parent::__construct('Attempting to unwrap a None option.'); + } +} diff --git a/src/Nexus/Option/Option.php b/src/Nexus/Option/Option.php new file mode 100644 index 0000000..3a8d54e --- /dev/null +++ b/src/Nexus/Option/Option.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Option; + +/** + * A PHP implementation of Rust's Option enum. + * + * @see https://doc.rust-lang.org/std/option/enum.Option.html + * + * @template T + * + * @extends \IteratorAggregate + */ +interface Option extends \IteratorAggregate +{ + /** + * Returns `true` if the option is a **Some** value. + */ + public function isSome(): bool; + + /** + * Returns `true` if the option is a **Some** and the value inside of it matches a predicate. + * + * @param (\Closure(T): bool) $predicate + * + * @param-immediately-invoked-callable $predicate + */ + public function isSomeAnd(\Closure $predicate): bool; + + /** + * Returns `true` if the option is a **None** value. + */ + public function isNone(): bool; + + /** + * Returns the contained **Some** value, consuming the `self` value. + * + * Because this method may throw, its use is generally discouraged. + * Instead, prefer to call `Option::unwrapOr()` or `Option::unwrapOrElse()`. + * + * @return T + * + * @throws NoneException + */ + public function unwrap(): mixed; + + /** + * Returns the contained **Some** value or a provided default. + * + * Arguments passed to `Option::unwrapOr()` are eagerly evaluated; if you are + * passing the result of a function call, it is recommended to use `Option::unwrapOrElse()`, + * which is lazily evaluated. + * + * @template S + * + * @param S $default + * + * @return S|T + */ + public function unwrapOr(mixed $default): mixed; + + /** + * Returns the contained **Some** value or computes it from a closure. + * + * @template S + * + * @param (\Closure(): S) $default + * + * @param-immediately-invoked-callable $default + * + * @return S|T + */ + public function unwrapOrElse(\Closure $default): mixed; + + /** + * Maps an `Option` to `Option` by applying a function to a + * contained value (if **Some**) or returns `None` (if **None**). + * + * @template U + * + * @param (\Closure(T): U) $predicate + * + * @param-immediately-invoked-callable $predicate + * + * @return self + */ + public function map(\Closure $predicate): self; + + /** + * Returns the provided default result (if none), or applies a function + * to the contained value (if any). + * + * Arguments passed to `Option::mapOr()` are eagerly evaluated; if you are + * passing the result of a function call, it is recommended to use `Option::mapOrElse()`, + * which is lazily evaluated. + * + * @template U + * + * @param U $default + * @param (\Closure(T): U) $predicate + * + * @param-immediately-invoked-callable $predicate + * + * @return U + */ + public function mapOr(mixed $default, \Closure $predicate): mixed; + + /** + * Computes a default function result (if none), or applies a different function + * to the contained value (if any). + * + * @template U + * + * @param (\Closure(): U) $default + * @param (\Closure(T): U) $predicate + * + * @param-immediately-invoked-callable $default + * @param-immediately-invoked-callable $predicate + * + * @return U + */ + public function mapOrElse(\Closure $default, \Closure $predicate): mixed; + + /** + * Returns **None** if the option is **None**, otherwise returns `$other`. + * + * Arguments passed to `Option::and()` are eagerly evaluated; if you are + * passing the result of a function call, it is recommended to use `Option::andThen()`, + * which is lazily evaluated. + * + * @template U + * + * @param self $other + * + * @return self + */ + public function and(self $other): self; + + /** + * Returns **None** if the option is **None**, otherwise calls `$other` with the wrapped + * value and returns the result. + * + * @template U + * + * @param (\Closure(T): self) $predicate + * + * @param-immediately-invoked-callable $predicate + * + * @return self + */ + public function andThen(\Closure $predicate): self; + + /** + * Returns **None** if the option is **None**, otherwise calls `$predicate` + * with the wrapped value and returns: + * - `Some(t)` if predicate returns true (where `t` is the wrapped value), and + * - `None` if predicate returns false. + * + * @param (\Closure(T): bool) $predicate + * + * @param-immediately-invoked-callable $predicate + * + * @return self + */ + public function filter(\Closure $predicate): self; + + /** + * Returns the option if it contains a value, otherwise returns `$other`. + * + * Arguments passed to `Option::or()` are eagerly evaluated; if you are + * passing the result of a function call, it is recommended to use `Option::orElse()`, + * which is lazily evaluated. + * + * @template S + * + * @param self $other + * + * @return self + */ + public function or(self $other): self; + + /** + * Returns the option if it contains a value, otherwise calls + * `$other` and returns the result. + * + * @template S + * + * @param (\Closure(): self) $other + * + * @param-immediately-invoked-callable $other + * + * @return self + */ + public function orElse(\Closure $other): self; + + /** + * Returns **Some** if exactly one of `self`, `$other` is **Some**, otherwise returns **None**. + * + * @template S + * + * @param self $other + * + * @return self + */ + public function xor(self $other): self; +} diff --git a/src/Nexus/Option/README.md b/src/Nexus/Option/README.md new file mode 100644 index 0000000..a645e6f --- /dev/null +++ b/src/Nexus/Option/README.md @@ -0,0 +1,92 @@ +# Nexus Option + +Nexus Option implements Rust's [Option type][1] into PHP. + +> **Note:** Not all methods of the Option enum are implemented by this library. + +## Installation + + composer require nexusphp/option + +## Getting Started + +The `Option` type represents an optional value: every `Option` is either +a `Some` and contains a value, or `None` and does not have a value. + +```php +locate($id); + + if (null === $cachedObject) { + return new None(); + } + + return new Some($cachedObject); + } + + // ... +} + +$container = new Container(); + +// if 'foo' exists in the container, it will return the cached object +// otherwise, this will return the `Bar` object +$object = $container->get('foo')->unwrapOr(new Bar()); + +// let's say the container has the `Bar` object stored in the 'bar' key +var_dump($container->get('bar')->map(static fn(object $object): string => $object::class)); +// Output: string(3) "Bar" + +``` + +The above examples eliminates boilerplate code and unnecessary control flow structures, like: +```php + +$object = $container->get('foo'); + +if (null === $object) { + $object = new Bar(); +} + +$bar = $container->get('bar'); +var_dump($bar::class); + +``` + +This library offers a terser approach by introducing the function `Nexus\Option\option`, which +allows to set the value for the `$none` parameter which currently defaults to `null`. +```php +unwrapOr('foo'); +$int = option(objectOrString(), 'bar')->unwrapOr(new Bar()); + +``` + +## License + +Nexus Option is licensed under the [MIT License][5]. + +## Resources + +* [Report issues][2] and [send pull requests][3] in the [main Nexus repository][4] + +[1]: https://doc.rust-lang.org/std/option/enum.Option.html +[2]: https://github.com/NexusPHP/framework/issues +[3]: https://github.com/NexusPHP/framework/pulls +[4]: https://github.com/NexusPHP/framework +[5]: LICENSE diff --git a/src/Nexus/Option/Some.php b/src/Nexus/Option/Some.php new file mode 100644 index 0000000..c9afa40 --- /dev/null +++ b/src/Nexus/Option/Some.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Option; + +/** + * @template T + * + * @implements Option + */ +final readonly class Some implements Option +{ + /** + * @param T $value + */ + public function __construct( + private mixed $value, + ) {} + + public function isSome(): bool + { + return true; + } + + public function isSomeAnd(\Closure $predicate): bool + { + return $predicate($this->value); + } + + public function isNone(): bool + { + return false; + } + + public function unwrap(): mixed + { + return $this->value; + } + + public function unwrapOr(mixed $default): mixed + { + return $this->value; + } + + public function unwrapOrElse(\Closure $default): mixed + { + return $this->value; + } + + public function map(\Closure $predicate): Option + { + return new self($predicate($this->value)); + } + + public function mapOr(mixed $default, \Closure $predicate): mixed + { + return $predicate($this->value); + } + + public function mapOrElse(\Closure $default, \Closure $predicate): mixed + { + return $predicate($this->value); + } + + public function and(Option $other): Option + { + return $other; + } + + public function andThen(\Closure $predicate): Option + { + return $predicate($this->value); + } + + public function filter(\Closure $predicate): Option + { + return $predicate($this->value) ? clone $this : new None(); + } + + public function or(Option $other): Option + { + return clone $this; + } + + public function orElse(\Closure $other): Option + { + return clone $this; + } + + public function xor(Option $other): Option + { + return $other->isSome() ? new None() : clone $this; + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator([$this->value]); + } +} diff --git a/src/Nexus/Option/composer.json b/src/Nexus/Option/composer.json new file mode 100644 index 0000000..697d346 --- /dev/null +++ b/src/Nexus/Option/composer.json @@ -0,0 +1,37 @@ +{ + "name": "nexusphp/option", + "description": "The Nexus Option library.", + "license": "MIT", + "type": "library", + "keywords": [ + "nexus", + "option" + ], + "authors": [ + { + "name": "John Paul E. Balandan, CPA", + "email": "paulbalandan@gmail.com" + } + ], + "support": { + "issues": "https://github.com/NexusPHP/framework/issues", + "source": "https://github.com/NexusPHP/framework" + }, + "require": { + "php": "^8.2" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Nexus\\Option\\": "" + }, + "files": [ + "functions.php" + ] + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/src/Nexus/Option/functions.php b/src/Nexus/Option/functions.php new file mode 100644 index 0000000..a12f25b --- /dev/null +++ b/src/Nexus/Option/functions.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Option; + +/** + * @template T + * @template S + * + * @param T $value + * @param S $none + * + * @return (T is S ? None : Some) + */ +function option(mixed $value, mixed $none = null): Option +{ + return Choice::from($value, $none); +} diff --git a/tests/Option/ChoiceTest.php b/tests/Option/ChoiceTest.php new file mode 100644 index 0000000..47a3d24 --- /dev/null +++ b/tests/Option/ChoiceTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Option; + +use Nexus\Option\Choice; +use Nexus\Option\None; +use Nexus\Option\Some; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +use function Nexus\Option\option; + +/** + * @internal + */ +#[CoversClass(Choice::class)] +#[CoversFunction('Nexus\Option\option')] +#[Group('unit')] +final class ChoiceTest extends TestCase +{ + public function testChoiceFrom(): void + { + self::assertInstanceOf(Some::class, Choice::from(2)); + self::assertInstanceOf(None::class, Choice::from(null)); + self::assertInstanceOf(Some::class, Choice::from(null, false)); + } + + public function testOptionFunction(): void + { + self::assertInstanceOf(Some::class, option(2)); + self::assertInstanceOf(None::class, option(null)); + self::assertInstanceOf(Some::class, option(null, false)); + } +} diff --git a/tests/Option/NoneExceptionTest.php b/tests/Option/NoneExceptionTest.php new file mode 100644 index 0000000..e2fc88d --- /dev/null +++ b/tests/Option/NoneExceptionTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Option; + +use Nexus\Option\NoneException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(NoneException::class)] +#[Group('unit')] +final class NoneExceptionTest extends TestCase +{ + public function testNoneExceptionGivesCorrectMessage(): void + { + $noneException = new NoneException(); + self::assertSame('Attempting to unwrap a None option.', $noneException->getMessage()); + } +} diff --git a/tests/Option/OptionTest.php b/tests/Option/OptionTest.php new file mode 100644 index 0000000..d9225f1 --- /dev/null +++ b/tests/Option/OptionTest.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Option; + +use Nexus\Option\None; +use Nexus\Option\NoneException; +use Nexus\Option\Some; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(None::class)] +#[CoversClass(Some::class)] +#[Group('unit')] +final class OptionTest extends TestCase +{ + public function testOptionIsSome(): void + { + self::assertTrue((new Some(2))->isSome()); + self::assertFalse((new None())->isSome()); + } + + public function testOptionIsSomeAnd(): void + { + $predicate = static fn(int $x): bool => $x > 1; + + self::assertTrue((new Some(2))->isSomeAnd($predicate)); + self::assertFalse((new Some(0))->isSomeAnd($predicate)); + self::assertFalse((new None())->isSomeAnd($predicate)); + } + + public function testOptionIsNone(): void + { + self::assertFalse((new Some(2))->isNone()); + self::assertTrue((new None())->isNone()); + } + + public function testOptionUnwrap(): void + { + self::assertSame('air', (new Some('air'))->unwrap()); + + $this->expectException(NoneException::class); + $this->expectExceptionMessage('Attempting to unwrap a None option.'); + (new None())->unwrap(); + } + + public function testOptionUnwrapOr(): void + { + $value = 'car'; + $default = 'bike'; + + self::assertSame($value, (new Some($value))->unwrapOr($default)); + self::assertSame($default, (new None())->unwrapOr($default)); + } + + public function testOptionUnwrapOrElse(): void + { + $value = 4; + $default = static fn(): int => 20; + + self::assertSame($value, (new Some($value))->unwrapOrElse($default)); + self::assertSame($default(), (new None())->unwrapOrElse($default)); + } + + public function testOptionMap(): void + { + $predicate = strlen(...); + + $option = (new Some('Hello, World!'))->map($predicate); + self::assertInstanceOf(Some::class, $option); + self::assertSame(13, $option->unwrap()); + + self::assertInstanceOf(None::class, (new None())->map($predicate)); + } + + public function testOptionMapOr(): void + { + $predicate = strlen(...); + + self::assertSame(3, (new Some('foo'))->mapOr(42, $predicate)); + self::assertSame(42, (new None())->mapOr(42, $predicate)); + } + + public function testOptionMapOrElse(): void + { + $predicate = strlen(...); + $default = static fn() => 42; + + self::assertSame(3, (new Some('foo'))->mapOrElse($default, $predicate)); + self::assertSame(42, (new None())->mapOrElse($default, $predicate)); + } + + public function testOptionAnd(): void + { + self::assertInstanceOf(None::class, (new Some(2))->and(new None())); + self::assertInstanceOf(None::class, (new None())->and(new Some('foo'))); + self::assertInstanceOf(None::class, (new None())->and(new None())); + + $option = (new Some(2))->and(new Some('foo')); + self::assertInstanceOf(Some::class, $option); + self::assertSame('foo', $option->unwrap()); + } + + public function testOptionAndThen(): void + { + $squareThenToString = static fn(int $number): Some => new Some((string) ($number ** 2)); + + $option = (new Some(2))->andThen($squareThenToString); + self::assertInstanceOf(Some::class, $option); + self::assertSame('4', $option->unwrap()); + self::assertInstanceOf(None::class, (new None())->andThen($squareThenToString)); + } + + public function testOptionFilter(): void + { + $isEven = static fn(int $n): bool => $n % 2 === 0; + + self::assertInstanceOf(None::class, (new None())->filter($isEven)); + self::assertInstanceOf(None::class, (new Some(3))->filter($isEven)); + self::assertInstanceOf(Some::class, (new Some(4))->filter($isEven)); + } + + public function testOptionOr(): void + { + $some02 = new Some(2); + $some100 = new Some(100); + $none = new None(); + + self::assertInstanceOf(Some::class, $some02->or($none)); + self::assertSame(2, $some02->or($none)->unwrap()); + + self::assertInstanceOf(Some::class, $none->or($some100)); + self::assertSame(100, $none->or($some100)->unwrap()); + + self::assertInstanceOf(Some::class, $some02->or($some100)); + self::assertSame(2, $some02->or($some100)->unwrap()); + + self::assertInstanceOf(None::class, $none->or($none)); + } + + public function testOptionOrElse(): void + { + $nobody = static fn(): None => new None(); + $vikings = static fn(): Some => new Some('vikings'); + + $option = (new Some('barbarians'))->orElse($vikings); + self::assertInstanceOf(Some::class, $option); + self::assertSame('barbarians', $option->unwrap()); + + $option = (new None())->orElse($vikings); + self::assertInstanceOf(Some::class, $option); + self::assertSame('vikings', $option->unwrap()); + + self::assertInstanceOf(None::class, (new None())->orElse($nobody)); + } + + public function testOptionXor(): void + { + $some = new Some(2); + $none = new None(); + + self::assertInstanceOf(Some::class, $some->xor($none)); + self::assertSame(2, $some->xor($none)->unwrap()); + + self::assertInstanceOf(Some::class, $none->xor($some)); + self::assertSame(2, $none->xor($some)->unwrap()); + + self::assertInstanceOf(None::class, $some->xor($some)); + self::assertInstanceOf(None::class, $none->xor($none)); + } + + public function testOptionIteration(): void + { + foreach (new Some(2) as $index => $value) { + self::assertSame(0, $index); + self::assertSame(2, $value); + } + + self::assertTrue((new Some(2))->getIterator()->valid()); + self::assertFalse((new None())->getIterator()->valid()); + } +}