Skip to content

Commit ea28d97

Browse files
authored
[symfony] Add ServicesExcludedDirectoryMustExistRule (#202)
1 parent 6234e4c commit ea28d97

File tree

12 files changed

+315
-6
lines changed

12 files changed

+315
-6
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ includes:
3939
- vendor/symplify/phpstan-rules/config/rector-rules.neon
4040
- vendor/symplify/phpstan-rules/config/doctrine-rules.neon
4141
- vendor/symplify/phpstan-rules/config/symfony-rules.neon
42+
43+
# special set for PHP configs
44+
- vendor/symplify/phpstan-rules/config/symfony-config-rules.neon
4245
```
4346
4447
<br>
@@ -1484,6 +1487,45 @@ abstract class AbstractController extends Controller
14841487

14851488
<br>
14861489

1490+
### ServicesExcludedDirectoryMustExistRule
1491+
1492+
Services excluded path must exist. If not, remove it
1493+
1494+
```yaml
1495+
rules:
1496+
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule
1497+
```
1498+
1499+
```php
1500+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
1501+
1502+
return static function (ContainerConfigurator $configurator): void {
1503+
$services = $configurator->serivces();
1504+
1505+
$services->load('App\\', __DIR__ . '/../src')
1506+
->exclude([__DIR__ . '/this-path-does-not-exist']);
1507+
};
1508+
```
1509+
1510+
:x:
1511+
1512+
<br>
1513+
1514+
```php
1515+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
1516+
1517+
return static function (ContainerConfigurator $configurator): void {
1518+
$services = $configurator->services();
1519+
1520+
$services->load('App\\', __DIR__ . '/../src')
1521+
->exclude([__DIR__ . '/../src/ValueObject']);
1522+
};
1523+
```
1524+
1525+
:+1:
1526+
1527+
<br>
1528+
14871529
### NoRoutingPrefixRule
14881530

14891531
Avoid global route prefixing. Use single place for paths in @Route/#[Route] and improve static analysis instead.

config/symfony-config-rules.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
rules:
2+
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule

src/Enum/SymfonyClass.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,6 @@ final class SymfonyClass
4040
public const COMMAND = 'Symfony\Component\Console\Command\Command';
4141

4242
public const VALIDATOR_TEST_CASE = 'Symfony\Component\Validator\Test\ConstraintValidatorTestCase';
43+
44+
public const CONTAINER_CONFIGURATOR = 'Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator';
4345
}

src/Enum/SymfonyRuleIdentifier.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,6 @@ final class SymfonyRuleIdentifier
4141
public const NO_FIND_TAGGED_SERVICE_IDS_CALL = 'symfony.noFindTaggedServiceIdsCall';
4242

4343
public const REQUIRE_ROUTE_NAME_TO_GENERATE_CONTROLLER_ROUTE = 'symfony.requireRouteNameToGenerateControllerRoute';
44+
45+
public const SERVICES_EXCLUDED_DIRECTORY_MUST_EXIST = 'symfony.servicesExcludedDirectoryMustExist';
4446
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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\ArrayItem;
9+
use PhpParser\Node\Expr\Array_;
10+
use PhpParser\Node\Expr\BinaryOp;
11+
use PhpParser\Node\Expr\BinaryOp\Concat;
12+
use PhpParser\Node\Expr\Closure;
13+
use PhpParser\Node\Expr\MethodCall;
14+
use PhpParser\Node\Identifier;
15+
use PhpParser\Node\Scalar\MagicConst\Dir;
16+
use PhpParser\Node\Scalar\String_;
17+
use PhpParser\NodeFinder;
18+
use PHPStan\Analyser\Scope;
19+
use PHPStan\Rules\IdentifierRuleError;
20+
use PHPStan\Rules\Rule;
21+
use PHPStan\Rules\RuleErrorBuilder;
22+
use Symplify\PHPStanRules\Enum\SymfonyRuleIdentifier;
23+
use Symplify\PHPStanRules\Symfony\NodeAnalyzer\SymfonyClosureDetector;
24+
25+
/**
26+
* @implements Rule<Closure>
27+
*/
28+
final class ServicesExcludedDirectoryMustExistRule implements Rule
29+
{
30+
/**
31+
* @var string
32+
*/
33+
public const ERROR_MESSAGE = 'Services excluded path "%s" does not exists. You can remove it';
34+
35+
public function getNodeType(): string
36+
{
37+
return Closure::class;
38+
}
39+
40+
/**
41+
* @param Closure $node
42+
* @return IdentifierRuleError[]
43+
*/
44+
public function processNode(Node $node, Scope $scope): array
45+
{
46+
if (! SymfonyClosureDetector::detect($node)) {
47+
return [];
48+
}
49+
50+
$excludeMethodCalls = $this->resolveExcludeMethodCalls($node);
51+
if ($excludeMethodCalls === []) {
52+
return [];
53+
}
54+
55+
$ruleErrors = [];
56+
57+
foreach ($excludeMethodCalls as $excludeMethodCall) {
58+
// check all array args
59+
$firstArgValue = $excludeMethodCall->getArgs()[0]->value;
60+
if (! $firstArgValue instanceof Array_) {
61+
continue;
62+
}
63+
64+
foreach ($firstArgValue->items as $arrayItem) {
65+
$directoryPath = $this->resolveDirectoryPath($arrayItem, $scope);
66+
if (! is_string($directoryPath)) {
67+
continue;
68+
}
69+
70+
// path exists, all good
71+
if (file_exists($directoryPath)) {
72+
continue;
73+
}
74+
75+
/** @var BinaryOp $binarOp */
76+
$binarOp = $arrayItem->value;
77+
78+
/** @var String_ $pathValue */
79+
$pathValue = $binarOp->right;
80+
81+
$errorMessage = sprintf(self::ERROR_MESSAGE, $pathValue->value);
82+
83+
$ruleErrors[] = RuleErrorBuilder::message($errorMessage)
84+
->line($arrayItem->getStartLine())
85+
->identifier(SymfonyRuleIdentifier::SERVICES_EXCLUDED_DIRECTORY_MUST_EXIST)
86+
->build();
87+
}
88+
}
89+
90+
return $ruleErrors;
91+
}
92+
93+
private function resolveDirectoryPath(ArrayItem $arrayItem, Scope $scope): ?string
94+
{
95+
if (! $arrayItem->value instanceof Concat) {
96+
return null;
97+
}
98+
99+
$concat = $arrayItem->value;
100+
if (! $concat->left instanceof Dir) {
101+
return null;
102+
}
103+
104+
if (! $concat->right instanceof String_) {
105+
return null;
106+
}
107+
108+
$stringPart = $concat->right->value;
109+
110+
// uses magic mask, nothing to validate
111+
if (str_contains($stringPart, '*') || str_contains($stringPart, '{')) {
112+
return null;
113+
}
114+
115+
// check full path
116+
$directory = dirname($scope->getFile());
117+
return $directory . '/' . $stringPart;
118+
}
119+
120+
/**
121+
* @return array<MethodCall>
122+
*/
123+
private function resolveExcludeMethodCalls(Closure $closure): array
124+
{
125+
$nodeFinder = new NodeFinder();
126+
127+
$methodCalls = $nodeFinder->find($closure, function (Node $node): bool {
128+
if (! $node instanceof MethodCall) {
129+
return false;
130+
}
131+
132+
if ($node->isFirstClassCallable()) {
133+
return false;
134+
}
135+
136+
// must have exactly 1 arg
137+
if (count($node->getArgs()) !== 1) {
138+
return false;
139+
}
140+
141+
if (! $node->name instanceof Identifier) {
142+
return false;
143+
}
144+
145+
return $node->name->toString() === 'exclude';
146+
});
147+
148+
/** @var MethodCall[] $methodCalls */
149+
return $methodCalls;
150+
}
151+
}

src/Rules/Symfony/FormTypeClassNameRule.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
*/
2323
final class FormTypeClassNameRule implements Rule
2424
{
25+
/**
26+
* @var string
27+
*/
28+
public const ERROR_MESSAGE = 'Class extends "%s" must have "FormType" suffix to make form explicit, "%s" given';
29+
2530
public function getNodeType(): string
2631
{
2732
return Class_::class;
@@ -50,11 +55,7 @@ public function processNode(Node $node, Scope $scope): array
5055
return [];
5156
}
5257

53-
$errorMessage = sprintf(
54-
'Class extends "%s" must have "FormType" suffix to make form explicit, "%s" given',
55-
SymfonyClass::FORM_TYPE,
56-
$className
57-
);
58+
$errorMessage = sprintf(self::ERROR_MESSAGE, SymfonyClass::FORM_TYPE, $className);
5859

5960
$identifierRuleError = RuleErrorBuilder::message($errorMessage)
6061
->identifier(SymfonyRuleIdentifier::FORM_TYPE_CLASS_NAME)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symplify\PHPStanRules\Symfony\NodeAnalyzer;
6+
7+
use PhpParser\Node\Expr\Closure;
8+
use PhpParser\Node\Name;
9+
use Symplify\PHPStanRules\Enum\SymfonyClass;
10+
11+
final class SymfonyClosureDetector
12+
{
13+
public static function detect(Closure $closure): bool
14+
{
15+
if (count($closure->getParams()) !== 1) {
16+
return false;
17+
}
18+
19+
$onlyParam = $closure->getParams()[0];
20+
if (! $onlyParam->type instanceof Name) {
21+
return false;
22+
}
23+
24+
$parameterName = $onlyParam->type->toString();
25+
return $parameterName === SymfonyClass::CONTAINER_CONFIGURATOR;
26+
}
27+
}
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+
$services->load('App\\', __DIR__ . '/../src')
10+
->exclude([
11+
__DIR__ . '/../*/missing'
12+
]);
13+
};
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+
$services->load('App\\', __DIR__ . '/../src')
10+
->exclude([
11+
__DIR__ . '/../{missing,here}'
12+
]);
13+
};
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+
$services->load('App\\', __DIR__ . '/../src')
10+
->exclude([
11+
__DIR__ . '/../missing'
12+
]);
13+
};

0 commit comments

Comments
 (0)