From 1eb0d93b450623274c0cd3b208114a565209bfba Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:24:48 +0000 Subject: [PATCH] Report `str_replace()`/`str_ireplace()`/`preg_replace()`/`preg_filter()` calls with fewer replacements than search values - New bleeding-edge rule `ReplaceCountMismatchRule` (level 5, feature toggle `replaceCountMismatch`): when both the search/pattern and the replace/replacement arguments are arrays and the replacement array size is definitely smaller than the search array size, report it, because PHP silently replaces the remaining values with an empty string. - Uses `Type::getArraySize()` + `isSmallerThan()` so constant arrays, unions of shapes and non-empty lists are handled; only definite mismatches are reported. - A string replacement and an explicitly empty replacement array (a deliberate "remove all" idiom) are not reported. - Applied to the whole family of replace functions with the same semantics: str_replace, str_ireplace, preg_replace, preg_filter. - Respects treatPhpDocTypesAsCertain and adds the tip when only PHPDoc types prove the mismatch. --- conf/bleedingEdge.neon | 1 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + .../Functions/ReplaceCountMismatchRule.php | 146 ++++++++++++++++++ .../ReplaceCountMismatchRuleTest.php | 65 ++++++++ .../Functions/data/bug-3889-named-args.php | 11 ++ .../PHPStan/Rules/Functions/data/bug-3889.php | 51 ++++++ 7 files changed, 276 insertions(+) create mode 100644 src/Rules/Functions/ReplaceCountMismatchRule.php create mode 100644 tests/PHPStan/Rules/Functions/ReplaceCountMismatchRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-3889-named-args.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-3889.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 6aec092c4cd..e8e63a9814a 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -35,3 +35,4 @@ parameters: unusedParameters: true reuseCollectedDataRuleErrors: true checkWholePhpVersionRange: true + replaceCountMismatch: true diff --git a/conf/config.neon b/conf/config.neon index dc7e9a7e8e2..84b5b277ef4 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -67,6 +67,7 @@ parameters: unusedParameters: false reuseCollectedDataRuleErrors: false checkWholePhpVersionRange: false + replaceCountMismatch: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index ac1ae55cbdd..01674de1d6a 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -64,6 +64,7 @@ parametersSchema: unusedParameters: bool() reuseCollectedDataRuleErrors: bool() checkWholePhpVersionRange: bool() + replaceCountMismatch: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/Functions/ReplaceCountMismatchRule.php b/src/Rules/Functions/ReplaceCountMismatchRule.php new file mode 100644 index 00000000000..9a3a19d5b84 --- /dev/null +++ b/src/Rules/Functions/ReplaceCountMismatchRule.php @@ -0,0 +1,146 @@ + + */ +#[Conditional( + attribute: new RegisteredRule(level: 5), + if: '%featureToggles.replaceCountMismatch%', +)] +final class ReplaceCountMismatchRule implements Rule +{ + + private const FUNCTIONS = [ + 'str_replace' => 'strReplace', + 'str_ireplace' => 'strReplace', + 'preg_replace' => 'pregReplace', + 'preg_filter' => 'pregReplace', + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private PhpVersion $phpVersion, + #[AutowiredParameter] + private bool $treatPhpDocTypesAsCertain, + #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] + private bool $treatPhpDocTypesAsCertainTip, + ) + { + } + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!($node->name instanceof Node\Name)) { + return []; + } + + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + $functionName = $functionReflection->getName(); + if (!array_key_exists($functionName, self::FUNCTIONS)) { + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + if ($normalizedFuncCall === null) { + return []; + } + + $args = $normalizedFuncCall->getArgs(); + if (!array_key_exists(0, $args) || !array_key_exists(1, $args)) { + return []; + } + + $parameters = $parametersAcceptor->getParameters(); + if (count($parameters) < 2) { + return []; + } + + if ($this->treatPhpDocTypesAsCertain) { + $searchType = $scope->getType($args[0]->value); + $replaceType = $scope->getType($args[1]->value); + } else { + $searchType = $scope->getNativeType($args[0]->value); + $replaceType = $scope->getNativeType($args[1]->value); + } + + if (!$this->hasFewerReplacements($searchType, $replaceType)) { + return []; + } + + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'Parameter #2 $%s of function %s has fewer elements (%s) than parameter #1 $%s (%s), the remaining values will be replaced with an empty string.', + $parameters[1]->getName(), + $functionName, + $replaceType->getArraySize()->describe(VerbosityLevel::value()), + $parameters[0]->getName(), + $searchType->getArraySize()->describe(VerbosityLevel::value()), + ))->identifier(sprintf('%s.replaceCount', self::FUNCTIONS[$functionName])); + + if ($this->treatPhpDocTypesAsCertain && $this->treatPhpDocTypesAsCertainTip) { + $nativeSearchType = $scope->getNativeType($args[0]->value); + $nativeReplaceType = $scope->getNativeType($args[1]->value); + if (!$this->hasFewerReplacements($nativeSearchType, $nativeReplaceType)) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + } + + return [ + $errorBuilder->build(), + ]; + } + + private function hasFewerReplacements(Type $searchType, Type $replaceType): bool + { + if (!$searchType->isArray()->yes() || !$replaceType->isArray()->yes()) { + return false; + } + + // an explicitly empty array of replacements is a deliberate way to remove all search values + if ($replaceType->isIterableAtLeastOnce()->no()) { + return false; + } + + return $replaceType->getArraySize()->isSmallerThan($searchType->getArraySize(), $this->phpVersion)->yes(); + } + +} diff --git a/tests/PHPStan/Rules/Functions/ReplaceCountMismatchRuleTest.php b/tests/PHPStan/Rules/Functions/ReplaceCountMismatchRuleTest.php new file mode 100644 index 00000000000..fde9bbe6ff5 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/ReplaceCountMismatchRuleTest.php @@ -0,0 +1,65 @@ + + */ +class ReplaceCountMismatchRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ReplaceCountMismatchRule( + self::createReflectionProvider(), + new PhpVersion(PHP_VERSION_ID), + true, + true, + ); + } + + public function testBug3889(): void + { + $this->analyse([__DIR__ . '/data/bug-3889.php'], [ + [ + 'Parameter #2 $replace of function str_replace has fewer elements (2) than parameter #1 $search (3), the remaining values will be replaced with an empty string.', + 12, + ], + [ + 'Parameter #2 $replace of function str_ireplace has fewer elements (1) than parameter #1 $search (3), the remaining values will be replaced with an empty string.', + 36, + ], + [ + 'Parameter #2 $replace of function str_replace has fewer elements (1) than parameter #1 $search (2), the remaining values will be replaced with an empty string.', + 40, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + [ + 'Parameter #2 $replacement of function preg_replace has fewer elements (1) than parameter #1 $pattern (2), the remaining values will be replaced with an empty string.', + 46, + ], + [ + 'Parameter #2 $replacement of function preg_filter has fewer elements (1) than parameter #1 $pattern (2), the remaining values will be replaced with an empty string.', + 49, + ], + ]); + } + + #[RequiresPhp('>= 8.0')] + public function testNamedArgs(): void + { + $this->analyse([__DIR__ . '/data/bug-3889-named-args.php'], [ + [ + 'Parameter #2 $replace of function str_replace has fewer elements (1) than parameter #1 $search (2), the remaining values will be replaced with an empty string.', + 9, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-3889-named-args.php b/tests/PHPStan/Rules/Functions/data/bug-3889-named-args.php new file mode 100644 index 00000000000..b657c6caf0e --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-3889-named-args.php @@ -0,0 +1,11 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug3889NamedArgs; + +function doFoo(string $s): void +{ + str_replace(search: ['a', 'b'], subject: $s, replace: ['x']); + str_replace(search: ['a', 'b'], subject: $s, replace: ['x', 'y']); +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-3889.php b/tests/PHPStan/Rules/Functions/data/bug-3889.php new file mode 100644 index 00000000000..20493bf8e55 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-3889.php @@ -0,0 +1,51 @@ + $list + * @param non-empty-list $nonEmptyList + * @param array{string, string} $two + * @param array{string, string, string}|array{string} $oneOrThree + */ + public function doFoo(string $s, array $list, array $nonEmptyList, array $two, array $oneOrThree): void + { + str_replace(['a', 'b'], ['x', 'y'], $s); + str_replace(['a', 'b'], ['x', 'y', 'z'], $s); + str_replace(['a', 'b'], [], $s); + str_ireplace(['a', 'b', 'c'], ['x'], $s); + str_replace('a', 'x', $s); + str_replace($list, ['x'], $s); + str_replace($nonEmptyList, ['x'], $s); + str_replace($two, ['x'], $s); + str_replace($two, $nonEmptyList, $s); + str_replace($oneOrThree, ['x'], $s); + str_replace($oneOrThree, [], $s); + str_replace(['a', 'b', 'c'], $oneOrThree, $s); + + preg_replace(['/a/', '/b/'], ['x'], $s); + preg_replace(['/a/', '/b/'], ['x', 'y'], $s); + preg_replace(['/a/', '/b/'], 'x', $s); + preg_filter(['/a/', '/b/'], ['x'], $s); + } +}