From 4635baec4c44017e0db6d9c1ba3fa147e939101c Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:45:04 +0000 Subject: [PATCH 1/7] Constant-fold `explode()` into a `ConstantArrayType` when separator, string and limit are all constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ExplodeFunctionDynamicReturnTypeExtension` now evaluates the split itself when the separator, the subject string and the limit are all known constants, returning the exact `array{...}` instead of `non-empty-list`. - Unions of constant separators/strings/limits are cross-multiplied into a union of constant arrays, bounded by `CONSTANT_COMBINATION_LIMIT`; results longer than `ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT` fall back to the previous generic type. - Folding is skipped when any separator may be the empty string, so the existing `never`/`false` handling for that case is untouched. - The limit is resolved through `getFiniteTypes()`, so `int<1, 3>` also folds. - Adjusted `nsrt/array-destructuring.php`, which asserted the previously imprecise `lowercase-string&uppercase-string` for `explode()` results. - Probed the sibling "constant string in, constant array out" extensions: `str_split`/`mb_str_split`, `preg_split` and `array_chunk` already fold, so `explode()` was the only gap. `str_word_count()` (locale-dependent), `sscanf()` and `str_getcsv()` (no extension) are deliberately left alone. - Probed every array-destructuring form (plain, nested, keyed, `list()`, skipped elements, `foreach` value patterns) against `ArrayDestructuringRule` and `NonexistentOffsetInArrayDimFetchCheck` — all of them already agree with plain offset access, so no change was needed there. --- ...lodeFunctionDynamicReturnTypeExtension.php | 83 +++++++++++++++++++ .../Analyser/nsrt/array-destructuring.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-15013.php | 16 ++++ tests/PHPStan/Analyser/nsrt/explode.php | 31 +++++++ .../Arrays/ArrayDestructuringRuleTest.php | 10 +++ tests/PHPStan/Rules/Arrays/data/bug-15013.php | 15 ++++ 6 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15013.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-15013.php diff --git a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php index 336e30953e3..d48cd6d123f 100644 --- a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php @@ -29,7 +29,9 @@ use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use function count; +use function explode; use function max; +use const PHP_INT_MAX; #[AutowiredService] final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -45,6 +47,12 @@ final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunction 'str_ends_with', ]; + /** + * How many delimiter/string/limit combinations may be evaluated when + * constant-folding the call before giving up on the exact result. + */ + private const CONSTANT_COMBINATION_LIMIT = 16; + public function __construct(private PhpVersion $phpVersion) { } @@ -90,6 +98,12 @@ public function getTypeFromFunctionCall( } $limitType = isset($args[2]) ? $scope->getType($args[2]->value) : null; + + $constantType = $this->createConstantSplitType($delimiterType, $stringType, $limitType); + if ($constantType !== null) { + return $constantType; + } + $delimiterGuaranteedPresent = $this->isDelimiterGuaranteedPresent($args, $scope); if ($this->isSingleElementLimit($limitType)) { @@ -144,6 +158,75 @@ private function isDelimiterGuaranteedPresent(array $args, Scope $scope): bool return false; } + /** + * The exact result of the split when the delimiter, the string and the limit + * are all known constants, or null when it cannot be computed. + */ + private function createConstantSplitType(Type $delimiterType, Type $stringType, ?Type $limitType): ?Type + { + $delimiters = []; + foreach ($delimiterType->getConstantStrings() as $delimiterString) { + $delimiterValue = $delimiterString->getValue(); + if ($delimiterValue === '') { + // explode() does not split on an empty separator, it errors out + return null; + } + + $delimiters[] = $delimiterValue; + } + + if (count($delimiters) === 0) { + return null; + } + + $strings = $stringType->getConstantStrings(); + if (count($strings) === 0) { + return null; + } + + if ($limitType === null) { + $limits = [PHP_INT_MAX]; + } else { + $limits = []; + foreach ($limitType->getFiniteTypes() as $finiteType) { + if (!$finiteType instanceof ConstantIntegerType) { + return null; + } + + $limits[] = $finiteType->getValue(); + } + + if (count($limits) === 0) { + return null; + } + } + + if (count($delimiters) * count($strings) * count($limits) > self::CONSTANT_COMBINATION_LIMIT) { + return null; + } + + $results = []; + foreach ($delimiters as $delimiter) { + foreach ($strings as $string) { + foreach ($limits as $limit) { + $items = explode($delimiter, $string->getValue(), $limit); + if (count($items) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return null; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($items as $i => $item) { + $builder->setOffsetValueType(new ConstantIntegerType($i), new ConstantStringType($item)); + } + + $results[] = $builder->getArray(); + } + } + } + + return TypeCombinator::union(...$results); + } + /** * A limit of 0 or 1 returns the whole string as the only element, without * splitting on the delimiter. diff --git a/tests/PHPStan/Analyser/nsrt/array-destructuring.php b/tests/PHPStan/Analyser/nsrt/array-destructuring.php index 39b4f2830a1..7c8f2cd2634 100644 --- a/tests/PHPStan/Analyser/nsrt/array-destructuring.php +++ b/tests/PHPStan/Analyser/nsrt/array-destructuring.php @@ -129,10 +129,10 @@ function (\stdClass $obj) { assertType('non-empty-string', $secondStringArrayForeachList); assertType('non-empty-string', $thirdStringArrayForeachList); assertType('non-empty-string', $fourthStringArrayForeachList); - assertType('lowercase-string&uppercase-string', $dateArray['Y']); - assertType('lowercase-string&uppercase-string', $dateArray['m']); + assertType("'2018'", $dateArray['Y']); + assertType("'12'", $dateArray['m']); assertType('int', $dateArray['d']); - assertType('lowercase-string&uppercase-string', $intArrayForRewritingFirstElement[0]); + assertType("''", $intArrayForRewritingFirstElement[0]); assertType('int', $intArrayForRewritingFirstElement[1]); assertType('ArrayAccess&stdClass', $obj); assertType('stdClass', $newArray['newKey']); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15013.php b/tests/PHPStan/Analyser/nsrt/bug-15013.php new file mode 100644 index 00000000000..0749cadd07e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15013.php @@ -0,0 +1,16 @@ +', $benevolentArrayOrFalse); }; + +/** + * @param ','|';' $delimiterUnion + * @param 'a,b'|'x;y;z' $stringUnion + * @param 1|2 $limitUnion + * @param int<1, 3> $limitRange + * @param ''|',' $maybeEmptyDelimiter + */ +function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUnion, int $limitRange, string $maybeEmptyDelimiter, string $unknown, int $unknownLimit): void +{ + assertType("array{'a', 'b', 'c'}", explode(',', 'a,b,c')); + assertType("array{'App/Service::foo'}", explode(':::', 'App/Service::foo')); + assertType("array{''}", explode(',', '')); + assertType("array{'a', 'b,c'}", explode(',', 'a,b,c', 2)); + assertType("array{'a,b,c'}", explode(',', 'a,b,c', 0)); + assertType("array{'a,b,c'}", explode(',', 'a,b,c', 1)); + assertType("array{'a', 'b'}", explode(',', 'a,b,c', -1)); + assertType('array{}', explode(',', 'a,b', -5)); + + assertType("array{'a', 'b'}|array{'a,b'}|array{'x', 'y', 'z'}|array{'x;y;z'}", explode($delimiterUnion, $stringUnion)); + assertType("array{'a', 'b'}|array{'x;y;z'}", explode(',', $stringUnion)); + assertType("array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitUnion)); + assertType("array{'a', 'b', 'c'}|array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitRange)); + + // the delimiter may be an empty string, which is not a valid split + assertType('non-empty-list', explode($maybeEmptyDelimiter, 'a,b')); + + assertType('non-empty-list', explode($unknown, 'a,b')); + assertType('non-empty-list', explode(',', $unknown)); + assertType('list', explode(',', 'a,b,c', $unknownLimit)); +} diff --git a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php index 2d1316f1b60..521b53d463b 100644 --- a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php @@ -102,6 +102,16 @@ public function testBug8075(): void ]); } + public function testBug15013(): void + { + $this->analyse([__DIR__ . '/data/bug-15013.php'], [ + [ + 'Offset 1 does not exist on array{\'App/Service::foo\'}.', + 8, + ], + ]); + } + #[RequiresPhp('>= 8.0.0')] public function testRuleWithNullsafeVariant(): void { diff --git a/tests/PHPStan/Rules/Arrays/data/bug-15013.php b/tests/PHPStan/Rules/Arrays/data/bug-15013.php new file mode 100644 index 00000000000..b450f6dd03b --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-15013.php @@ -0,0 +1,15 @@ + Date: Sat, 19 Sep 2026 13:28:20 +0000 Subject: [PATCH 2/7] Keep constant-folding explode() when one of the separators may be empty An empty separator does not make the whole call unfoldable: on PHP 8+ it throws a ValueError, so that combination contributes nothing to the return type, and before PHP 8 it contributes `false`. Skip the empty separator and fold the remaining ones instead of bailing out of the exact result. Co-Authored-By: Claude Opus 5 --- ...lodeFunctionDynamicReturnTypeExtension.php | 11 ++++-- tests/PHPStan/Analyser/ExplodePhp7Test.php | 36 +++++++++++++++++++ .../Analyser/data/explode-constant-php7.php | 16 +++++++++ tests/PHPStan/Analyser/nsrt/explode.php | 4 +-- 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/ExplodePhp7Test.php create mode 100644 tests/PHPStan/Analyser/data/explode-constant-php7.php diff --git a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php index d48cd6d123f..33da85f661d 100644 --- a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php @@ -165,11 +165,14 @@ private function isDelimiterGuaranteedPresent(array $args, Scope $scope): bool private function createConstantSplitType(Type $delimiterType, Type $stringType, ?Type $limitType): ?Type { $delimiters = []; + $hasEmptyDelimiter = false; foreach ($delimiterType->getConstantStrings() as $delimiterString) { $delimiterValue = $delimiterString->getValue(); if ($delimiterValue === '') { - // explode() does not split on an empty separator, it errors out - return null; + // explode() does not split on an empty separator: it throws + // a ValueError on PHP 8+, and returns false before that + $hasEmptyDelimiter = true; + continue; } $delimiters[] = $delimiterValue; @@ -224,6 +227,10 @@ private function createConstantSplitType(Type $delimiterType, Type $stringType, } } + if ($hasEmptyDelimiter && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + $results[] = new ConstantBooleanType(false); + } + return TypeCombinator::union(...$results); } diff --git a/tests/PHPStan/Analyser/ExplodePhp7Test.php b/tests/PHPStan/Analyser/ExplodePhp7Test.php new file mode 100644 index 00000000000..e76d923d553 --- /dev/null +++ b/tests/PHPStan/Analyser/ExplodePhp7Test.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/nodeScopeResolverPhp7.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/data/explode-constant-php7.php b/tests/PHPStan/Analyser/data/explode-constant-php7.php new file mode 100644 index 00000000000..0f2ce52593c --- /dev/null +++ b/tests/PHPStan/Analyser/data/explode-constant-php7.php @@ -0,0 +1,16 @@ +', explode($maybeEmptyDelimiter, 'a,b')); + // the empty separator throws a ValueError on PHP 8+, so only the ',' split remains + assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b')); assertType('non-empty-list', explode($unknown, 'a,b')); assertType('non-empty-list', explode(',', $unknown)); From 243727972824277dfc711a1e8365f3c5d76397ca Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 21 Sep 2026 06:04:17 +0000 Subject: [PATCH 3/7] Cover the constant-folding bail-outs of explode() with assertions Adds assertions pinning both guards in createConstantSplitType(): CONSTANT_COMBINATION_LIMIT (from the limit range dimension and from the delimiter/string cross product) and the ARRAY_COUNT_LIMIT check on the number of produced elements. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/explode.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/explode.php b/tests/PHPStan/Analyser/nsrt/explode.php index be1d6fc4372..4741bc0b39d 100644 --- a/tests/PHPStan/Analyser/nsrt/explode.php +++ b/tests/PHPStan/Analyser/nsrt/explode.php @@ -58,3 +58,26 @@ function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUn assertType('non-empty-list', explode(',', $unknown)); assertType('list', explode(',', 'a,b,c', $unknownLimit)); } + +/** + * @param ','|';' $twoDelimiters + * @param 'a,b;c'|'x,y;z' $twoStrings + * @param int<1, 4> $limitRangeAtLimit + * @param int<1, 5> $limitRangeOverLimit + * @param 'a'|'b'|'c'|'d'|'e' $fiveDelimiters + * @param 'xay'|'xby'|'xcy'|'xdy' $fourStrings + */ +function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $limitRangeAtLimit, int $limitRangeOverLimit, string $fiveDelimiters, string $fourStrings): void +{ + // 2 delimiters * 2 strings * 4 limits is exactly CONSTANT_COMBINATION_LIMIT combinations + 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)); + + // one more value in the limit range makes it 20 combinations, so the exact result is not computed + assertType('non-empty-list', explode($twoDelimiters, $twoStrings, $limitRangeOverLimit)); + + // 5 delimiters * 4 strings is over the limit too + assertType('non-empty-list', explode($fiveDelimiters, $fourStrings)); + + // 257 elements is more than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + assertType('non-empty-list', explode(',', ',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,')); +} \ No newline at end of file From 05457fdf9c93fee3cb8fcbd918b3c7ee58938fbb Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 21 Sep 2026 06:04:47 +0000 Subject: [PATCH 4/7] Also pin the explode() bail-out for an integer-range limit that is too wide getFiniteTypes() returns nothing for a range wider than CALCULATE_SCALARS_LIMIT, so the call cannot be folded. Also restores the trailing newline of the test data file. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/nsrt/explode.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/explode.php b/tests/PHPStan/Analyser/nsrt/explode.php index 4741bc0b39d..b891f705e83 100644 --- a/tests/PHPStan/Analyser/nsrt/explode.php +++ b/tests/PHPStan/Analyser/nsrt/explode.php @@ -66,8 +66,9 @@ function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUn * @param int<1, 5> $limitRangeOverLimit * @param 'a'|'b'|'c'|'d'|'e' $fiveDelimiters * @param 'xay'|'xby'|'xcy'|'xdy' $fourStrings + * @param int<1, 200> $wideLimitRange */ -function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $limitRangeAtLimit, int $limitRangeOverLimit, string $fiveDelimiters, string $fourStrings): void +function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $limitRangeAtLimit, int $limitRangeOverLimit, string $fiveDelimiters, string $fourStrings, int $wideLimitRange): void { // 2 delimiters * 2 strings * 4 limits is exactly CONSTANT_COMBINATION_LIMIT combinations 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)); @@ -75,9 +76,12 @@ function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $lim // one more value in the limit range makes it 20 combinations, so the exact result is not computed assertType('non-empty-list', explode($twoDelimiters, $twoStrings, $limitRangeOverLimit)); + // the limit range is too wide to enumerate its finite types + assertType('non-empty-list', explode(',', 'a,b', $wideLimitRange)); + // 5 delimiters * 4 strings is over the limit too assertType('non-empty-list', explode($fiveDelimiters, $fourStrings)); // 257 elements is more than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT assertType('non-empty-list', explode(',', ',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,')); -} \ No newline at end of file +} From 46d3c7d22857f54d347efad8eaa8ff8b0235a04f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 21 Sep 2026 06:05:31 +0000 Subject: [PATCH 5/7] 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 --- ...lodeFunctionDynamicReturnTypeExtension.php | 94 +++++++++++++++---- tests/PHPStan/Analyser/nsrt/explode.php | 24 +++-- 2 files changed, 90 insertions(+), 28 deletions(-) diff --git a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php index 33da85f661d..3ff342cd41e 100644 --- a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php @@ -28,10 +28,12 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; +use function array_keys; use function count; use function explode; use function max; -use const PHP_INT_MAX; +use function min; +use function substr_count; #[AutowiredService] final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -187,32 +189,25 @@ private function createConstantSplitType(Type $delimiterType, Type $stringType, return null; } - if ($limitType === null) { - $limits = [PHP_INT_MAX]; - } else { - $limits = []; - foreach ($limitType->getFiniteTypes() as $finiteType) { - if (!$finiteType instanceof ConstantIntegerType) { - return null; - } - - $limits[] = $finiteType->getValue(); - } - - if (count($limits) === 0) { - return null; - } - } - - if (count($delimiters) * count($strings) * count($limits) > self::CONSTANT_COMBINATION_LIMIT) { + if (count($delimiters) * count($strings) > self::CONSTANT_COMBINATION_LIMIT) { return null; } $results = []; foreach ($delimiters as $delimiter) { foreach ($strings as $string) { + $stringValue = $string->getValue(); + $limits = $this->getDistinctLimits($limitType, substr_count($stringValue, $delimiter) + 1); + if ($limits === null) { + return null; + } + foreach ($limits as $limit) { - $items = explode($delimiter, $string->getValue(), $limit); + if (count($results) >= self::CONSTANT_COMBINATION_LIMIT) { + return null; + } + + $items = explode($delimiter, $stringValue, $limit); if (count($items) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { return null; } @@ -234,6 +229,65 @@ private function createConstantSplitType(Type $delimiterType, Type $stringType, return TypeCombinator::union(...$results); } + /** + * The limits that lead to different results when splitting a string into + * $partsCount parts, or null when they cannot be enumerated. + * + * Limits are clamped into the [-$partsCount, $partsCount] window because + * every limit above $partsCount produces the full split and every limit at + * or below -$partsCount produces an empty array. That keeps wide and even + * unbounded integer ranges enumerable. + * + * @return list|null + */ + private function getDistinctLimits(?Type $limitType, int $partsCount): ?array + { + if ($limitType === null) { + return [$partsCount]; + } + + if (!$limitType->isInteger()->yes()) { + return null; + } + + $finiteTypes = $limitType->getFiniteTypes(); + if (count($finiteTypes) > 0) { + $clampedLimits = []; + foreach ($finiteTypes as $finiteType) { + if (!$finiteType instanceof ConstantIntegerType) { + return null; + } + + $value = $finiteType->getValue(); + $clampedLimits[max(-$partsCount, min($partsCount, $value))] = true; + } + + return array_keys($clampedLimits); + } + + $limits = []; + for ($limit = -$partsCount; $limit <= $partsCount; $limit++) { + if ($limit === -$partsCount) { + $candidateLimitType = IntegerRangeType::fromInterval(null, $limit); + } elseif ($limit === $partsCount) { + $candidateLimitType = IntegerRangeType::fromInterval($limit, null); + } else { + $candidateLimitType = new ConstantIntegerType($limit); + } + + if ($candidateLimitType->isSuperTypeOf($limitType)->no()) { + continue; + } + + $limits[] = $limit; + if (count($limits) > self::CONSTANT_COMBINATION_LIMIT) { + return null; + } + } + + return $limits; + } + /** * A limit of 0 or 1 returns the whole string as the only element, without * splitting on the delimiter. diff --git a/tests/PHPStan/Analyser/nsrt/explode.php b/tests/PHPStan/Analyser/nsrt/explode.php index b891f705e83..d023665afff 100644 --- a/tests/PHPStan/Analyser/nsrt/explode.php +++ b/tests/PHPStan/Analyser/nsrt/explode.php @@ -33,9 +33,11 @@ function (string $delimiter, $mixed) { * @param 'a,b'|'x;y;z' $stringUnion * @param 1|2 $limitUnion * @param int<1, 3> $limitRange + * @param int<5, max> $largeLimitRange + * @param int<-100, -2> $negativeLimitRange * @param ''|',' $maybeEmptyDelimiter */ -function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUnion, int $limitRange, string $maybeEmptyDelimiter, string $unknown, int $unknownLimit): void +function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUnion, int $limitRange, int $largeLimitRange, int $negativeLimitRange, string $maybeEmptyDelimiter, string $unknown, int $unknownLimit): void { assertType("array{'a', 'b', 'c'}", explode(',', 'a,b,c')); assertType("array{'App/Service::foo'}", explode(':::', 'App/Service::foo')); @@ -51,33 +53,39 @@ function constantSplit(string $delimiterUnion, string $stringUnion, int $limitUn assertType("array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitUnion)); assertType("array{'a', 'b', 'c'}|array{'a', 'b,c'}|array{'a,b,c'}", explode(',', 'a,b,c', $limitRange)); + // limits above the number of parts all produce the full split, limits at or + // below minus the number of parts all produce an empty array + assertType("array{'a', 'b', 'c'}", explode(',', 'a,b,c', $largeLimitRange)); + assertType("array{}|array{'a'}", explode(',', 'a,b,c', $negativeLimitRange)); + assertType("array{}|array{'a,b,c'}|list{0: 'a', 1?: 'b'|'b,c', 2?: 'c'}", explode(',', 'a,b,c', $unknownLimit)); + // the empty separator throws a ValueError on PHP 8+, so only the ',' split remains assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b')); assertType('non-empty-list', explode($unknown, 'a,b')); assertType('non-empty-list', explode(',', $unknown)); - assertType('list', explode(',', 'a,b,c', $unknownLimit)); } /** * @param ','|';' $twoDelimiters * @param 'a,b;c'|'x,y;z' $twoStrings * @param int<1, 4> $limitRangeAtLimit - * @param int<1, 5> $limitRangeOverLimit + * @param int<1, 20> $limitRangeOverLimit * @param 'a'|'b'|'c'|'d'|'e' $fiveDelimiters * @param 'xay'|'xby'|'xcy'|'xdy' $fourStrings * @param int<1, 200> $wideLimitRange */ function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $limitRangeAtLimit, int $limitRangeOverLimit, string $fiveDelimiters, string $fourStrings, int $wideLimitRange): void { - // 2 delimiters * 2 strings * 4 limits is exactly CONSTANT_COMBINATION_LIMIT combinations + // the clamping collapses int<1, 4> into the two limits that matter for a + // two-part split, so this stays within CONSTANT_COMBINATION_LIMIT 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)); - // one more value in the limit range makes it 20 combinations, so the exact result is not computed - assertType('non-empty-list', explode($twoDelimiters, $twoStrings, $limitRangeOverLimit)); + // 20 limits that are not collapsed by the clamping are over the limit, so the exact result is not computed + assertType('non-empty-list', explode(',', 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t', $limitRangeOverLimit)); - // the limit range is too wide to enumerate its finite types - assertType('non-empty-list', explode(',', 'a,b', $wideLimitRange)); + // a range wider than the number of parts is clamped down to the limits that matter + assertType("array{'a', 'b'}|array{'a,b'}", explode(',', 'a,b', $wideLimitRange)); // 5 delimiters * 4 strings is over the limit too assertType('non-empty-list', explode($fiveDelimiters, $fourStrings)); From be2705f566d4ba441be5bd11cd2537ae5f19d4ac Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 22 Sep 2026 05:20:47 +0000 Subject: [PATCH 6/7] Make explode() return type scope php-version aware The extension asked the DI-injected PhpVersion whether internal functions throw for an invalid separator. Ask $scope->getPhpVersion() instead, so a narrowed PHP_VERSION_ID in the analysed code decides whether explode('') is a never-returning ValueError or a false return value. PhpVersions gains the trinary-logic counterparts of throwsTypeErrorForInternalFunctions() and throwsValueErrorForInternalFunctions(). Co-Authored-By: Claude Opus 5 --- src/Php/PhpVersions.php | 10 ++++++++++ ...xplodeFunctionDynamicReturnTypeExtension.php | 17 +++++++---------- .../Analyser/data/explode-constant-php7.php | 12 ++++++++++++ tests/PHPStan/Analyser/nsrt/explode.php | 15 +++++++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php index a6849c0584c..426572ca849 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -56,6 +56,16 @@ public function supportsTrueAndFalseStandaloneType(): TrinaryLogic return IntegerRangeType::fromInterval(80200, null)->isSuperTypeOf($this->phpVersions)->result; } + public function throwsTypeErrorForInternalFunctions(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + + public function throwsValueErrorForInternalFunctions(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + public function supportsMaxMemoryLimit(): TrinaryLogic { return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result; diff --git a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php index 3ff342cd41e..0784ae18710 100644 --- a/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php @@ -7,7 +7,7 @@ use PhpParser\Node\Name\FullyQualified; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; +use PHPStan\Php\PhpVersions; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; @@ -55,10 +55,6 @@ final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunction */ private const CONSTANT_COMBINATION_LIMIT = 16; - public function __construct(private PhpVersion $phpVersion) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'explode'; @@ -75,10 +71,11 @@ public function getTypeFromFunctionCall( return null; } + $phpVersions = $scope->getPhpVersion(); $delimiterType = $scope->getType($args[0]->value); $isEmptyString = (new ConstantStringType(''))->isSuperTypeOf($delimiterType); if ($isEmptyString->yes()) { - if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + if ($phpVersions->throwsTypeErrorForInternalFunctions()->yes()) { return new NeverType(); } return new ConstantBooleanType(false); @@ -101,7 +98,7 @@ public function getTypeFromFunctionCall( $limitType = isset($args[2]) ? $scope->getType($args[2]->value) : null; - $constantType = $this->createConstantSplitType($delimiterType, $stringType, $limitType); + $constantType = $this->createConstantSplitType($delimiterType, $stringType, $limitType, $phpVersions); if ($constantType !== null) { return $constantType; } @@ -127,7 +124,7 @@ public function getTypeFromFunctionCall( } } - if (!$this->phpVersion->throwsValueErrorForInternalFunctions() && $isEmptyString->maybe()) { + if (!$phpVersions->throwsValueErrorForInternalFunctions()->yes() && $isEmptyString->maybe()) { $returnType = new UnionType([$returnType, new ConstantBooleanType(false)]); } @@ -164,7 +161,7 @@ private function isDelimiterGuaranteedPresent(array $args, Scope $scope): bool * The exact result of the split when the delimiter, the string and the limit * are all known constants, or null when it cannot be computed. */ - private function createConstantSplitType(Type $delimiterType, Type $stringType, ?Type $limitType): ?Type + private function createConstantSplitType(Type $delimiterType, Type $stringType, ?Type $limitType, PhpVersions $phpVersions): ?Type { $delimiters = []; $hasEmptyDelimiter = false; @@ -222,7 +219,7 @@ private function createConstantSplitType(Type $delimiterType, Type $stringType, } } - if ($hasEmptyDelimiter && !$this->phpVersion->throwsValueErrorForInternalFunctions()) { + if ($hasEmptyDelimiter && !$phpVersions->throwsValueErrorForInternalFunctions()->yes()) { $results[] = new ConstantBooleanType(false); } diff --git a/tests/PHPStan/Analyser/data/explode-constant-php7.php b/tests/PHPStan/Analyser/data/explode-constant-php7.php index 0f2ce52593c..e0fb1a5bb2d 100644 --- a/tests/PHPStan/Analyser/data/explode-constant-php7.php +++ b/tests/PHPStan/Analyser/data/explode-constant-php7.php @@ -14,3 +14,15 @@ function constantSplit(string $maybeEmptyDelimiter): void // the empty separator makes explode() return false before PHP 8 assertType("array{'a', 'b'}|false", explode($maybeEmptyDelimiter, 'a,b')); } + +/** + * @param ''|',' $maybeEmptyDelimiter + */ +function narrowedPhpVersion(string $maybeEmptyDelimiter): void +{ + if (PHP_VERSION_ID >= 80000) { + // the scope wins over the configured PHP 7.4 + assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b')); + assertType('*NEVER*', explode('', 'a,b')); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/explode.php b/tests/PHPStan/Analyser/nsrt/explode.php index d023665afff..32e2b1fe370 100644 --- a/tests/PHPStan/Analyser/nsrt/explode.php +++ b/tests/PHPStan/Analyser/nsrt/explode.php @@ -93,3 +93,18 @@ function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $lim // 257 elements is more than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT assertType('non-empty-list', explode(',', ',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,')); } + +/** + * @param ''|',' $maybeEmptyDelimiter + */ +function narrowedPhpVersion(string $maybeEmptyDelimiter): void +{ + if (PHP_VERSION_ID < 80000) { + // before PHP 8 the empty separator makes explode() return false + assertType("array{'a', 'b'}|false", explode($maybeEmptyDelimiter, 'a,b')); + assertType('false', explode('', 'a,b')); + } else { + assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b')); + assertType('*NEVER*', explode('', 'a,b')); + } +} From c77b85fc35ae5153291f4095ae3a7ec8c64e3c26 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 22 Sep 2026 05:25:14 +0000 Subject: [PATCH 7/7] Cover the pre-PHP-8 explode() result in nsrt instead of a separate test Now that the extension reads the PHP version from the scope, the PHP 7 behaviour of an empty separator can be asserted inside an `if (PHP_VERSION_ID < 80000)` branch of the existing nsrt file, so the dedicated ExplodePhp7Test and its php7-configured data file are no longer needed. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Analyser/ExplodePhp7Test.php | 36 ------------------- .../Analyser/data/explode-constant-php7.php | 28 --------------- tests/PHPStan/Analyser/nsrt/explode.php | 4 ++- 3 files changed, 3 insertions(+), 65 deletions(-) delete mode 100644 tests/PHPStan/Analyser/ExplodePhp7Test.php delete mode 100644 tests/PHPStan/Analyser/data/explode-constant-php7.php diff --git a/tests/PHPStan/Analyser/ExplodePhp7Test.php b/tests/PHPStan/Analyser/ExplodePhp7Test.php deleted file mode 100644 index e76d923d553..00000000000 --- a/tests/PHPStan/Analyser/ExplodePhp7Test.php +++ /dev/null @@ -1,36 +0,0 @@ -assertFileAsserts($assertType, $file, ...$args); - } - - public static function getAdditionalConfigFiles(): array - { - return [ - __DIR__ . '/nodeScopeResolverPhp7.neon', - ]; - } - -} diff --git a/tests/PHPStan/Analyser/data/explode-constant-php7.php b/tests/PHPStan/Analyser/data/explode-constant-php7.php deleted file mode 100644 index e0fb1a5bb2d..00000000000 --- a/tests/PHPStan/Analyser/data/explode-constant-php7.php +++ /dev/null @@ -1,28 +0,0 @@ -= 80000) { - // the scope wins over the configured PHP 7.4 - assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b')); - assertType('*NEVER*', explode('', 'a,b')); - } -} diff --git a/tests/PHPStan/Analyser/nsrt/explode.php b/tests/PHPStan/Analyser/nsrt/explode.php index 32e2b1fe370..9d0fa47a0a9 100644 --- a/tests/PHPStan/Analyser/nsrt/explode.php +++ b/tests/PHPStan/Analyser/nsrt/explode.php @@ -97,14 +97,16 @@ function constantSplitLimits(string $twoDelimiters, string $twoStrings, int $lim /** * @param ''|',' $maybeEmptyDelimiter */ -function narrowedPhpVersion(string $maybeEmptyDelimiter): void +function narrowedPhpVersion(string $maybeEmptyDelimiter, string $unknown): void { if (PHP_VERSION_ID < 80000) { // before PHP 8 the empty separator makes explode() return false assertType("array{'a', 'b'}|false", explode($maybeEmptyDelimiter, 'a,b')); assertType('false', explode('', 'a,b')); + assertType('non-empty-list|false', explode($maybeEmptyDelimiter, $unknown)); } else { assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b')); assertType('*NEVER*', explode('', 'a,b')); + assertType('non-empty-list', explode($maybeEmptyDelimiter, $unknown)); } }