Skip to content

Commit ee0047a

Browse files
authored
Narrow explode() when the delimiter is a known substring (#5959)
1 parent 709654e commit ee0047a

3 files changed

Lines changed: 285 additions & 7 deletions

File tree

‎src/Type/Php/ExplodeFunctionDynamicReturnTypeExtension.php‎

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace PHPStan\Type\Php;
44

5+
use PhpParser\Node\Arg;
56
use PhpParser\Node\Expr\FuncCall;
7+
use PhpParser\Node\Name\FullyQualified;
68
use PHPStan\Analyser\Scope;
79
use PHPStan\DependencyInjection\AutowiredService;
810
use PHPStan\Php\PhpVersion;
@@ -12,7 +14,9 @@
1214
use PHPStan\Type\Accessory\AccessoryUppercaseStringType;
1315
use PHPStan\Type\Accessory\NonEmptyArrayType;
1416
use PHPStan\Type\ArrayType;
17+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
1518
use PHPStan\Type\Constant\ConstantBooleanType;
19+
use PHPStan\Type\Constant\ConstantIntegerType;
1620
use PHPStan\Type\Constant\ConstantStringType;
1721
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
1822
use PHPStan\Type\IntegerRangeType;
@@ -25,11 +29,22 @@
2529
use PHPStan\Type\TypeUtils;
2630
use PHPStan\Type\UnionType;
2731
use function count;
32+
use function max;
2833

2934
#[AutowiredService]
3035
final class ExplodeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
3136
{
3237

38+
/**
39+
* Functions whose bool result proves the delimiter is a literal substring
40+
* of the string, so the result of exploding it has at least two elements.
41+
*/
42+
private const SUBSTRING_PROVING_FUNCTIONS = [
43+
'str_contains',
44+
'str_starts_with',
45+
'str_ends_with',
46+
];
47+
3348
public function __construct(private PhpVersion $phpVersion)
3449
{
3550
}
@@ -74,12 +89,16 @@ public function getTypeFromFunctionCall(
7489
$returnValueType = new StringType();
7590
}
7691

77-
$returnType = new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $returnValueType), new AccessoryArrayListType()]);
78-
if (
79-
!isset($args[2])
80-
|| IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($scope->getType($args[2]->value))->yes()
81-
) {
82-
$returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType());
92+
$limitType = isset($args[2]) ? $scope->getType($args[2]->value) : null;
93+
94+
if ($this->isDelimiterGuaranteedPresent($args, $scope) && ($limitType === null || IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($limitType)->yes())) {
95+
$returnType = $this->createGuaranteedSplitType($returnValueType, $limitType);
96+
} else {
97+
$returnType = new IntersectionType([new ArrayType(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $returnValueType), new AccessoryArrayListType()]);
98+
99+
if ($limitType === null || IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($limitType)->yes()) {
100+
$returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType());
101+
}
83102
}
84103

85104
if (!$this->phpVersion->throwsValueErrorForInternalFunctions() && $isEmptyString->maybe()) {
@@ -93,4 +112,76 @@ public function getTypeFromFunctionCall(
93112
return $returnType;
94113
}
95114

115+
/**
116+
* @param Arg[] $args
117+
*/
118+
private function isDelimiterGuaranteedPresent(array $args, Scope $scope): bool
119+
{
120+
$delimiter = $args[0]->value;
121+
$haystack = $args[1]->value;
122+
123+
foreach (self::SUBSTRING_PROVING_FUNCTIONS as $functionName) {
124+
$condition = new FuncCall(new FullyQualified($functionName), [
125+
new Arg($haystack),
126+
new Arg($delimiter),
127+
]);
128+
129+
if ($scope->getType($condition)->isTrue()->yes()) {
130+
return true;
131+
}
132+
}
133+
134+
return false;
135+
}
136+
137+
/**
138+
* The delimiter is guaranteed to occur in the string, so the result has at
139+
* least two elements. With a positive $limit the result has at most $limit
140+
* elements.
141+
*/
142+
private function createGuaranteedSplitType(Type $valueType, ?Type $limitType): Type
143+
{
144+
$builder = ConstantArrayTypeBuilder::createEmpty();
145+
$builder->setOffsetValueType(new ConstantIntegerType(0), $valueType);
146+
$builder->setOffsetValueType(new ConstantIntegerType(1), $valueType);
147+
148+
$maxElements = $this->getMaximumElementCount($limitType);
149+
if ($maxElements === null) {
150+
$builder->makeUnsealed(IntegerRangeType::createAllGreaterThanOrEqualTo(0), $valueType);
151+
} else {
152+
for ($i = 2; $i < $maxElements; $i++) {
153+
$builder->setOffsetValueType(new ConstantIntegerType($i), $valueType, true);
154+
}
155+
}
156+
157+
return $builder->getArray();
158+
}
159+
160+
/**
161+
* The greatest number of elements the split can produce, or null when the
162+
* limit is unbounded or larger than the constant array builder handles.
163+
*/
164+
private function getMaximumElementCount(?Type $limitType): ?int
165+
{
166+
if ($limitType === null) {
167+
return null;
168+
}
169+
170+
$max = null;
171+
foreach ($limitType->getFiniteTypes() as $finiteType) {
172+
if (!$finiteType instanceof ConstantIntegerType) {
173+
return null;
174+
}
175+
176+
$value = $finiteType->getValue();
177+
if ($value > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
178+
return null;
179+
}
180+
181+
$max = $max === null ? $value : max($max, $value);
182+
}
183+
184+
return $max;
185+
}
186+
96187
}

