diff --git a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php index 01bb1dd29e..3d40d0c5ea 100644 --- a/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReplaceFunctionsDynamicReturnTypeExtension.php @@ -6,10 +6,13 @@ use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\Accessory\NonEmptyArrayType; +use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntersectionType; @@ -84,6 +87,7 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( return TypeUtils::toBenevolentUnion($defaultReturnType); } + $replaceArgumentType = null; if (array_key_exists($functionReflection->getName(), self::FUNCTIONS_REPLACE_POSITION)) { $replaceArgumentPosition = self::FUNCTIONS_REPLACE_POSITION[$functionReflection->getName()]; @@ -92,68 +96,96 @@ private function getPreliminarilyResolvedTypeFromFunctionCall( if ($replaceArgumentType->isArray()->yes()) { $replaceArgumentType = $replaceArgumentType->getIterableValueType(); } + } + } - $accessories = []; - if ($subjectArgumentType->isNonFalsyString()->yes() && $replaceArgumentType->isNonFalsyString()->yes()) { - $accessories[] = new AccessoryNonFalsyStringType(); - } elseif ($subjectArgumentType->isNonEmptyString()->yes() && $replaceArgumentType->isNonEmptyString()->yes()) { - $accessories[] = new AccessoryNonEmptyStringType(); - } + $result = []; - if ($subjectArgumentType->isLowercaseString()->yes() && $replaceArgumentType->isLowercaseString()->yes()) { - $accessories[] = new AccessoryLowercaseStringType(); - } + if ($subjectArgumentType->isString()->yes()) { + $stringArgumentType = $subjectArgumentType; + } else { + $stringArgumentType = TypeCombinator::intersect(new StringType(), $subjectArgumentType); + } + if ($stringArgumentType->isString()->yes()) { + $result[] = $this->getReplaceType($stringArgumentType, $replaceArgumentType); + } - if ($subjectArgumentType->isUppercaseString()->yes() && $replaceArgumentType->isUppercaseString()->yes()) { - $accessories[] = new AccessoryUppercaseStringType(); + if ($subjectArgumentType->isArray()->yes()) { + $arrayArgumentType = $subjectArgumentType; + } else { + $arrayArgumentType = TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), $subjectArgumentType); + } + if ($arrayArgumentType->isArray()->yes()) { + $keyShouldBeOptional = in_array( + $functionReflection->getName(), + ['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'], + true, + ); + + $constantArrays = $arrayArgumentType->getConstantArrays(); + if ($constantArrays !== []) { + foreach ($constantArrays as $constantArray) { + $valueTypes = $constantArray->getValueTypes(); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($constantArray->getKeyTypes() as $index => $keyType) { + $builder->setOffsetValueType( + $keyType, + $this->getReplaceType($valueTypes[$index], $replaceArgumentType), + $keyShouldBeOptional || $constantArray->isOptionalKey($index), + ); + } + $result[] = $builder->getArray(); } - - if (count($accessories) > 0) { - $accessories[] = new StringType(); - return new IntersectionType($accessories); + } else { + $newArrayType = new ArrayType( + $arrayArgumentType->getIterableKeyType(), + $this->getReplaceType($arrayArgumentType->getIterableValueType(), $replaceArgumentType), + ); + if ($arrayArgumentType->isList()->yes()) { + $newArrayType = TypeCombinator::intersect($newArrayType, new AccessoryArrayListType()); } + if ($arrayArgumentType->isIterableAtLeastOnce()->yes()) { + $newArrayType = TypeCombinator::intersect($newArrayType, new NonEmptyArrayType()); + } + + $result[] = $newArrayType; } } - $isStringSuperType = $subjectArgumentType->isString(); - $isArraySuperType = $subjectArgumentType->isArray(); - $compareSuperTypes = $isStringSuperType->compareTo($isArraySuperType); - if ($compareSuperTypes === $isStringSuperType) { + return TypeCombinator::union(...$result); + } + + private function getReplaceType( + Type $subjectArgumentType, + ?Type $replaceArgumentType, + ): Type + { + if ($replaceArgumentType === null) { return new StringType(); - } elseif ($compareSuperTypes === $isArraySuperType) { - $subjectArrays = $subjectArgumentType->getArrays(); - if (count($subjectArrays) > 0) { - $result = []; - foreach ($subjectArrays as $arrayType) { - $constantArrays = $arrayType->getConstantArrays(); - - if ( - $constantArrays !== [] - && in_array($functionReflection->getName(), ['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'], true) - ) { - foreach ($constantArrays as $constantArray) { - $generalizedArray = $constantArray->generalizeValues(); - - $builder = ConstantArrayTypeBuilder::createEmpty(); - // turn all keys optional - foreach ($constantArray->getKeyTypes() as $keyType) { - $builder->setOffsetValueType($keyType, $generalizedArray->getOffsetValueType($keyType), true); - } - $result[] = $builder->getArray(); - } - - continue; - } + } - $result[] = $arrayType->generalizeValues(); - } + $accessories = []; + if ($subjectArgumentType->isNonFalsyString()->yes() && $replaceArgumentType->isNonFalsyString()->yes()) { + $accessories[] = new AccessoryNonFalsyStringType(); + } elseif ($subjectArgumentType->isNonEmptyString()->yes() && $replaceArgumentType->isNonEmptyString()->yes()) { + $accessories[] = new AccessoryNonEmptyStringType(); + } - return TypeCombinator::union(...$result); - } - return $subjectArgumentType; + if ($subjectArgumentType->isLowercaseString()->yes() && $replaceArgumentType->isLowercaseString()->yes()) { + $accessories[] = new AccessoryLowercaseStringType(); + } + + if ($subjectArgumentType->isUppercaseString()->yes() && $replaceArgumentType->isUppercaseString()->yes()) { + $accessories[] = new AccessoryUppercaseStringType(); + } + + if (count($accessories) > 0) { + $accessories[] = new StringType(); + return new IntersectionType($accessories); } - return $defaultReturnType; + return new StringType(); } private function getSubjectType( diff --git a/stubs/core.stub b/stubs/core.stub index de4c904423..be1ec9e647 100644 --- a/stubs/core.stub +++ b/stubs/core.stub @@ -227,7 +227,7 @@ function preg_match($pattern, $subject, &$matches = [], int $flags = 0, int $off * @param string|array $subject * @param int $count * @param-out 0|positive-int $count - * @return ($subject is array ? list|null : string|null) + * @return ($subject is array ? array|null : string|null) */ function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, &$count = null, int $flags = 0) {} @@ -237,7 +237,7 @@ function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, & * @param string|array $subject * @param int $count * @param-out 0|positive-int $count - * @return ($subject is array ? list|null : string|null) + * @return ($subject is array ? array|null : string|null) */ function preg_replace($pattern, $replacement, $subject, int $limit = -1, &$count = null) {} @@ -256,7 +256,7 @@ function preg_filter($pattern, $replacement, $subject, int $limit = -1, &$count * @param array|string $replace * @param array|string $subject * @param-out int $count - * @return list|string + * @return array|string */ function str_replace($search, $replace, $subject, ?int &$count = null) {} @@ -265,7 +265,7 @@ function str_replace($search, $replace, $subject, ?int &$count = null) {} * @param array|string $replace * @param array|string $subject * @param-out int $count - * @return list|string + * @return array|string */ function str_ireplace($search, $replace, $subject, ?int &$count = null) {} @@ -326,4 +326,3 @@ function get_defined_constants(bool $categorize = false): array {} * @return __benevolent|array|array>|false> */ function getopt(string $short_options, array $long_options = [], &$rest_index = null) {} - diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index 167cad5f8e..aa97937de4 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -7454,7 +7454,7 @@ public function dataReplaceFunctions(): array '$anotherExpectedString', ], [ - 'array{a: string, b: string}', + 'array{a: lowercase-string&non-falsy-string, b: lowercase-string&non-falsy-string}', '$expectedArray', ], [ @@ -7462,23 +7462,23 @@ public function dataReplaceFunctions(): array '$expectedArray2', ], [ - 'array{a?: string, b?: string}', + 'array{a?: lowercase-string&non-falsy-string, b?: lowercase-string&non-falsy-string}', '$anotherExpectedArray', ], [ - 'list|string', + 'array{}|(lowercase-string&non-falsy-string)', '$expectedArrayOrString', ], [ - '(list|string)', + '(array|string)', '$expectedBenevolentArrayOrString', ], [ - 'list|string|null', + 'array{}|string|null', '$expectedArrayOrString2', ], [ - 'list|string|null', + 'array{}|(lowercase-string&non-falsy-string)|null', '$anotherExpectedArrayOrString', ], [ diff --git a/tests/PHPStan/Analyser/nsrt/bug-11547.php b/tests/PHPStan/Analyser/nsrt/bug-11547.php index 3acb253f49..d2828a7ee1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11547.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11547.php @@ -45,7 +45,7 @@ function validPatternWithEmptyResult(string $s, array $arr) { assertType('string|null', $r); $r = preg_replace('/(\D+)*[12]/', 'x', $arr); - assertType('array', $r); + assertType('array', $r); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9870.php b/tests/PHPStan/Analyser/nsrt/bug-9870.php new file mode 100644 index 0000000000..420c346ef6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9870.php @@ -0,0 +1,43 @@ + $date + */ + public function sayHello($date): void + { + if (is_string($date)) { + assertType('non-empty-string', str_replace('-', '/', $date)); + } else { + assertType('list', str_replace('-', '/', $date)); + } + assertType('list|non-empty-string', str_replace('-', '/', $date)); + } + + /** + * @param string|array $stringOrArray + * @param non-empty-string|array $nonEmptyStringOrArray + * @param string|array $stringOrArrayNonEmptyString + * @param string|non-empty-array $stringOrNonEmptyArray + * @param string|array|bool|int $wrongParam + */ + public function moreCheck( + $stringOrArray, + $nonEmptyStringOrArray, + $stringOrArrayNonEmptyString, + $stringOrNonEmptyArray, + $wrongParam, + ): void { + assertType('array|string', str_replace('-', '/', $stringOrArray)); + assertType('array|non-empty-string', str_replace('-', '/', $nonEmptyStringOrArray)); + assertType('array|string', str_replace('-', '/', $stringOrArrayNonEmptyString)); + assertType('non-empty-array|string', str_replace('-', '/', $stringOrNonEmptyArray)); + assertType('array|string', str_replace('-', '/', $wrongParam)); + assertType('array|string', str_replace('-', '/')); + } +} diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index cc27629dcf..b0f40de695 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -103,15 +103,15 @@ public function testRule(): void 122, ], [ - 'Binary operation "." between array and \'xyz\' results in an error.', + 'Binary operation "." between array and \'xyz\' results in an error.', 127, ], [ - 'Binary operation "." between list|string and \'xyz\' results in an error.', + 'Binary operation "." between array{}|non-falsy-string and \'xyz\' results in an error.', 134, ], [ - 'Binary operation "+" between (list|string) and 1 results in an error.', + 'Binary operation "+" between (array|string) and 1 results in an error.', 136, ], [