Skip to content

Commit

Permalink
Support string assertions resulting in non-empty-string
Browse files Browse the repository at this point in the history
  • Loading branch information
herndlm committed Jul 15, 2022
1 parent 64c0042 commit ee51201
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 22 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
135 changes: 113 additions & 22 deletions src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -156,50 +158,69 @@ 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'))
)
);
}

return $expression;
return [$expr, $rootExpr];
}

/**
* @return Closure[]
* @return array<string, callable(Scope, Arg...): (Expr|array{?Expr, ?Expr}|null)>
*/
private static function getExpressionResolvers(): array
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -817,7 +871,8 @@ private function handleAll(
$node->getArgs()[0]->value,
static function () use ($type): Type {
return $type;
}
},
$rootExpr
);
}

Expand All @@ -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()));
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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];
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ public function testExtension(): void
'Call to static method Webmozart\Assert\Assert::allCount() with array<non-empty-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<non-empty-string> will always evaluate to true.',
94,
],
[
'Call to static method Webmozart\Assert\Assert::allContains() with array<non-empty-string> and \'foo\' will always evaluate to true.',
98,
],
]);
}

Expand Down
24 changes: 24 additions & 0 deletions tests/Type/WebMozartAssert/data/collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,30 @@ public function allStringNotEmpty(array $a, iterable $b, $c): void
assertType('iterable<non-empty-string>', $c);
}

public function allContains(array $a, iterable $b, $c): void
{
Assert::allContains($a, 'foo');
assertType('array<non-empty-string>', $a);

Assert::allContains($b, 'foo');
assertType('iterable<non-empty-string>', $b);

Assert::allContains($c, 'foo');
assertType('iterable<non-empty-string>', $c);
}

public function allNullOrContains(array $a, iterable $b, $c): void
{
Assert::allNullOrContains($a, 'foo');
assertType('array<non-empty-string|null>', $a);

Assert::allNullOrContains($b, 'foo');
assertType('iterable<non-empty-string|null>', $b);

Assert::allNullOrContains($c, 'foo');
assertType('iterable<non-empty-string|null>', $c);
}

public function allInteger(array $a, iterable $b, $c): void
{
Assert::allInteger($a);
Expand Down
23 changes: 23 additions & 0 deletions tests/Type/WebMozartAssert/data/impossible-check.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
Expand Down
Loading

0 comments on commit ee51201

Please sign in to comment.