Skip to content

Commit ee51201

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

File tree

6 files changed

+306
-22
lines changed

6 files changed

+306
-22
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

+113-22
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,69 @@ 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+
// Makes consecutive calls with a rootExpr adding unknown info via FAUX_FUNCTION evaluate to true
175+
$specifiedTypes = $specifiedTypes->unionWith(
176+
$this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy())
177+
);
178+
}
179+
180+
return $specifiedTypes;
169181
}
170182

171183
/**
172184
* @param Arg[] $args
185+
* @return array{?Expr, ?Expr}
173186
*/
174187
private static function createExpression(
175188
Scope $scope,
176189
string $name,
177190
array $args
178-
): ?Expr
191+
): array
179192
{
180193
$trimmedName = self::trimName($name);
181194
$resolvers = self::getExpressionResolvers();
182195
$resolver = $resolvers[$trimmedName];
183-
$expression = $resolver($scope, ...$args);
184-
if ($expression === null) {
185-
return null;
196+
197+
$resolverResult = $resolver($scope, ...$args);
198+
if (is_array($resolverResult)) {
199+
[$expr, $rootExpr] = $resolverResult;
200+
} else {
201+
$expr = $resolverResult;
202+
$rootExpr = null;
203+
}
204+
205+
if ($expr === null) {
206+
return [null, null];
186207
}
187208

188209
if (substr($name, 0, 6) === 'nullOr') {
189-
$expression = new BooleanOr(
190-
$expression,
210+
$expr = new BooleanOr(
211+
$expr,
191212
new Identical(
192213
$args[0]->value,
193214
new ConstFetch(new Name('null'))
194215
)
195216
);
196217
}
197218

198-
return $expression;
219+
return [$expr, $rootExpr];
199220
}
200221

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

728781
return self::$resolvers;
@@ -790,15 +843,16 @@ private function handleAll(
790843
{
791844
$args = $node->getArgs();
792845
$args[0] = new Arg(new ArrayDimFetch($args[0]->value, new LNumber(0)));
793-
$expression = self::createExpression($scope, $methodName, $args);
794-
if ($expression === null) {
846+
[$expr, $rootExpr] = self::createExpression($scope, $methodName, $args);
847+
if ($expr === null) {
795848
return new SpecifiedTypes();
796849
}
797850

798851
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
799852
$scope,
800-
$expression,
801-
TypeSpecifierContext::createTruthy()
853+
$expr,
854+
TypeSpecifierContext::createTruthy(),
855+
$rootExpr
802856
);
803857

804858
$sureNotTypes = $specifiedTypes->getSureNotTypes();
@@ -817,7 +871,8 @@ private function handleAll(
817871
$node->getArgs()[0]->value,
818872
static function () use ($type): Type {
819873
return $type;
820-
}
874+
},
875+
$rootExpr
821876
);
822877
}
823878

@@ -827,7 +882,8 @@ static function () use ($type): Type {
827882
private function arrayOrIterable(
828883
Scope $scope,
829884
Expr $expr,
830-
Closure $typeCallback
885+
Closure $typeCallback,
886+
?Expr $rootExpr = null
831887
): SpecifiedTypes
832888
{
833889
$currentType = TypeCombinator::intersect($scope->getType($expr), new IterableType(new MixedType(), new MixedType()));
@@ -854,13 +910,23 @@ private function arrayOrIterable(
854910
return new SpecifiedTypes([], []);
855911
}
856912

857-
return $this->typeSpecifier->create(
913+
$specifiedTypes = $this->typeSpecifier->create(
858914
$expr,
859915
$specifiedType,
860916
TypeSpecifierContext::createTruthy(),
861917
false,
862-
$scope
918+
$scope,
919+
$rootExpr
863920
);
921+
922+
if ($rootExpr !== null) {
923+
$specifiedTypes = $specifiedTypes->unionWith(
924+
// Makes consecutive calls with a rootExpr adding unknown info via FAUX_FUNCTION evaluate to true
925+
$this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy())
926+
);
927+
}
928+
929+
return $specifiedTypes;
864930
}
865931

866932
/**
@@ -900,4 +966,29 @@ static function (?ArrayItem $item) use ($scope, $value, $resolver) {
900966
return self::implodeExpr($resolvers, BooleanOr::class);
901967
}
902968

969+
/**
970+
* @param Arg[] $args
971+
* @return array{Expr, Expr}
972+
*/
973+
private static function createIsNonEmptyStringAndSomethingExprPair(array $args): array
974+
{
975+
$expr = new BooleanAnd(
976+
new FuncCall(
977+
new Name('is_string'),
978+
[$args[0]]
979+
),
980+
new NotIdentical(
981+
$args[0]->value,
982+
new String_('')
983+
)
984+
);
985+
986+
$rootExpr = new BooleanAnd(
987+
$expr,
988+
new FuncCall(new Name('FAUX_FUNCTION'), $args)
989+
);
990+
991+
return [$expr, $rootExpr];
992+
}
993+
903994
}

tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php

+16
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@ 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+
88,
78+
],
79+
[
80+
'Call to static method Webmozart\Assert\Assert::allUuid() with array<non-empty-string> will always evaluate to true.',
81+
94,
82+
],
83+
[
84+
'Call to static method Webmozart\Assert\Assert::allContains() with array<non-empty-string> and \'foo\' will always evaluate to true.',
85+
98,
86+
],
7187
]);
7288
}
7389

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

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

79+
public function nonEmptyStringAndSomethingUnknownNarrow($a, string $b, array $c, array $d): 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, 'foo'); // only this should report
89+
Assert::contains($b, 'bar');
90+
91+
Assert::allString($c);
92+
Assert::allStringNotEmpty($c);
93+
Assert::allUuid($c);
94+
Assert::allUuid($c); // only this should report
95+
96+
Assert::allStringNotEmpty($d);
97+
Assert::allContains($d, 'foo');
98+
Assert::allContains($d, 'foo'); // only this should report
99+
Assert::allContains($d, 'bar');
100+
}
101+
79102
}
80103

81104
interface Bar {};

0 commit comments

Comments
 (0)