Skip to content

Commit 4db13dc

Browse files
committed
Rework non-empty-string, nullOr* and all* assertions
1 parent 19b869e commit 4db13dc

File tree

10 files changed

+390
-160
lines changed

10 files changed

+390
-160
lines changed

src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php

+145-114
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44

55
use PhpParser\Node\Arg;
66
use PhpParser\Node\Expr\BinaryOp\BooleanAnd;
7-
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
87
use PhpParser\Node\Expr\StaticCall;
9-
use PhpParser\Node\Scalar\String_;
108
use PHPStan\Analyser\Scope;
119
use PHPStan\Analyser\SpecifiedTypes;
1210
use PHPStan\Analyser\TypeSpecifier;
1311
use PHPStan\Analyser\TypeSpecifierAwareExtension;
1412
use PHPStan\Analyser\TypeSpecifierContext;
1513
use PHPStan\Reflection\MethodReflection;
14+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1615
use PHPStan\Type\ArrayType;
1716
use PHPStan\Type\Constant\ConstantArrayType;
1817
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
@@ -21,28 +20,32 @@
2120
use PHPStan\Type\MixedType;
2221
use PHPStan\Type\ObjectType;
2322
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
23+
use PHPStan\Type\StringType;
2424
use PHPStan\Type\Type;
2525
use PHPStan\Type\TypeCombinator;
2626
use PHPStan\Type\TypeUtils;
2727

