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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Rector\Tests\TypeDeclaration\TypeGuardedClasses\Fixture;

use Rector\Tests\TypeDeclaration\TypeGuardedClasses\Source\GuardedRepository;

final class FinalChildApplied extends GuardedRepository
{
public function getData()
{
return [];
}
}

?>
-----
<?php

namespace Rector\Tests\TypeDeclaration\TypeGuardedClasses\Fixture;

use Rector\Tests\TypeDeclaration\TypeGuardedClasses\Source\GuardedRepository;

final class FinalChildApplied extends GuardedRepository
{
public function getData(): array
{
return [];
}
}

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Rector\Tests\TypeDeclaration\TypeGuardedClasses\Fixture;

use Rector\Tests\TypeDeclaration\TypeGuardedClasses\Source\GuardedRepository;

class SkipChildOfGuarded extends GuardedRepository
{
public function getData()
{
return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Rector\Tests\TypeDeclaration\TypeGuardedClasses\Source;

class GuardedRepository
{
public function getData()
{
return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Rector\Tests\TypeDeclaration\TypeGuardedClasses;

use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;

final class TypeGuardedClassesTest extends AbstractRectorTestCase
{
#[DataProvider('provideData')]
public function test(string $filePath): void
{
$this->doTestFile($filePath);
}

public static function provideData(): Iterator
{
return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
}

public function provideConfigFilePath(): string
{
return __DIR__ . '/config/configured_rule.php';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

use PHPStan\Type\ArrayType;
use PHPStan\Type\MixedType;
use Rector\Config\RectorConfig;
use Rector\Tests\TypeDeclaration\TypeGuardedClasses\Source\GuardedRepository;
use Rector\TypeDeclaration\Rector\ClassMethod\AddReturnTypeDeclarationRector;
use Rector\TypeDeclaration\ValueObject\AddReturnTypeDeclaration;

return static function (RectorConfig $rectorConfig): void {
$rectorConfig->typeGuardedClasses([GuardedRepository::class]);

$rectorConfig->ruleWithConfiguration(AddReturnTypeDeclarationRector::class, [
new AddReturnTypeDeclaration(
GuardedRepository::class,
'getData',
new ArrayType(new MixedType(), new MixedType())
),
]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ abstract protected function isMatchingParamType(Type $type): bool;

private function shouldSkipClassMethod(ClassMethod $classMethod): bool
{
// user-guarded class: adding a param type here would break its child classes
if ($this->parentClassMethodTypeOverrideGuard->isTypeGuardedClass($classMethod)) {
return true;
}

$isMissingParameterTypes = false;
foreach ($classMethod->params as $param) {
if ($param->type instanceof Node) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Rector\StaticTypeMapper\StaticTypeMapper;
use Rector\TypeDeclaration\ValueObject\AddParamTypeDeclaration;
use Rector\ValueObject\PhpVersionFeature;
use Rector\VendorLocker\ParentClassMethodTypeOverrideGuard;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
use Webmozart\Assert\Assert;
Expand All @@ -40,6 +41,7 @@ public function __construct(
private readonly TypeComparator $typeComparator,
private readonly PhpVersionProvider $phpVersionProvider,
private readonly StaticTypeMapper $staticTypeMapper,
private readonly ParentClassMethodTypeOverrideGuard $parentClassMethodTypeOverrideGuard,
) {
}

Expand Down Expand Up @@ -85,6 +87,11 @@ public function refactor(Node $node): ?Node
{
$this->hasChanged = false;

// skip guarded classes, where adding a param type would break child classes
if ($this->parentClassMethodTypeOverrideGuard->isTypeGuardedClass($node)) {
return null;
}

foreach ($node->getMethods() as $classMethod) {
if ($this->shouldSkip($node, $classMethod)) {
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ public function refactor(Node $node): ?Node
{
$this->hasChanged = false;

// skip guarded classes, where adding a return type would break child classes
if ($this->parentClassMethodTypeOverrideGuard->isTypeGuardedClass($node)) {
return null;
}

foreach ($this->methodReturnTypes as $methodReturnType) {
$objectType = $methodReturnType->getObjectType();
if (! $this->isObjectType($node, $objectType)) {
Expand Down
14 changes: 14 additions & 0 deletions src/Config/RectorConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,20 @@ public function treatClassesAsFinal(bool $treatClassesAsFinal = true): void
SimpleParameterProvider::setParameter(Option::TREAT_CLASSES_AS_FINAL, $treatClassesAsFinal);
}

/**
* Guard the listed classes and their descendants against method signature changes that would
* break child classes - e.g. adding a return type or a param type. Only non-final classes are
* guarded, as final classes cannot be extended.
*
* @param string[] $classes
*/
public function typeGuardedClasses(array $classes): void
{
Assert::allString($classes);

SimpleParameterProvider::setParameter(Option::TYPE_GUARDED_CLASSES, $classes);
}

/**
* @param string[] $extensions
*/
Expand Down
6 changes: 6 additions & 0 deletions src/Configuration/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,10 @@ final class Option
* @internal to allow process file without extension if explicitly registered
*/
public const string FILES_WITHOUT_EXTENSION = 'files_without_extension';

/**
* @internal Use @see \Rector\Config\RectorConfig::typeGuardedClasses() instead
* @var string
*/
public const string TYPE_GUARDED_CLASSES = 'type_guarded_classes';
}
21 changes: 21 additions & 0 deletions src/Configuration/RectorConfigBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ final class RectorConfigBuilder

private ?bool $isTreatClassesAsFinal = null;

/**
* @var string[]
*/
private array $typeGuardedClasses = [];

/**
* @var RegisteredService[]
*/
Expand Down Expand Up @@ -388,6 +393,10 @@ public function __invoke(RectorConfig $rectorConfig): void
$rectorConfig->newLineOnFluentCall($this->isFluentNewLine);
}

if ($this->typeGuardedClasses !== []) {
$rectorConfig->typeGuardedClasses($this->typeGuardedClasses);
}

if ($this->isTreatClassesAsFinal !== null) {
$rectorConfig->treatClassesAsFinal($this->isTreatClassesAsFinal);
}
Expand Down Expand Up @@ -1248,6 +1257,18 @@ public function withTreatClassesAsFinal(bool $isTreatClassesAsFinal = true): sel
return $this;
}

/**
* Guard the listed classes and their non-final descendants against method signature changes
* that would break child classes - e.g. adding a return type or a param type.
*
* @param string[] $typeGuardedClasses
*/
public function withTypeGuardedClasses(array $typeGuardedClasses): self
{
$this->typeGuardedClasses = $typeGuardedClasses;
return $this;
}

public function registerService(string $className, ?string $alias = null, ?string $tag = null): self
{
$this->registerServices[] = new RegisteredService($className, $alias, $tag);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
use PHPStan\Reflection\ClassReflection;
use Rector\NodeNameResolver\NodeNameResolver;
use Rector\Reflection\ReflectionResolver;
use Rector\VendorLocker\ParentClassMethodTypeOverrideGuard;

final readonly class ClassMethodParamVendorLockResolver
{
public function __construct(
private NodeNameResolver $nodeNameResolver,
private ReflectionResolver $reflectionResolver,
private ParentClassMethodTypeOverrideGuard $parentClassMethodTypeOverrideGuard,
) {
}

Expand All @@ -23,6 +25,11 @@ public function isVendorLocked(ClassMethod $classMethod): bool
return true;
}

// user-guarded class: adding a param type here would break its child classes
if ($this->parentClassMethodTypeOverrideGuard->isTypeGuardedClass($classMethod)) {
return true;
}

if ($classMethod->isPrivate()) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public function __construct(

public function shouldSkipClassMethod(ClassMethod $classMethod, Scope $scope): bool
{
// user-guarded class: adding a return type here would break its child classes
if ($this->parentClassMethodTypeOverrideGuard->isTypeGuardedClass($classMethod)) {
return true;
}

if ($this->magicClassMethodAnalyzer->isUnsafeOverridden($classMethod)) {
return true;
}
Expand Down
36 changes: 36 additions & 0 deletions src/VendorLocker/ParentClassMethodTypeOverrideGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
namespace Rector\VendorLocker;

use PhpParser\Node;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\Type;
use Rector\Configuration\Option;
use Rector\Configuration\Parameter\SimpleParameterProvider;
use Rector\NodeNameResolver\NodeNameResolver;
use Rector\NodeTypeResolver\TypeComparator\TypeComparator;
use Rector\Reflection\ClassReflectionAnalyzer;
Expand All @@ -27,6 +30,39 @@ public function __construct(
) {
}

/**
* Is the class user-guarded against method signature changes, via
* @see \Rector\Config\RectorConfig::typeGuardedClasses()
*
* A class is guarded when it - or any of its ancestors - is on the configured list and it is not
* final. Adding a return or param type to such a class is a breaking change for its child
* classes, so type-declaration rules must leave it untouched. Final classes are never guarded,
* as they cannot be extended.
*/
public function isTypeGuardedClass(ClassLike|ClassMethod $node): bool
{
$guardedClasses = SimpleParameterProvider::provideArrayParameter(Option::TYPE_GUARDED_CLASSES);
if ($guardedClasses === []) {
return false;
}

$classReflection = $this->reflectionResolver->resolveClassReflection($node);
if (! $classReflection instanceof ClassReflection) {
return false;
}

// final classes cannot be extended, so adding a type is never a breaking change
if ($classReflection->isFinalByKeyword()) {
return false;
}

// covers the guarded class itself and all its descendants
return array_any(
$guardedClasses,
static fn (string $guardedClass): bool => $classReflection->is($guardedClass)
);
}

public function hasParentClassMethod(ClassMethod|MethodReflection $classMethod): bool
{
try {
Expand Down
Loading