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 @@ -2850,6 +2850,41 @@ public function handle(): void

<br>

### NoNullableServiceInConstructorRule

A constructor service dependency must not be nullable - a service is always provided by the container, so `?SomeService` only hides that it is really required. Nullable is allowed on an abstract class (a child fills the dependency) and on values that are not services: scalars, arrays, exceptions (`?Throwable $previous`) and date value objects.

```yaml
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoNullableServiceInConstructorRule
```

```php
public function __construct(
private readonly ?SomeService $someService,
) {
}
```

:x:

<br>

```php
public function __construct(
private readonly SomeService $someService,
) {
}
```

:+1:

<br>

---

<br>

## 4. PHPUnit-specific Rules

### NoAssertFuncCallInTestsRule
Expand Down
1 change: 1 addition & 0 deletions config/symfony-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ rules:

# constructor injection
- Symplify\PHPStanRules\Rules\Symfony\PreferInterfaceInConstructorRule
- Symplify\PHPStanRules\Rules\Symfony\NoNullableServiceInConstructorRule
2 changes: 2 additions & 0 deletions src/Enum/RuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,6 @@ final class RuleIdentifier
public const string REQUIRE_ARRAY_SHAPE_RETURN = 'symplify.requireArrayShapeReturn';

public const string NO_SERVICE_JUGGLING = 'symplify.noServiceJuggling';

public const string NO_NULLABLE_SERVICE_IN_CONSTRUCTOR = 'symplify.noNullableServiceInConstructor';
}
183 changes: 183 additions & 0 deletions src/Rules/Symfony/NoNullableServiceInConstructorRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Rules\Symfony;

use DateTimeInterface;
use PhpParser\Node;
use PhpParser\Node\ComplexType;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\UnionType;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symplify\PHPStanRules\Enum\RuleIdentifier;
use Throwable;

/**
* A constructor service dependency must not be nullable.
*
* A service is always provided by the container, so "?SomeService $service" or "SomeService|null $service" only hides
* that it is really required. Nullable is allowed on an abstract class, whose optional dependency is filled by a child.
* A nullable scalar, array, exception ("$previous" is nullable by PHP convention) or date value object is left alone,
* as those are values, not services.
*
* @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\NoNullableServiceInConstructorRuleTest
*
* @implements Rule<ClassMethod>
*/
final readonly class NoNullableServiceInConstructorRule implements Rule
{
public const string ERROR_MESSAGE = 'Constructor service "%s" of type "%s" is nullable. A service is always provided, make it non-nullable';

public function __construct(
private ReflectionProvider $reflectionProvider,
) {
}

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

/**
* @param ClassMethod $node
*
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->name->toLowerString() !== '__construct') {
return [];
}

// an anonymous class is a local one-off, not a container service
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection || $classReflection->isAnonymous()) {
return [];
}

// an abstract base class may leave a dependency optional for a child to provide
if ($classReflection->isAbstract()) {
return [];
}

$paramTypes = $this->resolveParamClassTypes($node, $scope);

$ruleErrors = [];

foreach ($node->params as $param) {
$serviceName = $this->matchNullableServiceName($param->type);
if (! $serviceName instanceof Name) {
continue;
}

$serviceType = $scope->resolveName($serviceName);
if ($this->isValueObjectType($serviceType)) {
continue;
}

// a sibling param of the same type already provides it non-nullable, so this one is a real optional extra
if (count(array_keys($paramTypes, $serviceType, true)) > 1) {
continue;
}

$parameterName = $param->var instanceof Variable && is_string($param->var->name)
? '$' . $param->var->name
: '';

$ruleErrors[] = RuleErrorBuilder::message(
sprintf(self::ERROR_MESSAGE, $parameterName, $serviceType)
)
->identifier(RuleIdentifier::NO_NULLABLE_SERVICE_IN_CONSTRUCTOR)
->line($param->getStartLine())
->build();
}

return $ruleErrors;
}

/**
* Resolves every constructor param to its class-type name (nullable or not), for duplicate-type detection.
*
* @return string[]
*/
private function resolveParamClassTypes(ClassMethod $classMethod, Scope $scope): array
{
$paramTypes = [];

foreach ($classMethod->params as $param) {
$className = $this->matchClassName($param->type);
if ($className instanceof Name) {
$paramTypes[] = $scope->resolveName($className);
}
}

return $paramTypes;
}

/**
* Returns the class-type name node of any type, nullable or not, null when the type is not a class type.
*/
private function matchClassName(Identifier|Name|ComplexType|null $type): ?Name
{
if ($type instanceof Name) {
return $type;
}

return $this->matchNullableServiceName($type);
}

/**
* Returns the class-type name node when the type is a nullable class type, null otherwise.
*/
private function matchNullableServiceName(Identifier|Name|ComplexType|null $type): ?Name
{
if ($type instanceof NullableType) {
return $type->type instanceof Name ? $type->type : null;
}

if ($type instanceof UnionType) {
$className = null;
$hasNull = false;

foreach ($type->types as $unionedType) {
if ($unionedType instanceof Identifier && $unionedType->toLowerString() === 'null') {
$hasNull = true;

continue;
}

if ($unionedType instanceof Name) {
$className = $unionedType;
}
}

return $hasNull ? $className : null;
}

return null;
}

/**
* A nullable class type that is not really a service: an exception ("$previous") or a date value object.
*/
private function isValueObjectType(string $className): bool
{
if (! $this->reflectionProvider->hasClass($className)) {
return false;
}

$classReflection = $this->reflectionProvider->getClass($className);

return $classReflection->is(Throwable::class) || $classReflection->is(DateTimeInterface::class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

use Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Source\AnotherService;
use Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Source\SomeService;

final class ReportNullableService
{
public function __construct(
private readonly ?SomeService $someService,
private readonly AnotherService|null $anotherService,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

use Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Source\SomeService;

abstract class SkipAbstractClass
{
public function __construct(
protected readonly ?SomeService $someService,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

use Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Source\SomeService;

final class SkipAnonymousClass
{
public function create(): object
{
return new class(null) {
public function __construct(
private readonly ?SomeService $someService,
) {
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

use Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Source\SomeService;

final class SkipDuplicateType
{
public function __construct(
private readonly SomeService $primary,
private readonly ?SomeService $secondary,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

use DateTimeInterface;

final class SkipNullableDateTime
{
public function __construct(
private readonly ?DateTimeInterface $createdAt,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

use Throwable;

final class SkipNullableException
{
public function __construct(
private readonly ?Throwable $previous,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\Fixture;

final class SkipNullableScalar
{
/**
* @param mixed[]|null $options
*/
public function __construct(
private readonly ?string $name,
private readonly ?array $options,
) {
}
}
Loading
Loading