From ee51201712c8ceffe62dbecf3e2fd9fe3168318e Mon Sep 17 00:00:00 2001 From: Martin Herndl Date: Thu, 14 Jul 2022 21:38:48 +0200 Subject: [PATCH] Support string assertions resulting in non-empty-string --- README.md | 16 +++ .../AssertTypeSpecifyingExtension.php | 135 +++++++++++++++--- .../ImpossibleCheckTypeMethodCallRuleTest.php | 16 +++ .../Type/WebMozartAssert/data/collection.php | 24 ++++ .../WebMozartAssert/data/impossible-check.php | 23 +++ tests/Type/WebMozartAssert/data/string.php | 114 +++++++++++++++ 6 files changed, 306 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0c9eda8..3158150 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,26 @@ This extension specifies types of values passed to: * `Assert::methodExists` * `Assert::propertyExists` * `Assert::isArrayAccessible` +* `Assert::contains` +* `Assert::startsWith` +* `Assert::startsWithLetter` +* `Assert::endsWith` +* `Assert::unicodeLetters` +* `Assert::alpha` +* `Assert::digits` +* `Assert::alnum` +* `Assert::lower` +* `Assert::upper` * `Assert::length` * `Assert::minLength` * `Assert::maxLength` * `Assert::lengthBetween` +* `Assert::uuid` +* `Assert::ip` +* `Assert::ipv4` +* `Assert::ipv6` +* `Assert::email` +* `Assert::notWhitespaceOnly` * `nullOr*`, `all*` and `allNullOr*` variants of the above methods diff --git a/src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php b/src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php index 70781f1..a6ada2d 100644 --- a/src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php +++ b/src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php @@ -39,6 +39,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\IterableType; use PHPStan\Type\MixedType; @@ -58,6 +59,7 @@ use function array_reduce; use function array_shift; use function count; +use function is_array; use function lcfirst; use function substr; @@ -104,7 +106,7 @@ public function isStaticMethodSupported( } $resolver = $resolvers[$trimmedName]; - $resolverReflection = new ReflectionObject($resolver); + $resolverReflection = new ReflectionObject(Closure::fromCallable($resolver)); return count($node->getArgs()) >= count($resolverReflection->getMethod('__invoke')->getParameters()) - 1; } @@ -156,38 +158,57 @@ static function (Type $type) { ); } - $expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs()); - if ($expression === null) { + [$expr, $rootExpr] = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs()); + if ($expr === null) { return new SpecifiedTypes([], []); } - return $this->typeSpecifier->specifyTypesInCondition( + $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition( $scope, - $expression, - TypeSpecifierContext::createTruthy() + $expr, + TypeSpecifierContext::createTruthy(), + $rootExpr ); + + if ($rootExpr !== null) { + // Makes consecutive calls with a rootExpr adding unknown info via FAUX_FUNCTION evaluate to true + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy()) + ); + } + + return $specifiedTypes; } /** * @param Arg[] $args + * @return array{?Expr, ?Expr} */ private static function createExpression( Scope $scope, string $name, array $args - ): ?Expr + ): array { $trimmedName = self::trimName($name); $resolvers = self::getExpressionResolvers(); $resolver = $resolvers[$trimmedName]; - $expression = $resolver($scope, ...$args); - if ($expression === null) { - return null; + + $resolverResult = $resolver($scope, ...$args); + if (is_array($resolverResult)) { + [$expr, $rootExpr] = $resolverResult; + } else { + $expr = $resolverResult; + $rootExpr = null; + } + + if ($expr === null) { + return [null, null]; } if (substr($name, 0, 6) === 'nullOr') { - $expression = new BooleanOr( - $expression, + $expr = new BooleanOr( + $expr, new Identical( $args[0]->value, new ConstFetch(new Name('null')) @@ -195,11 +216,11 @@ private static function createExpression( ); } - return $expression; + return [$expr, $rootExpr]; } /** - * @return Closure[] + * @return array */ private static function getExpressionResolvers(): array { @@ -723,6 +744,38 @@ private static function getExpressionResolvers(): array ); }, ]; + + foreach (['contains', 'startsWith', 'endsWith'] as $name) { + self::$resolvers[$name] = static function (Scope $scope, Arg $value, Arg $subString): array { + if ($scope->getType($subString->value)->isNonEmptyString()->yes()) { + return self::createIsNonEmptyStringAndSomethingExprPair([$value, $subString]); + } + + return [self::$resolvers['string']($scope, $value), null]; + }; + } + + $assertionsResultingAtLeastInNonEmptyString = [ + 'startsWithLetter', + 'unicodeLetters', + 'alpha', + 'digits', + 'alnum', + 'lower', + 'upper', + 'uuid', + 'ip', + 'ipv4', + 'ipv6', + 'email', + 'notWhitespaceOnly', + ]; + foreach ($assertionsResultingAtLeastInNonEmptyString as $name) { + self::$resolvers[$name] = static function (Scope $scope, Arg $value): array { + return self::createIsNonEmptyStringAndSomethingExprPair([$value]); + }; + } + } return self::$resolvers; @@ -790,15 +843,16 @@ private function handleAll( { $args = $node->getArgs(); $args[0] = new Arg(new ArrayDimFetch($args[0]->value, new LNumber(0))); - $expression = self::createExpression($scope, $methodName, $args); - if ($expression === null) { + [$expr, $rootExpr] = self::createExpression($scope, $methodName, $args); + if ($expr === null) { return new SpecifiedTypes(); } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition( $scope, - $expression, - TypeSpecifierContext::createTruthy() + $expr, + TypeSpecifierContext::createTruthy(), + $rootExpr ); $sureNotTypes = $specifiedTypes->getSureNotTypes(); @@ -817,7 +871,8 @@ private function handleAll( $node->getArgs()[0]->value, static function () use ($type): Type { return $type; - } + }, + $rootExpr ); } @@ -827,7 +882,8 @@ static function () use ($type): Type { private function arrayOrIterable( Scope $scope, Expr $expr, - Closure $typeCallback + Closure $typeCallback, + ?Expr $rootExpr = null ): SpecifiedTypes { $currentType = TypeCombinator::intersect($scope->getType($expr), new IterableType(new MixedType(), new MixedType())); @@ -854,13 +910,23 @@ private function arrayOrIterable( return new SpecifiedTypes([], []); } - return $this->typeSpecifier->create( + $specifiedTypes = $this->typeSpecifier->create( $expr, $specifiedType, TypeSpecifierContext::createTruthy(), false, - $scope + $scope, + $rootExpr ); + + if ($rootExpr !== null) { + $specifiedTypes = $specifiedTypes->unionWith( + // Makes consecutive calls with a rootExpr adding unknown info via FAUX_FUNCTION evaluate to true + $this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy()) + ); + } + + return $specifiedTypes; } /** @@ -900,4 +966,29 @@ static function (?ArrayItem $item) use ($scope, $value, $resolver) { return self::implodeExpr($resolvers, BooleanOr::class); } + /** + * @param Arg[] $args + * @return array{Expr, Expr} + */ + private static function createIsNonEmptyStringAndSomethingExprPair(array $args): array + { + $expr = new BooleanAnd( + new FuncCall( + new Name('is_string'), + [$args[0]] + ), + new NotIdentical( + $args[0]->value, + new String_('') + ) + ); + + $rootExpr = new BooleanAnd( + $expr, + new FuncCall(new Name('FAUX_FUNCTION'), $args) + ); + + return [$expr, $rootExpr]; + } + } diff --git a/tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php index a7dc0f9..617f2b5 100644 --- a/tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php @@ -68,6 +68,22 @@ public function testExtension(): void 'Call to static method Webmozart\Assert\Assert::allCount() with array and 2 will always evaluate to true.', 76, ], + [ + 'Call to static method Webmozart\Assert\Assert::uuid() with non-empty-string will always evaluate to true.', + 84, + ], + [ + 'Call to static method Webmozart\Assert\Assert::contains() with non-empty-string and \'foo\' will always evaluate to true.', + 88, + ], + [ + 'Call to static method Webmozart\Assert\Assert::allUuid() with array will always evaluate to true.', + 94, + ], + [ + 'Call to static method Webmozart\Assert\Assert::allContains() with array and \'foo\' will always evaluate to true.', + 98, + ], ]); } diff --git a/tests/Type/WebMozartAssert/data/collection.php b/tests/Type/WebMozartAssert/data/collection.php index b8679ef..7457942 100644 --- a/tests/Type/WebMozartAssert/data/collection.php +++ b/tests/Type/WebMozartAssert/data/collection.php @@ -30,6 +30,30 @@ public function allStringNotEmpty(array $a, iterable $b, $c): void assertType('iterable', $c); } + public function allContains(array $a, iterable $b, $c): void + { + Assert::allContains($a, 'foo'); + assertType('array', $a); + + Assert::allContains($b, 'foo'); + assertType('iterable', $b); + + Assert::allContains($c, 'foo'); + assertType('iterable', $c); + } + + public function allNullOrContains(array $a, iterable $b, $c): void + { + Assert::allNullOrContains($a, 'foo'); + assertType('array', $a); + + Assert::allNullOrContains($b, 'foo'); + assertType('iterable', $b); + + Assert::allNullOrContains($c, 'foo'); + assertType('iterable', $c); + } + public function allInteger(array $a, iterable $b, $c): void { Assert::allInteger($a); diff --git a/tests/Type/WebMozartAssert/data/impossible-check.php b/tests/Type/WebMozartAssert/data/impossible-check.php index 93972b3..a0b81ed 100644 --- a/tests/Type/WebMozartAssert/data/impossible-check.php +++ b/tests/Type/WebMozartAssert/data/impossible-check.php @@ -76,6 +76,29 @@ public function allCount(array $a): void Assert::allCount($a, 2); } + public function nonEmptyStringAndSomethingUnknownNarrow($a, string $b, array $c, array $d): void + { + Assert::string($a); + Assert::stringNotEmpty($a); + Assert::uuid($a); + Assert::uuid($a); // only this should report + + Assert::stringNotEmpty($b); + Assert::contains($b, 'foo'); + Assert::contains($b, 'foo'); // only this should report + Assert::contains($b, 'bar'); + + Assert::allString($c); + Assert::allStringNotEmpty($c); + Assert::allUuid($c); + Assert::allUuid($c); // only this should report + + Assert::allStringNotEmpty($d); + Assert::allContains($d, 'foo'); + Assert::allContains($d, 'foo'); // only this should report + Assert::allContains($d, 'bar'); + } + } interface Bar {}; diff --git a/tests/Type/WebMozartAssert/data/string.php b/tests/Type/WebMozartAssert/data/string.php index 9066641..7ad6007 100644 --- a/tests/Type/WebMozartAssert/data/string.php +++ b/tests/Type/WebMozartAssert/data/string.php @@ -8,6 +8,84 @@ class TestStrings { + /** + * @param non-empty-string $b + */ + public function contains(string $a, string $b): void + { + Assert::contains($a, $a); + assertType('string', $a); + + Assert::contains($a, $b); + assertType('non-empty-string', $a); + } + + /** + * @param non-empty-string $b + */ + public function startsWith(string $a, string $b): void + { + Assert::startsWith($a, $a); + assertType('string', $a); + + Assert::startsWith($a, $b); + assertType('non-empty-string', $a); + } + + public function startsWithLetter(string $a): void + { + Assert::startsWithLetter($a); + assertType('non-empty-string', $a); + } + + /** + * @param non-empty-string $b + */ + public function endsWith(string $a, string $b): void + { + Assert::endsWith($a, $a); + assertType('string', $a); + + Assert::endsWith($a, $b); + assertType('non-empty-string', $a); + } + + public function unicodeLetters($a): void + { + Assert::unicodeLetters($a); + assertType('non-empty-string', $a); + } + + public function alpha($a): void + { + Assert::alpha($a); + assertType('non-empty-string', $a); + } + + public function digits(string $a): void + { + Assert::digits($a); + assertType('non-empty-string', $a); + } + + public function alnum(string $a): void + { + Assert::alnum($a); + assertType('non-empty-string', $a); + } + + public function lower(string $a): void + { + Assert::lower($a); + assertType('non-empty-string', $a); + } + + public function upper(string $a): void + { + Assert::upper($a); + assertType('non-empty-string', $a); + } + public function length(string $a, string $b, string $c, ?string $d): void { Assert::length($a, 0); @@ -74,4 +152,40 @@ public function lengthBetween(string $a, string $b, string $c, string $d, string assertType('non-empty-string|null', $f); } + public function uuid(string $a): void + { + Assert::uuid($a); + assertType('non-empty-string', $a); + } + + public function ip($a): void + { + Assert::ip($a); + assertType('non-empty-string', $a); + } + + public function ipv4($a): void + { + Assert::ipv4($a); + assertType('non-empty-string', $a); + } + + public function ipv6($a): void + { + Assert::ipv6($a); + assertType('non-empty-string', $a); + } + + public function email($a): void + { + Assert::email($a); + assertType('non-empty-string', $a); + } + + public function notWhitespaceOnly(string $a): void + { + Assert::notWhitespaceOnly($a); + assertType('non-empty-string', $a); + } + }