Skip to content
10 changes: 10 additions & 0 deletions src/Php/PhpVersions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
153 changes: 147 additions & 6 deletions src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,8 +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 function min;
use function substr_count;

#[AutowiredService]
final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
Expand All @@ -45,9 +49,11 @@ final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunction
'str_ends_with',
];

public function __construct(private PhpVersion $phpVersion)
{
}
/**
* 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 isFunctionSupported(FunctionReflection $functionReflection): bool
{
Expand All @@ -65,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);
Expand All @@ -90,6 +97,12 @@ public function getTypeFromFunctionCall(
}

$limitType = isset($args[2]) ? $scope->getType($args[2]->value) : null;

$constantType = $this->createConstantSplitType($delimiterType, $stringType, $limitType, $phpVersions);
if ($constantType !== null) {
return $constantType;
}

$delimiterGuaranteedPresent = $this->isDelimiterGuaranteedPresent($args, $scope);

if ($this->isSingleElementLimit($limitType)) {
Expand All @@ -111,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)]);
}

Expand Down Expand Up @@ -144,6 +157,134 @@ 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, PhpVersions $phpVersions): ?Type
{
$delimiters = [];
$hasEmptyDelimiter = false;
foreach ($delimiterType->getConstantStrings() as $delimiterString) {
$delimiterValue = $delimiterString->getValue();
if ($delimiterValue === '') {
// explode() does not split on an empty separator: it throws
// a ValueError on PHP 8+, and returns false before that
$hasEmptyDelimiter = true;
continue;
}
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 (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) {
if (count($results) >= self::CONSTANT_COMBINATION_LIMIT) {
return null;
}

$items = explode($delimiter, $stringValue, $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();
}
}
}

if ($hasEmptyDelimiter && !$phpVersions->throwsValueErrorForInternalFunctions()->yes()) {
$results[] = new ConstantBooleanType(false);
}

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<int>|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.
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);
};
83 changes: 83 additions & 0 deletions tests/PHPStan/Analyser/nsrt/explode.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,86 @@ 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 int<5, max> $largeLimitRange
* @param int<-100, -2> $negativeLimitRange
* @param ''|',' $maybeEmptyDelimiter
*/
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'));
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));

// 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<lowercase-string>', explode($unknown, 'a,b'));
assertType('non-empty-list<string>', explode(',', $unknown));
}

/**
* @param ','|';' $twoDelimiters
* @param 'a,b;c'|'x,y;z' $twoStrings
* @param int<1, 4> $limitRangeAtLimit
* @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
{
// 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));

// 20 limits that are not collapsed by the clamping are over the limit, so the exact result is not computed
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));

// 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<lowercase-string>', explode($fiveDelimiters, $fourStrings));

// 257 elements is more than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
assertType('non-empty-list<lowercase-string&uppercase-string>', explode(',', ',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,'));
}

/**
* @param ''|',' $maybeEmptyDelimiter
*/
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<string>|false', explode($maybeEmptyDelimiter, $unknown));
} else {
assertType("array{'a', 'b'}", explode($maybeEmptyDelimiter, 'a,b'));
assertType('*NEVER*', explode('', 'a,b'));
assertType('non-empty-list<string>', explode($maybeEmptyDelimiter, $unknown));
}
}
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);
};
Loading