diff --git a/composer.json b/composer.json index 85ca832..2dd2b85 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ } ], "require": { - "php": "^8.1" + "php": "^8.1", + "texthtml/object-reaper": "^1.0" }, "require-dev": { "phpunit/phpunit": "^10.0|^11.0|^12.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3095b52..49996b3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,6 +7,8 @@ parameters: check: missingCheckedExceptionInThrows: true tooWideThrowType: true + uncheckedExceptionClasses: + - LogicException ignoreErrors: - message: '#Unreachable statement - code above always terminates.#' diff --git a/psalm.xml b/psalm.xml index f3a177a..7928432 100644 --- a/psalm.xml +++ b/psalm.xml @@ -26,9 +26,15 @@ + + + + + + - + @@ -73,4 +79,7 @@ + + + diff --git a/src/Option/Some.php b/src/Option/Some.php index b5bcf5d..36431a2 100644 --- a/src/Option/Some.php +++ b/src/Option/Some.php @@ -198,7 +198,7 @@ public function zip(Option $option): Option * @param Option $option * @param callable(T, U):V $callback * @return (U is never ? Option\None : Option) - * @return Option + * @phpstan-return Option */ public function zipWith(Option $option, callable $callback): Option { diff --git a/src/Result/MustBeUsed.php b/src/Result/MustBeUsed.php index 27ddefb..6fd968b 100644 --- a/src/Result/MustBeUsed.php +++ b/src/Result/MustBeUsed.php @@ -2,64 +2,46 @@ namespace TH\Maybe\Result; -use TH\Maybe\Result; +use TH\ObjectReaper\Reaper; /** @internal */ trait MustBeUsed { - /** @var \ArrayAccess,ResultCreationTrace>|null */ - private static ?\ArrayAccess $toBeUsed = null; - - /** - * @return \ArrayAccess,ResultCreationTrace> - */ - private static function toBeUsedMap(): \ArrayAccess + /** @return \WeakMap */ + private static function reapers(): \WeakMap { - return self::$toBeUsed ??= self::emptyToBeUsedMap(); - } + static $reapers; - /** - * @return \WeakMap,ResultCreationTrace> - */ - private static function emptyToBeUsedMap(): \WeakMap - { - /** @var \WeakMap,ResultCreationTrace> */ - return new \WeakMap(); + return $reapers ??= new \WeakMap(); } /** - * Mark a result as needed to be used + * Mark a result as needed to be used. Must be called at most once on an object. */ private function mustBeUsed(): void { - self::toBeUsedMap()[$this] = new ResultCreationTrace(); + $reapers = self::reapers(); + + if (isset($reapers[$this])) { + throw new \LogicException('Object already register to be used'); + } + + $creationTrace = new ResultCreationTrace(); + + $reapers[$this] = Reaper::watch($this, static fn () => throw new UnusedResultException($creationTrace)); } /** - * Mark a result as used + * Mark a result as used. */ private function used(): void { - unset(self::toBeUsedMap()[$this]); + $reaper = self::reapers()[$this] ?? throw new \LogicException('Object not registered to be used'); + $reaper->forget(); } public function __clone() { $this->mustBeUsed(); } - - /** - * @throws UnusedResultException - */ - public function __destruct() - { - $map = self::toBeUsedMap(); - - if (isset($map[$this])) { - $creationTrace = $map[$this]; - unset($map[$this]); - - throw new UnusedResultException($creationTrace); - } - } } diff --git a/src/Result/ResultCreationTrace.php b/src/Result/ResultCreationTrace.php index 07c4b78..bea3e66 100644 --- a/src/Result/ResultCreationTrace.php +++ b/src/Result/ResultCreationTrace.php @@ -3,7 +3,7 @@ namespace TH\Maybe\Result; /** @internal */ -final class ResultCreationTrace extends \RuntimeException +final class ResultCreationTrace extends \LogicException { public function __construct() { diff --git a/src/functions/internal.php b/src/functions/internal.php new file mode 100644 index 0000000..a42ed9e --- /dev/null +++ b/src/functions/internal.php @@ -0,0 +1,31 @@ + $exceptionClasses + * @throws \Throwable + * @return T + * @internal + * @nodoc + */ +function trap( + \Throwable $error, + callable $callback, + string ...$exceptionClasses, +): mixed { + foreach ($exceptionClasses as $exceptionClass) { + if (\is_a($error, $exceptionClass)) { + return $callback($error); + } + } + + throw $error; +} diff --git a/src/functions/option.php b/src/functions/option.php index 509e2bb..bb1dc48 100644 --- a/src/functions/option.php +++ b/src/functions/option.php @@ -209,8 +209,7 @@ function unzip(Option $option): array function transpose(Option $option): Result { /** @var Result, E> */ - return $option->mapOrElse( // @phpstan-ignore varTag.type - // @phpstan-ignore-next-line + return $option->mapOrElse( static fn (Result $result) => $result->map(Option\some(...)), static fn () => Result\ok(Option\none()), ); diff --git a/src/functions/result.php b/src/functions/result.php index 490eee4..24b37f8 100644 --- a/src/functions/result.php +++ b/src/functions/result.php @@ -156,8 +156,7 @@ function flatten(Result $result): Result function transpose(Result $result): Option { /** @var Option> */ - return $result->mapOrElse( // @phpstan-ignore varTag.type - // @phpstan-ignore-next-line + return $result->mapOrElse( static fn (Option $option) => $option->map(Result\ok(...)), static fn () => Option\some(clone $result), ); diff --git a/tests/Constraint/HasBeen.php b/tests/Constraint/HasBeen.php index 7917ef6..3f6a8b8 100644 --- a/tests/Constraint/HasBeen.php +++ b/tests/Constraint/HasBeen.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Constraint\Constraint; use TH\Maybe\Result; +use TH\ObjectReaper\Reaper; final class HasBeen extends Constraint { @@ -28,21 +29,29 @@ protected function matches(mixed $other): bool } /** - * @param Result $result + * @template T of Result + * @param T $result */ protected function toBeUsed(Result $result): bool { $ro = new \ReflectionObject($result); - /** - * @phpstan-throws void - * @psalm-suppress MissingThrowsDocblock - */ - $rp = $ro->getProperty("toBeUsed"); + /** @throws void */ + $reapers = $ro->getMethod('reapers')->invoke(null); + /** @var \WeakMap $reapers */ + $reaper = $reapers[$result]; - /** @var \ArrayAccess, mixed> $toBeUsedMap */ - $toBeUsedMap = $rp->getValue(null); + $ro = new \ReflectionObject($reaper); - return isset($toBeUsedMap[$result]); + /** @throws void */ + $rp = $ro->getProperty('active'); + /** @psalm-suppress UnusedMethodCall */ + $rp->setAccessible(true); + + $result = $rp->getValue($reaper); + + \assert(\is_bool($result)); + + return $result; } } diff --git a/tests/Helpers/IgnoreUnusedResults.php b/tests/Helpers/IgnoreUnusedResults.php index 4ad026e..e3a8993 100644 --- a/tests/Helpers/IgnoreUnusedResults.php +++ b/tests/Helpers/IgnoreUnusedResults.php @@ -2,75 +2,48 @@ namespace TH\Maybe\Tests\Helpers; -use TH\Maybe\Result; use TH\Maybe\Result\Err; use TH\Maybe\Result\Ok; -use TH\Maybe\Result\ResultCreationTrace; +use TH\ObjectReaper\Reaper; /** * @internal */ final class IgnoreUnusedResults { - /** @var \SplObjectStorage<\ReflectionProperty, \ArrayAccess|null> */ - private \SplObjectStorage $unusedResults; + public function __construct() + { + $this->clearReapers(); + } /** - * Don't check whether new Results are used or not. + * @return iterable */ - public function __construct() + private function reapers(): iterable { - $this->unusedResults = new \SplObjectStorage(); - - foreach ($this->properties() as $rp) { - $this->unusedResults[$rp] = $rp->isInitialized() - ? $rp->getValue() - : null; - - $rp->setValue( - null, - new - /** - * @implements \ArrayAccess,ResultCreationTrace> - */ - class implements \ArrayAccess { - public function offsetExists(mixed $offset): bool - { - return false; - } - - public function offsetGet(mixed $offset): mixed - { - return null; - } + foreach ([Ok::class, Err::class] as $className) { + /** @phpstan-throws void */ + $rm = (new \ReflectionClass($className))->getMethod("reapers"); - public function offsetSet(mixed $offset, mixed $value): void - { - } + /** + * @var iterable $reapers + * @throws void + */ + $reapers = $rm->invoke(null); - public function offsetUnset(mixed $offset): void - { - } - }, - ); + yield from $reapers; } } - /** - * @return iterable<\ReflectionProperty> - */ - private function properties(): iterable + private function clearReapers(): void { - foreach ([Ok::class, Err::class] as $className) { - /** @phpstan-throws void */ - yield (new \ReflectionClass($className))->getProperty("toBeUsed"); + foreach ($this->reapers() as $reaper) { + $reaper->forget(); } } public function __destruct() { - foreach ($this->unusedResults as $rp) { - $rp->setValue(null, $this->unusedResults[$rp] ?? new \WeakMap()); - } + $this->clearReapers(); } } diff --git a/tests/Unit/Result/ConvertToOptionTest.php b/tests/Unit/Result/ConvertToOptionTest.php index 1595f4c..e063089 100644 --- a/tests/Unit/Result/ConvertToOptionTest.php +++ b/tests/Unit/Result/ConvertToOptionTest.php @@ -76,9 +76,7 @@ public function testTranspose(Option $option, Result $expected): void Assert::assertEquals($option, $option2 = Result\transpose($expected)); Assert::assertResultUsed($expected); - // @phpstan-ignore-next-line $option->map(Assert::assertResultNotUsed(...)); - // @phpstan-ignore-next-line $option2->map(Assert::assertResultNotUsed(...)); } }