Skip to content
Open
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ vendor/bin/swiss-knife finalize-classes src tests --skip-file '*Controller.php'

<br>

Using Doctrine ORM 3.4+ on PHP 8.4+? Finalize Doctrine entities too, as they no longer need proxy subclasses:

```bash
vendor/bin/swiss-knife finalize-classes src tests --finalize-doctrine-entities
```

This option works only when `composer.json` requires `php` 8.4+ and `doctrine/orm` 3.4+, otherwise the command fails.

<br>

## 5. Privatize local class constants

PHPStan can report unused private class constants, but it skips all the public ones.
Expand Down
151 changes: 119 additions & 32 deletions src/Command/FinalizeClassesCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Rector\SwissKnife\Command;

use Composer\Semver\Intervals;
use Composer\Semver\VersionParser;
use Entropy\Console\Contract\CommandInterface;
use Entropy\Console\Enum\ExitCode;
use Entropy\Console\Output\OutputPrinter;
Expand All @@ -14,9 +16,11 @@
use Rector\SwissKnife\EntityClassResolver;
use Rector\SwissKnife\FileSystem\PathHelper;
use Rector\SwissKnife\Finder\PhpFilesFinder;
use Rector\SwissKnife\Jack\Utils\JsonFileLoader;
use Rector\SwissKnife\MockedClassResolver;
use Rector\SwissKnife\ParentClassResolver;
use Rector\SwissKnife\PhpParser\CachedPhpParser;
use UnexpectedValueException;

