diff --git a/docs/README.md b/docs/README.md index 0ca96ef8..49a3eca4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ - [Psl\Channel](./component/channel.md) - [Psl\Class](./component/class.md) - [Psl\Collection](./component/collection.md) +- [Psl\Comparison](./component/comparison.md) - [Psl\DataStructure](./component/data-structure.md) - [Psl\Dict](./component/dict.md) - [Psl\Encoding\Base64](./component/encoding-base64.md) diff --git a/docs/component/comparison.md b/docs/component/comparison.md new file mode 100644 index 00000000..8820f0ea --- /dev/null +++ b/docs/component/comparison.md @@ -0,0 +1,33 @@ + + +[*index](./../README.md) + +--- + +### `Psl\Comparison` Component + +#### `Functions` + +- [compare](./../../src/Psl/Comparison/compare.php#L19) +- [equal](./../../src/Psl/Comparison/equal.php#L13) +- [greater](./../../src/Psl/Comparison/greater.php#L13) +- [greater_or_equal](./../../src/Psl/Comparison/greater_or_equal.php#L13) +- [less](./../../src/Psl/Comparison/less.php#L13) +- [less_or_equal](./../../src/Psl/Comparison/less_or_equal.php#L13) +- [not_equal](./../../src/Psl/Comparison/not_equal.php#L13) +- [sort](./../../src/Psl/Comparison/sort.php#L17) + +#### `Interfaces` + +- [Comparable](./../../src/Psl/Comparison/Comparable.php#L12) +- [Equable](./../../src/Psl/Comparison/Equable.php#L10) + +#### `Enums` + +- [Order](./../../src/Psl/Comparison/Order.php#L7) + + diff --git a/docs/component/option.md b/docs/component/option.md index a1083dd1..439dba7a 100644 --- a/docs/component/option.md +++ b/docs/component/option.md @@ -18,6 +18,6 @@ #### `Classes` -- [Option](./../../src/Psl/Option/Option.php#L12) +- [Option](./../../src/Psl/Option/Option.php#L16) diff --git a/docs/documenter.php b/docs/documenter.php index fc59868a..e9802ea7 100644 --- a/docs/documenter.php +++ b/docs/documenter.php @@ -189,6 +189,7 @@ function get_all_components(): array 'Psl\\Channel', 'Psl\\Class', 'Psl\\Collection', + 'Psl\\Comparison', 'Psl\\DataStructure', 'Psl\\Dict', 'Psl\\Encoding\\Base64', diff --git a/src/Psl/Comparison/Comparable.php b/src/Psl/Comparison/Comparable.php new file mode 100644 index 00000000..e2aa21a0 --- /dev/null +++ b/src/Psl/Comparison/Comparable.php @@ -0,0 +1,20 @@ +compare($b); + } + + return Order::from($a <=> $b); +} diff --git a/src/Psl/Comparison/equal.php b/src/Psl/Comparison/equal.php new file mode 100644 index 00000000..e5d42039 --- /dev/null +++ b/src/Psl/Comparison/equal.php @@ -0,0 +1,16 @@ +value; +} diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index f1c3af90..39e400d1 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -57,6 +57,14 @@ final class Loader ]; public const FUNCTIONS = [ + 'Psl\\Comparison\\compare' => 'Psl/Comparison/compare.php', + 'Psl\\Comparison\\equal' => 'Psl/Comparison/equal.php', + 'Psl\\Comparison\\greater' => 'Psl/Comparison/greater.php', + 'Psl\\Comparison\\greater_or_equal' => 'Psl/Comparison/greater_or_equal.php', + 'Psl\\Comparison\\less' => 'Psl/Comparison/less.php', + 'Psl\\Comparison\\less_or_equal' => 'Psl/Comparison/less_or_equal.php', + 'Psl\\Comparison\\not_equal' => 'Psl/Comparison/not_equal.php', + 'Psl\\Comparison\\sort' => 'Psl/Comparison/sort.php', 'Psl\\Dict\\associate' => 'Psl/Dict/associate.php', 'Psl\\Dict\\count_values' => 'Psl/Dict/count_values.php', 'Psl\\Dict\\drop' => 'Psl/Dict/drop.php', @@ -514,6 +522,8 @@ final class Loader ]; public const INTERFACES = [ + 'Psl\\Comparison\\Comparable' => 'Psl/Comparison/Comparable.php', + 'Psl\\Comparison\\Equable' => 'Psl/Comparison/Equable.php', 'Psl\\DataStructure\\PriorityQueueInterface' => 'Psl/DataStructure/PriorityQueueInterface.php', 'Psl\\DataStructure\\QueueInterface' => 'Psl/DataStructure/QueueInterface.php', 'Psl\\DataStructure\\StackInterface' => 'Psl/DataStructure/StackInterface.php', @@ -611,6 +621,7 @@ final class Loader public const CLASSES = [ 'Psl\\Ref' => 'Psl/Ref.php', + 'Psl\\Comparison\\Exception\\IncomparableException' => 'Psl/Comparison/Exception/IncomparableException.php', 'Psl\\DataStructure\\PriorityQueue' => 'Psl/DataStructure/PriorityQueue.php', 'Psl\\DataStructure\\Queue' => 'Psl/DataStructure/Queue.php', 'Psl\\DataStructure\\Stack' => 'Psl/DataStructure/Stack.php', @@ -805,6 +816,7 @@ final class Loader ]; public const ENUMS = [ + 'Psl\\Comparison\\Order' => 'Psl/Comparison/Order.php', 'Psl\\Encoding\\Base64\\Variant' => 'Psl/Encoding/Base64/Variant.php', 'Psl\\File\\LockType' => 'Psl/File/LockType.php', 'Psl\\File\\WriteMode' => 'Psl/File/WriteMode.php', diff --git a/src/Psl/Option/Option.php b/src/Psl/Option/Option.php index d7dc521f..17b17c07 100644 --- a/src/Psl/Option/Option.php +++ b/src/Psl/Option/Option.php @@ -5,11 +5,15 @@ namespace Psl\Option; use Closure; +use Psl\Comparison; /** * @template T + * + * @implements Comparison\Comparable> + * @implements Comparison\Equable> */ -final class Option +final class Option implements Comparison\Comparable, Comparison\Equable { /** * @param ?array{T} $option @@ -279,4 +283,26 @@ public function mapOrElse(Closure $closure, Closure $default): Option return some($default()); } + + /** + * @param Option $other + */ + public function compare(mixed $other): Comparison\Order + { + $aIsNone = $this->isNone(); + $bIsNone = $other->isNone(); + + return match (true) { + $aIsNone || $bIsNone => Comparison\compare($bIsNone, $aIsNone), + default => Comparison\compare($this->unwrap(), $other->unwrap()) + }; + } + + /** + * @param Option $other + */ + public function equals(mixed $other): bool + { + return Comparison\equal($this, $other); + } } diff --git a/tests/static-analysis/Comparison/comparable.php b/tests/static-analysis/Comparison/comparable.php new file mode 100644 index 00000000..6a11260e --- /dev/null +++ b/tests/static-analysis/Comparison/comparable.php @@ -0,0 +1,65 @@ + + */ +abstract class Size implements Comparable +{ + abstract public function normalizedValue(): int; + + public function compare(mixed $other): Order + { + return Comparison\compare($this->normalizedValue(), $other->normalizedValue()); + } +} + +class Inches extends Size +{ + public function normalizedValue(): int + { + return 1; + } +} + +class Centimeters extends Size +{ + public function normalizedValue(): int + { + return 2; + } +} + + +function test_covariant_limitations(): Order +{ + $cm = new Centimeters(); + $inch = new Inches(); + + return $cm->compare($inch); +} + +function compare_mixed(mixed $a, mixed $b): Order +{ + return Comparison\compare($a, $b); +} + +function test_mixed(): void +{ + compare_mixed('a', 1); + compare_mixed(new stdClass(), []); +} diff --git a/tests/unit/Comparison/AbstractComparisonTest.php b/tests/unit/Comparison/AbstractComparisonTest.php new file mode 100644 index 00000000..1494f624 --- /dev/null +++ b/tests/unit/Comparison/AbstractComparisonTest.php @@ -0,0 +1,67 @@ + [0, 0, Order::Equal]; + yield 'scalar-less' => [0, 1, Order::Less]; + yield 'scalar-greater' => [1, 0, Order::Greater]; + + yield 'comparable-equal' => [ + self::createComparableIntWrapper(0), + self::createComparableIntWrapper(0), + Order::Equal + ]; + yield 'comparable-less' => [ + self::createComparableIntWrapper(0), + self::createComparableIntWrapper(1), + Order::Less + ]; + yield 'comparable-greater' => [ + self::createComparableIntWrapper(1), + self::createComparableIntWrapper(0), + Order::Greater + ]; + } + + protected static function createComparableIntWrapper(int $i): Comparable + { + return new class ($i) implements Comparable { + public function __construct( + public readonly int $int + ) { + } + public function compare(mixed $other): Order + { + return Order::from($this->int <=> $other->int); + } + }; + } + + protected static function createIncomparableWrapper(int $i, string $additionalInfo = ''): Comparable + { + return new class ($i, $additionalInfo) implements Comparable { + public function __construct( + public readonly int $int, + public readonly string $additionalInfo + ) { + } + + public function compare(mixed $other): Order + { + throw IncomparableException::fromValues($this->int, $other->int, $this->additionalInfo); + } + }; + } +} diff --git a/tests/unit/Comparison/CompareTest.php b/tests/unit/Comparison/CompareTest.php new file mode 100644 index 00000000..05d024e1 --- /dev/null +++ b/tests/unit/Comparison/CompareTest.php @@ -0,0 +1,43 @@ +expectException(Comparison\Exception\IncomparableException::class); + $this->expectExceptionMessage('Unable to compare "int" with "int".'); + + Comparison\compare($a, $b); + } + + + public function testItCanFailComparingWithAdditionalInfo(): void + { + $a = self::createIncomparableWrapper(1, 'Can only compare even numbers'); + $b = self::createIncomparableWrapper(2); + + $this->expectException(Comparison\Exception\IncomparableException::class); + $this->expectExceptionMessage('Unable to compare "int" with "int": Can only compare even numbers'); + + Comparison\compare($a, $b); + } +} diff --git a/tests/unit/Comparison/EqualTest.php b/tests/unit/Comparison/EqualTest.php new file mode 100644 index 00000000..dccf9261 --- /dev/null +++ b/tests/unit/Comparison/EqualTest.php @@ -0,0 +1,19 @@ +value, Comparison\sort($a, $b)); + } +} diff --git a/tests/unit/Option/NoneTest.php b/tests/unit/Option/NoneTest.php index 7a4c73dc..33c4a47a 100644 --- a/tests/unit/Option/NoneTest.php +++ b/tests/unit/Option/NoneTest.php @@ -5,6 +5,9 @@ namespace Psl\Tests\Unit\Option; use PHPUnit\Framework\TestCase; +use Psl\Comparison\Comparable; +use Psl\Comparison\Equable; +use Psl\Comparison\Order; use Psl\Option; use Psl\Option\Exception\NoneException; @@ -108,4 +111,23 @@ public function testAndThen(): void static::assertNull($option->andThen(static fn($i) => Option\some($i + 1))->unwrapOr(null)); } + + public function testComparable(): void + { + $a = Option\none(); + + static::assertInstanceOf(Comparable::class, $a); + static::assertSame(Order::Equal, $a->compare(Option\none())); + static::assertSame(Order::Less, $a->compare(Option\some('some'))); + static::assertSame(Order::Greater, Option\some('some')->compare($a)); + } + + public function testEquality(): void + { + $a = Option\none(); + + static::assertInstanceOf(Equable::class, $a); + static::assertTrue($a->equals(Option\none())); + static::assertFalse($a->equals(Option\some('other'))); + } } diff --git a/tests/unit/Option/SomeTest.php b/tests/unit/Option/SomeTest.php index 4b216f12..7ce1a40f 100644 --- a/tests/unit/Option/SomeTest.php +++ b/tests/unit/Option/SomeTest.php @@ -5,6 +5,9 @@ namespace Psl\Tests\Unit\Option; use PHPUnit\Framework\TestCase; +use Psl\Comparison\Comparable; +use Psl\Comparison\Equable; +use Psl\Comparison\Order; use Psl\Option; final class SomeTest extends TestCase @@ -105,4 +108,25 @@ public function testAndThen(): void static::assertSame(3, $option->andThen(static fn($i) => Option\some($i + 1))->unwrapOr(null)); } + + public function testComparable(): void + { + $a = Option\some(2); + + static::assertInstanceOf(Comparable::class, $a); + static::assertSame(Order::Equal, $a->compare(Option\some(2))); + static::assertSame(Order::Less, Option\none()->compare(Option\some(1))); + static::assertSame(Order::Greater, $a->compare(Option\none())); + static::assertSame(Order::Less, $a->compare(Option\some(3))); + } + + public function testEquality() + { + $a = Option\some('a'); + + static::assertInstanceOf(Equable::class, $a); + static::assertFalse($a->equals(Option\none())); + static::assertFalse($a->equals(Option\some('other'))); + static::assertTrue($a->equals(Option\some('a'))); + } }