Skip to content

Commit e15206b

Browse files
Improve ReplaceFunctionsDynamicReturnTypeExtension
1 parent 7870d2d commit e15206b

File tree

6 files changed

+138
-63
lines changed

6 files changed

+138
-63
lines changed

src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php

Lines changed: 81 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
use PHPStan\DependencyInjection\AutowiredService;
88
use PHPStan\Reflection\FunctionReflection;
99
use PHPStan\Reflection\ParametersAcceptorSelector;
10+
use PHPStan\Type\Accessory\AccessoryArrayListType;
1011
use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
1112
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1213
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
1314
use PHPStan\Type\Accessory\AccessoryUppercaseStringType;
15+
use PHPStan\Type\Accessory\NonEmptyArrayType;
16+
use PHPStan\Type\ArrayType;
1417
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
1518
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
1619
use PHPStan\Type\IntersectionType;
@@ -86,6 +89,7 @@ private function getPreliminarilyResolvedTypeFromFunctionCall(
8689
return TypeUtils::toBenevolentUnion($defaultReturnType);
8790
}
8891

92+
$replaceArgumentType = null;
8993
if (array_key_exists($functionReflection->getName(), self::FUNCTIONS_REPLACE_POSITION)) {
9094
$replaceArgumentPosition = self::FUNCTIONS_REPLACE_POSITION[$functionReflection->getName()];
9195

@@ -94,68 +98,96 @@ private function getPreliminarilyResolvedTypeFromFunctionCall(
9498
if ($replaceArgumentType->isArray()->yes()) {
9599
$replaceArgumentType = $replaceArgumentType->getIterableValueType();
96100
}
101+
}
102+
}
97103

98-
$accessories = [];
99-
if ($subjectArgumentType->isNonFalsyString()->yes() && $replaceArgumentType->isNonFalsyString()->yes()) {
100-
$accessories[] = new AccessoryNonFalsyStringType();
101-
} elseif ($subjectArgumentType->isNonEmptyString()->yes() && $replaceArgumentType->isNonEmptyString()->yes()) {
102-
$accessories[] = new AccessoryNonEmptyStringType();
103-
}
104+
$result = [];
104105

105-
if ($subjectArgumentType->isLowercaseString()->yes() && $replaceArgumentType->isLowercaseString()->yes()) {
106-
$accessories[] = new AccessoryLowercaseStringType();
107-
}
106+
if ($subjectArgumentType->isString()->yes()) {
107+
$stringArgumentType = $subjectArgumentType;
108+
} else {
109+
$stringArgumentType = TypeCombinator::intersect(new StringType(), $subjectArgumentType);
110+
}
111+
if ($stringArgumentType->isString()->yes()) {
112+
$result[] = $this->getReplaceType($stringArgumentType, $replaceArgumentType);
113+
}
108114

109-
if ($subjectArgumentType->isUppercaseString()->yes() && $replaceArgumentType->isUppercaseString()->yes()) {
110-
$accessories[] = new AccessoryUppercaseStringType();
115+
if ($subjectArgumentType->isArray()->yes()) {
116+
$arrayArgumentType = $subjectArgumentType;
117+
} else {
118+
$arrayArgumentType = TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), $subjectArgumentType);
119+
}
120+
if ($arrayArgumentType->isArray()->yes()) {
121+
$keyShouldBeOptional = in_array(
122+
$functionReflection->getName(),
123+
['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'],
124+
true,
125+
);
126+
127+
$constantArrays = $arrayArgumentType->getConstantArrays();
128+
if ($constantArrays !== []) {
129+
foreach ($constantArrays as $constantArray) {
130+
$valueTypes = $constantArray->getValueTypes();
131+
132+
$builder = ConstantArrayTypeBuilder::createEmpty();
133+
foreach ($constantArray->getKeyTypes() as $index => $keyType) {
134+
$builder->setOffsetValueType(
135+
$keyType,
136+
$this->getReplaceType($valueTypes[$index], $replaceArgumentType),
137+
$keyShouldBeOptional || $constantArray->isOptionalKey($index),
138+
);
139+
}
140+
$result[] = $builder->getArray();
111141
}
112-
113-
if (count($accessories) > 0) {
114-
$accessories[] = new StringType();
115-
return new IntersectionType($accessories);
142+
} else {
143+
$newArrayType = new ArrayType(
144+
$arrayArgumentType->getIterableKeyType(),
145+
$this->getReplaceType($arrayArgumentType->getIterableValueType(), $replaceArgumentType),
146+
);
147+
if ($arrayArgumentType->isList()->yes()) {
148+
$newArrayType = TypeCombinator::intersect($newArrayType, new AccessoryArrayListType());
116149
}
150+
if ($arrayArgumentType->isIterableAtLeastOnce()->yes()) {
151+
$newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType());
152+
}
153+
154+
$result[] = $newArrayType;
117155
}
118156
}
119157

