Skip to content

Commit c060efa

Browse files
committed
Support string assertions resulting in non-empty-string
1 parent 64c0042 commit c060efa

File tree

6 files changed

+278
-21
lines changed

6 files changed

+278
-21
lines changed

README.md

+16
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,26 @@ This extension specifies types of values passed to:
8585
* `Assert::methodExists`
8686
* `Assert::propertyExists`
8787
* `Assert::isArrayAccessible`
88+
* `Assert::contains`
89+
* `Assert::startsWith`
90+
* `Assert::startsWithLetter`
91+
* `Assert::endsWith`
92+
* `Assert::unicodeLetters`
93+
* `Assert::alpha`
94+
* `Assert::digits`
95+
* `Assert::alnum`
96+
* `Assert::lower`
97+
* `Assert::upper`
8898
* `Assert::length`
8999
* `Assert::minLength`
90100
* `Assert::maxLength`
91101
* `Assert::lengthBetween`
102+
* `Assert::uuid`
103+
* `Assert::ip`
104+
* `Assert::ipv4`
105+
* `Assert::ipv6`
106+
* `Assert::email`
107+
* `Assert::notWhitespaceOnly`
92108
* `nullOr*`, `all*` and `allNullOr*` variants of the above methods
93109

94110

src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php

+102-21
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
use PHPStan\Type\ArrayType;
4040
use PHPStan\Type\Constant\ConstantArrayType;
4141
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
42+
use PHPStan\Type\Constant\ConstantBooleanType;
4243
use PHPStan\Type\Constant\ConstantStringType;
4344
use PHPStan\Type\IterableType;
4445
use PHPStan\Type\MixedType;
@@ -58,6 +59,7 @@
5859
use function array_reduce;
5960
use function array_shift;
6061
use function count;
62+
use function is_array;
6163
use function lcfirst;
6264
use function substr;
6365

@@ -104,7 +106,7 @@ public function isStaticMethodSupported(
104106
}
105107

106108
$resolver = $resolvers[$trimmedName];
107-
$resolverReflection = new ReflectionObject($resolver);
109+
$resolverReflection = new ReflectionObject(Closure::fromCallable($resolver));
108110

109111
return count($node->getArgs()) >= count($resolverReflection->getMethod('__invoke')->getParameters()) - 1;
110112
}
@@ -156,50 +158,68 @@ static function (Type $type) {
156158
);
157159
}
158160

159-
$expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
160-
if ($expression === null) {
161+
[$expr, $rootExpr] = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
162+
if ($expr === null) {
161163
return new SpecifiedTypes([], []);
162164
}
163165

164-
return $this->typeSpecifier->specifyTypesInCondition(
166+
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
165167
$scope,
166-
$expression,
167-
TypeSpecifierContext::createTruthy()
168+
$expr,
169+
TypeSpecifierContext::createTruthy(),
170+
$rootExpr
168171
);
172+
173+
if ($rootExpr !== null) {
174+
$specifiedTypes = $specifiedTypes->unionWith(
175+
$this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy())
176+
);
177+
}
178+
179+
return $specifiedTypes;
169180
}
170181

171182
/**
172183
* @param Arg[] $args
184+
* @return array{?Expr, ?Expr}
173185
*/
174186
private static function createExpression(
175187
Scope $scope,
176188
string $name,
177189
array $args
178-
): ?Expr
190+
): array
179191
{
180192
$trimmedName = self::trimName($name);
181193
$resolvers = self::getExpressionResolvers();
182194
$resolver = $resolvers[$trimmedName];
183-
$expression = $resolver($scope, ...$args);
184-
if ($expression === null) {
185-
return null;
195+
196+
$resolverResult = $resolver($scope, ...$args);
197+
if (is_array($resolverResult)) {
198+
[$expr, $rootExpr] = $resolverResult;
199+
} else {
200+
$expr = $resolverResult;
201+
$rootExpr = null;
202+
}
203+
204+
if ($expr === null) {
205+
return [null, null];
186206
}
187207

188208
if (substr($name, 0, 6) === 'nullOr') {
189-
$expression = new BooleanOr(
190-
$expression,
209+
$expr = new BooleanOr(
210+
$expr,
191211
new Identical(
192212
$args[0]->value,
193213
new ConstFetch(new Name('null'))
194214
)
195215
);
196216
}
197217

198-
return $expression;
218+
return [$expr, $rootExpr];
199219
}
200220

201221
/**
202-
* @return Closure[]
222+
* @return array<string, callable(Scope, Arg...): (Expr|array{?Expr, ?Expr}|null)>
203223
*/
204224
private static function getExpressionResolvers(): array
205225
{
@@ -723,6 +743,38 @@ private static function getExpressionResolvers(): array
723743
);
724744
},
725745
];
746+
747+
foreach (['contains', 'startsWith', 'endsWith'] as $name) {
748+
self::$resolvers[$name] = static function (Scope $scope, Arg $value, Arg $subString): array {
749+
if ($scope->getType($subString->value)->isNonEmptyString()->yes()) {
750+
return self::createIsNonEmptyStringAndSomethingExprPair([$value, $subString]);
751+
}
752+
753+
return [self::$resolvers['string']($scope, $value), null];
754+
};
755+
}
756+
757+
$assertionsResultingAtLeastInNonEmptyString = [
758+
'startsWithLetter',
759+
'unicodeLetters',
760+
'alpha',
761+
'digits',
762+
'alnum',
763+
'lower',
764+
'upper',
765+
'uuid',
766+
'ip',
767+
'ipv4',
768+
'ipv6',
769+
'email',
770+
'notWhitespaceOnly',
771+
];
772+
foreach ($assertionsResultingAtLeastInNonEmptyString as $name) {
773+
self::$resolvers[$name] = static function (Scope $scope, Arg $value): array {
774+
return self::createIsNonEmptyStringAndSomethingExprPair([$value]);
775+
};
776+
}
777+
726778
}
727779

