Skip to content

Commit b179760

Browse files
phpstan-botclaudestaabm
authored
Report str_replace()/str_ireplace()/preg_replace()/preg_filter() calls with fewer replacements than search values (#7007)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Markus Staab <maggus.staab@googlemail.com> Co-authored-by: Markus Staab <markus.staab@redaxo.de>
1 parent 71ee6b4 commit b179760

7 files changed

Lines changed: 260 additions & 0 deletions

File tree

‎conf/bleedingEdge.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ parameters:
3535
unusedParameters: true
3636
reuseCollectedDataRuleErrors: true
3737
checkWholePhpVersionRange: true
38+
replaceCountMismatch: true

‎conf/config.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ parameters:
6767
unusedParameters: false
6868
reuseCollectedDataRuleErrors: false
6969
checkWholePhpVersionRange: false
70+
replaceCountMismatch: false
7071
fileExtensions:
7172
- php
7273
checkAdvancedIsset: false

‎conf/parametersSchema.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ parametersSchema:
6464
unusedParameters: bool()
6565
reuseCollectedDataRuleErrors: bool()
6666
checkWholePhpVersionRange: bool()
67+
replaceCountMismatch: bool()
6768
])
6869
fileExtensions: listOf(string())
6970
checkAdvancedIsset: bool()
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\ArgumentsNormalizer;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\DependencyInjection\Conditional;
10+
use PHPStan\DependencyInjection\RegisteredRule;
11+
use PHPStan\Php\PhpVersion;
12+
use PHPStan\Reflection\ParametersAcceptorSelector;
13+
use PHPStan\Reflection\ReflectionProvider;
14+
use PHPStan\Rules\Rule;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
use PHPStan\Type\Type;
17+
use function array_key_exists;
18+
use function count;
19+
use function sprintf;
20+
21+
/**
22+
* Reports replace functions called with an array of replacements that is shorter
23+
* than the array of search values. PHP silently replaces the remaining search values
24+
* with an empty string, which is usually a mistake.
25+
*
26+
* @implements Rule<Node\Expr\FuncCall>
27+
*/
28+
#[Conditional(
29+
attribute: new RegisteredRule(level: 5),
30+
if: '%featureToggles.replaceCountMismatch%',
31+
)]
32+
final class ReplaceCountMismatchRule implements Rule
33+
{
34+
35+
private const FUNCTIONS = [
36+
'str_replace' => true,
37+
'str_ireplace' => true,
38+
'preg_replace' => true,
39+
'preg_filter' => true,
40+
];
41+
42+
public function __construct(
43+
private ReflectionProvider $reflectionProvider,
44+
private PhpVersion $phpVersion,
45+
)
46+
{
47+
}
48+
49+
public function getNodeType(): string
50+
{
51+
return FuncCall::class;
52+
}
53+
54+
public function processNode(Node $node, Scope $scope): array
55+
{
56+
if (count($node->getArgs()) < 2) {
57+
return [];
58+
}
59+
60+
if (!($node->name instanceof Node\Name)) {
61+
return [];
62+
}
63+
64+
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
65+
return [];
66+
}
67+
68+
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
69+
$functionName = $functionReflection->getName();
70+
if (!array_key_exists($functionName, self::FUNCTIONS)) {
71+
return [];
72+
}
73+
74+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
75+
$scope,
76+
$node->getArgs(),
77+
$functionReflection->getVariants(),
78+
$functionReflection->getNamedArgumentsVariants(),
79+
);
80+
81+
$normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node);
82+
if ($normalizedFuncCall === null) {
83+
return [];
84+
}
85+
86+
$args = $normalizedFuncCall->getArgs();
87+
if (count($args) < 2) {
88+
return [];
89+
}
90+
91+
$searchType = $scope->getType($args[0]->value);
92+
$replaceType = $scope->getType($args[1]->value);
93+
if (!$this->hasFewerReplacements($searchType, $replaceType)) {
94+
return [];
95+
}
96+
97+
return [
98+
RuleErrorBuilder::message(sprintf(
99+
'Call to %s is using fewer replacements than search values.',
100+
$functionName,
101+
))->identifier('argument.replaceCount')->build(),
102+
];
103+
}
104+
105+
private function hasFewerReplacements(Type $searchType, Type $replaceType): bool
106+
{
107+
if (!$searchType->isArray()->yes() || !$replaceType->isArray()->yes()) {
108+
return false;
109+
}
110+
111+
// an explicitly empty array of replacements is a deliberate way to remove all search values
112+
if ($replaceType->isIterableAtLeastOnce()->no()) {
113+
return false;
114+
}
115+
116+
return $replaceType->getArraySize()->isSmallerThan($searchType->getArraySize(), $this->phpVersion)->yes();
117+
}
118+
119+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Functions;
4+
5+
use PHPStan\Php\PhpVersion;
6+
use PHPStan\Rules\Rule;
7+
use PHPStan\Testing\RuleTestCase;
8+
use PHPUnit\Framework\Attributes\RequiresPhp;
9+
use const PHP_VERSION_ID;
10+
11+
/**
12+
* @extends RuleTestCase<ReplaceCountMismatchRule>
13+
*/
14+
class ReplaceCountMismatchRuleTest extends RuleTestCase
15+
{
16+
17+
protected function getRule(): Rule
18+
{
19+
return new ReplaceCountMismatchRule(
20+
self::createReflectionProvider(),
21+
new PhpVersion(PHP_VERSION_ID),
22+
);
23+
}
24+
25+
public function testBug3889(): void
26+
{
27+
$this->analyse([__DIR__ . '/data/bug-3889.php'], [
28+
[
29+
'Call to str_replace is using fewer replacements than search values.',
30+
12,
31+
],
32+
[
33+
'Call to str_ireplace is using fewer replacements than search values.',
34+
37,
35+
],
36+
[
37+
'Call to str_replace is using fewer replacements than search values.',
38+
41,
39+
],
40+
[
41+
'Call to preg_replace is using fewer replacements than search values.',
42+
47,
43+
],
44+
[
45+
'Call to preg_filter is using fewer replacements than search values.',
46+
50,
47+
],
48+
[
49+
'Call to str_replace is using fewer replacements than search values.',
50+
53,
51+
],
52+
]);
53+
}
54+
55+
#[RequiresPhp('>= 8.0.0')]
56+
public function testNamedArgs(): void
57+
{
58+
$this->analyse([__DIR__ . '/data/bug-3889-named-args.php'], [
59+
[
60+
'Call to str_replace is using fewer replacements than search values.',
61+
9,
62+
],
63+
]);
64+
}
65+
66+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug3889NamedArgs;
6+
7+
function doFoo(string $s, bool $b): void
8+
{
9+
str_replace(search: ['a', 'b'], subject: $s, replace: ['x']);
10+
str_replace(search: ['a', 'b'], subject: $s, replace: ['x', 'y']);
11+
str_replace(search: ['a', 'b'], subject: $s, replace: []);
12+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug3889;
4+
5+
class HelloWorld
6+
{
7+
public function sayHello(): void
8+
{
9+
$str = 'Hello';
10+
$search = ['a', 'b', 'c'];
11+
$replace = ['x', 'y'];
12+
echo str_replace($search, $replace, $str);
13+
}
14+
15+
public function replaceIsString(): void
16+
{
17+
$str = 'Hello';
18+
$search = ['a', 'b', 'c'];
19+
echo str_replace($search, 'foo', $str);
20+
}
21+
}
22+
23+
class Variants
24+
{
25+
/**
26+
* @param list<string> $list
27+
* @param non-empty-list<string> $nonEmptyList
28+
* @param array{string, string} $two
29+
* @param array{string, string, string}|array{string} $oneOrThree
30+
*/
31+
public function doFoo(string $s, array $list, array $nonEmptyList, array $two, array $oneOrThree, bool $b): void
32+
{
33+
// see https://3v4l.org/Ct0Ts#v
34+
str_replace(['a', 'b'], ['x', 'y'], $s);
35+
str_replace(['a', 'b'], ['x', 'y', 'z'], $s);
36+
str_replace(['a', 'b'], [], $s);
37+
str_ireplace(['a', 'b', 'c'], ['x'], $s);
38+
str_replace('a', 'x', $s);
39+
str_replace($list, ['x'], $s);
40+
str_replace($nonEmptyList, ['x'], $s);
41+
str_replace($two, ['x'], $s);
42+
str_replace($two, $nonEmptyList, $s);
43+
str_replace($oneOrThree, ['x'], $s);
44+
str_replace($oneOrThree, [], $s);
45+
str_replace(['a', 'b', 'c'], $oneOrThree, $s);
46+
47+
preg_replace(['/a/', '/b/'], ['x'], $s);
48+
preg_replace(['/a/', '/b/'], ['x', 'y'], $s);
49+
preg_replace(['/a/', '/b/'], 'x', $s);
50+
preg_filter(['/a/', '/b/'], ['x'], $s);
51+
52+
$r = $b ? [] : ['x'];
53+
str_replace(['a', 'b', 'c'], $r, $s);
54+
55+
$r = $b ? ['x'] : ['x', 'y', 'z'];
56+
str_replace(['a', 'b', 'c'], $r, $s);
57+
58+
str_replace(['a', 'b', 'c'], ['x', 'y', 'z'], $s);
59+
}
60+
}

0 commit comments

Comments
 (0)