Skip to content
Next Next commit
Constant-fold explode() into a ConstantArrayType when separator, …
…string and limit are all constant

- `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<string>`.
- 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.
  • Loading branch information
phpstan-bot authored and staabm committed Sep 22, 2026
commit 4635baec4c44017e0db6d9c1ba3fa147e939101c
83 changes: 83 additions & 0 deletions src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
{
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}
Comment thread
VincentLanglet marked this conversation as resolved.

$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;
}
Comment thread
VincentLanglet marked this conversation as resolved.
Outdated

$limits[] = $finiteType->getValue();
}

if (count($limits) === 0) {
return null;
}
}

if (count($delimiters) * count($strings) * count($limits) > self::CONSTANT_COMBINATION_LIMIT) {
return null;
}
Comment thread
VincentLanglet marked this conversation as resolved.
Outdated

$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.
Expand Down
6 changes: 3 additions & 3 deletions tests/PHPStan/Analyser/nsrt/array-destructuring.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
16 changes: 16 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15013.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types = 1);

namespace Bug15013;

use function PHPStan\Testing\assertType;

function () {
$string = 'App/Service::foo';

assertType("array{'App/Service::foo'}", explode(':::', $string));

[$first, $second] = explode(':::', $string);

assertType("'App/Service::foo'", $first);
Comment thread
VincentLanglet marked this conversation as resolved.
assertType('*ERROR*', $second);
};
31 changes: 31 additions & 0 deletions tests/PHPStan/Analyser/nsrt/explode.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,34 @@ function (string $delimiter, $mixed) {
assertType('non-empty-list<string>', $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<lowercase-string>', explode($maybeEmptyDelimiter, 'a,b'));

assertType('non-empty-list<lowercase-string>', explode($unknown, 'a,b'));
assertType('non-empty-list<string>', explode(',', $unknown));
assertType('list<lowercase-string>', explode(',', 'a,b,c', $unknownLimit));
}
10 changes: 10 additions & 0 deletions tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
15 changes: 15 additions & 0 deletions tests/PHPStan/Rules/Arrays/data/bug-15013.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types = 1);

namespace Bug15013RuleTest;

function () {
$string = 'App/Service::foo';

[$first, $second] = explode(':::', $string);
};

function () {
$string = 'App/Service::foo';

[$first, $second] = explode('::', $string);
};