Skip to content

Commit 12956d2

Browse files
Improve StrSplit returnType
1 parent 6e87a98 commit 12956d2

11 files changed

+287
-69
lines changed

build/baseline-8.0.neon

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,6 @@ parameters:
2525
count: 1
2626
path: ../src/Type/Php/StrSplitFunctionReturnTypeExtension.php
2727

28-
-
29-
message: "#^Strict comparison using \\=\\=\\= between list<string> and false will always evaluate to false\\.$#"
30-
count: 1
31-
path: ../src/Type/Php/StrSplitFunctionReturnTypeExtension.php
32-
3328
-
3429
message: "#^Call to function is_bool\\(\\) with string will always evaluate to false\\.$#"
3530
count: 1

src/Php/PhpVersion.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ public function deprecatesDynamicProperties(): bool
236236
return $this->versionId >= 80200;
237237
}
238238

239-
public function strSplitReturnsEmptyArray(): bool
239+
public function strSplitReturnsEmptyArrayOnEmptyString(): bool
240240
{
241241
return $this->versionId >= 80200;
242242
}

src/Type/Php/StrSplitFunctionReturnTypeExtension.php

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
use PHPStan\ShouldNotHappenException;
1010
use PHPStan\TrinaryLogic;
1111
use PHPStan\Type\Accessory\AccessoryArrayListType;
12+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1213
use PHPStan\Type\Accessory\NonEmptyArrayType;
1314
use PHPStan\Type\ArrayType;
1415
use PHPStan\Type\Constant\ConstantArrayType;
1516
use PHPStan\Type\Constant\ConstantBooleanType;
1617
use PHPStan\Type\Constant\ConstantIntegerType;
1718
use PHPStan\Type\Constant\ConstantStringType;
1819
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
20+
use PHPStan\Type\IntegerRangeType;
1921
use PHPStan\Type\IntegerType;
22+
use PHPStan\Type\NeverType;
2023
use PHPStan\Type\StringType;
2124
use PHPStan\Type\Type;
2225
use PHPStan\Type\TypeCombinator;
@@ -51,14 +54,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5154

5255
if (count($functionCall->getArgs()) >= 2) {
5356
$splitLengthType = $scope->getType($functionCall->getArgs()[1]->value);
54-
if ($splitLengthType instanceof ConstantIntegerType) {
55-
$splitLength = $splitLengthType->getValue();
56-
if ($splitLength < 1) {
57-
return new ConstantBooleanType(false);
58-
}
59-
}
6057
} else {
61-
$splitLength = 1;
58+
$splitLengthType = new ConstantIntegerType(1);
59+
}
60+
61+
if ($splitLengthType instanceof ConstantIntegerType) {
62+
$splitLength = $splitLengthType->getValue();
63+
if ($splitLength < 1) {
64+
return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false);
65+
}
6266
}
6367

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

70-
if (count($values) !== 1) {
71-
return null;
72-
}
73-
74-
$encoding = $values[0];
75-
if (!$this->isSupportedEncoding($encoding)) {
76-
return new ConstantBooleanType(false);
74+
if (count($values) === 1) {
75+
$encoding = $values[0];
76+
if (!$this->isSupportedEncoding($encoding)) {
77+
return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false);
78+
}
7779
}
7880
} else {
7981
$encoding = mb_internal_encoding();
8082
}
8183
}
8284

