diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a7627b..eca3431 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: run: composer update --prefer-dist --no-progress --no-suggest --prefer-stable - name: Run test suite - run: php -dpcov.enabled=1 -dpcov.exclude="~vendor~" vendor/bin/phpunit --testsuite unit,integration --coverage-clover ./.coverage/coverage.xml + run: php -dpcov.enabled=1 -dpcov.exclude="~vendor~" vendor/bin/phpunit --coverage-clover ./.coverage/coverage.xml - name: Check coverage run: test ! -f ./.coverage/coverage.xml || php vendor/bin/phpfci inspect ./.coverage/coverage.xml --exit-code-on-failure --reportText diff --git a/README.md b/README.md index b57d9d6..78afc56 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Fluent assertion methods, inspired by `webmozart/assert`: - `nonEmptyArray` - `nonEmptyString` - `isInstanceOf` +- `type` - `fileExists` - `file` - `directory` diff --git a/extension.neon b/extension.neon index ccb730d..959d89f 100644 --- a/extension.neon +++ b/extension.neon @@ -1,5 +1,15 @@ services: - - - class: DR\Utils\PHPStan\ArraysReturnExtension - tags: - - phpstan.broker.dynamicStaticMethodReturnTypeExtension + - + class: DR\Utils\PHPStan\Extension\ArraysRemoveTypesReturnExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension + - + class: DR\Utils\PHPStan\Extension\AssertTypeReturnExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension + - + class: DR\Utils\PHPStan\Extension\AssertTypeTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension + - + class: DR\Utils\PHPStan\Lib\TypeNarrower diff --git a/phpstan.neon b/phpstan.neon index 5b9ff2b..5711b48 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,8 +1,5 @@ -services: - - - class: DR\Utils\PHPStan\ArraysReturnExtension - tags: - - phpstan.broker.dynamicStaticMethodReturnTypeExtension +includes: + - extension.neon parameters: level: max @@ -10,3 +7,5 @@ parameters: paths: - src - tests + excludePaths: + - tests/Integration/PHPStan/data diff --git a/src/Assert.php b/src/Assert.php index 9924b47..7ee72bc 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -8,6 +8,9 @@ use Stringable; use function file_exists; +use function get_debug_type; +use function implode; +use function in_array; use function is_bool; use function is_callable; use function is_dir; @@ -169,7 +172,6 @@ public static function integer(mixed $value, ?string $message = null): int /** * Assert the value is a numeric value. See is_numeric - * * @template T * @phpstan-assert numeric-string $value * @@ -241,6 +243,23 @@ public static function stringable(mixed $value, ?string $message = null): string return $value; } + /** + * Assert value is at least one of the given types + * @template T + * @param T $value + * @param string[] $types + * + * @return T; + */ + public static function type(mixed $value, array $types, ?string $message = null): mixed + { + if (in_array(get_debug_type($value), $types, true) === false) { + throw ExceptionFactory::createException('in type (' . implode(',', $types) . ')', $value, $message); + } + + return $value; + } + /** * Assert value is a class-string * @template T diff --git a/src/PHPStan/ArraysReturnExtension.php b/src/PHPStan/Extension/ArraysRemoveTypesReturnExtension.php similarity index 64% rename from src/PHPStan/ArraysReturnExtension.php rename to src/PHPStan/Extension/ArraysRemoveTypesReturnExtension.php index d172918..5e3e873 100644 --- a/src/PHPStan/ArraysReturnExtension.php +++ b/src/PHPStan/Extension/ArraysRemoveTypesReturnExtension.php @@ -1,17 +1,12 @@ getItemTypes($arrayType); // convert the disallowed types as string to phpstan types - $disallowedStanTypes = $this->getDisallowedTypes($disallowedTypes); + $disallowedStanTypes = $this->typeNarrower->getTypesFromStringArray($disallowedTypes); $allowedStanTypes = []; foreach ($types as $index => $type) { @@ -78,27 +73,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, ); } - /** - * @return Type[] - */ - private function getDisallowedTypes(Arg $arrayArgument): array - { - /** @var Array_ $disallowedTypesValue */ - $disallowedTypesValue = $arrayArgument->value; - $disallowedStanTypes = []; - foreach ($disallowedTypesValue->items as $item) { - if ($item?->value instanceof String_) { - // type definition is string, convert to type object - $disallowedStanTypes[] = $this->typeStringResolver->resolve($item->value->value); - } elseif ($item?->value instanceof ClassConstFetch && $item->value->class instanceof Name) { - // type definition is class-string, convert to type object - $disallowedStanTypes[] = $this->typeStringResolver->resolve($item->value->class->toString()); - } - } - - return $disallowedStanTypes; - } - /** * @return Type[] */ diff --git a/src/PHPStan/Extension/AssertTypeReturnExtension.php b/src/PHPStan/Extension/AssertTypeReturnExtension.php new file mode 100644 index 0000000..504fbaa --- /dev/null +++ b/src/PHPStan/Extension/AssertTypeReturnExtension.php @@ -0,0 +1,39 @@ +getName() === 'type'; + } + + /** + * @inheritDoc + */ + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type + { + [$item, $allowedTypes] = $methodCall->getArgs(); + + return $this->typeNarrower->narrow($item, $allowedTypes, $scope); + } +} diff --git a/src/PHPStan/Extension/AssertTypeTypeSpecifyingExtension.php b/src/PHPStan/Extension/AssertTypeTypeSpecifyingExtension.php new file mode 100644 index 0000000..bee23db --- /dev/null +++ b/src/PHPStan/Extension/AssertTypeTypeSpecifyingExtension.php @@ -0,0 +1,71 @@ + + * Assert::type($value, ['int', 'null']); + * + * @see \PHPStan\Type\PHPUnit\Assert\AssertStaticMethodTypeSpecifyingExtension as an example + */ +class AssertTypeTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension +{ + private TypeSpecifier $typeSpecifier; + + /** + * @codeCoverageIgnore Will only be hit during initialisation + */ + public function __construct(private readonly TypeNarrower $typeNarrower) + { + } + + /** + * @codeCoverageIgnore Will only be hit during initialisation + */ + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + + public function getClass(): string + { + return Assert::class; + } + + /** + * @inheritDoc + */ + public function isStaticMethodSupported(MethodReflection $staticMethodReflection, StaticCall $node, TypeSpecifierContext $context): bool + { + return $staticMethodReflection->getName() === 'type'; + } + + /** + * @inheritDoc + */ + public function specifyTypes( + MethodReflection $staticMethodReflection, + StaticCall $node, + Scope $scope, + TypeSpecifierContext $context + ): SpecifiedTypes { + [$item, $allowedTypes] = $node->getArgs(); + $type = $this->typeNarrower->narrow($item, $allowedTypes, $scope); + + return $this->typeSpecifier->create($item->value, $type, TypeSpecifierContext::createTruthy()); + } +} diff --git a/src/PHPStan/Lib/TypeNarrower.php b/src/PHPStan/Lib/TypeNarrower.php new file mode 100644 index 0000000..9cd7bd1 --- /dev/null +++ b/src/PHPStan/Lib/TypeNarrower.php @@ -0,0 +1,63 @@ +getTypesFromStringArray($allowedTypes); + $type = match (true) { + count($allowedStanTypes) === 0 => new NeverType(), + count($allowedStanTypes) === 1 => reset($allowedStanTypes), + default => new UnionType($allowedStanTypes), + }; + + return TypeCombinator::intersect($scope->getType($item->value), $type); + } + + /** + * @param Arg $arg arg of type Array + * + * @return Type[] + */ + public function getTypesFromStringArray(Arg $arg): array + { + $argValue = $arg->value; + if ($argValue instanceof Array_ === false) { + return []; + } + + $types = []; + foreach ($argValue->items as $item) { + if ($item?->value instanceof String_) { + // type definition is string, convert to type object + $types[] = $this->typeStringResolver->resolve($item->value->value); + } elseif ($item?->value instanceof ClassConstFetch && $item->value->class instanceof Name) { + // type definition is class-string, convert to type object + $types[] = $this->typeStringResolver->resolve($item->value->class->toString()); + } + } + + return $types; + } +} diff --git a/tests/Integration/PHPStan/ArraysReturnExtensionTest.php b/tests/Integration/PHPStan/ArraysRemoveTypeReturnExtensionTest.php similarity index 73% rename from tests/Integration/PHPStan/ArraysReturnExtensionTest.php rename to tests/Integration/PHPStan/ArraysRemoveTypeReturnExtensionTest.php index 7919de1..4ce1139 100644 --- a/tests/Integration/PHPStan/ArraysReturnExtensionTest.php +++ b/tests/Integration/PHPStan/ArraysRemoveTypeReturnExtensionTest.php @@ -4,12 +4,14 @@ namespace DR\Utils\Tests\Integration\PHPStan; use DR\Utils\Assert; -use DR\Utils\PHPStan\ArraysReturnExtension; +use DR\Utils\PHPStan\Extension\ArraysRemoveTypesReturnExtension; +use DR\Utils\PHPStan\Lib\TypeNarrower; use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\CoversClass; -#[CoversClass(ArraysReturnExtension::class)] -class ArraysReturnExtensionTest extends TypeInferenceTestCase +#[CoversClass(ArraysRemoveTypesReturnExtension::class)] +#[CoversClass(TypeNarrower::class)] +class ArraysRemoveTypeReturnExtensionTest extends TypeInferenceTestCase { public function testFileAsserts(): void { diff --git a/tests/Integration/PHPStan/AssertTypeExtensionTest.php b/tests/Integration/PHPStan/AssertTypeExtensionTest.php new file mode 100644 index 0000000..c19241b --- /dev/null +++ b/tests/Integration/PHPStan/AssertTypeExtensionTest.php @@ -0,0 +1,35 @@ +assertFileAsserts($assertType, $file, ...$result); + } + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [dirname(__DIR__, 3) . '/extension.neon']; + } +} diff --git a/tests/Integration/PHPStan/data/AssertTypeAssertions.php b/tests/Integration/PHPStan/data/AssertTypeAssertions.php new file mode 100644 index 0000000..1e56777 --- /dev/null +++ b/tests/Integration/PHPStan/data/AssertTypeAssertions.php @@ -0,0 +1,37 @@ +getType(), ['int'])); + assertType("int|null", Assert::type($this->getType(), ['int', 'null'])); + assertType("float", Assert::type($this->getType(), ['float'])); + assertType("array|bool|float|int|string|null", Assert::type($this->getType(), ['bool', 'int', 'float', 'string', 'array', 'object', 'null'])); + assertType("*NEVER*", Assert::type($this->getType(), ['object'])); + + // assert argument + $value = $this->getType(); + Assert::type($value, ['float']); + assertType("float", $value); + + // assert to invalid type + $value = $this->getType(); + Assert::type($value, ['object']); + assertType("*NEVER*", $value); + } + + private function getType(): bool|int|float|string|array|null + { + return true; + } +} diff --git a/tests/Unit/AssertTest.php b/tests/Unit/AssertTest.php index dd0fe89..448d0e5 100644 --- a/tests/Unit/AssertTest.php +++ b/tests/Unit/AssertTest.php @@ -194,6 +194,29 @@ public function testClassStringClassFailure(): void Assert::classString('DR\Utils\FakeClass'); } + #[TestWith([null, 'null'])] + #[TestWith([true, 'bool'])] + #[TestWith([123, 'int'])] + #[TestWith([12.3, 'float'])] + #[TestWith(['string', 'string'])] + #[TestWith([[], 'array'])] + public function testType(mixed $value, string $type): void + { + static::assertSame($value, Assert::type($value, [$type])); // @phpstan-ignore-line + } + + /** + * @param string[] $types + */ + #[TestWith([null, ['int', 'bool', 'float', 'array']])] + #[TestWith([123, ['bool', 'null']])] + public function testTypeFailure(mixed $value, array $types): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Expecting value to be in type'); + Assert::type($value, $types); // @phpstan-ignore-line + } + #[TestWith(['string', 'str', true])] #[TestWith(['string', 'str', false])] #[TestWith(['string', 'STR', false])] diff --git a/tests/Unit/PHPStan/Extension/ArraysRemoveTypesReturnExtensionTest.php b/tests/Unit/PHPStan/Extension/ArraysRemoveTypesReturnExtensionTest.php new file mode 100644 index 0000000..a2e62ec --- /dev/null +++ b/tests/Unit/PHPStan/Extension/ArraysRemoveTypesReturnExtensionTest.php @@ -0,0 +1,21 @@ +createMock(TypeNarrower::class)); + self::assertSame(Arrays::class, $extension->getClass()); + } +} diff --git a/tests/Unit/PHPStan/Extension/AssertTypeReturnExtensionTest.php b/tests/Unit/PHPStan/Extension/AssertTypeReturnExtensionTest.php new file mode 100644 index 0000000..1185bae --- /dev/null +++ b/tests/Unit/PHPStan/Extension/AssertTypeReturnExtensionTest.php @@ -0,0 +1,21 @@ +createMock(TypeNarrower::class)); + self::assertSame(Assert::class, $extension->getClass()); + } +} diff --git a/tests/Unit/PHPStan/Extension/AssertTypeTypeSpecifyingExtensionTest.php b/tests/Unit/PHPStan/Extension/AssertTypeTypeSpecifyingExtensionTest.php new file mode 100644 index 0000000..15e529d --- /dev/null +++ b/tests/Unit/PHPStan/Extension/AssertTypeTypeSpecifyingExtensionTest.php @@ -0,0 +1,21 @@ +createMock(TypeNarrower::class)); + self::assertSame(Assert::class, $extension->getClass()); + } +} diff --git a/tests/Unit/PHPStan/Lib/TypeNarrowerTest.php b/tests/Unit/PHPStan/Lib/TypeNarrowerTest.php new file mode 100644 index 0000000..86309b7 --- /dev/null +++ b/tests/Unit/PHPStan/Lib/TypeNarrowerTest.php @@ -0,0 +1,24 @@ +createMock(Expr::class)); + + $narrower = new TypeNarrower($this->createMock(TypeStringResolver::class)); + static::assertSame([], $narrower->getTypesFromStringArray($arg)); + } +}