Skip to content
Merged
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2885,6 +2885,41 @@ public function __construct(

<br>

### PreferClassServiceReferenceRule

In a Symfony PHP config closure, when a `$services->alias('some.helper', SomeHelper::class)` points a string id at a class, a `service('some.helper')` reference should name the service by its class instead - `service(SomeHelper::class)`. Then the string alias nothing else asks for can be dropped.

```yaml
rules:
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule
```

```php
$services->alias('some.helper', SomeHelper::class);

$services->set(SomeConsumer::class)
->args([service('some.helper')]);
```

:x:

<br>

```php
$services->alias('some.helper', SomeHelper::class);

$services->set(SomeConsumer::class)
->args([service(SomeHelper::class)]);
```

:+1:

<br>

---

<br>

## 4. PHPUnit-specific Rules

### NoAssertFuncCallInTestsRule
Expand Down
11 changes: 11 additions & 0 deletions config/symfony-config-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,14 @@ rules:

# $services->set('X')->class('X')
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoSetClassServiceDuplicationRule

# service('id') where an alias points 'id' at a class
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule

services:
-
class: Symplify\PHPStanRules\Collector\ClassTargetServiceAliasCollector
tags: [phpstan.collector]
-
class: Symplify\PHPStanRules\Collector\ServiceStringReferenceCollector
tags: [phpstan.collector]
72 changes: 72 additions & 0 deletions src/Collector/ClassTargetServiceAliasCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Collector;

use PhpParser\Node;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;

/**
* Collects the config-closure aliases that point a string service id at a class,
* e.g. $services->alias('some.helper', SomeHelper::class).
*
* Such an id names the very same service its class name does, so a reference by the class name says the same
* without the loose string. The alias the other way around, $services->alias(SomeHelper::class, 'some.helper'),
* is left out.
*
* @implements Collector<MethodCall, array{string, string, int}>
*/
final class ClassTargetServiceAliasCollector implements Collector
{
public function getNodeType(): string
{
return MethodCall::class;
}

/**
* @return array{string, string, int}|null the string service id, the class name it points at and the
* line of the alias() call
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $node->name instanceof Identifier || $node->name->toString() !== 'alias') {
return null;
}

$args = $node->getArgs();
if (count($args) !== 2) {
return null;
}

if (! $args[0]->value instanceof String_) {
return null;
}

$className = $this->matchClassName($args[1]->value);
if ($className === null) {
return null;
}

return [$args[0]->value->value, $className, $node->getStartLine()];
}

private function matchClassName(Node $aliasValue): ?string
{
if (! $aliasValue instanceof ClassConstFetch || ! $aliasValue->class instanceof Name) {
return null;
}

if (! $aliasValue->name instanceof Identifier || $aliasValue->name->toLowerString() !== 'class') {
return null;
}

return $aliasValue->class->toString();
}
}
53 changes: 53 additions & 0 deletions src/Collector/ServiceStringReferenceCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Collector;

use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;

/**
* Collects the config-closure references that ask for a service by a string id,
* e.g. service('some.helper').
*
* A service(SomeHelper::class) reference is left out, that one already names the service by its class.
*
* @implements Collector<FuncCall, array{string, int}>
*/
final class ServiceStringReferenceCollector implements Collector
{
private const string SERVICE_FUNCTION = 'Symfony\Component\DependencyInjection\Loader\Configurator\service';

public function getNodeType(): string
{
return FuncCall::class;
}

/**
* @return array{string, int}|null the string service id with the line the reference is on
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $node->name instanceof Name) {
return null;
}

$functionName = $node->name->toString();
if ($functionName !== 'service' && $functionName !== self::SERVICE_FUNCTION) {
return null;
}

$firstArg = $node->getArgs()[0] ?? null;
if (! $firstArg instanceof Arg || ! $firstArg->value instanceof String_) {
return null;
}

return [$firstArg->value->value, $node->getStartLine()];
}
}
2 changes: 2 additions & 0 deletions src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,6 @@ final class SymfonyRuleIdentifier
public const string NO_CONTROLLER_METHOD_INJECTION = 'symfony.noControllerMethodInjection';

public const string FILE_NAME_MATCHES_EXTENSION = 'symfony.fileNameMatchesExtension';

public const string PREFER_CLASS_SERVICE_REFERENCE = 'symfony.preferClassServiceReference';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Rules\Symfony\ConfigClosure;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\CollectedDataNode;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symplify\PHPStanRules\Collector\ClassTargetServiceAliasCollector;
use Symplify\PHPStanRules\Collector\ServiceStringReferenceCollector;
use Symplify\PHPStanRules\Enum\RuleIdentifier\SymfonyRuleIdentifier;

/**
* Reports a config-closure service() reference that asks for a service by a string id an alias already points at
* the very class of, e.g. service('some.helper') while $services->alias('some.helper', SomeHelper::class) is
* registered.
*
* The class name names the very same service, so the reference should say the same by the type instead:
*
* $services->alias('some.helper', SomeHelper::class);
* ...
* ->args([service('some.helper')]);
*
* ->args([service(SomeHelper::class)]);
*
* @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\PreferClassServiceReferenceRuleTest
*
* @implements Rule<CollectedDataNode>
*/
final class PreferClassServiceReferenceRule implements Rule
{
public const string ERROR_MESSAGE = 'Reference the service by its class, service(%s::class), rather than by the string id "%s" a class name alias already covers';

public function getNodeType(): string
{
return CollectedDataNode::class;
}

/**
* @param CollectedDataNode $node
*
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
$classNamesByServiceId = $this->resolveClassNamesByServiceId($node);

/** @var array<string, list<array{string, int}>> $referencesByFilePath */
$referencesByFilePath = $node->get(ServiceStringReferenceCollector::class);

$ruleErrors = [];

foreach ($referencesByFilePath as $filePath => $references) {
foreach ($references as [$serviceId, $line]) {
$className = $classNamesByServiceId[$serviceId] ?? null;
if ($className === null) {
continue;
}

$ruleErrors[] = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $className, $serviceId))
->identifier(SymfonyRuleIdentifier::PREFER_CLASS_SERVICE_REFERENCE)
->file($filePath)
->line($line)
->build();
}
}

return $ruleErrors;
}

/**
* @return array<string, string> the class name every string service id is aliased to
*/
private function resolveClassNamesByServiceId(CollectedDataNode $collectedDataNode): array
{
/** @var array<string, list<array{string, string, int}>> $aliasesByFilePath */
$aliasesByFilePath = $collectedDataNode->get(ClassTargetServiceAliasCollector::class);

$classNamesByServiceId = [];

foreach ($aliasesByFilePath as $aliases) {
foreach ($aliases as [$serviceId, $className]) {
$classNamesByServiceId[$serviceId] = $className;
}
}

return $classNamesByServiceId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\Fixture;

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\Source\SomeHelper;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;

return function (ContainerConfigurator $container) {
$services = $container->services();

$services->alias('some.helper', SomeHelper::class);

$services->set(SomeHelper::class)
->args([service(SomeHelper::class)]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\Fixture;

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\Source\SomeHelper;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;

return function (ContainerConfigurator $container) {
$services = $container->services();

$services->set(SomeHelper::class)
->args([service('unaliased.service')]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\Fixture;

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule\Source\SomeHelper;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;

return function (ContainerConfigurator $container) {
$services = $container->services();

$services->alias('some.helper', SomeHelper::class);

$services->set(SomeHelper::class)
->args([service('some.helper')]);
};
Loading
Loading