2828
class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
2929
{
3030

31-
private const ASSERTIONS_RESULTING_IN_NON_EMPTY_STRING = [
32-
'stringNotEmpty',
33-
'startsWithLetter',
34-
'unicodeLetters',
35-
'alpha',
36-
'digits',
37-
'alnum',
38-
'lower',
39-
'upper',
40-
'uuid',
41-
'ip',
42-
'ipv4',
43-
'ipv6',
44-
'email',
45-
'notWhitespaceOnly',
31+
private const ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER = [
32+
'stringNotEmpty' => 1,
33+
'contains' => 2,
34+
'startsWith' => 2,
35+
'startsWithLetter' => 1,
36+
'endsWith' => 2,
37+
'unicodeLetters' => 1,
38+
'alpha' => 1,
39+
'digits' => 1,
40+
'alnum' => 1,
41+
'lower' => 1,
42+
'upper' => 1,
43+
'uuid' => 1,
44+
'ip' => 1,
45+
'ipv4' => 1,
46+
'ipv6' => 1,
47+
'email' => 1,
48+
'notWhitespaceOnly' => 1,
4649
];
4750

4851
/** @var \Closure[] */
@@ -67,10 +70,6 @@ public function isStaticMethodSupported(
6770
TypeSpecifierContext $context
6871
): bool
6972
{
70-
if (in_array($staticMethodReflection->getName(), self::ASSERTIONS_RESULTING_IN_NON_EMPTY_STRING, true)) {
71-
return true;
72-
}
73-
7473
if (substr($staticMethodReflection->getName(), 0, 6) === 'allNot') {
7574
$methods = [
7675
'allNotInstanceOf' => 2,
@@ -84,6 +83,13 @@ public function isStaticMethodSupported(
8483
$trimmedName = self::trimName($staticMethodReflection->getName());
8584
$resolvers = self::getExpressionResolvers();
8685

86+
if (
87+
array_key_exists($trimmedName, self::ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER)
88+
&& count($node->getArgs()) >= self::ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER[$trimmedName]
89+
) {
90+
return true;
91+
}
92+
8793
if (!array_key_exists($trimmedName, $resolvers)) {
8894
return false;
8995
}
@@ -113,45 +119,138 @@ public function specifyTypes(
113119
TypeSpecifierContext $context
114120
): SpecifiedTypes
115121
{
122+
$trimmedName = self::trimName($staticMethodReflection->getName());
123+
116124
if (substr($staticMethodReflection->getName(), 0, 6) === 'allNot') {
117125
return $this->handleAllNot(
118126
$staticMethodReflection->getName(),
119127
$node,
120128
$scope
121129
);
122130
}
123-
$expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
124-
if ($expression === null) {
125-
return new SpecifiedTypes([], []);
131+
132+
if (array_key_exists($trimmedName, self::ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER)) {
133+
$specifiedTypes = $this->specifyTypesViaCustomTypeSpecifier(
134+
$staticMethodReflection,
135+
$node,
136+
$scope
137+
);
138+
} else {
139+
$expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
140+
if ($expression === null) {
141+
return new SpecifiedTypes([], []);
142+
}
143+
144+
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
145+
$scope,
146+
$expression,
147+
TypeSpecifierContext::createTruthy()
148+
);
126149
}
127-
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
128-
$scope,
129-
$expression,
130-
TypeSpecifierContext::createTruthy()
131-
);
132150

133151
if (substr($staticMethodReflection->getName(), 0, 3) === 'all') {
134-
if (count($specifiedTypes->getSureTypes()) > 0) {
135-
$sureTypes = $specifiedTypes->getSureTypes();
136-
reset($sureTypes);
137-
$exprString = key($sureTypes);
138-
$sureType = $sureTypes[$exprString];
139-
return $this->arrayOrIterable(
140-
$scope,
141-
$sureType[0],
142-
function () use ($sureType): Type {
143-
return $sureType[1];
144-
}
145-
);
146-
}
147-
if (count($specifiedTypes->getSureNotTypes()) > 0) {
148-
throw new \PHPStan\ShouldNotHappenException();
149-
}
152+
return $this->arrayOrIterable(
153+
$scope,
154+
$node->getArgs()[0]->value,
155+
function () use ($specifiedTypes): Type {
156+
return $this->getResultingTypeFromSpecifiedTypes($specifiedTypes);
157+
}
158+
);
159+
}
160+
161+
if (substr($staticMethodReflection->getName(), 0, 6) === 'nullOr') {
162+
return $this->typeSpecifier->create(
163+
$node->getArgs()[0]->value,
164+
TypeCombinator::addNull($this->getResultingTypeFromSpecifiedTypes($specifiedTypes)),
165+
TypeSpecifierContext::createTruthy(),
166+
true
167+
);
150168
}
151169

152170
return $specifiedTypes;
153171
}
154172

173+
private function getResultingTypeFromSpecifiedTypes(SpecifiedTypes $specifiedTypes): Type
174+
{
175+
if (count($specifiedTypes->getSureTypes()) > 0) {
176+
$sureTypes = $specifiedTypes->getSureTypes();
177+
reset($sureTypes);
178+
$exprString = key($sureTypes);
179+
$sureType = $sureTypes[$exprString];
180+
181+
$sureNotTypes = $specifiedTypes->getSureNotTypes();
182+
183+
return array_key_exists($exprString, $sureNotTypes)
184+
? TypeCombinator::remove($sureType[1], $sureNotTypes[$exprString][1])
185+
: $sureType[1];
186+
}
187+
188+
throw new \PHPStan\ShouldNotHappenException();
189+
}
190+
191+
private function specifyTypesViaCustomTypeSpecifier(
192+
MethodReflection $staticMethodReflection,
193+
StaticCall $node,
194+
Scope $scope
195+
): SpecifiedTypes
196+
{
197+
$trimmedName = self::trimName($staticMethodReflection->getName());
198+
199+
$expression = $node->getArgs()[0]->value;
200+
$typeBefore = $scope->getType($expression);
201+
202+
// Adds support for calling via all*
203+
$typeBefore = $typeBefore->isIterable()->yes() ? $typeBefore->getIterableValueType() : $typeBefore;
204+
205+
switch ($trimmedName) {
206+
case 'startsWithLetter':
207+
case 'digits':
208+
case 'alnum':
209+
case 'lower':
210+
case 'upper':
211+
case 'uuid':
212+
case 'notWhitespaceOnly':
213+
// Assertions narrowing down to non-empty-string if the input is a string
214+
$type = (new StringType())->isSuperTypeOf($typeBefore)->yes()
215+
? TypeCombinator::intersect($typeBefore, new AccessoryNonEmptyStringType())
216+
: $typeBefore;
217+
218+
return $this->typeSpecifier->create(
219+
$expression,
220+
$type,
221+
TypeSpecifierContext::createTruthy()
222+
);
223+
case 'stringNotEmpty':
224+
case 'unicodeLetters':
225+
case 'alpha':
226+
case 'ip':
227+
case 'ipv4':
228+
case 'ipv6':
229+
case 'email':
230+
// Assertions always narrowing down to non-empty-string
231+
return $this->typeSpecifier->create(
232+
$expression,
233+
TypeCombinator::intersect($typeBefore, new AccessoryNonEmptyStringType()),
234+
TypeSpecifierContext::createTruthy()
235+
);
236+
case 'contains':
237+
case 'startsWith':
238+
case 'endsWith':
239+
// Assertions narrowing down to non-empty-string if the input is a string and arg1 is a non-empty-string
240+
$type = (new StringType())->isSuperTypeOf($typeBefore)->yes() && $scope->getType($node->getArgs()[1]->value)->isNonEmptyString()->yes()
241+
? TypeCombinator::intersect($typeBefore, new AccessoryNonEmptyStringType())
242+
: $typeBefore;
243+
244+
return $this->typeSpecifier->create(
245+
$expression,
246+
$type,
247+
TypeSpecifierContext::createTruthy()
248+
);
249+
}
250+
251+
throw new \PHPStan\ShouldNotHappenException();
252+
}
253+
155254
/**
156255
* @param Scope $scope
157256
* @param string $name
@@ -165,29 +264,10 @@ private static function createExpression(
165264
): ?\PhpParser\Node\Expr
166265
{
167266
$trimmedName = self::trimName($name);
168-
169-
if (in_array($trimmedName, self::ASSERTIONS_RESULTING_IN_NON_EMPTY_STRING, true)) {
170-
return self::createIsNonEmptyStringExpression($args);
171-
}
172-
173267
$resolvers = self::getExpressionResolvers();
174268
$resolver = $resolvers[$trimmedName];
175-
$expression = $resolver($scope, ...$args);
176-
if ($expression === null) {
177-
return null;
178-
}
179269

180-
if (substr($name, 0, 6) === 'nullOr') {
181-
$expression = new \PhpParser\Node\Expr\BinaryOp\BooleanOr(
182-
$expression,
183-
new \PhpParser\Node\Expr\BinaryOp\Identical(
184-
$args[0]->value,
185-
new \PhpParser\Node\Expr\ConstFetch(new \PhpParser\Node\Name('null'))
186-
)
187-
);
188-
}
189-
190-
return $expression;
270+
return $resolver($scope, ...$args);
191271
}
192272

193273
/**
@@ -486,27 +566,6 @@ private static function getExpressionResolvers(): array
486566
)
487567
);
488568
},
489-
'contains' => function (Scope $scope, Arg $value, Arg $subString): \PhpParser\Node\Expr {
490-
if ($scope->getType($subString->value)->isNonEmptyString()->yes()) {
491-
return self::createIsNonEmptyStringExpression([$value]);
492-
}
493-
494-
return self::createIsStringExpression([$value]);
495-
},
496-
'startsWith' => function (Scope $scope, Arg $value, Arg $prefix): \PhpParser\Node\Expr {
497-
if ($scope->getType($prefix->value)->isNonEmptyString()->yes()) {
498-
return self::createIsNonEmptyStringExpression([$value]);
499-
}
500-
501-
return self::createIsStringExpression([$value]);
502-
},
503-
'endsWith' => function (Scope $scope, Arg $value, Arg $suffix): \PhpParser\Node\Expr {
504-
if ($scope->getType($suffix->value)->isNonEmptyString()->yes()) {
505-
return self::createIsNonEmptyStringExpression([$value]);
506-
}
507-
508-
return self::createIsStringExpression([$value]);
509-
},
510569
'length' => function (Scope $scope, Arg $value, Arg $length): \PhpParser\Node\Expr {
511570
return new BooleanAnd(
512571
new \PhpParser\Node\Expr\FuncCall(
@@ -709,32 +768,4 @@ private function arrayOrIterable(
709768
);
710769
}
711770

712-
/**
713-
* @param \PhpParser\Node\Arg[] $args
714-
*/
715-
private static function createIsStringExpression(array $args): \PhpParser\Node\Expr
716-
{
717-
return new \PhpParser\Node\Expr\FuncCall(
718-
new \PhpParser\Node\Name('is_string'),
719-
[$args[0]]
720-
);
721-
}
722-
723-
/**
724-
* @param \PhpParser\Node\Arg[] $args
725-
*/
726-
private static function createIsNonEmptyStringExpression(array $args): \PhpParser\Node\Expr
727-
{
728-
return new BooleanAnd(
729-
new \PhpParser\Node\Expr\FuncCall(
730-
new \PhpParser\Node\Name('is_string'),
731-
[$args[0]]
732-
),
733-
new NotIdentical(
734-
$args[0]->value,
735-
new String_('')
736-
)
737-
);
738-
}
739-
740771
}

tests/Type/WebMozartAssert/data/array.php

+8-2
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,16 @@ public function keyExists(array $a): void
2525
\PHPStan\Testing\assertType('array{foo: string, bar?: string}', $a);
2626
}
2727

28-
public function validArrayKey($a, bool $b): void
28+
public function validArrayKey($a, bool $b, $c): void
2929
{
3030
Assert::validArrayKey($a);
3131
\PHPStan\Testing\assertType('int|string', $a);
3232

3333
Assert::validArrayKey($b);
3434
\PHPStan\Testing\assertType('*NEVER*', $b);
35+
36+
Assert::nullOrValidArrayKey($c);
37+
\PHPStan\Testing\assertType('int|string|null', $c);
3538
}
3639

3740
/**
@@ -94,10 +97,13 @@ public function countBetween(array $a, array $b, array $c, array $d): void
9497
\PHPStan\Testing\assertType('*NEVER*', $d);
9598
}
9699

97-
public function isList($a): void
100+
public function isList($a, $b): void
98101
{
99102
Assert::isList($a);
100103
\PHPStan\Testing\assertType('array', $a);
104+
105+
Assert::nullOrIsList($b);
106+
\PHPStan\Testing\assertType('array|null', $b);
101107
}
102108

103109
}

0 commit comments

Comments
 (0)