diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a2f57a4871f..f0b822f0b06 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -27,3 +27,4 @@ parameters: switchConditionAlwaysFalse: true checkImportedClassNameCase: true sortWithoutEffect: true + stringReplaceWithoutEffect: true diff --git a/conf/config.level4.neon b/conf/config.level4.neon index 4206d36d3c1..1b9834a6654 100644 --- a/conf/config.level4.neon +++ b/conf/config.level4.neon @@ -18,6 +18,8 @@ conditionalTags: phpstan.rules.rule: %featureToggles.finiteTypesInHaystack% PHPStan\Rules\Comparison\SwitchConditionRule: phpstan.rules.rule: %featureToggles.switchConditionAlwaysFalse% + PHPStan\Rules\Functions\StringReplaceWithoutEffectRule: + phpstan.rules.rule: %featureToggles.stringReplaceWithoutEffect% parameters: checkAdvancedIsset: true @@ -49,3 +51,9 @@ services: class: PHPStan\Rules\Comparison\SwitchConditionRule arguments: treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain% + + - + class: PHPStan\Rules\Functions\StringReplaceWithoutEffectRule + arguments: + treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain% + treatPhpDocTypesAsCertainTip: %tips.treatPhpDocTypesAsCertain% diff --git a/conf/config.neon b/conf/config.neon index 16e588434ac..083181aadc6 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -58,6 +58,7 @@ parameters: switchConditionAlwaysFalse: false checkImportedClassNameCase: false sortWithoutEffect: false + stringReplaceWithoutEffect: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 5d968573089..b8395eeedef 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -56,6 +56,7 @@ parametersSchema: switchConditionAlwaysFalse: bool() checkImportedClassNameCase: bool() sortWithoutEffect: bool() + stringReplaceWithoutEffect: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/Functions/StringReplaceWithoutEffectRule.php b/src/Rules/Functions/StringReplaceWithoutEffectRule.php new file mode 100644 index 00000000000..d66a24b8990 --- /dev/null +++ b/src/Rules/Functions/StringReplaceWithoutEffectRule.php @@ -0,0 +1,551 @@ + + */ +final class StringReplaceWithoutEffectRule implements Rule +{ + + private const IDENTIFIERS = [ + 'strtr' => 'strtr.noEffect', + 'str_replace' => 'strReplace.noEffect', + 'str_ireplace' => 'strIreplace.noEffect', + 'substr_replace' => 'substrReplace.noEffect', + 'preg_replace' => 'pregReplace.noEffect', + 'preg_replace_callback' => 'pregReplaceCallback.noEffect', + 'preg_replace_callback_array' => 'pregReplaceCallbackArray.noEffect', + ]; + + /** + * Position of the by-reference $count parameter. When it's passed, the call + * writes to it even when nothing gets replaced, so it's never without effect. + */ + private const COUNT_PARAMETER_POSITION = [ + 'str_replace' => 3, + 'str_ireplace' => 3, + 'preg_replace' => 4, + 'preg_replace_callback' => 4, + 'preg_replace_callback_array' => 3, + ]; + + /** + * Upper bound on how many subject/needle pairs are cross-checked. Both sides + * come from unions of constant strings which can grow large. + */ + private const STRING_COMBINATIONS_LIMIT = 64; + + public function __construct( + private ReflectionProvider $reflectionProvider, + private bool $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::IDENTIFIERS)) { + return []; + } + + foreach ($node->getArgs() as $arg) { + if ($arg->unpack) { + 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($functionName, self::COUNT_PARAMETER_POSITION) + && array_key_exists(self::COUNT_PARAMETER_POSITION[$functionName], $args) + ) { + return []; + } + + $message = $this->findNoEffectMessage($functionName, $parametersAcceptor, $args, $scope, !$this->treatPhpDocTypesAsCertain); + if ($message === null) { + return []; + } + + $errorBuilder = RuleErrorBuilder::message($message)->identifier(self::IDENTIFIERS[$functionName]); + + if ( + $this->treatPhpDocTypesAsCertain + && $this->treatPhpDocTypesAsCertainTip + && $this->findNoEffectMessage($functionName, $parametersAcceptor, $args, $scope, true) === null + ) { + $errorBuilder->treatPhpDocTypesAsCertainTip(); + } + + return [$errorBuilder->build()]; + } + + /** + * @param Node\Arg[] $args + */ + private function findNoEffectMessage( + string $functionName, + ParametersAcceptor $parametersAcceptor, + array $args, + Scope $scope, + bool $nativeTypes, + ): ?string + { + $types = []; + foreach ($args as $i => $arg) { + $types[$i] = $nativeTypes ? $scope->getNativeType($arg->value) : $scope->getType($arg->value); + } + + if ($functionName === 'strtr') { + return $this->findStrtrNoEffectMessage($parametersAcceptor, $types); + } + + if ($functionName === 'substr_replace') { + return $this->findSubstrReplaceNoEffectMessage($parametersAcceptor, $types); + } + + if (in_array($functionName, ['preg_replace', 'preg_replace_callback', 'preg_replace_callback_array'], true)) { + return $this->findPregReplaceNoEffectMessage($functionName, $parametersAcceptor, $types); + } + + return $this->findStrReplaceNoEffectMessage($functionName, $parametersAcceptor, $types); + } + + /** + * @param array $types + */ + private function findStrtrNoEffectMessage(ParametersAcceptor $parametersAcceptor, array $types): ?string + { + if (!array_key_exists(0, $types) || !array_key_exists(1, $types)) { + return null; + } + + if (!array_key_exists(2, $types)) { + return $this->findStrtrPairsNoEffectMessage($parametersAcceptor, $types[0], $types[1]); + } + + [$stringType, $fromType, $toType] = [$types[0], $types[1], $types[2]]; + if (!$fromType->isString()->yes() || !$toType->isString()->yes()) { + return null; + } + + if ($fromType->isNonEmptyString()->no()) { + return sprintf( + 'Parameter #2 $%s (%s) of function strtr is an empty string, call has no effect.', + $this->getParameterName($parametersAcceptor, 1), + $fromType->describe(VerbosityLevel::value()), + ); + } + + if ($toType->isNonEmptyString()->no()) { + return sprintf( + 'Parameter #3 $%s (%s) of function strtr is an empty string, call has no effect.', + $this->getParameterName($parametersAcceptor, 2), + $toType->describe(VerbosityLevel::value()), + ); + } + + $fromValues = $this->getConstantStringValues($fromType); + if ($fromValues === null) { + return null; + } + + $toValues = $this->getConstantStringValues($toType); + if ($toValues !== null && $this->isIdentityMapping($fromValues, $toValues)) { + return sprintf( + 'Parameter #2 $%s (%s) and parameter #3 $%s (%s) of function strtr map every character to itself, call has no effect.', + $this->getParameterName($parametersAcceptor, 1), + $fromType->describe(VerbosityLevel::value()), + $this->getParameterName($parametersAcceptor, 2), + $toType->describe(VerbosityLevel::value()), + ); + } + + $subjectValues = $this->getConstantStringValues($stringType); + if ($subjectValues === null) { + return null; + } + + if (count($subjectValues) * count($fromValues) > self::STRING_COMBINATIONS_LIMIT) { + return null; + } + + foreach ($subjectValues as $subject) { + foreach ($fromValues as $from) { + // $to may be shorter than $from, in which case only a prefix of $from is + // taken into account. Checking the whole $from is therefore conservative. + if ($from !== '' && strpbrk($subject, $from) !== false) { + return null; + } + } + } + + return sprintf( + 'Parameter #1 $%s (%s) of function strtr does not contain any character from parameter #2 $%s (%s), call has no effect.', + $this->getParameterName($parametersAcceptor, 0), + $stringType->describe(VerbosityLevel::value()), + $this->getParameterName($parametersAcceptor, 1), + $fromType->describe(VerbosityLevel::value()), + ); + } + + private function findStrtrPairsNoEffectMessage( + ParametersAcceptor $parametersAcceptor, + Type $stringType, + Type $pairsType, + ): ?string + { + if (!$pairsType->isArray()->yes()) { + return null; + } + + if ($pairsType->isIterableAtLeastOnce()->no()) { + return sprintf( + 'Parameter #2 $%s (%s) of function strtr is empty, call has no effect.', + $this->getParameterName($parametersAcceptor, 1), + $pairsType->describe(VerbosityLevel::value()), + ); + } + + if ($this->mapsEveryPairToItself($pairsType)) { + return sprintf( + 'Parameter #2 $%s (%s) of function strtr maps every string to itself, call has no effect.', + $this->getParameterName($parametersAcceptor, 1), + $pairsType->describe(VerbosityLevel::value()), + ); + } + + $subjectValues = $this->getConstantStringValues($stringType); + $keyValues = $this->getConstantStringValues($pairsType->getIterableKeyType()->toString()); + if ($subjectValues === null || $keyValues === null) { + return null; + } + + if (count($subjectValues) * count($keyValues) > self::STRING_COMBINATIONS_LIMIT) { + return null; + } + + foreach ($subjectValues as $subject) { + foreach ($keyValues as $key) { + if ($key === '') { + return null; + } + + if (str_contains($subject, $key)) { + return null; + } + } + } + + return sprintf( + 'Parameter #1 $%s (%s) of function strtr does not contain any of the replaced strings from parameter #2 $%s (%s), call has no effect.', + $this->getParameterName($parametersAcceptor, 0), + $stringType->describe(VerbosityLevel::value()), + $this->getParameterName($parametersAcceptor, 1), + $pairsType->describe(VerbosityLevel::value()), + ); + } + + /** + * @param array $types + */ + private function findStrReplaceNoEffectMessage( + string $functionName, + ParametersAcceptor $parametersAcceptor, + array $types, + ): ?string + { + if (!array_key_exists(0, $types) || !array_key_exists(1, $types) || !array_key_exists(2, $types)) { + return null; + } + + [$searchType, $replaceType, $subjectType] = [$types[0], $types[1], $types[2]]; + + if ($searchType->isArray()->yes() && $searchType->isIterableAtLeastOnce()->no()) { + return sprintf( + 'Parameter #1 $%s (%s) of function %s is empty, call has no effect.', + $this->getParameterName($parametersAcceptor, 0), + $searchType->describe(VerbosityLevel::value()), + $functionName, + ); + } + + if ($searchType->isString()->yes() && $searchType->isNonEmptyString()->no()) { + return sprintf( + 'Parameter #1 $%s (%s) of function %s is an empty string, call has no effect.', + $this->getParameterName($parametersAcceptor, 0), + $searchType->describe(VerbosityLevel::value()), + $functionName, + ); + } + + // str_ireplace('A', 'A', $s) still rewrites every lowercase 'a' to 'A' + $searchConstantStrings = $searchType->getConstantStrings(); + $replaceConstantStrings = $replaceType->getConstantStrings(); + if ( + $functionName === 'str_replace' + && count($searchConstantStrings) === 1 + && count($replaceConstantStrings) === 1 + && $searchConstantStrings[0]->getValue() === $replaceConstantStrings[0]->getValue() + ) { + return sprintf( + 'Parameter #1 $%s (%s) and parameter #2 $%s (%s) of function str_replace are the same, call has no effect.', + $this->getParameterName($parametersAcceptor, 0), + $searchType->describe(VerbosityLevel::value()), + $this->getParameterName($parametersAcceptor, 1), + $replaceType->describe(VerbosityLevel::value()), + ); + } + + $searchValues = $this->getConstantStringValues($searchType); + $subjectValues = $this->getConstantStringValues($subjectType); + if ($searchValues === null || $subjectValues === null) { + return null; + } + + if (count($subjectValues) * count($searchValues) > self::STRING_COMBINATIONS_LIMIT) { + return null; + } + + foreach ($subjectValues as $subject) { + foreach ($searchValues as $search) { + if ($search === '') { + continue; + } + + if ($functionName === 'str_ireplace') { + if (stripos($subject, $search) !== false) { + return null; + } + continue; + } + + if (str_contains($subject, $search)) { + return null; + } + } + } + + $message = $searchType->isString()->yes() && count($searchValues) === 1 + ? 'Parameter #3 $%s (%s) of function %s does not contain parameter #1 $%s (%s), call has no effect.' + : 'Parameter #3 $%s (%s) of function %s does not contain any of the strings from parameter #1 $%s (%s), call has no effect.'; + + return sprintf( + $message, + $this->getParameterName($parametersAcceptor, 2), + $subjectType->describe(VerbosityLevel::value()), + $functionName, + $this->getParameterName($parametersAcceptor, 0), + $searchType->describe(VerbosityLevel::value()), + ); + } + + /** + * @param array $types + */ + private function findSubstrReplaceNoEffectMessage(ParametersAcceptor $parametersAcceptor, array $types): ?string + { + if (!array_key_exists(1, $types) || !array_key_exists(3, $types)) { + return null; + } + + $replaceType = $types[1]; + $lengthType = $types[3]; + + if (!$replaceType->isString()->yes() || !$replaceType->isNonEmptyString()->no()) { + return null; + } + + if (!(new ConstantIntegerType(0))->isSuperTypeOf($lengthType)->yes()) { + return null; + } + + return sprintf( + 'Parameter #2 $%s (%s) of function substr_replace is an empty string and parameter #4 $%s (%s) is zero, call has no effect.', + $this->getParameterName($parametersAcceptor, 1), + $replaceType->describe(VerbosityLevel::value()), + $this->getParameterName($parametersAcceptor, 3), + $lengthType->describe(VerbosityLevel::value()), + ); + } + + /** + * @param array $types + */ + private function findPregReplaceNoEffectMessage( + string $functionName, + ParametersAcceptor $parametersAcceptor, + array $types, + ): ?string + { + if (!array_key_exists(0, $types)) { + return null; + } + + $patternType = $types[0]; + if (!$patternType->isArray()->yes() || !$patternType->isIterableAtLeastOnce()->no()) { + return null; + } + + return sprintf( + 'Parameter #1 $%s (%s) of function %s is empty, call has no effect.', + $this->getParameterName($parametersAcceptor, 0), + $patternType->describe(VerbosityLevel::value()), + $functionName, + ); + } + + /** + * Whether every character mapped by strtr()'s $from/$to pair maps to itself. + * Only the common prefix of both strings is taken into account, like PHP does. + * + * @param non-empty-list $fromValues + * @param non-empty-list $toValues + */ + private function isIdentityMapping(array $fromValues, array $toValues): bool + { + if (count($fromValues) * count($toValues) > self::STRING_COMBINATIONS_LIMIT) { + return false; + } + + foreach ($fromValues as $from) { + foreach ($toValues as $to) { + $length = min(strlen($from), strlen($to)); + if (substr($from, 0, $length) !== substr($to, 0, $length)) { + return false; + } + } + } + + return true; + } + + private function mapsEveryPairToItself(Type $pairsType): bool + { + $constantArrays = $pairsType->getConstantArrays(); + if ($constantArrays === []) { + return false; + } + + foreach ($constantArrays as $constantArray) { + $valueTypes = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + if (!array_key_exists($i, $valueTypes)) { + return false; + } + + $keyValues = $this->getConstantStringValues($keyType->toString()); + $valueValues = $this->getConstantStringValues($valueTypes[$i]); + if ($keyValues === null || $valueValues === null) { + return false; + } + + if (count($keyValues) !== 1 || $keyValues !== $valueValues) { + return false; + } + } + } + + return true; + } + + private function getParameterName(ParametersAcceptor $parametersAcceptor, int $position): string + { + $parameters = $parametersAcceptor->getParameters(); + if (!array_key_exists($position, $parameters)) { + return (string) ($position + 1); + } + + return $parameters[$position]->getName(); + } + + /** + * Returns every possible string value of $type, or null if they're not all known. + * Arrays are unwrapped to their value types, mirroring how the replacement + * functions accept both a string and an array of strings. + * + * @return non-empty-list|null + */ + private function getConstantStringValues(Type $type): ?array + { + if ($type->isString()->yes()) { + $constantStrings = $type->getConstantStrings(); + } elseif ($type->isArray()->yes()) { + $constantStrings = $type->getIterableValueType()->getConstantStrings(); + } else { + return null; + } + + if ($constantStrings === []) { + return null; + } + + $values = []; + foreach ($constantStrings as $constantString) { + $values[] = $constantString->getValue(); + } + + return array_values(array_unique($values)); + } + +} diff --git a/tests/PHPStan/Rules/Functions/StringReplaceWithoutEffectRuleTest.php b/tests/PHPStan/Rules/Functions/StringReplaceWithoutEffectRuleTest.php new file mode 100644 index 00000000000..cbb7b89b2fa --- /dev/null +++ b/tests/PHPStan/Rules/Functions/StringReplaceWithoutEffectRuleTest.php @@ -0,0 +1,155 @@ + + */ +class StringReplaceWithoutEffectRuleTest extends RuleTestCase +{ + + private bool $treatPhpDocTypesAsCertain = true; + + protected function getRule(): Rule + { + return new StringReplaceWithoutEffectRule( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return $this->treatPhpDocTypesAsCertain; + } + + public function testRule(): void + { + $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; + + $this->analyse([__DIR__ . '/data/string-replace-without-effect.php'], [ + [ + 'Parameter #1 $str (\'\\\\\') of function strtr does not contain any character from parameter #2 $from (\'/\'), call has no effect.', + 11, + ], + [ + 'Parameter #1 $str (\'\\\\\') of function strtr does not contain any character from parameter #2 $from (\'/\'), call has no effect.', + 12, + ], + [ + 'Parameter #2 $from (\'\') of function strtr is an empty string, call has no effect.', + 23, + ], + [ + 'Parameter #3 $to (\'\') of function strtr is an empty string, call has no effect.', + 24, + ], + [ + 'Parameter #2 $replace_pairs (array{}) of function strtr is empty, call has no effect.', + 43, + ], + [ + 'Parameter #1 $str (\'abc\') of function strtr does not contain any of the replaced strings from parameter #2 $replace_pairs (array{x: \'y\'}), call has no effect.', + 44, + ], + [ + 'Parameter #1 $str (\'abc\') of function strtr does not contain any of the replaced strings from parameter #2 $replace_pairs (array{xy: \'z\', qq: \'w\'}), call has no effect.', + 47, + ], + [ + 'Parameter #1 $str (\'abc\'|\'def\') of function strtr does not contain any character from parameter #2 $from (\'xy\'), call has no effect.', + 53, + ], + [ + 'Parameter #3 $subject (\'a/b\') of function str_replace does not contain parameter #1 $search (\'\\\\\'), call has no effect.', + 64, + ], + [ + 'Parameter #3 $subject (\'abc\') of function str_replace does not contain any of the strings from parameter #1 $search (array{\'x\', \'y\'}), call has no effect.', + 67, + ], + [ + 'Parameter #1 $search (array{}) of function str_replace is empty, call has no effect.', + 69, + ], + [ + 'Parameter #1 $search (\'\') of function str_replace is an empty string, call has no effect.', + 70, + ], + [ + 'Parameter #1 $search (\'\') of function str_replace is an empty string, call has no effect.', + 71, + ], + [ + 'Parameter #3 $subject (\'abc\') of function str_ireplace does not contain parameter #1 $search (\'X\'), call has no effect.', + 77, + ], + [ + 'Parameter #3 $subject (array{\'abc\', \'def\'}) of function str_replace does not contain parameter #1 $search (\'x\'), call has no effect.', + 90, + ], + [ + 'Parameter #3 $subject (\'abc\') of function str_replace does not contain parameter #1 $search (\'x\'), call has no effect.', + 100, + $tipText, + ], + [ + 'Parameter #3 $subject (\'abc\') of function str_replace does not contain parameter #1 $search (\'x\'), call has no effect.', + 101, + $tipText, + ], + [ + 'Parameter #3 $subject (\'abc\') of function str_replace does not contain parameter #1 $search (\'x\'), call has no effect.', + 106, + ], + [ + 'Parameter #1 $string (\'\\\\\') of function strtr does not contain any character from parameter #2 $from (\'/\'), call has no effect.', + 107, + ], + [ + 'Parameter #2 $from (\'ab\') and parameter #3 $to (\'ab\') of function strtr map every character to itself, call has no effect.', + 117, + ], + [ + 'Parameter #2 $from (\'abc\') and parameter #3 $to (\'ab\') of function strtr map every character to itself, call has no effect.', + 118, + ], + [ + 'Parameter #2 $replace_pairs (array{a: \'a\', bb: \'bb\'}) of function strtr maps every string to itself, call has no effect.', + 120, + ], + [ + 'Parameter #1 $search (\'/\') and parameter #2 $replace (\'/\') of function str_replace are the same, call has no effect.', + 126, + ], + [ + 'Parameter #2 $replace (\'\') of function substr_replace is an empty string and parameter #4 $length (0) is zero, call has no effect.', + 138, + ], + [ + 'Parameter #1 $pattern (array{}) of function preg_replace is empty, call has no effect.', + 149, + ], + [ + 'Parameter #1 $pattern (array{}) of function preg_replace_callback is empty, call has no effect.', + 151, + ], + [ + 'Parameter #1 $pattern (array{}) of function preg_replace_callback_array is empty, call has no effect.', + 152, + ], + ]); + } + + public function testRuleWithoutTreatPhpDocTypesAsCertain(): void + { + $this->treatPhpDocTypesAsCertain = false; + + $this->analyse([__DIR__ . '/data/string-replace-without-effect-phpdoc-types.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/string-replace-without-effect-phpdoc-types.php b/tests/PHPStan/Rules/Functions/data/string-replace-without-effect-phpdoc-types.php new file mode 100644 index 00000000000..591978e95cf --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/string-replace-without-effect-phpdoc-types.php @@ -0,0 +1,19 @@ += 8.0 + +namespace StringReplaceWithoutEffect; + +class Foo +{ + + public function addClass(string $className, string $path): void + { + // swapped arguments, real world bug from composer/class-map-generator + echo strtr('\\', '/', $path); + echo rtrim(strtr('\\', '/', $path), '/'); + } + + public function correctOrder(string $path): void + { + echo strtr($path, '\\', '/'); + echo strtr('a\\b', '\\', '/'); + } + + public function emptyFromTo(string $path): void + { + echo strtr($path, '', '/'); + echo strtr($path, '\\', ''); + } + + /** + * @param non-empty-string $nonEmpty + */ + public function unknownStrings(string $path, string $from, string $nonEmpty): void + { + echo strtr($path, $from, '/'); + echo strtr('abc', $from, '/'); + echo strtr($path, $nonEmpty, '/'); + } + + /** + * @param array $pairs + */ + public function pairs(string $path, array $pairs): void + { + echo strtr($path, $pairs); + echo strtr($path, []); + echo strtr('abc', ['x' => 'y']); + echo strtr('abc', ['b' => 'y']); + echo strtr('a1c', [1 => 'y']); + echo strtr('abc', ['xy' => 'z', 'qq' => 'w']); + } + + public function unions(bool $b): void + { + $subject = $b ? 'abc' : 'def'; + echo strtr($subject, 'xy', 'zw'); + echo strtr($subject, 'xc', 'zw'); + } + +} + +class Bar +{ + + public function strReplace(string $path): void + { + echo str_replace('\\', '/', 'a/b'); + echo str_replace('/', '\\', 'a/b'); + echo str_replace('\\', '/', $path); + echo str_replace(['x', 'y'], '/', 'abc'); + echo str_replace(['x', 'b'], '/', 'abc'); + echo str_replace([], '/', 'abc'); + echo str_replace('', '/', 'abc'); + echo str_replace('', '/', $path); + } + + public function strIreplace(string $path): void + { + echo str_ireplace('B', '/', 'abc'); + echo str_ireplace('X', '/', 'abc'); + echo str_ireplace('X', '/', $path); + } + + public function withCount(string $path): void + { + $count = 0; + echo str_replace('x', '/', 'abc', $count); + echo $count; + } + + public function arraySubject(): void + { + echo implode(str_replace('x', '/', ['abc', 'def'])); + echo implode(str_replace('a', '/', ['abc', 'def'])); + } + + /** + * @param 'abc' $subject + * @param 'x' $search + */ + public function phpDocTypes(string $subject, string $search): void + { + echo str_replace('x', '/', $subject); + echo str_replace($search, '/', 'abc'); + } + + public function namedArguments(string $path): void + { + echo str_replace(subject: 'abc', search: 'x', replace: '/'); + echo strtr(to: '.', from: '/', string: '\\'); + } + +} + +class Identity +{ + + public function strtrIdentity(string $path): void + { + echo strtr($path, 'ab', 'ab'); + echo strtr($path, 'abc', 'ab'); + echo strtr($path, 'ab', 'ba'); + echo strtr($path, ['a' => 'a', 'bb' => 'bb']); + echo strtr($path, ['a' => 'b']); + } + + public function strReplaceIdentity(string $path): void + { + echo str_replace('/', '/', $path); + echo str_replace('/', '\\', $path); + echo str_ireplace('A', 'A', $path); + } + +} + +class Others +{ + + public function substrReplace(string $path): void + { + echo substr_replace($path, '', 3, 0); + echo substr_replace($path, '', 3, 1); + echo substr_replace($path, 'x', 3, 0); + echo substr_replace($path, '', 3); + } + + /** + * @param array): string> $callbacks + */ + public function pregReplace(string $path, array $callbacks): void + { + echo preg_replace([], [], $path); + echo preg_replace('/a/', 'b', $path); + echo preg_replace_callback([], static fn (array $matches): string => '', $path); + echo preg_replace_callback_array([], $path); + echo preg_replace_callback_array($callbacks, $path); + + $count = 0; + echo preg_replace([], [], $path, -1, $count); + echo $count; + } + +} + +class Unions +{ + + public function searchAndReplaceFromSameUnion(string $path, bool $b): void + { + $search = $b ? 'a' : 'b'; + $replace = $b ? 'b' : 'a'; + echo str_replace($search, $replace, $path); + } + + public function strtrFromSameUnion(string $path, bool $b): void + { + $from = $b ? 'a' : 'b'; + $to = $b ? 'b' : 'a'; + echo strtr($path, $from, $to); + } + +}