Skip to content

Improve StrSplit returnType #3999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: 1.12.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions build/baseline-8.0.neon
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@ parameters:
count: 1
path: ../src/Type/Php/StrSplitFunctionReturnTypeExtension.php

-
message: "#^Strict comparison using \\=\\=\\= between list<string> and false will always evaluate to false\\.$#"
count: 1
path: ../src/Type/Php/StrSplitFunctionReturnTypeExtension.php

-
message: "#^Call to function is_bool\\(\\) with string will always evaluate to false\\.$#"
count: 1
Expand Down
2 changes: 1 addition & 1 deletion src/Php/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ public function deprecatesDynamicProperties(): bool
return $this->versionId >= 80200;
}

public function strSplitReturnsEmptyArray(): bool
public function strSplitReturnsEmptyArrayOnEmptyString(): bool
{
return $this->versionId >= 80200;
}
Expand Down
95 changes: 61 additions & 34 deletions src/Type/Php/StrSplitFunctionReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\NeverType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
Expand Down Expand Up @@ -51,14 +54,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,

if (count($functionCall->getArgs()) >= 2) {
$splitLengthType = $scope->getType($functionCall->getArgs()[1]->value);
if ($splitLengthType instanceof ConstantIntegerType) {
$splitLength = $splitLengthType->getValue();
if ($splitLength < 1) {
return new ConstantBooleanType(false);
}
}
} else {
$splitLength = 1;
$splitLengthType = new ConstantIntegerType(1);
}

if ($splitLengthType instanceof ConstantIntegerType) {
$splitLength = $splitLengthType->getValue();
if ($splitLength < 1) {
return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false);
}
}

$encoding = null;
Expand All @@ -67,47 +71,70 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
$strings = $scope->getType($functionCall->getArgs()[2]->value)->getConstantStrings();
$values = array_unique(array_map(static fn (ConstantStringType $encoding): string => $encoding->getValue(), $strings));

if (count($values) !== 1) {
return null;
}

$encoding = $values[0];
if (!$this->isSupportedEncoding($encoding)) {
return new ConstantBooleanType(false);
if (count($values) === 1) {
$encoding = $values[0];
if (!$this->isSupportedEncoding($encoding)) {
return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false);
}
}
} else {
$encoding = mb_internal_encoding();
}
}

if (!isset($splitLength)) {
return null;
}

$stringType = $scope->getType($functionCall->getArgs()[0]->value);

$constantStrings = $stringType->getConstantStrings();
if (count($constantStrings) > 0) {
$results = [];
foreach ($constantStrings as $constantString) {
$items = $encoding === null
? str_split($constantString->getValue(), $splitLength)
: @mb_str_split($constantString->getValue(), $splitLength, $encoding);
if ($items === false) {
throw new ShouldNotHappenException();
if (
isset($splitLength)
&& ($functionReflection->getName() === 'str_split' || $encoding !== null)
) {
$constantStrings = $stringType->getConstantStrings();
if (count($constantStrings) > 0) {
$results = [];
foreach ($constantStrings as $constantString) {
$value = $constantString->getValue();

if ($encoding === null && $value === '') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic exists to avoid the same issue than phpstan/phpstan#13129

// Simulate the str_split call with the analysed PHP Version instead of the runtime one.
$items = $this->phpVersion->strSplitReturnsEmptyArrayOnEmptyString() ? [] : [''];
} else {
$items = $encoding === null
? str_split($value, $splitLength)
: @mb_str_split($value, $splitLength, $encoding);
}

$results[] = self::createConstantArrayFrom($items, $scope);
}

$results[] = self::createConstantArrayFrom($items, $scope);
return TypeCombinator::union(...$results);
}
}

return TypeCombinator::union(...$results);
$isInputNonEmptyString = $stringType->isNonEmptyString()->yes();

if ($isInputNonEmptyString || $this->phpVersion->strSplitReturnsEmptyArrayOnEmptyString()) {
$returnValueType = TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType());
} else {
$returnValueType = new StringType();
}

$returnType = AccessoryArrayListType::intersectWith(new ArrayType(new IntegerType(), new StringType()));
$returnType = AccessoryArrayListType::intersectWith(TypeCombinator::intersect(new ArrayType(new IntegerType(), $returnValueType)));
if (
// Non-empty-string will return an array with at least an element
$isInputNonEmptyString
// str_split('', 1) returns [''] on old PHP version and [] on new ones
|| ($functionReflection->getName() === 'str_split' && !$this->phpVersion->strSplitReturnsEmptyArrayOnEmptyString())
) {
$returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType());
}
if (
// Length parameter accepts int<1, max> or throws a ValueError/return false based on PHP Version.
!$this->phpVersion->throwsValueErrorForInternalFunctions()
&& !IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($splitLengthType)->yes()
) {
$returnType = TypeCombinator::union($returnType, new ConstantBooleanType(false));
}

return $encoding === null && !$this->phpVersion->strSplitReturnsEmptyArray()
? TypeCombinator::intersect($returnType, new NonEmptyArrayType())
: $returnType;
return $returnType;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion tests/PHPStan/Analyser/NodeScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ private static function findTestFiles(): iterable
} else {
yield __DIR__ . '/data/str-split-php74.php';
}
if (PHP_VERSION_ID >= 80000) {
if (PHP_VERSION_ID >= 80200) {
yield __DIR__ . '/data/mb-str-split-php82.php';
} elseif (PHP_VERSION_ID >= 80000) {
yield __DIR__ . '/data/mb-str-split-php80.php';
} elseif (PHP_VERSION_ID >= 74000) {
yield __DIR__ . '/data/mb-str-split-php74.php';
Expand Down
57 changes: 39 additions & 18 deletions tests/PHPStan/Analyser/data/mb-str-split-php80.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,69 +26,90 @@ public function legacyTest(): void
assertType('array{\'abcdef\'}', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLength);

$mbStrSplitConstantStringWithFailureSplitLength = mb_str_split('abcdef', 0);
assertType('false', $mbStrSplitConstantStringWithFailureSplitLength);
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLength);

$mbStrSplitConstantStringWithInvalidSplitLengthType = mb_str_split('abcdef', []);
assertType('list<string>', $mbStrSplitConstantStringWithInvalidSplitLengthType);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithInvalidSplitLengthType);

