Skip to content

Commit 46d3c7d

Browse files
phpstan-botclaude
authored andcommitted
Constant-fold explode() for wide and unbounded limit ranges
Limits are now clamped into the [-partsCount, partsCount] window before they are enumerated: every limit above the number of parts produces the full split, every limit at or below minus the number of parts produces an empty array. Ranges such as int<5, max>, int<-100, -2> and plain int are therefore foldable too, instead of only the small ranges whose finite types could be listed one by one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 05457fd commit 46d3c7d

2 files changed

Lines changed: 90 additions & 28 deletions

File tree

‎src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php‎

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
use PHPStan\Type\TypeCombinator;
2929
use PHPStan\Type\TypeUtils;
3030
use PHPStan\Type\UnionType;
31+
use function array_keys;
3132
use function count;
3233
use function explode;
3334
use function max;
34-
use const PHP_INT_MAX;
35+
use function min;
36+
use function substr_count;
3537

3638
#[AutowiredService]
3739
final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
@@ -187,32 +189,25 @@ private function createConstantSplitType(Type $delimiterType, Type $stringType,
187189
return null;
188190
}
189191

190-
if ($limitType === null) {
191-
$limits = [PHP_INT_MAX];
192-
} else {
193-
$limits = [];
194-
foreach ($limitType->getFiniteTypes() as $finiteType) {
195-
if (!$finiteType instanceof ConstantIntegerType) {
196-
return null;
197-
}
198-
199-
$limits[] = $finiteType->getValue();
200-
}
201-
202-
if (count($limits) === 0) {
203-
return null;
204-
}
205-
}
206-
207-
if (count($delimiters) * count($strings) * count($limits) > self::CONSTANT_COMBINATION_LIMIT) {
192+
if (count($delimiters) * count($strings) > self::CONSTANT_COMBINATION_LIMIT) {
208193
return null;
209194
}
210195

211196
$results = [];
212197
foreach ($delimiters as $delimiter) {
213198
foreach ($strings as $string) {
199+
$stringValue = $string->getValue();
200+
$limits = $this->getDistinctLimits($limitType, substr_count($stringValue, $delimiter) + 1);
201+
if ($limits === null) {
202+
return null;
203+
}
204+
214205
foreach ($limits as $limit) {
215-
$items = explode($delimiter, $string->getValue(), $limit);
206+
if (count($results) >= self::CONSTANT_COMBINATION_LIMIT) {
207+
return null;
208+
}
209+
210+
$items = explode($delimiter, $stringValue, $limit);
216211
if (count($items) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
217212
return null;
218213
}
@@ -234,6 +229,65 @@ private function createConstantSplitType(Type $delimiterType, Type $stringType,
234229
return TypeCombinator::union(...$results);
235230
}
236231

232+
/**
233+
* The limits that lead to different results when splitting a string into
234+
* $partsCount parts, or null when they cannot be enumerated.
235+
*
236+
* Limits are clamped into the [-$partsCount, $partsCount] window because
237+
* every limit above $partsCount produces the full split and every limit at
238+
* or below -$partsCount produces an empty array. That keeps wide and even
239+
* unbounded integer ranges enumerable.
240+
*
241+
* @return list<int>|null
242+
*/
243+
private function getDistinctLimits(?Type $limitType, int $partsCount): ?array
244+
{
245+
if ($limitType === null) {
246+
return [$partsCount];
247+
}
248+
249+
if (!$limitType->isInteger()->yes()) {
250+
return null;
251+
}
252+
253+
$finiteTypes = $limitType->getFiniteTypes();
254+
if (count($finiteTypes) > 0) {
255+
$clampedLimits = [];
256+
foreach ($finiteTypes as $finiteType) {
257+
if (!$finiteType instanceof ConstantIntegerType) {
258+
return null;
259+
}
260+
261+
$value = $finiteType->getValue();
262+
$clampedLimits[max(-$partsCount, min($partsCount, $value))] = true;
263+
}
264+
265+
return array_keys($clampedLimits);
266+
}
267+
268+
$limits = [];
269+
for ($limit = -$partsCount; $limit <= $partsCount; $limit++) {
270+
if ($limit === -$partsCount) {
271+
$candidateLimitType = IntegerRangeType::fromInterval(null, $limit);
272+
} elseif ($limit === $partsCount) {
273+
$candidateLimitType = IntegerRangeType::fromInterval($limit, null);
274+
} else {
275+
$candidateLimitType = new ConstantIntegerType($limit);
276+
}
277+
278+
if ($candidateLimitType->isSuperTypeOf($limitType)->no()) {
279+
continue;
280+
}
281+
282+
$limits[] = $limit;
283+
if (count($limits) > self::CONSTANT_COMBINATION_LIMIT) {
284+
return null;
285+
}
286+
}
287+
288+
return $limits;
289+
}
290+
237291
/**
238292
* A limit of 0 or 1 returns the whole string as the only element, without
239293
* splitting on the delimiter.

‎tests/PHPStan/Analyser/nsrt/explode.php‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ function (string $delimiter, $mixed) {
3333
* @param 'a,b'|'x;y;z' $stringUnion
3434
* @param 1|2 $limitUnion
3535
* @param int<1, 3> $limitRange
36+
* @param int<5, max> $largeLimitRange
37+
* @param int<-100, -2> $negativeLimitRange
3638
* @param ''|',' $maybeEmptyDelimiter
3739
*/
38-
function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUnion, int $limitRange, string $maybeEmptyDelimiter, string $unknown, int $unknownLimit): void
40+
function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUnion, int $limitRange, int $largeLimitRange, int $negativeLimitRange, string $maybeEmptyDelimiter, string $unknown, int $unknownLimit): void
3941
{
4042
assertType("array{'a', 'b', 'c'}", explode(',', 'a,b,c'));
4143
assertType("array{'App/Service::foo'}", explode(':::', 'App/Service::foo'));
@@ -51,33 +53,39 @@ function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUn
5153
assertType("array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitUnion));
5254
assertType("array{'a', 'b', 'c'}|array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitRange));
5355

56+
// limits above the number of parts all produce the full split, limits at or
57+
// below minus the number of parts all produce an empty array
58+
assertType("array{'a', 'b', 'c'}", explode(',', 'a,b,c', $largeLimitRange));
59+
assertType("array{}|array{'a'}", explode(',', 'a,b,c', $negativeLimitRange));
60+
assertType("array{}|array{'a,b,c'}|list{0: 'a', 1?: 'b'|'b,c', 2?: 'c'}", explode(',', 'a,b,c', $unknownLimit));
61+
5462
// the empty separator throws a ValueError on PHP 8+, so only the ',' split remains
5563
assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b'));
5664

5765
assertType('non-empty-list<lowercase-string>', explode($unknown, 'a,b'));
5866
assertType('non-empty-list<string>', explode(',', $unknown));
59-
assertType('list<lowercase-string>', explode(',', 'a,b,c', $unknownLimit));
6067
}
6168

6269
/**
6370
* @param ','|';' $twoDelimiters
6471
* @param 'a,b;c'|'x,y;z' $twoStrings
6572
* @param int<1, 4> $limitRangeAtLimit
66-
* @param int<1, 5> $limitRangeOverLimit
73+
* @param int<1, 20> $limitRangeOverLimit
6774
* @param 'a'|'b'|'c'|'d'|'e' $fiveDelimiters
6875
* @param 'xay'|'xby'|'xcy'|'xdy' $fourStrings
6976
* @param int<1, 200> $wideLimitRange
7077
*/
7178
function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $limitRangeAtLimit, int $limitRangeOverLimit, string $fiveDelimiters, string $fourStrings, int $wideLimitRange): void
7279
{
73-
// 2 delimiters * 2 strings * 4 limits is exactly CONSTANT_COMBINATION_LIMIT combinations
80+
// the clamping collapses int<1, 4> into the two limits that matter for a
81+
// two-part split, so this stays within CONSTANT_COMBINATION_LIMIT
7482
assertType("array{'a', 'b;c'}|array{'a,b', 'c'}|array{'a,b;c'}|array{'x', 'y;z'}|array{'x,y', 'z'}|array{'x,y;z'}", explode($twoDelimiters, $twoStrings, $limitRangeAtLimit));
7583

76-
// one more value in the limit range makes it 20 combinations, so the exact result is not computed
77-
assertType('non-empty-list<lowercase-string>', explode($twoDelimiters, $twoStrings, $limitRangeOverLimit));
84+
// 20 limits that are not collapsed by the clamping are over the limit, so the exact result is not computed
85+
assertType('non-empty-list<lowercase-string>', explode(',', 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t', $limitRangeOverLimit));
7886

79-
// the limit range is too wide to enumerate its finite types
80-
assertType('non-empty-list<lowercase-string>', explode(',', 'a,b', $wideLimitRange));
87+
// a range wider than the number of parts is clamped down to the limits that matter
88+
assertType("array{'a', 'b'}|array{'a,b'}", explode(',', 'a,b', $wideLimitRange));
8189

8290
// 5 delimiters * 4 strings is over the limit too
8391
assertType('non-empty-list<lowercase-string>', explode($fiveDelimiters, $fourStrings));

0 commit comments

Comments
 (0)