diff --git a/.github/workflows/static-code-analysis.yml b/.github/workflows/static-code-analysis.yml index 9e76d02..d8e796d 100644 --- a/.github/workflows/static-code-analysis.yml +++ b/.github/workflows/static-code-analysis.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2929983..f88f726 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/src/Expectation.php b/src/Expectation.php index b11d8ac..5181fd9 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -20,7 +20,7 @@ */ final readonly class Expectation implements Expectable { - private Exporter $exporter; + public Exporter $exporter; /** * @param TValue $value @@ -39,6 +39,14 @@ public function not(): NegatedExpectation return new NegatedExpectation($this); } + /** + * @return NullableExpectation + */ + public function nullOr(): NullableExpectation + { + return new NullableExpectation($this); + } + /** * @return self */ diff --git a/src/NegatedExpectation.php b/src/NegatedExpectation.php index 383ab88..165e55b 100644 --- a/src/NegatedExpectation.php +++ b/src/NegatedExpectation.php @@ -24,7 +24,10 @@ */ final readonly class NegatedExpectation implements Expectable { - private Exporter $exporter; + /** + * @var TValue + */ + public mixed $value; /** * @param Expectation $expectation @@ -32,7 +35,7 @@ public function __construct( public Expectation $expectation, ) { - $this->exporter = new Exporter(); + $this->value = $expectation->value; } /** @@ -48,7 +51,7 @@ public function isArray(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isArray".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -65,7 +68,7 @@ public function isBool(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isBool".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -82,7 +85,7 @@ public function isFalse(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isFalse".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -99,7 +102,7 @@ public function isFloat(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isFloat".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -116,7 +119,7 @@ public function isInt(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isInt".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -133,7 +136,7 @@ public function isIterable(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isIterable".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -150,7 +153,7 @@ public function isNull(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isNull".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -167,7 +170,7 @@ public function isNumeric(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isNumeric".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -184,7 +187,7 @@ public function isObject(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isObject".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -201,7 +204,7 @@ public function isScalar(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isScalar".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -218,7 +221,7 @@ public function isString(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isString".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } @@ -235,7 +238,7 @@ public function isTrue(?string $message = null): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "isTrue".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } } diff --git a/src/NullableExpectation.php b/src/NullableExpectation.php new file mode 100644 index 0000000..63ed651 --- /dev/null +++ b/src/NullableExpectation.php @@ -0,0 +1,281 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Assert; + +/** + * An expectation that allows null values in addition to the original expectation. + * + * @template TValue + * + * @implements Expectable + * + * @auto-generated + */ +final readonly class NullableExpectation implements Expectable +{ + /** + * @var TValue + */ + public mixed $value; + + /** + * @param Expectation $expectation + */ + public function __construct( + public Expectation $expectation, + ) { + $this->value = $expectation->value; + } + + /** + * @return self + */ + public function isArray(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isArray($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isArray".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isBool(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isBool($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isBool".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isFalse(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isFalse($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isFalse".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isFloat(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isFloat($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isFloat".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isInt(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isInt($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isInt".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isIterable(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isIterable($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isIterable".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isNull(?string $message = null): self + { + $this->expectation->isNull($message); + + return $this; + } + + /** + * @return self + */ + public function isNumeric(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isNumeric($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isNumeric".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isObject(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isObject($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isObject".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isScalar(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isScalar($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isScalar".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isString(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isString($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isString".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + + /** + * @return self + */ + public function isTrue(?string $message = null): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->isTrue($message); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "isTrue".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } +} diff --git a/src/Type/AssertDynamicStaticMethodReturnTypeExtension.php b/src/Type/AssertDynamicStaticMethodReturnTypeExtension.php index 0b6e29a..7b5cd22 100644 --- a/src/Type/AssertDynamicStaticMethodReturnTypeExtension.php +++ b/src/Type/AssertDynamicStaticMethodReturnTypeExtension.php @@ -14,6 +14,7 @@ namespace Nexus\Assert\Type; use Nexus\Assert\Assert; +use Nexus\Assert\Expectation; use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; @@ -42,6 +43,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $valueExpr = $args[0]->value; - return new ExpectationObjectType([$scope->getType($valueExpr)], $valueExpr); + return new ExpectationObjectType(Expectation::class, [$scope->getType($valueExpr)], $valueExpr); } } diff --git a/src/Type/ExpectableObjectType.php b/src/Type/ExpectableObjectType.php deleted file mode 100644 index 7f83452..0000000 --- a/src/Type/ExpectableObjectType.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace Nexus\Assert\Type; - -use PhpParser\Node; -use PHPStan\Type\Type; - -interface ExpectableObjectType -{ - public function getValueExpr(): Node\Expr; - - /** - * @return array - */ - public function getTypes(): array; -} diff --git a/src/Type/ExpectationDynamicMethodReturnTypeExtension.php b/src/Type/ExpectationDynamicMethodReturnTypeExtension.php index 387a11b..b64a355 100644 --- a/src/Type/ExpectationDynamicMethodReturnTypeExtension.php +++ b/src/Type/ExpectationDynamicMethodReturnTypeExtension.php @@ -16,6 +16,7 @@ use Nexus\Assert\Expectable; use Nexus\Assert\Expectation; use Nexus\Assert\NegatedExpectation; +use Nexus\Assert\NullableExpectation; use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; @@ -28,11 +29,6 @@ final class ExpectationDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { - private const EXPECTATION_OBJECT_TYPE_MAP = [ - Expectation::class => ExpectationObjectType::class, - NegatedExpectation::class => NegatedExpectationObjectType::class, - ]; - public function getClass(): string { return Expectable::class; @@ -47,7 +43,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method { $calledOnType = $scope->getType($methodCall->var); - if (! $calledOnType instanceof ExpectableObjectType) { + if (! $calledOnType instanceof ExpectationObjectType) { return null; } @@ -60,14 +56,16 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method if ($returnType instanceof GenericObjectType) { $expectationClass = $returnType->getClassName(); - if (! \array_key_exists($expectationClass, self::EXPECTATION_OBJECT_TYPE_MAP)) { - return $returnType; + if (NegatedExpectation::class === $expectationClass) { + return self::getTypeFromNegatedExpectationMethodCall( + $methodReflection, + $returnType, + $calledOnType, + ); } - $expectationObjectType = self::EXPECTATION_OBJECT_TYPE_MAP[$expectationClass]; - - if (NegatedExpectationObjectType::class === $expectationObjectType) { - return self::getTypeFromNegatedExpectationMethodCall( + if (NullableExpectation::class === $expectationClass) { + return self::getTypeFromNullableExpectationMethodCall( $methodReflection, $returnType, $calledOnType, @@ -84,39 +82,80 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return $returnType; } - private static function getTypeFromNegatedExpectationMethodCall(MethodReflection $methodReflection, GenericObjectType $returnType, ExpectableObjectType $calledOnType): Type + private static function getTypeFromNegatedExpectationMethodCall(MethodReflection $methodReflection, GenericObjectType $returnType, ExpectationObjectType $calledOnType): Type { $methodName = $methodReflection->getName(); $subtractedType = ExpectationMethodResolver::create()->resolve($methodName); - $newType = null !== $subtractedType - ? TypeCombinator::remove( - TypeCombinator::union(...$returnType->getTypes()), - $subtractedType, - ) - : TypeCombinator::union(...$returnType->getTypes()); + + if (null === $subtractedType) { + return new ExpectationObjectType( + NegatedExpectation::class, + $returnType->getTypes(), + $calledOnType->getValueExpr(), + ); + } + + $newType = TypeCombinator::remove( + TypeCombinator::union(...$returnType->getTypes()), + $subtractedType, + ); + + if ($newType instanceof NeverType) { + return new NeverType(true); + } + + return new ExpectationObjectType(NegatedExpectation::class, [$newType], $calledOnType->getValueExpr()); + } + + private static function getTypeFromNullableExpectationMethodCall(MethodReflection $methodReflection, GenericObjectType $returnType, ExpectationObjectType $calledOnType): Type + { + $methodName = $methodReflection->getName(); + $unionedType = ExpectationMethodResolver::create()->resolve($methodName); + + if (null === $unionedType) { + return new ExpectationObjectType( + NullableExpectation::class, + $returnType->getTypes(), + $calledOnType->getValueExpr(), + ); + } + + $newType = TypeCombinator::intersect( + TypeCombinator::union(...$returnType->getTypes()), + $unionedType, + ); if ($newType instanceof NeverType) { return new NeverType(true); } - return new NegatedExpectationObjectType([$newType], $calledOnType->getValueExpr()); + $newTypeWithNull = TypeCombinator::addNull($newType); + + return new ExpectationObjectType(NullableExpectation::class, [$newTypeWithNull], $calledOnType->getValueExpr()); } - private static function getTypeFromRegularExpectationMethodCall(MethodReflection $methodReflection, GenericObjectType $returnType, ExpectableObjectType $calledOnType): Type + private static function getTypeFromRegularExpectationMethodCall(MethodReflection $methodReflection, GenericObjectType $returnType, ExpectationObjectType $calledOnType): Type { $methodName = $methodReflection->getName(); $intersectedType = ExpectationMethodResolver::create()->resolve($methodName); - $newType = null !== $intersectedType - ? TypeCombinator::intersect( - TypeCombinator::union(...$returnType->getTypes()), - $intersectedType, - ) - : TypeCombinator::union(...$returnType->getTypes()); + + if (null === $intersectedType) { + return new ExpectationObjectType( + Expectation::class, + $returnType->getTypes(), + $calledOnType->getValueExpr(), + ); + } + + $newType = TypeCombinator::intersect( + TypeCombinator::union(...$returnType->getTypes()), + $intersectedType, + ); if ($newType instanceof NeverType) { return new NeverType(true); } - return new ExpectationObjectType([$newType], $calledOnType->getValueExpr()); + return new ExpectationObjectType(Expectation::class, [$newType], $calledOnType->getValueExpr()); } } diff --git a/src/Type/ExpectationMethodResolver.php b/src/Type/ExpectationMethodResolver.php index 950dd86..c5da013 100644 --- a/src/Type/ExpectationMethodResolver.php +++ b/src/Type/ExpectationMethodResolver.php @@ -31,6 +31,7 @@ final class ExpectationMethodResolver { private const UNSUPPORTED_EXPECTATION_METHODS = [ 'not', + 'nullOr', ]; /** @@ -81,7 +82,7 @@ public function resolve(string $methodName): ?Type } if (! isset($resolvers[$methodName])) { - throw new \LogicException(\sprintf('No expression resolver found for method %s()', $methodName)); + throw new \LogicException(\sprintf('No type resolver found for method %s()', $methodName)); } return $resolvers[$methodName]; diff --git a/src/Type/ExpectationMethodTypeSpecifyingExtension.php b/src/Type/ExpectationMethodTypeSpecifyingExtension.php index 12cc6fc..1339980 100644 --- a/src/Type/ExpectationMethodTypeSpecifyingExtension.php +++ b/src/Type/ExpectationMethodTypeSpecifyingExtension.php @@ -14,6 +14,8 @@ namespace Nexus\Assert\Type; use Nexus\Assert\Expectable; +use Nexus\Assert\NegatedExpectation; +use Nexus\Assert\NullableExpectation; use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; @@ -51,7 +53,7 @@ public function specifyTypes( ): SpecifiedTypes { $calledOnType = $scope->getType($node->var); - if (! $calledOnType instanceof ExpectableObjectType) { + if (! $calledOnType instanceof ExpectationObjectType) { return new SpecifiedTypes(); } @@ -65,7 +67,7 @@ public function specifyTypes( return new SpecifiedTypes(); } - if ($calledOnType instanceof NegatedExpectationObjectType) { + if ($calledOnType->getClassName() === NegatedExpectation::class) { return $this->typeSpecifier->create( $calledOnType->getValueExpr(), TypeCombinator::remove(TypeCombinator::union(...$calledOnType->getTypes()), $returnType), @@ -74,6 +76,15 @@ public function specifyTypes( ); } + if ($calledOnType->getClassName() === NullableExpectation::class) { + return $this->typeSpecifier->create( + $calledOnType->getValueExpr(), + TypeCombinator::addNull(TypeCombinator::intersect(...$calledOnType->getTypes(), ...[$returnType])), + TypeSpecifierContext::createTruthy(), + $scope, + ); + } + return $this->typeSpecifier->create( $calledOnType->getValueExpr(), TypeCombinator::intersect(...$calledOnType->getTypes(), ...[$returnType]), diff --git a/src/Type/ExpectationObjectType.php b/src/Type/ExpectationObjectType.php index 3008da4..1d87649 100644 --- a/src/Type/ExpectationObjectType.php +++ b/src/Type/ExpectationObjectType.php @@ -13,21 +13,25 @@ namespace Nexus\Assert\Type; -use Nexus\Assert\Expectation; +use Nexus\Assert\Expectable; use PhpParser\Node; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Type; -final class ExpectationObjectType extends GenericObjectType implements ExpectableObjectType +final class ExpectationObjectType extends GenericObjectType { /** - * @param array $types + * @template T + * + * @param class-string> $className + * @param array $types */ public function __construct( + string $className, array $types, private readonly Node\Expr $expr, ) { - parent::__construct(Expectation::class, $types); + parent::__construct($className, $types); } public function getValueExpr(): Node\Expr diff --git a/src/Type/NegatedExpectationObjectType.php b/src/Type/NegatedExpectationObjectType.php deleted file mode 100644 index 8e5b2b2..0000000 --- a/src/Type/NegatedExpectationObjectType.php +++ /dev/null @@ -1,37 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace Nexus\Assert\Type; - -use Nexus\Assert\NegatedExpectation; -use PhpParser\Node; -use PHPStan\Type\Generic\GenericObjectType; -use PHPStan\Type\Type; - -final class NegatedExpectationObjectType extends GenericObjectType implements ExpectableObjectType -{ - /** - * @param array $types - */ - public function __construct( - array $types, - private readonly Node\Expr $expr, - ) { - parent::__construct(NegatedExpectation::class, $types); - } - - public function getValueExpr(): Node\Expr - { - return $this->expr; - } -} diff --git a/tests/ExpectationTest.php b/tests/ExpectationTest.php index e1ccf78..74acf42 100644 --- a/tests/ExpectationTest.php +++ b/tests/ExpectationTest.php @@ -28,6 +28,18 @@ #[Group('unit')] final class ExpectationTest extends TestCase { + public function testExpectationVariantReturns(): void + { + $expectation = Assert::that(42); + self::assertSame($expectation, $expectation->isInt()); + + $negatedExpectation = Assert::that(42)->not(); + self::assertSame($negatedExpectation, $negatedExpectation->isString()); + + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isArray()); + } + public function testIsArray(): void { $expectation = Assert::that([]); @@ -199,11 +211,11 @@ public function testExpectationMethodsAreArrangedInOrder(): void return 1; } - if (\in_array($a->getName(), ['not'], true)) { + if (\in_array($a->getName(), ['not', 'nullOr'], true)) { return -1; } - if (\in_array($b->getName(), ['not'], true)) { + if (\in_array($b->getName(), ['not', 'nullOr'], true)) { return 1; } diff --git a/tests/NullableExpectationTest.php b/tests/NullableExpectationTest.php new file mode 100644 index 0000000..33fc983 --- /dev/null +++ b/tests/NullableExpectationTest.php @@ -0,0 +1,191 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Assert\Tests; + +use Nexus\Assert\Assert; +use Nexus\Assert\ExpectationFailedException; +use Nexus\Assert\NullableExpectation; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(NullableExpectation::class)] +#[Group('unit')] +final class NullableExpectationTest extends TestCase +{ + public function testIsArray(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isArray()); + + $nullableExpectation = Assert::that([])->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isArray()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null or pass the expectation for method "isArray".'); + Assert::that(42)->nullOr()->isArray(); + } + + public function testIsBool(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isBool()); + + $nullableExpectation = Assert::that(true)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isBool()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null or pass the expectation for method "isBool".'); + Assert::that(42)->nullOr()->isBool(); + } + + public function testIsFalse(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isFalse()); + + $nullableExpectation = Assert::that(false)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isFalse()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "true" is expected to be null or pass the expectation for method "isFalse".'); + Assert::that(true)->nullOr()->isFalse(); + } + + public function testIsFloat(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isFloat()); + + $nullableExpectation = Assert::that(3.14)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isFloat()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null or pass the expectation for method "isFloat".'); + Assert::that(42)->nullOr()->isFloat(); + } + + public function testIsInt(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isInt()); + + $nullableExpectation = Assert::that(42)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isInt()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "3.14" is expected to be null or pass the expectation for method "isInt".'); + Assert::that(3.14)->nullOr()->isInt(); + } + + public function testIsIterable(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isIterable()); + + $nullableExpectation = Assert::that([1])->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isIterable()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null or pass the expectation for method "isIterable".'); + Assert::that(42)->nullOr()->isIterable(); + } + + public function testIsNull(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isNull()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null but got int instead.'); + Assert::that(42)->nullOr()->isNull(); + } + + public function testIsNumeric(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isNumeric()); + + $nullableExpectation = Assert::that(42)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isNumeric()); + + $nullableExpectation = Assert::that(3.14)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isNumeric()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "true" is expected to be null or pass the expectation for method "isNumeric".'); + Assert::that(true)->nullOr()->isNumeric(); + } + + public function testIsObject(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isObject()); + + $nullableExpectation = Assert::that(new \stdClass())->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isObject()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null or pass the expectation for method "isObject".'); + Assert::that(42)->nullOr()->isObject(); + } + + public function testIsScalar(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isScalar()); + + $nullableExpectation = Assert::that(42)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isScalar()); + + $nullableExpectation = Assert::that(3.14)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isScalar()); + + $nullableExpectation = Assert::that('hello')->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isScalar()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "[]" is expected to be null or pass the expectation for method "isScalar".'); + Assert::that([])->nullOr()->isScalar(); + } + + public function testIsString(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isString()); + + $nullableExpectation = Assert::that('hello')->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isString()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "42" is expected to be null or pass the expectation for method "isString".'); + Assert::that(42)->nullOr()->isString(); + } + + public function testIsTrue(): void + { + $nullableExpectation = Assert::that(null)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isTrue()); + + $nullableExpectation = Assert::that(true)->nullOr(); + self::assertSame($nullableExpectation, $nullableExpectation->isTrue()); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Value "false" is expected to be null or pass the expectation for method "isTrue".'); + Assert::that(false)->nullOr()->isTrue(); + } +} diff --git a/tests/data/nullable_expectation.php b/tests/data/nullable_expectation.php new file mode 100644 index 0000000..f5931e0 --- /dev/null +++ b/tests/data/nullable_expectation.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Assert\Tests; + +use Nexus\Assert\Assert; + +use function PHPStan\Testing\assertType; + +function test_is_array(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isArray(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('array|null', $value); +} + +function test_is_bool(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isBool(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('bool|null', $value); +} + +function test_is_false(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isFalse(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('false|null', $value); +} + +function test_is_float(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isFloat(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('float|null', $value); +} + +function test_is_int(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isInt(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('int|null', $value); +} + +function test_is_iterable(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isIterable(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('iterable|null', $value); +} + +function test_is_null(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isNull(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('null', $value); +} + +function test_is_numeric(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isNumeric(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('float|int|numeric-string|null', $value); +} + +function test_is_object(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isObject(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('object|null', $value); +} + +function test_is_scalar(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isScalar(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('bool|float|int|string|null', $value); +} + +function test_is_string(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isString(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('string|null', $value); +} + +function test_is_true(mixed $value): void +{ + $assert = Assert::that($value)->nullOr()->isTrue(); + assertType('Nexus\\Assert\\NullableExpectation', $assert); + assertType('true|null', $value); +} diff --git a/tools/src/ExpectationVariantsGenerator.php b/tools/src/ExpectationVariantsGenerator.php index 2045da5..5fc9c00 100644 --- a/tools/src/ExpectationVariantsGenerator.php +++ b/tools/src/ExpectationVariantsGenerator.php @@ -40,7 +40,10 @@ final class ExpectationVariantsGenerator */ final readonly class {{CLASS_NAME}} implements Expectable { - private Exporter $exporter; + /** + * @var TValue + */ + public mixed $value; /** * @param Expectation $expectation @@ -48,7 +51,7 @@ final class ExpectationVariantsGenerator public function __construct( public Expectation $expectation, ) { - $this->exporter = new Exporter(); + $this->value = $expectation->value; } {{CLASS_METHODS}} @@ -60,9 +63,14 @@ public function __construct( 'name' => 'NegatedExpectation', 'description' => 'An expectation that negates the original expectation.', ], + 'generateNullableExpectation' => [ + 'name' => 'NullableExpectation', + 'description' => 'An expectation that allows null values in addition to the original expectation.', + ], ]; private const UNSUPPORTED_METHODS = [ 'not', + 'nullOr', ]; private const SRC_PATH = __DIR__.'/../../src/'; @@ -77,16 +85,17 @@ public function __construct( public function __construct() { - $options = getopt('', ['all', 'negated', 'help']); + $options = getopt('', ['all', 'negated', 'nullable', 'help']); \assert(\is_array($options)); $this->options = $options; if (isset($this->options['help']) || [] === $this->options) { - echo "Usage: \033[32mbin/generate\033[0m [--all|--negated|--help]\n\n"; + echo "Usage: \033[32mbin/generate\033[0m [--all|--negated|--nullable|--help]\n\n"; echo "\033[33mOptions:\033[0m\n"; echo " --all Generate all expectation variants.\n"; echo " --negated Generate only the NegatedExpectation variant.\n"; + echo " --nullable Generate only the NullableExpectation variant.\n"; echo " --help Display this help message.\n"; exit(0); @@ -97,26 +106,35 @@ public function generate(): void { $options = [ 'negated' => isset($this->options['all']) || isset($this->options['negated']), + 'nullable' => isset($this->options['all']) || isset($this->options['nullable']), ]; /** @var \ReflectionClass> $expectation */ $expectation = new \ReflectionClass(Expectation::class); if ($options['negated']) { - self::generateNegatedExpectation( + self::generateExpectation( $expectation, self::EXPECTATION_VARIANTS['generateNegatedExpectation']['name'], self::EXPECTATION_VARIANTS['generateNegatedExpectation']['description'], + 'generateNegatedMethodCode', + ); + } + + if ($options['nullable']) { + self::generateExpectation( + $expectation, + self::EXPECTATION_VARIANTS['generateNullableExpectation']['name'], + self::EXPECTATION_VARIANTS['generateNullableExpectation']['description'], + 'generateNullableMethodCode', ); } } /** - * @template T - * - * @param \ReflectionClass> $expectation + * @param \ReflectionClass> $expectation */ - private static function generateNegatedExpectation(\ReflectionClass $expectation, string $name, string $description): void + private static function generateExpectation(\ReflectionClass $expectation, string $name, string $description, string $methodGenerationCode): void { $methodsCode = ''; @@ -125,7 +143,8 @@ private static function generateNegatedExpectation(\ReflectionClass $expectation continue; } - $methodCode = self::generateNegatedMethodCode($method); + $methodCode = self::$methodGenerationCode($method); // @phpstan-ignore staticMethod.dynamicName + \assert(\is_string($methodCode)); $methodsCode .= $methodCode."\n\n"; } @@ -138,9 +157,11 @@ private static function generateNegatedExpectation(\ReflectionClass $expectation file_put_contents(self::SRC_PATH.$name.'.php', $classCode); } - private static function generateNegatedMethodCode(\ReflectionMethod $method): string + /** + * @return array{string, string} + */ + private static function generateMethodParametersAndArguments(\ReflectionMethod $method): array { - $methodName = $method->getName(); $parameters = []; $parameterCalls = []; @@ -169,8 +190,13 @@ private static function generateNegatedMethodCode(\ReflectionMethod $method): st $parameterCalls[] = '$'.$parameter->getName(); } - $parametersCode = implode(', ', $parameters); - $parameterCallsCode = implode(', ', $parameterCalls); + return [implode(', ', $parameters), implode(', ', $parameterCalls)]; + } + + private static function generateNegatedMethodCode(\ReflectionMethod $method): string + { + $methodName = $method->getName(); + [$parametersCode, $parameterCallsCode] = self::generateMethodParametersAndArguments($method); return \sprintf( <<<'PHP' @@ -187,7 +213,7 @@ public function %1$s(%2$s): self throw new ExpectationFailedException( $message ?? 'Value "{value}" is not expected to pass the negated expectation for method "%1$s".', - ['value' => $this->exporter->exportValue($this->expectation->value)], + ['value' => $this->expectation->exporter->exportValue($this->value)], ); } PHP, @@ -196,4 +222,57 @@ public function %1$s(%2$s): self $parameterCallsCode, ); } + + private static function generateNullableMethodCode(\ReflectionMethod $method): string + { + $methodName = $method->getName(); + [$parametersCode, $parameterCallsCode] = self::generateMethodParametersAndArguments($method); + + if ('isNull' === $methodName) { + return \sprintf( + <<<'PHP' + /** + * @return self + */ + public function %1$s(%2$s): self + { + $this->expectation->%1$s(%3$s); + + return $this; + } + PHP, + $methodName, + $parametersCode, + $parameterCallsCode, + ); + } + + return \sprintf( + <<<'PHP' + /** + * @return self + */ + public function %1$s(%2$s): self + { + if (null === $this->expectation->value) { + return $this; + } + + try { + $this->expectation->%1$s(%3$s); + + return $this; + } catch (ExpectationFailedException) { + throw new ExpectationFailedException( + $message ?? 'Value "{value}" is expected to be null or pass the expectation for method "%1$s".', + ['value' => $this->expectation->exporter->exportValue($this->value)], + ); + } + } + PHP, + $methodName, + $parametersCode, + $parameterCallsCode, + ); + } }