728780
return self::$resolvers;
@@ -790,15 +842,16 @@ private function handleAll(
790842
{
791843
$args = $node->getArgs();
792844
$args[0] = new Arg(new ArrayDimFetch($args[0]->value, new LNumber(0)));
793-
$expression = self::createExpression($scope, $methodName, $args);
794-
if ($expression === null) {
845+
[$expr, $rootExpr] = self::createExpression($scope, $methodName, $args);
846+
if ($expr === null) {
795847
return new SpecifiedTypes();
796848
}
797849

798850
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
799851
$scope,
800-
$expression,
801-
TypeSpecifierContext::createTruthy()
852+
$expr,
853+
TypeSpecifierContext::createTruthy(),
854+
$rootExpr
802855
);
803856

804857
$sureNotTypes = $specifiedTypes->getSureNotTypes();
@@ -817,7 +870,8 @@ private function handleAll(
817870
$node->getArgs()[0]->value,
818871
static function () use ($type): Type {
819872
return $type;
820-
}
873+
},
874+
$rootExpr
821875
);
822876
}
823877

@@ -827,7 +881,8 @@ static function () use ($type): Type {
827881
private function arrayOrIterable(
828882
Scope $scope,
829883
Expr $expr,
830-
Closure $typeCallback
884+
Closure $typeCallback,
885+
?Expr $rootExpr = null
831886
): SpecifiedTypes
832887
{
833888
$currentType = TypeCombinator::intersect($scope->getType($expr), new IterableType(new MixedType(), new MixedType()));
@@ -859,7 +914,8 @@ private function arrayOrIterable(
859914
$specifiedType,
860915
TypeSpecifierContext::createTruthy(),
861916
false,
862-
$scope
917+
$scope,
918+
$rootExpr
863919
);
864920
}
865921

@@ -900,4 +956,29 @@ static function (?ArrayItem $item) use ($scope, $value, $resolver) {
900956
return self::implodeExpr($resolvers, BooleanOr::class);
901957
}
902958

959+
/**
960+
* @param Arg[] $args
961+
* @return array{Expr, Expr}
962+
*/
963+
private static function createIsNonEmptyStringAndSomethingExprPair(array $args): array
964+
{
965+
$expr = new BooleanAnd(
966+
new FuncCall(
967+
new Name('is_string'),
968+
[$args[0]]
969+
),
970+
new NotIdentical(
971+
$args[0]->value,
972+
new String_('')
973+
)
974+
);
975+
976+
$rootExpr = new BooleanAnd(
977+
$expr,
978+
new FuncCall(new Name('FAUX_FUNCTION'), $args)
979+
);
980+
981+
return [$expr, $rootExpr];
982+
}
983+
903984
}

tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php

+8
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ public function testExtension(): void
6868
'Call to static method Webmozart\Assert\Assert::allCount() with array<non-empty-array> and 2 will always evaluate to true.',
6969
76,
7070
],
71+
[
72+
'Call to static method Webmozart\Assert\Assert::uuid() with non-empty-string will always evaluate to true.',
73+
84,
74+
],
75+
[
76+
'Call to static method Webmozart\Assert\Assert::contains() with non-empty-string and \'foo\' will always evaluate to true.',
77+
89,
78+
],
7179
]);
7280
}
7381

tests/Type/WebMozartAssert/data/collection.php

+24
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,30 @@ public function allStringNotEmpty(array $a, iterable $b, $c): void
3030
assertType('iterable<non-empty-string>', $c);
3131
}
3232

33+
public function allContains(array $a, iterable $b, $c): void
34+
{
35+
Assert::allContains($a, 'foo');
36+
assertType('array<non-empty-string>', $a);
37+
38+
Assert::allContains($b, 'foo');
39+
assertType('iterable<non-empty-string>', $b);
40+
41+
Assert::allContains($c, 'foo');
42+
assertType('iterable<non-empty-string>', $c);
43+
}
44+
45+
public function allNullOrContains(array $a, iterable $b, $c): void
46+
{
47+
Assert::allNullOrContains($a, 'foo');
48+
assertType('array<non-empty-string|null>', $a);
49+
50+
Assert::allNullOrContains($b, 'foo');
51+
assertType('iterable<non-empty-string|null>', $b);
52+
53+
Assert::allNullOrContains($c, 'foo');
54+
assertType('iterable<non-empty-string|null>', $c);
55+
}
56+
3357
public function allInteger(array $a, iterable $b, $c): void
3458
{
3559
Assert::allInteger($a);

tests/Type/WebMozartAssert/data/impossible-check.php

+14
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ public function allCount(array $a): void
7676
Assert::allCount($a, 2);
7777
}
7878

79+
public function nonEmptyStringAndSomethingUnknownNarrow($a, string $b): void
80+
{
81+
Assert::string($a);
82+
Assert::stringNotEmpty($a);
83+
Assert::uuid($a);
84+
Assert::uuid($a); // only this should report
85+
86+
Assert::stringNotEmpty($b);
87+
Assert::contains($b, 'foo');
88+
Assert::contains($b, 'bar');
89+
Assert::contains($b, 'foo'); // only this should report
90+
Assert::contains($b, 'baz');
91+
}
92+
7993
}
8094

8195
interface Bar {};

0 commit comments

Comments
 (0)