Skip to content

Commit 092e729

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

File tree

5 files changed

+259
-20
lines changed

5 files changed

+259
-20
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

+95-20
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,29 @@
5858
use function array_reduce;
5959
use function array_shift;
6060
use function count;
61+
use function is_array;
6162
use function lcfirst;
6263
use function substr;
6364

6465
class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
6566
{
6667

68+
private const ASSERTIONS_RESULTING_AT_LEAST_IN_NON_EMPTY_STRING = [
69+
'startsWithLetter',
70+
'unicodeLetters',
71+
'alpha',
72+
'digits',
73+
'alnum',
74+
'lower',
75+
'upper',
76+
'uuid',
77+
'ip',
78+
'ipv4',
79+
'ipv6',
80+
'email',
81+
'notWhitespaceOnly',
82+
];
83+
6784
/** @var Closure[] */
6885
private static $resolvers;
6986

@@ -104,7 +121,7 @@ public function isStaticMethodSupported(
104121
}
105122

106123
$resolver = $resolvers[$trimmedName];
107-
$resolverReflection = new ReflectionObject($resolver);
124+
$resolverReflection = new ReflectionObject(Closure::fromCallable($resolver));
108125

109126
return count($node->getArgs()) >= count($resolverReflection->getMethod('__invoke')->getParameters()) - 1;
110127
}
@@ -156,50 +173,60 @@ static function (Type $type) {
156173
);
157174
}
158175

159-
$expression = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
160-
if ($expression === null) {
176+
[$expr, $rootExpr] = self::createExpression($scope, $staticMethodReflection->getName(), $node->getArgs());
177+
if ($expr === null) {
161178
return new SpecifiedTypes([], []);
162179
}
163180

164181
return $this->typeSpecifier->specifyTypesInCondition(
165182
$scope,
166-
$expression,
167-
TypeSpecifierContext::createTruthy()
183+
$expr,
184+
TypeSpecifierContext::createTruthy(),
185+
$rootExpr
168186
);
169187
}
170188

171189
/**
172190
* @param Arg[] $args
191+
* @return array{?Expr, ?Expr}
173192
*/
174193
private static function createExpression(
175194
Scope $scope,
176195
string $name,
177196
array $args
178-
): ?Expr
197+
): array
179198
{
180199
$trimmedName = self::trimName($name);
181200
$resolvers = self::getExpressionResolvers();
182201
$resolver = $resolvers[$trimmedName];
183-
$expression = $resolver($scope, ...$args);
184-
if ($expression === null) {
185-
return null;
202+
203+
$resolverResult = $resolver($scope, ...$args);
204+
if (is_array($resolverResult)) {
205+
[$expr, $rootExpr] = $resolverResult;
206+
} else {
207+
$expr = $resolverResult;
208+
$rootExpr = null;
209+
}
210+
211+
if ($expr === null) {
212+
return [null, null];
186213
}
187214

188215
if (substr($name, 0, 6) === 'nullOr') {
189-
$expression = new BooleanOr(
190-
$expression,
216+
$expr = new BooleanOr(
217+
$expr,
191218
new Identical(
192219
$args[0]->value,
193220
new ConstFetch(new Name('null'))
194221
)
195222
);
196223
}
197224

198-
return $expression;
225+
return [$expr, $rootExpr];
199226
}
200227

201228
/**
202-
* @return Closure[]
229+
* @return array<string, callable(Scope, Arg...): (Expr|array{?Expr, ?Expr}|null)>
203230
*/
204231
private static function getExpressionResolvers(): array
205232
{
@@ -723,6 +750,23 @@ private static function getExpressionResolvers(): array
723750
);
724751
},
725752
];
753+
754+
foreach (['contains', 'startsWith', 'endsWith'] as $name) {
755+
self::$resolvers[$name] = static function (Scope $scope, Arg $value, Arg $subString): array {
756+
if ($scope->getType($subString->value)->isNonEmptyString()->yes()) {
757+
return self::createIsNonEmptyStringAndSomethingExprPair([$value]);
758+
}
759+
760+
return [self::$resolvers['string']($scope, $value), null];
761+
};
762+
}
763+
764+
foreach (self::ASSERTIONS_RESULTING_AT_LEAST_IN_NON_EMPTY_STRING as $name) {
765+
self::$resolvers[$name] = static function (Scope $scope, Arg $value): array {
766+
return self::createIsNonEmptyStringAndSomethingExprPair([$value]);
767+
};
768+
}
769+
726770
}
727771

728772
return self::$resolvers;
@@ -790,15 +834,16 @@ private function handleAll(
790834
{
791835
$args = $node->getArgs();
792836
$args[0] = new Arg(new ArrayDimFetch($args[0]->value, new LNumber(0)));
793-
$expression = self::createExpression($scope, $methodName, $args);
794-
if ($expression === null) {
837+
[$expr, $rootExpr] = self::createExpression($scope, $methodName, $args);
838+
if ($expr === null) {
795839
return new SpecifiedTypes();
796840
}
797841

798842
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
799843
$scope,
800-
$expression,
801-
TypeSpecifierContext::createTruthy()
844+
$expr,
845+
TypeSpecifierContext::createTruthy(),
846+
$rootExpr
802847
);
803848

804849
$sureNotTypes = $specifiedTypes->getSureNotTypes();
@@ -817,7 +862,8 @@ private function handleAll(
817862
$node->getArgs()[0]->value,
818863
static function () use ($type): Type {
819864
return $type;
820-
}
865+
},
866+
$rootExpr
821867
);
822868
}
823869

@@ -827,7 +873,8 @@ static function () use ($type): Type {
827873
private function arrayOrIterable(
828874
Scope $scope,
829875
Expr $expr,
830-
Closure $typeCallback
876+
Closure $typeCallback,
877+
?Expr $rootExpr = null
831878
): SpecifiedTypes
832879
{
833880
$currentType = TypeCombinator::intersect($scope->getType($expr), new IterableType(new MixedType(), new MixedType()));
@@ -859,7 +906,8 @@ private function arrayOrIterable(
859906
$specifiedType,
860907
TypeSpecifierContext::createTruthy(),
861908
false,
862-
$scope
909+
$scope,
910+
$rootExpr
863911
);
864912
}
865913

@@ -900,4 +948,31 @@ static function (?ArrayItem $item) use ($scope, $value, $resolver) {
900948
return self::implodeExpr($resolvers, BooleanOr::class);
901949
}
902950

951+
/**
952+
* @param Arg[] $args
953+
* @return array{Expr, Expr}
954+
*/
955+
private static function createIsNonEmptyStringAndSomethingExprPair(array $args): array
956+
{
957+
$expr = new BooleanAnd(
958+
new FuncCall(
959+
new Name('is_string'),
960+
[$args[0]]
961+
),
962+
new NotIdentical(
963+
$args[0]->value,
964+
new String_('')
965+
)
966+
);
967+
968+
$rootExpr = new BooleanAnd(
969+
$expr,
970+
new FuncCall(new Name('FAUX_FUNCTION'), [
971+
new Arg($args[0]->value),
972+
])
973+
);
974+
975+
return [$expr, $rootExpr];
976+
}
977+
903978
}

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

+10
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ 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+
85+
Assert::stringNotEmpty($b);
86+
Assert::contains($b, 'foo');
87+
}
88+
7989
}
8090

8191
interface Bar {};

0 commit comments

Comments
 (0)