$mbStrSplitConstantStringWithVariableStringAndConstantSplitLength = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', 1);
assertType("array{'a', 'b', 'c', 'd', 'e', 'f'}|array{'g', 'h', 'i', 'j', 'k', 'l'}", $mbStrSplitConstantStringWithVariableStringAndConstantSplitLength);

$mbStrSplitConstantStringWithVariableStringAndVariableSplitLength = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', doFoo() ? 1 : 2);
assertType('list<string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLength);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLength);

$mbStrSplitConstantStringWithOneSplitLengthAndValidEncoding = mb_str_split('abcdef', 1, 'UTF-8');
assertType("array{'a', 'b', 'c', 'd', 'e', 'f'}", $mbStrSplitConstantStringWithOneSplitLengthAndValidEncoding);

$mbStrSplitConstantStringWithOneSplitLengthAndInvalidEncoding = mb_str_split('abcdef', 1, 'FAKE');
assertType('false', $mbStrSplitConstantStringWithOneSplitLengthAndInvalidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithOneSplitLengthAndInvalidEncoding);

$mbStrSplitConstantStringWithOneSplitLengthAndVariableEncoding = mb_str_split('abcdef', 1, doFoo());
assertType('list<string>', $mbStrSplitConstantStringWithOneSplitLengthAndVariableEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithOneSplitLengthAndVariableEncoding);

$mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndValidEncoding = mb_str_split('abcdef', 999, 'UTF-8');
assertType("array{'abcdef'}", $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndValidEncoding);

$mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndInvalidEncoding = mb_str_split('abcdef', 999, 'FAKE');
assertType('false', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndInvalidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndInvalidEncoding);

$mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndVariableEncoding = mb_str_split('abcdef', 999, doFoo());
assertType('list<string>', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndVariableEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndVariableEncoding);

$mbStrSplitConstantStringWithFailureSplitLengthAndValidEncoding = mb_str_split('abcdef', 0, 'UTF-8');
assertType('false', $mbStrSplitConstantStringWithFailureSplitLengthAndValidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLengthAndValidEncoding);

$mbStrSplitConstantStringWithFailureSplitLengthAndInvalidEncoding = mb_str_split('abcdef', 0, 'FAKE');
assertType('false', $mbStrSplitConstantStringWithFailureSplitLengthAndInvalidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLengthAndInvalidEncoding);

$mbStrSplitConstantStringWithFailureSplitLengthAndVariableEncoding = mb_str_split('abcdef', 0, doFoo());
assertType('false', $mbStrSplitConstantStringWithFailureSplitLengthAndVariableEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLengthAndVariableEncoding);

$mbStrSplitConstantStringWithInvalidSplitLengthTypeAndValidEncoding = mb_str_split('abcdef', [], 'UTF-8');
assertType('list<string>', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndValidEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndValidEncoding);

$mbStrSplitConstantStringWithInvalidSplitLengthTypeAndInvalidEncoding = mb_str_split('abcdef', [], 'FAKE');
assertType('false', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndInvalidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndInvalidEncoding);

$mbStrSplitConstantStringWithInvalidSplitLengthTypeAndVariableEncoding = mb_str_split('abcdef', [], doFoo());
assertType('list<string>', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndVariableEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndVariableEncoding);

$mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndValidEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', 1, 'UTF-8');
assertType("array{'a', 'b', 'c', 'd', 'e', 'f'}|array{'g', 'h', 'i', 'j', 'k', 'l'}", $mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndValidEncoding);

$mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndInvalidEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', 1, 'FAKE');
assertType('false', $mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndInvalidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndInvalidEncoding);

$mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndVariableEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', 1, doFoo());
assertType('list<string>', $mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndVariableEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithVariableStringAndConstantSplitLengthAndVariableEncoding);

$mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndValidEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', doFoo() ? 1 : 2, 'UTF-8');
assertType('list<string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndValidEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndValidEncoding);

$mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndInvalidEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', doFoo() ? 1 : 2, 'FAKE');
assertType('false', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndInvalidEncoding);
assertType('*NEVER*', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndInvalidEncoding);

$mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndVariableEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', doFoo() ? 1 : 2, doFoo());
assertType('list<string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndVariableEncoding);
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndVariableEncoding);
}

/**
* @param non-empty-string $nonEmptyString
* @param non-falsy-string $nonFalsyString
*/
function doFoo(
string $string,
string $nonEmptyString,
string $nonFalsyString,
string $lowercaseString,
string $uppercaseString,
int $integer,
):void {
assertType('list<string>', mb_str_split($string));
assertType('non-empty-list<non-empty-string>', mb_str_split($nonEmptyString));
assertType('non-empty-list<non-empty-string>', mb_str_split($nonFalsyString));

assertType('list<string>', mb_str_split($string, $integer));
assertType('non-empty-list<non-empty-string>', mb_str_split($nonEmptyString, $integer));
assertType('non-empty-list<non-empty-string>', mb_str_split($nonFalsyString, $integer));
}
}
Loading
Loading