120-
$isStringSuperType = $subjectArgumentType->isString();
121-
$isArraySuperType = $subjectArgumentType->isArray();
122-
$compareSuperTypes = $isStringSuperType->compareTo($isArraySuperType);
123-
if ($compareSuperTypes === $isStringSuperType) {
158+
return TypeCombinator::union(...$result);
159+
}
160+
161+
private function getReplaceType(
162+
Type $subjectArgumentType,
163+
?Type $replaceArgumentType,
164+
): Type
165+
{
166+
if ($replaceArgumentType === null) {
124167
return new StringType();
125-
} elseif ($compareSuperTypes === $isArraySuperType) {
126-
$subjectArrays = $subjectArgumentType->getArrays();
127-
if (count($subjectArrays) > 0) {
128-
$result = [];
129-
foreach ($subjectArrays as $arrayType) {
130-
$constantArrays = $arrayType->getConstantArrays();
131-
132-
if (
133-
$constantArrays !== []
134-
&& in_array($functionReflection->getName(), ['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'], true)
135-
) {
136-
foreach ($constantArrays as $constantArray) {
137-
$generalizedArray = $constantArray->generalizeValues();
138-
139-
$builder = ConstantArrayTypeBuilder::createEmpty();
140-
// turn all keys optional
141-
foreach ($constantArray->getKeyTypes() as $keyType) {
142-
$builder->setOffsetValueType($keyType, $generalizedArray->getOffsetValueType($keyType), true);
143-
}
144-
$result[] = $builder->getArray();
145-
}
146-
147-
continue;
148-
}
168+
}
149169

150-
$result[] = $arrayType->generalizeValues();
151-
}
170+
$accessories = [];
171+
if ($subjectArgumentType->isNonFalsyString()->yes() && $replaceArgumentType->isNonFalsyString()->yes()) {
172+
$accessories[] = new AccessoryNonFalsyStringType();
173+
} elseif ($subjectArgumentType->isNonEmptyString()->yes() && $replaceArgumentType->isNonEmptyString()->yes()) {
174+
$accessories[] = new AccessoryNonEmptyStringType();
175+
}
152176

153-
return TypeCombinator::union(...$result);
154-
}
155-
return $subjectArgumentType;
177+
if ($subjectArgumentType->isLowercaseString()->yes() && $replaceArgumentType->isLowercaseString()->yes()) {
178+
$accessories[] = new AccessoryLowercaseStringType();
179+
}
180+
181+
if ($subjectArgumentType->isUppercaseString()->yes() && $replaceArgumentType->isUppercaseString()->yes()) {
182+
$accessories[] = new AccessoryUppercaseStringType();
183+
}
184+
185+
if (count($accessories) > 0) {
186+
$accessories[] = new StringType();
187+
return new IntersectionType($accessories);
156188
}
157189

158-
return $defaultReturnType;
190+
return new StringType();
159191
}
160192