83-
if (!isset($splitLength)) {
84-
return null;
85-
}
86-
8785
$stringType = $scope->getType($functionCall->getArgs()[0]->value);
88-
89-
$constantStrings = $stringType->getConstantStrings();
90-
if (count($constantStrings) > 0) {
91-
$results = [];
92-
foreach ($constantStrings as $constantString) {
93-
$items = $encoding === null
94-
? str_split($constantString->getValue(), $splitLength)
95-
: @mb_str_split($constantString->getValue(), $splitLength, $encoding);
96-
if ($items === false) {
97-
throw new ShouldNotHappenException();
86+
if (
87+
isset($splitLength)
88+
&& ($functionReflection->getName() === 'str_split' || $encoding !== null)
89+
) {
90+
$constantStrings = $stringType->getConstantStrings();
91+
if (count($constantStrings) > 0) {
92+
$results = [];
93+
foreach ($constantStrings as $constantString) {
94+
$value = $constantString->getValue();
95+
96+
if ($encoding === null && $value === '') {
97+
// Simulate the str_split call with the analysed PHP Version instead of the runtime one.
98+
$items = $this->phpVersion->strSplitReturnsEmptyArrayOnEmptyString() ? [] : [''];
99+
} else {
100+
$items = $encoding === null
101+
? str_split($value, $splitLength)
102+
: @mb_str_split($value, $splitLength, $encoding);
103+
}
104+
105+
$results[] = self::createConstantArrayFrom($items, $scope);
98106
}
99107

100-
$results[] = self::createConstantArrayFrom($items, $scope);
108+
return TypeCombinator::union(...$results);
101109
}
110+
}
102111

103-
return TypeCombinator::union(...$results);
112+
$isInputNonEmptyString = $stringType->isNonEmptyString()->yes();
113+
114+
if ($isInputNonEmptyString || $this->phpVersion->strSplitReturnsEmptyArrayOnEmptyString()) {
115+
$returnValueType = TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType());
116+
} else {
117+
$returnValueType = new StringType();
104118
}
105119

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

108-
return $encoding === null && !$this->phpVersion->strSplitReturnsEmptyArray()
109-
? TypeCombinator::intersect($returnType, new NonEmptyArrayType())
110-
: $returnType;
137+
return $returnType;
111138
}
112139

113140
/**

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ private static function findTestFiles(): iterable
5656
} else {
5757
yield __DIR__ . '/data/str-split-php74.php';
5858
}
59-
if (PHP_VERSION_ID >= 80000) {
59+
if (PHP_VERSION_ID >= 80200) {
60+
yield __DIR__ . '/data/mb-str-split-php82.php';
61+
} elseif (PHP_VERSION_ID >= 80000) {
6062
yield __DIR__ . '/data/mb-str-split-php80.php';
6163
} elseif (PHP_VERSION_ID >= 74000) {
6264
yield __DIR__ . '/data/mb-str-split-php74.php';

tests/PHPStan/Analyser/data/mb-str-split-php80.php

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,69 +26,90 @@ public function legacyTest(): void
2626
assertType('array{\'abcdef\'}', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLength);
2727

2828
$mbStrSplitConstantStringWithFailureSplitLength = mb_str_split('abcdef', 0);
29-
assertType('false', $mbStrSplitConstantStringWithFailureSplitLength);
29+
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLength);
3030

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

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

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

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

4343
$mbStrSplitConstantStringWithOneSplitLengthAndInvalidEncoding = mb_str_split('abcdef', 1, 'FAKE');
44-
assertType('false', $mbStrSplitConstantStringWithOneSplitLengthAndInvalidEncoding);
44+
assertType('*NEVER*', $mbStrSplitConstantStringWithOneSplitLengthAndInvalidEncoding);
4545

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

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

5252
$mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndInvalidEncoding = mb_str_split('abcdef', 999, 'FAKE');
53-
assertType('false', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndInvalidEncoding);
53+
assertType('*NEVER*', $mbStrSplitConstantStringWithGreaterSplitLengthThanStringLengthAndInvalidEncoding);
5454

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

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

6161
$mbStrSplitConstantStringWithFailureSplitLengthAndInvalidEncoding = mb_str_split('abcdef', 0, 'FAKE');
62-
assertType('false', $mbStrSplitConstantStringWithFailureSplitLengthAndInvalidEncoding);
62+
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLengthAndInvalidEncoding);
6363

6464
$mbStrSplitConstantStringWithFailureSplitLengthAndVariableEncoding = mb_str_split('abcdef', 0, doFoo());
65-
assertType('false', $mbStrSplitConstantStringWithFailureSplitLengthAndVariableEncoding);
65+
assertType('*NEVER*', $mbStrSplitConstantStringWithFailureSplitLengthAndVariableEncoding);
6666

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

7070
$mbStrSplitConstantStringWithInvalidSplitLengthTypeAndInvalidEncoding = mb_str_split('abcdef', [], 'FAKE');
71-
assertType('false', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndInvalidEncoding);
71+
assertType('*NEVER*', $mbStrSplitConstantStringWithInvalidSplitLengthTypeAndInvalidEncoding);
7272

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

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

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

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

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

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

9191
$mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndVariableEncoding = mb_str_split(doFoo() ? 'abcdef' : 'ghijkl', doFoo() ? 1 : 2, doFoo());
92-
assertType('list<string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndVariableEncoding);
92+
assertType('non-empty-list<non-empty-string>', $mbStrSplitConstantStringWithVariableStringAndVariableSplitLengthAndVariableEncoding);
93+
}
94+
95+
/**
96+
* @param non-empty-string $nonEmptyString
97+
* @param non-falsy-string $nonFalsyString
98+
*/
99+
function doFoo(
100+
string $string,
101+
string $nonEmptyString,
102+
string $nonFalsyString,
103+
string $lowercaseString,
104+
string $uppercaseString,
105+
int $integer,
106+
):void {
107+
assertType('list<string>', mb_str_split($string));
108+
assertType('non-empty-list<non-empty-string>', mb_str_split($nonEmptyString));
109+
assertType('non-empty-list<non-empty-string>', mb_str_split($nonFalsyString));
110+
111+
assertType('list<string>', mb_str_split($string, $integer));
112+
assertType('non-empty-list<non-empty-string>', mb_str_split($nonEmptyString, $integer));
113+
assertType('non-empty-list<non-empty-string>', mb_str_split($nonFalsyString, $integer));
93114
}
94115
}

0 commit comments

Comments
 (0)