Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ parameters:
unusedParameters: true
reuseCollectedDataRuleErrors: true
checkWholePhpVersionRange: true
replaceCountMismatch: true
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ parameters:
unusedParameters: false
reuseCollectedDataRuleErrors: false
checkWholePhpVersionRange: false
replaceCountMismatch: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ parametersSchema:
unusedParameters: bool()
reuseCollectedDataRuleErrors: bool()
checkWholePhpVersionRange: bool()
replaceCountMismatch: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
146 changes: 146 additions & 0 deletions src/Rules/Functions/ReplaceCountMismatchRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\Conditional;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Php\PhpVersion;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Type;
use PHPStan\Type\VerbosityLevel;
use function array_key_exists;
use function count;
use function sprintf;

/**
* Reports replace functions called with an array of replacements that is shorter
* than the array of search values. PHP silently replaces the remaining search values
* with an empty string, which is usually a mistake.
*
* @implements Rule<Node\Expr\FuncCall>
*/
#[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();
}

}
65 changes: 65 additions & 0 deletions tests/PHPStan/Rules/Functions/ReplaceCountMismatchRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Functions;

use PHPStan\Php\PhpVersion;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;
use const PHP_VERSION_ID;

/**
* @extends RuleTestCase<ReplaceCountMismatchRule>
*/
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 <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%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,
],
]);
}

}
11 changes: 11 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-3889-named-args.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php // lint >= 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']);
}
51 changes: 51 additions & 0 deletions tests/PHPStan/Rules/Functions/data/bug-3889.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php declare(strict_types = 1);

namespace Bug3889;

class HelloWorld
{
public function sayHello(): void
{
$str = 'Hello';
$search = ['a', 'b', 'c'];
$replace = ['x', 'y'];
echo str_replace($search, $replace, $str);
}

public function replaceIsString(): void
{
$str = 'Hello';
$search = ['a', 'b', 'c'];
echo str_replace($search, 'foo', $str);
}
}

class Variants
{
/**
* @param list<string> $list
* @param non-empty-list<string> $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);
}
}
Loading