Skip to content

Commit

Permalink
Add "finalize-classes" command (#2)
Browse files Browse the repository at this point in the history
* misc

* move finalize here

* fixup! move finalize here

* tidy up

* bump
  • Loading branch information
TomasVotruba committed Feb 10, 2024
1 parent acb6fe4 commit 5d74acc
Show file tree
Hide file tree
Showing 11 changed files with 554 additions and 1 deletion.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,17 @@ This will update all files in your `/src` directory, to starts with `App\\` and
<br>
### 4. Dependency tools speed testing
### 4. Finalize classes without children
Do you want to finalize all classes that don't have children?
```bash
vendor/bin/swiss-knife finalize-classes src
```
<br>
### 5. Dependency tools speed testing
Do you want to test speed of your dependency tools? E.g. if PHPStan or Rector got slower after upgrade?
Expand Down
41 changes: 41 additions & 0 deletions src/Analyzer/NeedsFinalizeAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Rector\SwissKnife\Analyzer;

use PhpParser\NodeTraverser;
use Rector\SwissKnife\PhpParser\CachedPhpParser;
use Rector\SwissKnife\PhpParser\NodeVisitor\NeedForFinalizeNodeVisitor;
use Webmozart\Assert\Assert;

final class NeedsFinalizeAnalyzer
{
private readonly NodeTraverser $finalizingNodeTraverser;

private readonly NeedForFinalizeNodeVisitor $needForFinalizeNodeVisitor;

/**
* @param string[] $excludedClasses
*/
public function __construct(
array $excludedClasses,
private readonly CachedPhpParser $cachedPhpParser
) {
Assert::allString($excludedClasses);

$finalizingNodeTraverser = new NodeTraverser();
$this->needForFinalizeNodeVisitor = new NeedForFinalizeNodeVisitor($excludedClasses);
$finalizingNodeTraverser->addVisitor($this->needForFinalizeNodeVisitor);

$this->finalizingNodeTraverser = $finalizingNodeTraverser;
}

public function isNeeded(string $filePath): bool
{
$stmts = $this->cachedPhpParser->parseFile($filePath);
$this->finalizingNodeTraverser->traverse($stmts);

return $this->needForFinalizeNodeVisitor->isNeeded();
}
}
109 changes: 109 additions & 0 deletions src/Command/FinalizeClassesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace Rector\SwissKnife\Command;

use Nette\Utils\FileSystem;
use Nette\Utils\Strings;
use Rector\SwissKnife\Analyzer\NeedsFinalizeAnalyzer;
use Rector\SwissKnife\EntityClassResolver;
use Rector\SwissKnife\FileSystem\PhpFilesFinder;
use Rector\SwissKnife\ParentClassResolver;
use Rector\SwissKnife\PhpParser\CachedPhpParser;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

final class FinalizeClassesCommand extends Command
{
/**
* @see https://regex101.com/r/Q5Nfbo/1
*/
public const NEWLINE_CLASS_START_REGEX = '#^class\s#m';

public function __construct(
private readonly SymfonyStyle $symfonyStyle,
private readonly ParentClassResolver $parentClassResolver,
private readonly EntityClassResolver $entityClassResolver,
private readonly CachedPhpParser $cachedPhpParser
) {
parent::__construct();
}

protected function configure(): void
{
$this->setName('finalize-classes');

$this->setDescription('Finalize classes without children');

$this->addArgument('paths', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Directories to finalize');
}

/**
* @return self::FAILURE|self::SUCCESS
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$paths = (array) $input->getArgument('paths');

$phpFileInfos = PhpFilesFinder::findPhpFileInfos($paths);

$this->symfonyStyle->title('1. Detecting parent and entity classes');

// double to count for both parent and entity resolver
$this->symfonyStyle->progressStart(2 * count($phpFileInfos));

$progressClosure = function (): void {
$this->symfonyStyle->progressAdvance();
};

$parentClassNames = $this->parentClassResolver->resolve($phpFileInfos, $progressClosure);
$entityClassNames = $this->entityClassResolver->resolve($phpFileInfos, $progressClosure);

$this->symfonyStyle->progressFinish();

$this->symfonyStyle->writeln(sprintf(
'Found %d parent and %d entity classes',
count($parentClassNames),
count($entityClassNames)
));

$this->symfonyStyle->newLine(1);

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

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

$finalizedFilePaths = [];

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

$this->symfonyStyle->writeln(sprintf('File "%s" was finalized', $phpFileInfo->getRelativePath()));

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

$finalizedFilePaths[] = $phpFileInfo->getRelativePath();
FileSystem::write($phpFileInfo->getRealPath(), $finalizedContents);
}

if ($finalizedFilePaths === []) {
$this->symfonyStyle->success('Nothign to finalize');
return self::SUCCESS;
}

$this->symfonyStyle->listing($finalizedFilePaths);
$this->symfonyStyle->success(sprintf('%d classes were finalized', count($finalizedFilePaths)));

return Command::SUCCESS;
}
}
2 changes: 2 additions & 0 deletions src/DependencyInjection/ContainerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Rector\SwissKnife\Command\CheckCommentedCodeCommand;
use Rector\SwissKnife\Command\CheckConflictsCommand;
use Rector\SwissKnife\Command\DumpEditorconfigCommand;
use Rector\SwissKnife\Command\FinalizeClassesCommand;
use Rector\SwissKnife\Command\FindMultiClassesCommand;
use Rector\SwissKnife\Command\NamespaceToPSR4Command;
use Rector\SwissKnife\Command\SpeedRunToolCommand;
Expand Down Expand Up @@ -40,6 +41,7 @@ public function create(): Container
$container->make(NamespaceToPSR4Command::class),
$container->make(DumpEditorconfigCommand::class),
$container->make(SpeedRunToolCommand::class),
$container->make(FinalizeClassesCommand::class),
];

$application->addCommands($commands);
Expand Down
51 changes: 51 additions & 0 deletions src/EntityClassResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Rector\SwissKnife;

use Closure;
use PhpParser\NodeTraverser;
use Rector\SwissKnife\PhpParser\CachedPhpParser;
use Rector\SwissKnife\PhpParser\NodeVisitor\EntityClassNameCollectingNodeVisitor;
use Symfony\Component\Finder\SplFileInfo;

final readonly class EntityClassResolver
{
public function __construct(
private CachedPhpParser $cachedPhpParser
) {
}

/**
* @param SplFileInfo[] $phpFileInfos
* @return string[]
*/
public function resolve(array $phpFileInfos, Closure $progressClosure): array
{
$entityClassNameCollectingNodeVisitor = new EntityClassNameCollectingNodeVisitor();

$nodeTraverser = new NodeTraverser();
$nodeTraverser->addVisitor($entityClassNameCollectingNodeVisitor);

$this->traverseFileInfos($phpFileInfos, $nodeTraverser, $progressClosure);

return $entityClassNameCollectingNodeVisitor->getEntityClassNames();
}

/**
* @param SplFileInfo[] $phpFileInfos
*/
private function traverseFileInfos(
array $phpFileInfos,
NodeTraverser $nodeTraverser,
callable $progressClosure
): void {
foreach ($phpFileInfos as $phpFileInfo) {
$stmts = $this->cachedPhpParser->parseFile($phpFileInfo->getRealPath());

$nodeTraverser->traverse($stmts);
$progressClosure();
}
}
}
25 changes: 25 additions & 0 deletions src/Finder/PhpFilesFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Rector\SwissKnife\FileSystem;

use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

final class PhpFilesFinder
{
/**
* @param string[] $paths
* @return SplFileInfo[]
*/
public static function findPhpFileInfos(array $paths): array
{
$phpFinder = Finder::create()
->files()
->in($paths)
->name('*.php');

return iterator_to_array($phpFinder);
}
}
50 changes: 50 additions & 0 deletions src/ParentClassResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Rector\SwissKnife;

use PhpParser\NodeTraverser;
use Rector\SwissKnife\PhpParser\CachedPhpParser;
use Rector\SwissKnife\PhpParser\NodeVisitor\ParentClassNameCollectingNodeVisitor;
use Symfony\Component\Finder\SplFileInfo;

final readonly class ParentClassResolver
{
public function __construct(
private CachedPhpParser $cachedPhpParser
) {
}

/**
* @param SplFileInfo[] $phpFileInfos
* @return string[]
*/
public function resolve(array $phpFileInfos, callable $progressClosure): array
{
$parentClassNameCollectingNodeVisitor = new ParentClassNameCollectingNodeVisitor();

$nodeTraverser = new NodeTraverser();
$nodeTraverser->addVisitor($parentClassNameCollectingNodeVisitor);

$this->traverseFileInfos($phpFileInfos, $nodeTraverser, $progressClosure);

return $parentClassNameCollectingNodeVisitor->getParentClassNames();
}

/**
* @param SplFileInfo[] $phpFileInfos
*/
private function traverseFileInfos(
array $phpFileInfos,
NodeTraverser $nodeTraverser,
callable $progressClosure
): void {
foreach ($phpFileInfos as $phpFileInfo) {
$stmts = $this->cachedPhpParser->parseFile($phpFileInfo->getRealPath());

$nodeTraverser->traverse($stmts);
$progressClosure();
}
}
}
50 changes: 50 additions & 0 deletions src/PhpParser/CachedPhpParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Rector\SwissKnife\PhpParser;

use Nette\Utils\FileSystem;
use PhpParser\Node\Stmt;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser;

/**
* Parse file just once
*/
final class CachedPhpParser
{
/**
* @var array<string, Stmt[]>
*/
private array $cachedStmts = [];

public function __construct(
private readonly Parser $phpParser
) {
}

/**
* @return Stmt[]
*/
public function parseFile(string $filePath): array
{
if (isset($this->cachedStmts[$filePath])) {
return $this->cachedStmts[$filePath];
}

$fileContents = FileSystem::read($filePath);
$stmts = $this->phpParser->parse($fileContents);

if (is_array($stmts)) {
$nodeTraverser = new NodeTraverser();
$nodeTraverser->addVisitor(new NameResolver());
$nodeTraverser->traverse($stmts);
}

$this->cachedStmts[$filePath] = $stmts ?? [];

return $stmts ?? [];
}
}
Loading

0 comments on commit 5d74acc

Please sign in to comment.