Skip to content

Commit 64a07f2

Browse files
authored
[symfony] Add TaggedIteratorOverRepeatedServiceCallRule (#209)
* [symfony] Add TaggedIteratorOverRepeatedServiceCallRule * README
1 parent 0a960ad commit 64a07f2

File tree

10 files changed

+347
-0
lines changed

10 files changed

+347
-0
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,6 +1438,49 @@ rules:
14381438
<br>
14391439

14401440

1441+
### TaggedIteratorOverRepeatedServiceCallRuleTest
1442+
1443+
Instead of repeated "->call(%s, ...)" calls, pass services as tagged iterator argument to the constructor
1444+
1445+
```yaml
1446+
rules:
1447+
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule
1448+
```
1449+
1450+
```php
1451+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
1452+
use Symfony\Component\DependencyInjection\Loader\Configurator\ref;
1453+
1454+
return static function (ContainerConfigurator $containerConfigurator): void {
1455+
$services = $containerConfigurator->services();
1456+
1457+
$services->set(SomeService::class)
1458+
->call('setService', [ref('service1')])
1459+
->call('setService', [ref('service2')])
1460+
->call('setService', [ref('service3')]);
1461+
};
1462+
```
1463+
1464+
:x:
1465+
1466+
<br>
1467+
1468+
```php
1469+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
1470+
use Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
1471+
1472+
return static function (ContainerConfigurator $containerConfigurator): void {
1473+
$services = $containerConfigurator->services();
1474+
1475+
$services->set(SomeService::class)
1476+
->arg('$services', tagged_iterator('SomeServiceTag'));
1477+
};
1478+
```
1479+
1480+
:+1:
1481+
1482+
<br>
1483+
14411484
### NoGetInCommandRule
14421485

14431486
Prevents using `$this->get(...)` in commands, to promote dependency injection.

config/symfony-config-rules.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ rules:
22
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule
33
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoBundleResourceConfigRule
44
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\AlreadyRegisteredAutodiscoveryServiceRule
5+
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule

src/Enum/SymfonyFunctionName.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Enum;
6+
7+
final class SymfonyFunctionName
8+
{
9+
public const REFERENCE = 'Symfony\Component\DependencyInjection\Loader\Configurator\ref';
10+
11+
public const SERVICE = 'Symfony\Component\DependencyInjection\Loader\Configurator\service';
12+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Rules\Symfony\ConfigClosure;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\Closure;
9+
use PhpParser\Node\Expr\MethodCall;
10+
use PhpParser\Node\Stmt\Expression;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Rules\Rule;
13+
use PHPStan\Rules\RuleError;
14+
use PHPStan\Rules\RuleErrorBuilder;
15+
use Symplify\PHPStanRules\Symfony\NodeAnalyzer\SymfonyClosureDetector;
16+
use Symplify\PHPStanRules\Symfony\NodeFinder\RepeatedServiceAdderCallNameFinder;
17+
18+
/**
19+
* @implements Rule<Closure>
20+
*
21+
* @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule\TaggedIteratorOverRepeatedServiceCallRuleTest
22+
*/
23+
final class TaggedIteratorOverRepeatedServiceCallRule implements Rule
24+
{
25+
/**
26+
* @var string
27+
*/
28+
public const ERROR_MESSAGE = 'Instead of repeated "->call(%s, ...)" calls, pass services as tagged iterator argument to the constructor';
29+
30+
/**
31+
* @var string
32+
*/
33+
private const RULE_IDENTIFIER = 'symfony.taggedIteratorOverRepeatedServiceCall';
34+
35+
public function getNodeType(): string
36+
{
37+
return Closure::class;
38+
}
39+
40+
/**
41+
* @param Closure $node
42+
* @return RuleError[]
43+
*/
44+
public function processNode(Node $node, Scope $scope): array
45+
{
46+
if (! SymfonyClosureDetector::detect($node)) {
47+
return [];
48+
}
49+
50+
$ruleErrors = [];
51+
52+
foreach ($node->stmts as $stmt) {
53+
if (! $stmt instanceof Expression) {
54+
continue;
55+
}
56+
57+
$nestedExpr = $stmt->expr;
58+
if (! $nestedExpr instanceof MethodCall) {
59+
continue;
60+
}
61+
62+
if ($nestedExpr->isFirstClassCallable()) {
63+
continue;
64+
}
65+
66+
$adderCallName = RepeatedServiceAdderCallNameFinder::find($nestedExpr);
67+
if (! is_string($adderCallName)) {
68+
continue;
69+
}
70+
71+
$ruleError = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $adderCallName))
72+
->identifier(self::RULE_IDENTIFIER)
73+
->line($stmt->getStartLine())
74+
->build();
75+
76+
$ruleErrors[] = $ruleError;
77+
}
78+
79+
return $ruleErrors;
80+
}
81+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Symfony\ConfigClosure;
6+
7+
use PhpParser\Node\Expr;
8+
use PhpParser\Node\Expr\FuncCall;
9+
use PhpParser\Node\Name;
10+
use Symplify\PHPStanRules\Enum\SymfonyFunctionName;
11+
12+
final class SymfonyServiceReferenceFunctionAnalyzer
13+
{
14+
public static function isReferenceCall(Expr $expr): bool
15+
{
16+
if (! $expr instanceof FuncCall) {
17+
return false;
18+
}
19+
20+
if (! $expr->name instanceof Name) {
21+
return false;
22+
}
23+
24+
$functionName = $expr->name->toString();
25+
26+
return in_array($functionName, [
27+
SymfonyFunctionName::REFERENCE,
28+
SymfonyFunctionName::SERVICE,
29+
]);
30+
}
31+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Symfony\NodeFinder;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\Array_;
9+
use PhpParser\Node\Expr\MethodCall;
10+
use PhpParser\Node\Scalar\String_;
11+
use PhpParser\NodeFinder;
12+
use Symplify\PHPStanRules\Symfony\ConfigClosure\SymfonyServiceReferenceFunctionAnalyzer;
13+
14+
final class RepeatedServiceAdderCallNameFinder
15+
{
16+
private const CALL_NAME = 'call';
17+
18+
private const MIN_ALERT_COUNT = 3;
19+
20+
public static function find(MethodCall $methodCall): ?string
21+
{
22+
$callMethodCalls = self::findCallMethodCalls($methodCall);
23+
24+
$callMethodNames = [];
25+
26+
foreach ($callMethodCalls as $callMethodCall) {
27+
/** @var String_ $calledMethodNameExpr */
28+
$calledMethodNameExpr = $callMethodCall->getArgs()[0]->value;
29+
$callMethodName = $calledMethodNameExpr->value;
30+
31+
// is passing a service references?
32+
$passedExpr = $callMethodCall->getArgs()[1]->value;
33+
if (! $passedExpr instanceof Array_) {
34+
continue;
35+
}
36+
37+
if (count($passedExpr->items) !== 1) {
38+
continue;
39+
}
40+
41+
$firstArrayItem = $passedExpr->items[0];
42+
if (! SymfonyServiceReferenceFunctionAnalyzer::isReferenceCall($firstArrayItem->value)) {
43+
continue;
44+
}
45+
46+
$callMethodNames[] = $callMethodName;
47+
}
48+
49+
$methodNamesToCount = array_count_values($callMethodNames);
50+
foreach ($methodNamesToCount as $methodName => $count) {
51+
if ($count < self::MIN_ALERT_COUNT) {
52+
continue;
53+
}
54+
55+
return $methodName;
56+
}
57+
58+
return null;
59+
}
60+
61+
/**
62+
* @return MethodCall[]
63+
*/
64+
private static function findCallMethodCalls(MethodCall $methodCall): array
65+
{
66+
$nodeFinder = new NodeFinder();
67+
68+
/** @var MethodCall[] $callMethodCalls */
69+
$callMethodCalls = $nodeFinder->find($methodCall, function (Node $node): bool {
70+
if (! $node instanceof MethodCall) {
71+
return false;
72+
}
73+
74+
if (! fast_node_named($node->name, self::CALL_NAME)) {
75+
return false;
76+
}
77+
78+
if (count($node->getArgs()) !== 2) {
79+
return false;
80+
}
81+
82+
$callNameExpr = $node->getArgs()[0]->value;
83+
return $callNameExpr instanceof String_;
84+
});
85+
86+
return $callMethodCalls;
87+
}
88+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule\Fixture;
4+
5+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
6+
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
7+
8+
return function (ContainerConfigurator $container) {
9+
$services = $container->services();
10+
11+
$services->set('some')
12+
->call('repeatedMethod', [service('some')])
13+
->call('repeatedMethod', [service('some')])
14+
->call('repeatedMethod', [service('some')])
15+
->call('repeatedMethod', [service('some')])
16+
->call('repeatedMethod', [service('some')])
17+
->call('repeatedMethod', [service('some')])
18+
->call('repeatedMethod', [service('some')]);
19+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule\Fixture;
4+
5+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
6+
7+
return function (ContainerConfigurator $container) {
8+
$services = $container->services();
9+
10+
$services->set('some')
11+
->call('repeatedMethod', [100])
12+
->call('repeatedMethod', [100])
13+
->call('repeatedMethod', [100])
14+
->call('repeatedMethod', [100])
15+
->call('repeatedMethod', [100])
16+
->call('repeatedMethod', [100])
17+
->call('repeatedMethod', [100]);
18+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule\Fixture;
4+
5+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
6+
7+
return function (ContainerConfigurator $container) {
8+
$services = $container->services();
9+
10+
$services->set('some')
11+
->call('repeatedMethod', [])
12+
->call('repeatedMethod', []);
13+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule;
6+
7+
use Iterator;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Testing\RuleTestCase;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule;
12+
13+
final class TaggedIteratorOverRepeatedServiceCallRuleTest extends RuleTestCase
14+
{
15+
/**
16+
* @param mixed[] $expectedErrorMessagesWithLines
17+
*/
18+
#[DataProvider('provideData')]
19+
public function testRule(string $filePath, array $expectedErrorMessagesWithLines): void
20+
{
21+
$this->analyse([$filePath], $expectedErrorMessagesWithLines);
22+
}
23+
24+
public static function provideData(): Iterator
25+
{
26+
yield [__DIR__ . '/Fixture/ConfigWithRepeatedCalls.php', [
27+
[
28+
sprintf(TaggedIteratorOverRepeatedServiceCallRule::ERROR_MESSAGE, 'repeatedMethod'),
29+
11,
30+
],
31+
]];
32+
33+
yield [__DIR__ . '/Fixture/SkipOnlyFewRepeats.php', []];
34+
yield [__DIR__ . '/Fixture/SkipNonServiceCalls.php', []];
35+
}
36+
37+
protected function getRule(): Rule
38+
{
39+
return new TaggedIteratorOverRepeatedServiceCallRule();
40+
}
41+
}

0 commit comments

Comments
 (0)