Skip to content

Commit 90f0733

Browse files
committed
[symfony] Add AlreadyRegisteredAutodiscoveryServiceRule
1 parent a6f9b22 commit 90f0733

File tree

11 files changed

+490
-0
lines changed

11 files changed

+490
-0
lines changed

src/Enum/SymfonyRuleIdentifier.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,6 @@ final class SymfonyRuleIdentifier
4545
public const SERVICES_EXCLUDED_DIRECTORY_MUST_EXIST = 'symfony.servicesExcludedDirectoryMustExist';
4646

4747
public const NO_BUNDLE_RESOURCE_CONFIG = 'symfony.noBundleResourceConfig';
48+
49+
public const ALREADY_REGISTERED_AUTODISCOVERY_SERVICE = 'symfony.alreadyRegisteredAutodiscoveryService';
4850
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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 PHPStan\Analyser\Scope;
10+
use PHPStan\Reflection\ReflectionProvider;
11+
use PHPStan\Rules\Rule;
12+
use PHPStan\Rules\RuleError;
13+
use PHPStan\Rules\RuleErrorBuilder;
14+
use Symplify\PHPStanRules\Enum\SymfonyRuleIdentifier;
15+
use Symplify\PHPStanRules\Symfony\ConfigClosure\SymfonyClosureServicesExcludeResolver;
16+
use Symplify\PHPStanRules\Symfony\ConfigClosure\SymfonyClosureServicesLoadResolver;
17+
use Symplify\PHPStanRules\Symfony\ConfigClosure\SymfonyClosureServicesSetClassesResolver;
18+
use Symplify\PHPStanRules\Symfony\NodeAnalyzer\SymfonyClosureDetector;
19+
20+
/**
21+
* @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\AlreadyRegisteredAutodiscoveryServiceRule\AlreadyRegisteredAutodiscoveryServiceRuleTest
22+
*
23+
* @implements Rule<Closure>
24+
*/
25+
final readonly class AlreadyRegisteredAutodiscoveryServiceRule implements Rule
26+
{
27+
/**
28+
* @var string
29+
*/
30+
public const ERROR_MESSAGE = 'The "%s" service is already registered via autodiscovery ->load(), no need to set it twice';
31+
32+
public function __construct(
33+
private ReflectionProvider $reflectionProvider
34+
) {
35+
}
36+
37+
public function getNodeType(): string
38+
{
39+
return Closure::class;
40+
}
41+
42+
/**
43+
* @param Closure $node
44+
* @return RuleError[]
45+
*/
46+
public function processNode(Node $node, Scope $scope): array
47+
{
48+
if (! SymfonyClosureDetector::detect($node)) {
49+
return [];
50+
}
51+
52+
// 1. collect all load("X") namespaces
53+
$loadedServiceNamespaces = SymfonyClosureServicesLoadResolver::resolve($node);
54+
if ($loadedServiceNamespaces === []) {
55+
return [];
56+
}
57+
58+
// 2. check all bare $services->set("Y");
59+
$standaloneSetServicesToLines = SymfonyClosureServicesSetClassesResolver::resolve($node);
60+
if ($standaloneSetServicesToLines === []) {
61+
return [];
62+
}
63+
64+
// 3. collect all $services->load()->exclude([...]); paths
65+
$excludedPaths = SymfonyClosureServicesExcludeResolver::resolve($node, $scope);
66+
67+
$twiceRegisteredServices = $this->findTwiceRegisteredServices($standaloneSetServicesToLines, $loadedServiceNamespaces);
68+
69+
// filter out excluded paths
70+
$twiceRegisteredServices = $this->filterOutExcludedPaths($twiceRegisteredServices, $excludedPaths);
71+
72+
if ($twiceRegisteredServices === []) {
73+
return [];
74+
}
75+
76+
$ruleErrors = [];
77+
78+
foreach ($twiceRegisteredServices as $serviceClass => $line) {
79+
$errorMessage = sprintf(self::ERROR_MESSAGE, $serviceClass);
80+
81+
$identifierRuleError = RuleErrorBuilder::message($errorMessage)
82+
->identifier(SymfonyRuleIdentifier::ALREADY_REGISTERED_AUTODISCOVERY_SERVICE)
83+
->line($line)
84+
->build();
85+
86+
$ruleErrors[] = $identifierRuleError;
87+
}
88+
89+
return $ruleErrors;
90+
}
91+
92+
/**
93+
* @param array<string, int> $standaloneSetServicesToLines
94+
* @param array<string> $loadedServiceNamespaces
95+
*
96+
* @return array<string, int>
97+
*/
98+
private function findTwiceRegisteredServices(array $standaloneSetServicesToLines, array $loadedServiceNamespaces): array
99+
{
100+
$twiceRegisteredServices = [];
101+
102+
foreach ($standaloneSetServicesToLines as $serviceClass => $line) {
103+
foreach ($loadedServiceNamespaces as $loadedServiceNamespace) {
104+
if (str_starts_with($serviceClass, $loadedServiceNamespace)) {
105+
$twiceRegisteredServices[$serviceClass] = $line;
106+
continue 2;
107+
}
108+
}
109+
}
110+
111+
return $twiceRegisteredServices;
112+
}
113+
114+
/**
115+
* @param array<string, int> $servicesToLine
116+
* @param array<string> $excludedPaths
117+
*
118+
* @return array<string, int>
119+
*/
120+
private function filterOutExcludedPaths(array $servicesToLine, array $excludedPaths): array
121+
{
122+
foreach (array_keys($servicesToLine) as $serviceClass) {
123+
if ($this->isClassInExcludedPaths($serviceClass, $excludedPaths)) {
124+
unset($servicesToLine[$serviceClass]);
125+
}
126+
}
127+
128+
return $servicesToLine;
129+
}
130+
131+
private function resolveServiceFilePath(string $className): string|null
132+
{
133+
if (! $this->reflectionProvider->hasClass($className)) {
134+
return null;
135+
}
136+
137+
$classReflection = $this->reflectionProvider->getClass($className);
138+
return $classReflection->getFileName();
139+
}
140+
141+
/**
142+
* @param string[] $excludedPaths
143+
*/
144+
private function isClassInExcludedPaths(string $serviceClass, array $excludedPaths): bool
145+
{
146+
$serviceFilePath = $this->resolveServiceFilePath($serviceClass);
147+
if (! is_string($serviceFilePath)) {
148+
return false;
149+
}
150+
151+
foreach ($excludedPaths as $excludedPath) {
152+
if (str_starts_with($serviceFilePath, $excludedPath)) {
153+
return true;
154+
}
155+
}
156+
157+
return false;
158+
}
159+
}
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\Symfony\ConfigClosure;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\Array_;
9+
use PhpParser\Node\Expr\BinaryOp\Concat;
10+
use PhpParser\Node\Expr\Closure;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PhpParser\Node\Identifier;
13+
use PhpParser\Node\Name;
14+
use PhpParser\Node\Scalar\String_;
15+
use PhpParser\NodeFinder;
16+
use PHPStan\Analyser\Scope;
17+
18+
/**
19+
* Look for:
20+
*
21+
* $services->load('...', '...')
22+
* ->exclude(['X', 'Y']);
23+
*/
24+
final class SymfonyClosureServicesExcludeResolver
25+
{
26+
/**
27+
* @return string[]
28+
*/
29+
public static function resolve(Closure $closure, Scope $scope): array
30+
{
31+
$excludedPaths = [];
32+
33+
$nodeFinder = new NodeFinder();
34+
$nodeFinder->find($closure->stmts, function (Node $node) use (&$excludedPaths, $scope): bool {
35+
if (! $node instanceof MethodCall) {
36+
return false;
37+
}
38+
39+
if (! self::isName($node->name, 'exclude')) {
40+
return false;
41+
}
42+
43+
$excludedExpr = $node->getArgs()[0]->value;
44+
if (! $excludedExpr instanceof Array_) {
45+
return false;
46+
}
47+
48+
foreach ($excludedExpr->items as $arrayItem) {
49+
if (! $arrayItem->value instanceof Concat) {
50+
continue;
51+
}
52+
53+
$concat = $arrayItem->value;
54+
if (! $concat->right instanceof String_) {
55+
continue;
56+
}
57+
58+
$excludedPath = dirname($scope->getFile()) . $concat->right->value;
59+
$realExcludedPath = realpath($excludedPath);
60+
if (! is_string($realExcludedPath)) {
61+
continue;
62+
}
63+
64+
$excludedPaths[] = $realExcludedPath;
65+
}
66+
67+
return true;
68+
});
69+
70+
return array_unique($excludedPaths);
71+
}
72+
73+
private static function isName(Node $node, string $name): bool
74+
{
75+
if (! $node instanceof Name && ! $node instanceof Identifier) {
76+
return false;
77+
}
78+
79+
return $node->toString() === $name;
80+
}
81+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Symfony\ConfigClosure;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\Closure;
9+
use PhpParser\Node\Expr\MethodCall;
10+
use PhpParser\Node\Identifier;
11+
use PhpParser\Node\Name;
12+
use PhpParser\Node\Scalar\String_;
13+
use PhpParser\NodeFinder;
14+
15+
/**
16+
* Look for:
17+
*
18+
* $services->load('Y')
19+
*/
20+
final class SymfonyClosureServicesLoadResolver
21+
{
22+
/**
23+
* @return string[]
24+
*/
25+
public static function resolve(Closure $closure): array
26+
{
27+
$loadedNamespaces = [];
28+
29+
$nodeFinder = new NodeFinder();
30+
$nodeFinder->find($closure->stmts, function (Node $node) use (&$loadedNamespaces): bool {
31+
if (! $node instanceof MethodCall) {
32+
return false;
33+
}
34+
35+
if (! self::isName($node->name, 'load')) {
36+
return false;
37+
}
38+
39+
$namespaceExpr = $node->getArgs()[0]->value;
40+
if (! $namespaceExpr instanceof String_) {
41+
return false;
42+
}
43+
44+
$loadedNamespaces[] = $namespaceExpr->value;
45+
return true;
46+
});
47+
48+
return $loadedNamespaces;
49+
}
50+
51+
private static function isName(Node $node, string $name): bool
52+
{
53+
if (! $node instanceof Name && ! $node instanceof Identifier) {
54+
return false;
55+
}
56+
57+
return $node->toString() === $name;
58+
}
59+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Symfony\ConfigClosure;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr\ClassConstFetch;
9+
use PhpParser\Node\Expr\Closure;
10+
use PhpParser\Node\Expr\MethodCall;
11+
use PhpParser\Node\Expr\Variable;
12+
use PhpParser\Node\Identifier;
13+
use PhpParser\Node\Name;
14+
use PhpParser\Node\Stmt\Expression;
15+
use PhpParser\NodeFinder;
16+
17+
/**
18+
* Look for:
19+
*
20+
* $services->set(X);
21+
*/
22+
final class SymfonyClosureServicesSetClassesResolver
23+
{
24+
/**
25+
* @return array<string, int>
26+
*/
27+
public static function resolve(Closure $closure): array
28+
{
29+
$standaloneSetServices = [];
30+
31+
$nodeFinder = new NodeFinder();
32+
$nodeFinder->find($closure, function (Node $node) use (&$standaloneSetServices): bool {
33+
if (! $node instanceof Expression) {
34+
return false;
35+
}
36+
37+
if (! $node->expr instanceof MethodCall) {
38+
return false;
39+
}
40+
41+
$methodCall = $node->expr;
42+
if (! $methodCall->var instanceof Variable) {
43+
return false;
44+
}
45+
46+
// dummy services check, to avoid collecting parameters
47+
if (! self::isName($methodCall->var->name, 'services')) {
48+
return false;
49+
}
50+
51+
if (! self::isName($methodCall->name, 'set')) {
52+
return false;
53+
}
54+
55+
$setServiceExpr = $methodCall->getArgs()[0]->value;
56+
if (! $setServiceExpr instanceof ClassConstFetch) {
57+
return false;
58+
}
59+
60+
if (! $setServiceExpr->class instanceof Name) {
61+
return false;
62+
}
63+
64+
$serviceClass = $setServiceExpr->class->toString();
65+
$standaloneSetServices[$serviceClass] = $setServiceExpr->getStartLine();
66+
67+
return true;
68+
});
69+
70+
return $standaloneSetServices;
71+
}
72+
73+
private static function isName(Node|string $node, string $name): bool
74+
{
75+
if (is_string($node)) {
76+
return $node === $name;
77+
}
78+
79+
if (! $node instanceof Name && ! $node instanceof Identifier) {
80+
return false;
81+
}
82+
83+
return $node->toString() === $name;
84+
}
85+
}

0 commit comments

Comments
 (0)