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 336e30953e3..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; @@ -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 @@ -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 { @@ -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); @@ -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)) { @@ -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)]); } @@ -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; + } + + $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|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/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 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', explode($unknown, 'a,b')); + assertType('non-empty-list', 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', 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', explode($fiveDelimiters, $fourStrings)); + + // 257 elements is more than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + assertType('non-empty-list', 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|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)); + } +} 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 @@ +