‎src/Type/Php/StrContainingTypeSpecifyingExtension.php‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use PHPStan\Type\StringType;
2424
use function array_key_exists;
2525
use function count;
26+
use function in_array;
2627
use function strtolower;
2728

2829
#[AutowiredService]
@@ -46,6 +47,16 @@ final class StrContainingTypeSpecifyingExtension implements FunctionTypeSpecifyi
4647
'mb_strstr' => [0, 1],
4748
];
4849

50+
/**
51+
* Functions whose bool result proves the needle is a literal substring of
52+
* the haystack, so the call value itself is remembered as `true`.
53+
*/
54+
private const SUBSTRING_PROVING_FUNCTIONS = [
55+
'str_contains',
56+
'str_starts_with',
57+
'str_ends_with',
58+
];
59+
4960
private TypeSpecifier $typeSpecifier;
5061

5162
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
@@ -84,7 +95,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
8495
$accessories[] = new AccessoryNonEmptyStringType();
8596
}
8697

87-
return $this->typeSpecifier->create(
98+
$specifiedTypes = $this->typeSpecifier->create(
8899
$args[$hackstackArg]->value,
89100
new IntersectionType($accessories),
90101
$context,
@@ -98,6 +109,14 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n
98109
new Arg($args[$needleArg]->value),
99110
]),
100111
));
112+
113+
if (in_array($lowerFunctionName, self::SUBSTRING_PROVING_FUNCTIONS, true)) {
114+
$specifiedTypes = $specifiedTypes
115+
->unionWith($this->typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $node, $scope))
116+
->setRootExpr($specifiedTypes->getRootExpr());
117+
}
118+
119+
return $specifiedTypes;
101120
}
102121
}
103122

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Bug14651;
4+
5+
use function PHPStan\Testing\assertType;
6+
use function str_contains;
7+
use function str_ends_with;
8+
use function str_starts_with;
9+
10+
/**
11+
* @return list<string>
12+
*/
13+
function readLines(string $buffer): array
14+
{
15+
$result = [];
16+
17+
while (str_contains($buffer, "\n")) {
18+
assertType('array{string, string}', explode("\n", $buffer, 2));
19+
[$first, $rest] = explode("\n", $buffer, 2);
20+
$result[] = $first;
21+
$buffer = $rest;
22+
}
23+
24+
return $result;
25+
}
26+
27+
function inIf(string $s): void
28+
{
29+
if (str_contains($s, "\n")) {
30+
assertType('non-falsy-string', $s);
31+
assertType('array{string, string}', explode("\n", $s, 2));
32+
assertType('array{string, string, ...<string>}', explode("\n", $s));
33+
assertType('array{0: string, 1: string, 2?: string}', explode("\n", $s, 3));
34+
} else {
35+
assertType('string', $s);
36+
}
37+
38+
assertType('string', $s);
39+
}
40+
41+
function startsWith(string $s): void
42+
{
43+
if (str_starts_with($s, "\n")) {
44+
assertType('non-falsy-string', $s);
45+
assertType('array{string, string}', explode("\n", $s, 2));
46+
} else {
47+
assertType('string', $s);
48+
}
49+
50+
assertType('string', $s);
51+
}
52+
53+
function endsWith(string $s): void
54+
{
55+
if (str_ends_with($s, "\n")) {
56+
assertType('non-falsy-string', $s);
57+
assertType('array{string, string}', explode("\n", $s, 2));
58+
} else {
59+
assertType('string', $s);
60+
}
61+
62+
assertType('string', $s);
63+
}
64+
65+
/**
66+
* @param non-empty-string $needle
67+
*/
68+
function variableNeedle(string $s, string $needle): void
69+
{
70+
if (str_contains($s, $needle)) {
71+
assertType('non-empty-string', $s);
72+
assertType('array{string, string}', explode($needle, $s, 2));
73+
} else {
74+
assertType('string', $s);
75+
}
76+
77+
assertType('string', $s);
78+
}
79+
80+
function notGuaranteed(string $s): void
81+
{
82+
// no guard at all
83+
assertType('non-empty-list<string>', explode("\n", $s, 2));
84+
85+
if (str_contains($s, "\n")) {
86+
assertType('non-falsy-string', $s);
87+
// different delimiter than the proven substring
88+
assertType('non-empty-list<string>', explode(",", $s, 2));
89+
// limit 1 always yields a single element
90+
assertType('non-empty-list<string>', explode("\n", $s, 1));
91+
// negative limit may drop the guaranteed parts
92+
assertType('list<string>', explode("\n", $s, -1));
93+
} else {
94+
assertType('string', $s);
95+
}
96+
97+
assertType('string', $s);
98+
}
99+
100+
function unknownLimit(string $s, int $limit): void
101+
{
102+
// a limit that is not proven to be >= 2 falls back regardless of the guard
103+
assertType('list<string>', explode("\n", $s, $limit));
104+
105+
if (str_contains($s, "\n")) {
106+
assertType('non-falsy-string', $s);
107+
assertType('list<string>', explode("\n", $s, $limit));
108+
} else {
109+
assertType('string', $s);
110+
}
111+
112+
assertType('string', $s);
113+
}
114+
115+
/**
116+
* @param int<2, 4> $bounded
117+
* @param int<5, max> $unbounded
118+
* @param int<-2, 3> $negativeAndPositive
119+
* @param int<-5, -1> $alwaysNegative
120+
* @param int<min, -1> $unboundedNegative
121+
*/
122+
function rangeLimit(string $s, int $bounded, int $unbounded, int $negativeAndPositive, int $alwaysNegative, int $unboundedNegative): void
123+
{
124+
if (str_contains($s, "\n")) {
125+
assertType('non-falsy-string', $s);
126+
// a bounded range yields optional keys up to its maximum
127+
assertType('array{0: string, 1: string, 2?: string, 3?: string}', explode("\n", $s, $bounded));
128+
// an unbounded range stays a >= 2 list
129+
assertType('array{string, string, ...<string>}', explode("\n", $s, $unbounded));
130+
// ranges that may be negative are not proven to yield two elements
131+
assertType('list<string>', explode("\n", $s, $negativeAndPositive));
132+
assertType('list<string>', explode("\n", $s, $alwaysNegative));
133+
assertType('list<string>', explode("\n", $s, $unboundedNegative));
134+
} else {
135+
assertType('string', $s);
136+
}
137+
138+
assertType('string', $s);
139+
}
140+
141+
function zeroAndNegativeLimit(string $s): void
142+
{
143+
if (str_contains($s, "\n")) {
144+
assertType('non-falsy-string', $s);
145+
// limit 0 is treated as 1, so a single non-empty element
146+
assertType('non-empty-list<string>', explode("\n", $s, 0));
147+
// a negative limit drops trailing components and may empty the result
148+
assertType('list<string>', explode("\n", $s, -1));
149+
assertType('list<string>', explode("\n", $s, -2));
150+
} else {
151+
assertType('string', $s);
152+
}
153+
154+
assertType('string', $s);
155+
}
156+
157+
function largeConstantLimit(string $s): void
158+
{
159+
if (str_contains($s, "\n")) {
160+
assertType('non-falsy-string', $s);
161+
// a limit larger than the array builder handles stays a >= 2 list
162+
assertType('array{string, string, ...<string>}', explode("\n", $s, 100000));
163+
} else {
164+
assertType('string', $s);
165+
}
166+
167+
assertType('string', $s);
168+
}

0 commit comments

Comments
 (0)