Skip to content

Commit bb222fa

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

File tree

6 files changed

+304
-22
lines changed

6 files changed

+304
-22
lines changed

README.md

Lines changed: 16 additions & 0 deletions
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

Lines changed: 111 additions & 22 deletions
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,62 @@ 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+
return $this->specifyRootExprIfSet($rootExpr, $specifiedTypes);
169174
}
170175

171176
/**
172177
* @param Arg[] $args
178+
* @return array{?Expr, ?Expr}
173179
*/
174180
private static function createExpression(
175181
Scope $scope,
176182
string $name,
177183
array $args
178-
): ?Expr
184+
): array
179185
{
180186
$trimmedName = self::trimName($name);
181187
$resolvers = self::getExpressionResolvers();
182188
$resolver = $resolvers[$trimmedName];
183-
$expression = $resolver($scope, ...$args);
184-
if ($expression === null) {
185-
return null;
189+
190+
$resolverResult = $resolver($scope, ...$args);
191+
if (is_array($resolverResult)) {
192+
[$expr, $rootExpr] = $resolverResult;
193+
} else {
194+
$expr = $resolverResult;
195+
$rootExpr = null;
196+
}
197+
198+
if ($expr === null) {
199+
return [null, null];
186200
}
187201

188202
if (substr($name, 0, 6) === 'nullOr') {
189-
$expression = new BooleanOr(
190-
$expression,
203+
$expr = new BooleanOr(
204+
$expr,
191205
new Identical(
192206
$args[0]->value,
193207
new ConstFetch(new Name('null'))
194208
)
195209
);
196210
}
197211

198-
return $expression;
212+
return [$expr, $rootExpr];
199213
}
200214

201215
/**
202-
* @return Closure[]
216+
* @return array<string, callable(Scope, Arg...): (Expr|array{?Expr, ?Expr}|null)>
203217
*/
204218
private static function getExpressionResolvers(): array
205219
{
@@ -723,6 +737,38 @@ private static function getExpressionResolvers(): array
723737
);
724738
},
725739
];
740+
741+
foreach (['contains', 'startsWith', 'endsWith'] as $name) {
742+
self::$resolvers[$name] = static function (Scope $scope, Arg $value, Arg $subString): array {
743+
if ($scope->getType($subString->value)->isNonEmptyString()->yes()) {
744+
return self::createIsNonEmptyStringAndSomethingExprPair([$value, $subString]);
745+
}
746+
747+
return [self::$resolvers['string']($scope, $value), null];
748+
};
749+
}
750+
751+
$assertionsResultingAtLeastInNonEmptyString = [
752+
'startsWithLetter',
753+
'unicodeLetters',
754+
'alpha',
755+
'digits',
756+
'alnum',
757+
'lower',
758+
'upper',
759+
'uuid',
760+
'ip',
761+
'ipv4',
762+
'ipv6',
763+
'email',
764+
'notWhitespaceOnly',
765+
];
766+
foreach ($assertionsResultingAtLeastInNonEmptyString as $name) {
767+
self::$resolvers[$name] = static function (Scope $scope, Arg $value): array {
768+
return self::createIsNonEmptyStringAndSomethingExprPair([$value]);
769+
};
770+
}
771+
726772
}
727773

728774
return self::$resolvers;
@@ -790,15 +836,16 @@ private function handleAll(
790836
{
791837
$args = $node->getArgs();
792838
$args[0] = new Arg(new ArrayDimFetch($args[0]->value, new LNumber(0)));
793-
$expression = self::createExpression($scope, $methodName, $args);
794-
if ($expression === null) {
839+
[$expr, $rootExpr] = self::createExpression($scope, $methodName, $args);
840+
if ($expr === null) {
795841
return new SpecifiedTypes();
796842
}
797843

798844
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
799845
$scope,
800-
$expression,
801-
TypeSpecifierContext::createTruthy()
846+
$expr,
847+
TypeSpecifierContext::createTruthy(),
848+
$rootExpr
802849
);
803850

804851
$sureNotTypes = $specifiedTypes->getSureNotTypes();
@@ -817,7 +864,8 @@ private function handleAll(
817864
$node->getArgs()[0]->value,
818865
static function () use ($type): Type {
819866
return $type;
820-
}
867+
},
868+
$rootExpr
821869
);
822870
}
823871

@@ -827,7 +875,8 @@ static function () use ($type): Type {
827875
private function arrayOrIterable(
828876
Scope $scope,
829877
Expr $expr,
830-
Closure $typeCallback
878+
Closure $typeCallback,
879+
?Expr $rootExpr = null
831880
): SpecifiedTypes
832881
{
833882
$currentType = TypeCombinator::intersect($scope->getType($expr), new IterableType(new MixedType(), new MixedType()));
@@ -854,13 +903,16 @@ private function arrayOrIterable(
854903
return new SpecifiedTypes([], []);
855904
}
856905

857-
return $this->typeSpecifier->create(
906+
$specifiedTypes = $this->typeSpecifier->create(
858907
$expr,
859908
$specifiedType,
860909
TypeSpecifierContext::createTruthy(),
861910
false,
862-
$scope
911+
$scope,
912+
$rootExpr
863913
);
914+
915+
return $this->specifyRootExprIfSet($rootExpr, $specifiedTypes);
864916
}
865917

866918
/**
@@ -900,4 +952,41 @@ static function (?ArrayItem $item) use ($scope, $value, $resolver) {
900952
return self::implodeExpr($resolvers, BooleanOr::class);
901953
}
902954

955+
/**
956+
* @param Arg[] $args
957+
* @return array{Expr, Expr}
958+
*/
959+
private static function createIsNonEmptyStringAndSomethingExprPair(array $args): array
960+
{
961+
$expr = new BooleanAnd(
962+
new FuncCall(
963+
new Name('is_string'),
964+
[$args[0]]
965+
),
966+
new NotIdentical(
967+
$args[0]->value,
968+
new String_('')
969+
)
970+
);
971+
972+
$rootExpr = new BooleanAnd(
973+
$expr,
974+
new FuncCall(new Name('FAUX_FUNCTION'), $args)
975+
);
976+
977+
return [$expr, $rootExpr];
978+
}
979+
980+
private function specifyRootExprIfSet(?Expr $rootExpr, SpecifiedTypes $specifiedTypes): SpecifiedTypes
981+
{
982+
if ($rootExpr === null) {
983+
return $specifiedTypes;
984+
}
985+
986+
// Makes consecutive calls with a rootExpr adding unknown info via FAUX_FUNCTION evaluate to true
987+
return $specifiedTypes->unionWith(
988+
$this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy())
989+
);
990+
}
991+
903992
}

tests/Type/WebMozartAssert/ImpossibleCheckTypeMethodCallRuleTest.php

Lines changed: 16 additions & 0 deletions
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

Lines changed: 24 additions & 0 deletions
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

Lines changed: 23 additions & 0 deletions
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)