161193
private function getSubjectType(

stubs/core.stub

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ function preg_match($pattern, $subject, &$matches = [], int $flags = 0, int $off
227227
* @param string|array<string|int|float> $subject
228228
* @param int $count
229229
* @param-out 0|positive-int $count
230-
* @return ($subject is array ? list<string>|null : string|null)
230+
* @return ($subject is array ? array<string>|null : string|null)
231231
*/
232232
function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, &$count = null, int $flags = 0) {}
233233

@@ -237,7 +237,7 @@ function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, &
237237
* @param string|array<string|int|float> $subject
238238
* @param int $count
239239
* @param-out 0|positive-int $count
240-
* @return ($subject is array ? list<string>|null : string|null)
240+
* @return ($subject is array ? array<string>|null : string|null)
241241
*/
242242
function preg_replace($pattern, $replacement, $subject, int $limit = -1, &$count = null) {}
243243

@@ -256,7 +256,7 @@ function preg_filter($pattern, $replacement, $subject, int $limit = -1, &$count
256256
* @param array<string>|string $replace
257257
* @param array<string>|string $subject
258258
* @param-out int $count
259-
* @return list<string>|string
259+
* @return array<string>|string
260260
*/
261261
function str_replace($search, $replace, $subject, ?int &$count = null) {}
262262

@@ -265,7 +265,7 @@ function str_replace($search, $replace, $subject, ?int &$count = null) {}
265265
* @param array<string>|string $replace
266266
* @param array<string>|string $subject
267267
* @param-out int $count
268-
* @return list<string>|string
268+
* @return array<string>|string
269269
*/
270270
function str_ireplace($search, $replace, $subject, ?int &$count = null) {}
271271

tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7256,31 +7256,31 @@ public static function dataReplaceFunctions(): array
72567256
'$anotherExpectedString',
72577257
],
72587258
[
7259-
'array{a: string, b: string}',
7259+
'array{a: lowercase-string&non-falsy-string, b: lowercase-string&non-falsy-string}',
72607260
'$expectedArray',
72617261
],
72627262
[
72637263
'array{a?: string, b?: string}',
72647264
'$expectedArray2',
72657265
],
72667266
[
7267-
'array{a?: string, b?: string}',
7267+
'array{a?: lowercase-string&non-falsy-string, b?: lowercase-string&non-falsy-string}',
72687268
'$anotherExpectedArray',
72697269
],
72707270
[
7271-
'list<string>|string',
7271+
'array{}|(lowercase-string&non-falsy-string)',
72727272
'$expectedArrayOrString',
72737273
],
72747274
[
7275-
'(list<string>|string)',
7275+
'(array<string>|string)',
72767276
'$expectedBenevolentArrayOrString',
72777277
],
72787278
[
7279-
'list<string>|string|null',
7279+
'array{}|string|null',
72807280
'$expectedArrayOrString2',
72817281
],
72827282
[
7283-
'list<string>|string|null',
7283+
'array{}|(lowercase-string&non-falsy-string)|null',
72847284
'$anotherExpectedArrayOrString',
72857285
],
72867286
[

tests/PHPStan/Analyser/nsrt/bug-11547.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function validPatternWithEmptyResult(string $s, array $arr) {
4545
assertType('string|null', $r);
4646

4747
$r = preg_replace('/(\D+)*[12]/', 'x', $arr);
48-
assertType('array', $r);
48+
assertType('array<string>', $r);
4949
}
5050

5151

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug9870;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class HelloWorld
8+
{
9+
/**
10+
* @param non-empty-string|list<non-empty-string> $date
11+
*/
12+
public function sayHello($date): void
13+
{
14+
if (is_string($date)) {
15+
assertType('non-empty-string', str_replace('-', '/', $date));
16+
} else {
17+
assertType('list<non-empty-string>', str_replace('-', '/', $date));
18+
}
19+
assertType('list<non-empty-string>|non-empty-string', str_replace('-', '/', $date));
20+
}
21+
22+
/**
23+
* @param string|array<string> $stringOrArray
24+
* @param non-empty-string|array<string> $nonEmptyStringOrArray
25+
* @param string|array<non-empty-string> $stringOrArrayNonEmptyString
26+
* @param string|non-empty-array<string> $stringOrNonEmptyArray
27+
* @param string|array<string>|bool|int $wrongParam
28+
*/
29+
public function moreCheck(
30+
$stringOrArray,
31+
$nonEmptyStringOrArray,
32+
$stringOrArrayNonEmptyString,
33+
$stringOrNonEmptyArray,
34+
$wrongParam,
35+
): void {
36+
assertType('array<string>|string', str_replace('-', '/', $stringOrArray));
37+
assertType('array<string>|non-empty-string', str_replace('-', '/', $nonEmptyStringOrArray));
38+
assertType('array<non-empty-string>|string', str_replace('-', '/', $stringOrArrayNonEmptyString));
39+
assertType('non-empty-array<string>|string', str_replace('-', '/', $stringOrNonEmptyArray));
40+
assertType('array<string>|string', str_replace('-', '/', $wrongParam));
41+
assertType('array<string>|string', str_replace('-', '/'));
42+
}
43+
}

tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,15 @@ public function testRule(): void
103103
122,
104104
],
105105
[
106-
'Binary operation "." between array and \'xyz\' results in an error.',
106+
'Binary operation "." between array<string> and \'xyz\' results in an error.',
107107
127,
108108
],
109109
[
110-
'Binary operation "." between list<string>|string and \'xyz\' results in an error.',
110+
'Binary operation "." between array{}|non-falsy-string and \'xyz\' results in an error.',
111111
134,
112112
],
113113
[
114-
'Binary operation "+" between (list<string>|string) and 1 results in an error.',
114+
'Binary operation "+" between (array<string>|string) and 1 results in an error.',
115115
136,
116116
],
117117
[

0 commit comments

Comments
 (0)