Skip to content

Commit 2e817b6

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

File tree

10 files changed

+406
-160
lines changed

10 files changed

+406
-160
lines changed

src/Type/WebMozartAssert/AssertTypeSpecifyingExtension.php

+146-114
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,49 @@
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;
1918
use PHPStan\Type\Constant\ConstantStringType;
19+
use PHPStan\Type\IntersectionType;
2020
use PHPStan\Type\IterableType;
2121
use PHPStan\Type\MixedType;
2222
use PHPStan\Type\ObjectType;
2323
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
24+
use PHPStan\Type\StringType;
2425
use PHPStan\Type\Type;
2526
use PHPStan\Type\TypeCombinator;
2627
use PHPStan\Type\TypeUtils;
2728

2829
class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
2930
{
3031

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',
32+
private const ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER = [
33+
'stringNotEmpty' => 1,
34+
'contains' => 2,
35+
'startsWith' => 2,
36+
'startsWithLetter' => 1,
37+
'endsWith' => 2,
38+
'unicodeLetters' => 1,
39+
'alpha' => 1,
40+
'digits' => 1,
41+
'alnum' => 1,
42+
'lower' => 1,
43+
'upper' => 1,
44+
'uuid' => 1,
45+
'ip' => 1,
46+
'ipv4' => 1,
47+
'ipv6' => 1,
48+
'email' => 1,
49+
'notWhitespaceOnly' => 1,
4650
];
4751

4852
/** @var \Closure[] */
@@ -67,10 +71,6 @@ public function isStaticMethodSupported(
6771
TypeSpecifierContext $context
6872
): bool
6973
{
70-
if (in_array($staticMethodReflection->getName(), self::ASSERTIONS_RESULTING_IN_NON_EMPTY_STRING, true)) {
71-
return true;
72-
}
73-
7474
if (substr($staticMethodReflection->getName(), 0, 6) === 'allNot') {
7575
$methods = [
7676
'allNotInstanceOf' => 2,
@@ -84,6 +84,13 @@ public function isStaticMethodSupported(
8484
$trimmedName = self::trimName($staticMethodReflection->getName());
8585
$resolvers = self::getExpressionResolvers();
8686

87+
if (
88+
array_key_exists($trimmedName, self::ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER)
89+
&& count($node->getArgs()) >= self::ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER[$trimmedName]
90+
) {
91+
return true;
92+
}
93+
8794
if (!array_key_exists($trimmedName, $resolvers)) {
8895
return false;
8996
}
@@ -113,45 +120,138 @@ public function specifyTypes(
113120
TypeSpecifierContext $context
114121
): SpecifiedTypes
115122
{
123+
$trimmedName = self::trimName($staticMethodReflection->getName());
124+
116125
if (substr($staticMethodReflection->getName(), 0, 6) === 'allNot') {
117126
return $this->handleAllNot(
118127
$staticMethodReflection->getName(),
119128
$node,
120129
$scope
121130
);
122131
}
123-
$expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
124-
if ($expression === null) {
125-
return new SpecifiedTypes([], []);
132+
133+
if (array_key_exists($trimmedName, self::ASSERTIONS_SUPPORTED_VIA_CUSTOM_TYPE_SPECIFIER)) {
134+
$specifiedTypes = $this->specifyTypesViaCustomTypeSpecifier(
135+
$staticMethodReflection,
136+
$node,
137+
$scope
138+
);
139+
} else {
140+
$expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
141+
if ($expression === null) {
142+
return new SpecifiedTypes([], []);
143+
}
144+
145+
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
146+
$scope,
147+
$expression,
148+
TypeSpecifierContext::createTruthy()
149+
);
126150
}
127-
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
128-
$scope,
129-
$expression,
130-
TypeSpecifierContext::createTruthy()
131-
);
132151

133152
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-
}
153+
return $this->arrayOrIterable(
154+
$scope,
155+
$node->getArgs()[0]->value,
156+
function () use ($specifiedTypes): Type {
157+
return $this->getResultingTypeFromSpecifiedTypes($specifiedTypes);
158+
}
159+
);
160+
}
161+
162+
if (substr($staticMethodReflection->getName(), 0, 6) === 'nullOr') {
163+
return $this->typeSpecifier->create(
164+
$node->getArgs()[0]->value,
165+
TypeCombinator::addNull($this->getResultingTypeFromSpecifiedTypes($specifiedTypes)),
166+
TypeSpecifierContext::createTruthy(),
167+
true
168+
);
150169
}
151170

152171
return $specifiedTypes;
153172
}
154173

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

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;
271+
return $resolver($scope, ...$args);
191272
}
192273

193274
/**
@@ -486,27 +567,6 @@ private static function getExpressionResolvers(): array
486567
)
487568
);
488569
},
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-
},
510570
'length' => function (Scope $scope, Arg $value, Arg $length): \PhpParser\Node\Expr {
511571
return new BooleanAnd(
512572
new \PhpParser\Node\Expr\FuncCall(
@@ -709,32 +769,4 @@ private function arrayOrIterable(
709769
);
710770
}
711771

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-
740772
}

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)