final class FinalizeClassesCommand implements CommandInterface
{
Expand Down Expand Up @@ -51,6 +55,10 @@ final class FinalizeClassesCommand implements CommandInterface
*/
private const NEWLINE_CLASS_START_REGEX = '#^(readonly )?class\s#m';

private const MINIMAL_PHP_CONSTRAINT = '>=8.4.0';

private const MINIMAL_DOCTRINE_ORM_CONSTRAINT = '>=3.4.0';

public function __construct(OutputPrinter $outputPrinter, ParentClassResolver $parentClassResolver, EntityClassResolver $entityClassResolver, CachedPhpParser $cachedPhpParser, MockedClassResolver $mockedClassResolver)
{
$this->outputPrinter = $outputPrinter;
Expand All @@ -66,14 +74,87 @@ public function __construct(OutputPrinter $outputPrinter, ParentClassResolver $p
* @param bool $skipMocked Skip mocked classes as well (use only if unable to run bypass-finals package)
* @param string[] $skipFiles Skip file or files by path
* @param bool $noProgress Do not show progress bar, only results
* @param bool $finalizeDoctrineEntities Finalize Doctrine entities too. Requires PHP 8.4+ and doctrine/orm 3.4+ in composer.json
*/
public function run(
array $paths,
bool $dryRun = false,
bool $skipMocked = false,
array $skipFiles = [],
bool $noProgress = false
bool $noProgress = false,
bool $finalizeDoctrineEntities = false
): int {
if ($finalizeDoctrineEntities && ! $this->isDoctrineEntitiesFinalizeSupported(getcwd() . '/composer.json')) {
$this->outputPrinter->error(
'--finalize-doctrine-entities requires "php" 8.4+ and "doctrine/orm" 3.4+ in composer.json'
);

return ExitCode::ERROR;
}

$filePathsToFinalize = $this->resolveFilePathsToFinalize(
$paths,
$skipMocked,
$skipFiles,
$finalizeDoctrineEntities,
$noProgress
);

$this->outputPrinter->title('2. Finalizing safe classes');

if ($filePathsToFinalize === []) {
$this->outputPrinter->success('Nothing to finalize');
return ExitCode::SUCCESS;
}

$finalizedFilePaths = [];
foreach ($filePathsToFinalize as $filePathToFinalize) {
$finalizedFilePaths[] = PathHelper::relativeToCwd($filePathToFinalize);

if ($dryRun === false) {
$finalizedContents = Regex::replace(
FileSystem::read($filePathToFinalize),
self::NEWLINE_CLASS_START_REGEX,
'final $1class '
);

FileSystem::write($filePathToFinalize, $finalizedContents);
}
}

$this->outputPrinter->listing($finalizedFilePaths);

$countFinalizedClasses = count($finalizedFilePaths);
$pluralClassText = $countFinalizedClasses === 1 ? 'class' : 'classes';

// to make it fail in CI
if ($dryRun) {
$this->outputPrinter->error(sprintf(
'%d %s can be finalized',
$countFinalizedClasses,
$pluralClassText,
));

return ExitCode::ERROR;
}

$this->outputPrinter->success(sprintf('%d %s finalized', $countFinalizedClasses, $pluralClassText));

return ExitCode::SUCCESS;
}

/**
* @param string[] $paths
* @param string[] $skipFiles
* @return string[]
*/
public function resolveFilePathsToFinalize(
array $paths,
bool $skipMocked = false,
array $skipFiles = [],
bool $finalizeDoctrineEntities = false,
bool $noProgress = true
): array {
$this->outputPrinter->title('1. Detecting parent and entity classes');

$phpFileInfos = PhpFilesFinder::find($paths, $skipFiles);
Expand Down Expand Up @@ -125,56 +206,62 @@ public function run(

$this->outputPrinter->newline(1);

$this->outputPrinter->title('2. Finalizing safe classes');
$excludedClasses = array_merge($parentClassNames, $mockedClassNames);
if (! $finalizeDoctrineEntities) {
$excludedClasses = array_merge($excludedClasses, $entityClassNames);
}

$excludedClasses = array_merge($parentClassNames, $entityClassNames, $mockedClassNames);
$needsFinalizeAnalyzer = new NeedsFinalizeAnalyzer($excludedClasses, $this->cachedPhpParser);

$finalizedFilePaths = [];

$filePathsToFinalize = [];
foreach ($phpFileInfos as $phpFileInfo) {
// should be file be finalize, is not and is not excluded?
if (! $needsFinalizeAnalyzer->isNeeded($phpFileInfo->getRealPath())) {
continue;
}

$finalizedContents = Regex::replace(
$phpFileInfo->getContents(),
self::NEWLINE_CLASS_START_REGEX,
'final $1class '
);

$finalizedFilePaths[] = PathHelper::relativeToCwd($phpFileInfo->getRealPath());

if ($dryRun === false) {
FileSystem::write($phpFileInfo->getRealPath(), $finalizedContents);
}
$filePathsToFinalize[] = $phpFileInfo->getRealPath();
}

if ($finalizedFilePaths === []) {
$this->outputPrinter->success('Nothing to finalize');
return ExitCode::SUCCESS;
return $filePathsToFinalize;
}

public function isDoctrineEntitiesFinalizeSupported(string $composerJsonFilePath): bool
{
if (! file_exists($composerJsonFilePath)) {
return false;
}

$this->outputPrinter->listing($finalizedFilePaths);
$composerJson = JsonFileLoader::loadFileToJson($composerJsonFilePath);
$requires = (array) ($composerJson['require'] ?? []);
$requiresDev = (array) ($composerJson['require-dev'] ?? []);

$countFinalizedClasses = count($finalizedFilePaths);
$pluralClassText = $countFinalizedClasses === 1 ? 'class' : 'classes';
$phpConstraint = $requires['php'] ?? null;
$doctrineOrmConstraint = $requires['doctrine/orm'] ?? $requiresDev['doctrine/orm'] ?? null;

// to make it fail in CI
if ($dryRun) {
$this->outputPrinter->error(sprintf(
'%d %s can be finalized',
$countFinalizedClasses,
$pluralClassText,
));
return $this->isConstraintWithin($phpConstraint, self::MINIMAL_PHP_CONSTRAINT)
&& $this->isConstraintWithin($doctrineOrmConstraint, self::MINIMAL_DOCTRINE_ORM_CONSTRAINT);
}

return ExitCode::ERROR;
/**
* @param mixed $constraint
*/
private function isConstraintWithin($constraint, string $minimalConstraint): bool
{
if (! is_string($constraint) || $constraint === '') {
return false;
}

$this->outputPrinter->success(sprintf('%d %s finalized', $countFinalizedClasses, $pluralClassText));
$versionParser = new VersionParser();

return ExitCode::SUCCESS;
try {
return Intervals::isSubsetOf(
$versionParser->parseConstraints($constraint),
$versionParser->parseConstraints($minimalConstraint)
);
} catch (UnexpectedValueException $exception) {
return false;
}
}

public function getName(): string
Expand Down
66 changes: 66 additions & 0 deletions tests/Command/FinalizeClassesCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Rector\SwissKnife\Tests\Command;

use Rector\SwissKnife\Command\FinalizeClassesCommand;
use Rector\SwissKnife\Tests\AbstractTestCase;

final class FinalizeClassesCommandTest extends AbstractTestCase
{
private FinalizeClassesCommand $finalizeClassesCommand;

protected function setUp(): void
{
parent::setUp();

$this->finalizeClassesCommand = $this->make(FinalizeClassesCommand::class);
}

public function testSkipDoctrineEntitiesByDefault(): void
{
$filePathsToFinalize = $this->finalizeClassesCommand->resolveFilePathsToFinalize([
__DIR__ . '/Fixture/FinalizeClasses',
]);

$this->assertSame([__DIR__ . '/Fixture/FinalizeClasses/SomeService.php'], $filePathsToFinalize);
}

public function testFinalizeDoctrineEntities(): void
{
$filePathsToFinalize = $this->finalizeClassesCommand->resolveFilePathsToFinalize(
[__DIR__ . '/Fixture/FinalizeClasses'],
false,
[],
true
);

$this->assertSame([
__DIR__ . '/Fixture/FinalizeClasses/Entity/SomeEntity.php',
__DIR__ . '/Fixture/FinalizeClasses/SomeService.php',
], $filePathsToFinalize);
}

/**
* @dataProvider provideComposerJsonFiles
*/
public function testDoctrineEntitiesFinalizeSupport(string $composerJsonFilePath, bool $expectedIsSupported): void
{
$this->assertSame(
$expectedIsSupported,
$this->finalizeClassesCommand->isDoctrineEntitiesFinalizeSupported($composerJsonFilePath)
);
}

/**
* @return iterable<array{string, bool}>
*/
public static function provideComposerJsonFiles(): iterable
{
yield [__DIR__ . '/Fixture/Composer/supported-composer.json', true];
yield [__DIR__ . '/Fixture/Composer/php-too-low-composer.json', false];
yield [__DIR__ . '/Fixture/Composer/doctrine-dev-too-low-composer.json', false];
yield [__DIR__ . '/Fixture/Composer/missing-composer.json', false];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"require": {
"php": "^8.4"
},
"require-dev": {
"doctrine/orm": "^3.3"
}
}
6 changes: 6 additions & 0 deletions tests/Command/Fixture/Composer/php-too-low-composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"require": {
"php": "^8.3",
"doctrine/orm": "^3.4"
}
}
6 changes: 6 additions & 0 deletions tests/Command/Fixture/Composer/supported-composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"require": {
"php": "^8.4",
"doctrine/orm": "^3.4"
}
}
12 changes: 12 additions & 0 deletions tests/Command/Fixture/FinalizeClasses/Entity/SomeEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Rector\SwissKnife\Tests\Command\Fixture\FinalizeClasses\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity
*/
class SomeEntity
{
}
7 changes: 7 additions & 0 deletions tests/Command/Fixture/FinalizeClasses/SomeService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Rector\SwissKnife\Tests\Command\Fixture\FinalizeClasses;

class SomeService
{
}