Skip to content

Commit 0e64b28

Browse files
Improve StrSplit returnType
1 parent 5878035 commit 0e64b28

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
@@ -24,8 +24,3 @@ parameters:
2424
message: "#^Strict comparison using \\=\\=\\= between list\\<non\\-falsy\\-string\\> and false will always evaluate to false\\.$#"
2525
count: 1
2626
path: ../src/Type/Php/StrSplitFunctionReturnTypeExtension.php
27-
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

src/Php/PhpVersion.php

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

265-
public function strSplitReturnsEmptyArray(): bool
265+
public function strSplitReturnsEmptyArrayOnEmptyString(): bool
266266
{
267267
return $this->versionId >= 80200;
268268
}

src/Type/Php/StrSplitFunctionReturnTypeExtension.php

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@
1010
use PHPStan\ShouldNotHappenException;
1111
use PHPStan\TrinaryLogic;
1212
use PHPStan\Type\Accessory\AccessoryArrayListType;
13+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1314
use PHPStan\Type\Accessory\NonEmptyArrayType;
1415
use PHPStan\Type\ArrayType;
1516
use PHPStan\Type\Constant\ConstantArrayType;
1617
use PHPStan\Type\Constant\ConstantBooleanType;
1718
use PHPStan\Type\Constant\ConstantIntegerType;
1819
use PHPStan\Type\Constant\ConstantStringType;
1920
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
21+
use PHPStan\Type\IntegerRangeType;
2022
use PHPStan\Type\IntegerType;
23+
use PHPStan\Type\NeverType;
2124
use PHPStan\Type\StringType;
2225
use PHPStan\Type\Type;
2326
use PHPStan\Type\TypeCombinator;
@@ -53,14 +56,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
5356

5457
if (count($functionCall->getArgs()) >= 2) {
5558
$splitLengthType = $scope->getType($functionCall->getArgs()[1]->value);
56-
if ($splitLengthType instanceof ConstantIntegerType) {
57-
$splitLength = $splitLengthType->getValue();
58-
if ($splitLength < 1) {
59-
return new ConstantBooleanType(false);
60-
}
61-
}
6259
} else {
63-
$splitLength = 1;
60+
$splitLengthType = new ConstantIntegerType(1);
61+
}
62+
63+
if ($splitLengthType instanceof ConstantIntegerType) {
64+
$splitLength = $splitLengthType->getValue();
65+
if ($splitLength < 1) {
66+
return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false);
67+
}
6468
}
6569

6670
$encoding = null;
@@ -69,47 +73,70 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6973
$strings = $scope->getType($functionCall->getArgs()[2]->value)->getConstantStrings();
7074
$values = array_unique(array_map(static fn (ConstantStringType $encoding): string => $encoding->getValue(), $strings));
7175

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

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

102-
$results[] = self::createConstantArrayFrom($items, $scope);
110+
return TypeCombinator::union(...$results);
103111
}
112+
}
104113

105-
return TypeCombinator::union(...$results);
114+
$isInputNonEmptyString = $stringType->isNonEmptyString()->yes();
115+
116+
if ($isInputNonEmptyString || $this->phpVersion->strSplitReturnsEmptyArrayOnEmptyString()) {
117+
$returnValueType = TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType());
118+
} else {
119+
$returnValueType = new StringType();
106120
}
107121

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

110-
return $encoding === null && !$this->phpVersion->strSplitReturnsEmptyArray()
111-
? TypeCombinator::intersect($returnType, new NonEmptyArrayType())
112-
: $returnType;
139+
return $returnType;
113140
}
114141

115142
/**

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ private static function findTestFiles(): iterable
5959
} else {
6060
yield __DIR__ . '/data/str-split-php74.php';
6161
}
62-
if (PHP_VERSION_ID >= 80000) {
62+
if (PHP_VERSION_ID >= 80200) {
63+
yield __DIR__ . '/data/mb-str-split-php82.php';
64+
} elseif (PHP_VERSION_ID >= 80000) {
6365
yield __DIR__ . '/data/mb-str-split-php80.php';
6466
} elseif (PHP_VERSION_ID >= 74000) {
6567
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)