Skip to content

Commit e4c7553

Browse files
authored
[new] Add trait spotter command, to spot traits to be easily refactored to class of origin (#76)
* [new] Add trait spotter command, to spot traits to be easily refactored to class of origin * add simple test
1 parent 9ed729e commit e4c7553

File tree

9 files changed

+310
-0
lines changed

9 files changed

+310
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\SwissKnife\Command;
6+
7+
use Rector\SwissKnife\Traits\TraitSpotter;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputArgument;
10+
use Symfony\Component\Console\Input\InputInterface;
11+
use Symfony\Component\Console\Input\InputOption;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
use Symfony\Component\Console\Style\SymfonyStyle;
14+
15+
final class TraitSpottingCommand extends Command
16+
{
17+
public function __construct(
18+
private readonly SymfonyStyle $symfonyStyle,
19+
private readonly TraitSpotter $traitSpotter,
20+
) {
21+
parent::__construct();
22+
}
23+
24+
protected function configure(): void
25+
{
26+
$this->setName('trait-spotting');
27+
28+
$this->addArgument(
29+
'sources',
30+
InputArgument::REQUIRED | InputArgument::IS_ARRAY,
31+
'One or more paths to check'
32+
);
33+
34+
$this->addOption('max-used', null, InputOption::VALUE_REQUIRED, 'Maximum count the trait is used', 2);
35+
36+
$this->setDescription(
37+
'Spot traits that are use only once, to potentially inline them and make code more robust and readable'
38+
);
39+
}
40+
41+
protected function execute(InputInterface $input, OutputInterface $output): int
42+
{
43+
$sources = (array) $input->getArgument('sources');
44+
$maxUsedCount = (int) $input->getArgument('max-used');
45+
46+
$this->symfonyStyle->title('Analysing single-used traits, shorter first');
47+
$traitSpottingResult = $this->traitSpotter->analyse($sources);
48+
49+
$this->symfonyStyle->note(sprintf('Found %d traits', $traitSpottingResult->getTraitCount()));
50+
51+
$maxTimesUsedTraits = $traitSpottingResult->getTraitMaximumUsedTimes($maxUsedCount);
52+
53+
foreach ($maxTimesUsedTraits as $traitUsage) {
54+
$this->symfonyStyle->writeln(sprintf(
55+
'Trait "%s" (%d lines) is used only in %d files',
56+
$traitUsage->shortTraitName,
57+
$traitUsage->lineCount,
58+
count($traitUsage->usingFiles)
59+
));
60+
$this->symfonyStyle->newLine();
61+
62+
$this->symfonyStyle->listing($traitUsage->usingFiles);
63+
$this->symfonyStyle->newLine();
64+
}
65+
66+
$this->symfonyStyle->newLine();
67+
68+
$this->symfonyStyle->warning(sprintf(
69+
'Found %d traits, the less the better to make dependencies explicit',
70+
count($maxTimesUsedTraits)
71+
));
72+
73+
return self::SUCCESS;
74+
}
75+
}

src/Finder/TraitFilesFinder.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\SwissKnife\Finder;
6+
7+
use Symfony\Component\Finder\Finder;
8+
use Symfony\Component\Finder\SplFileInfo;
9+
use Webmozart\Assert\Assert;
10+
11+
final class TraitFilesFinder
12+
{
13+
/**
14+
* @param string[] $directories
15+
* @return SplFileInfo[]
16+
*/
17+
public function findTraitUsages(array $directories): array
18+
{
19+
Assert::allString($directories);
20+
21+
$traitUsersFinder = Finder::create()
22+
->files()
23+
->in($directories)
24+
->name('*.php')
25+
->sortByName()
26+
->filter(function (SplFileInfo $fileInfo): bool {
27+
$fileContent = $fileInfo->getContents();
28+
return str_contains($fileContent, ' use ');
29+
});
30+
31+
return iterator_to_array($traitUsersFinder->getIterator());
32+
}
33+
34+
/**
35+
* @param string[] $directories
36+
* @return array<SplFileInfo>
37+
*/
38+
public function find(array $directories): array
39+
{
40+
Assert::allString($directories);
41+
42+
$traitFinder = Finder::create()
43+
->files()
44+
->in($directories)
45+
->name('*.php')
46+
->notPath('Entity')
47+
->notPath('Document')
48+
->sortByName()
49+
->filter(function (SplFileInfo $fileInfo): bool {
50+
$fileContent = $fileInfo->getContents();
51+
return str_contains($fileContent, 'trait ');
52+
});
53+
54+
return iterator_to_array($traitFinder->getIterator());
55+
}
56+
}

src/Traits/TraitSpotter.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\SwissKnife\Traits;
6+
7+
use Nette\Utils\Strings;
8+
use Rector\SwissKnife\Finder\TraitFilesFinder;
9+
use Rector\SwissKnife\ValueObject\Traits\TraitSpottingResult;
10+
use Symfony\Component\Finder\SplFileInfo;
11+
12+
/**
13+
* @see \Rector\SwissKnife\Tests\Traits\TraitSpotterTest
14+
*/
15+
final readonly class TraitSpotter
16+
{
17+
public function __construct(
18+
private TraitFilesFinder $traitFilesFinder
19+
) {
20+
}
21+
22+
/**
23+
* @param string[] $directories
24+
*/
25+
public function analyse(array $directories): TraitSpottingResult
26+
{
27+
$traitFiles = $this->traitFilesFinder->find($directories);
28+
29+
$shortNameToLineCount = [];
30+
foreach ($traitFiles as $traitFile) {
31+
$traitShortName = $traitFile->getBasename('.php');
32+
$shortNameToLineCount[$traitShortName] = substr_count($traitFile->getContents(), PHP_EOL);
33+
}
34+
35+
$shortTraitNamesToLineCount = $shortNameToLineCount;
36+
37+
$traitUsageFiles = $this->traitFilesFinder->findTraitUsages($directories);
38+
39+
$usagesToFiles = [];
40+
foreach ($traitUsageFiles as $traitUsageFile) {
41+
$matches = Strings::matchAll($traitUsageFile->getContents(), '# use (?<short_trait_name>[\w]+);#');
42+
43+
foreach ($matches as $match) {
44+
$shortTraitName = $match['short_trait_name'];
45+
$usagesToFiles[$shortTraitName][] = $this->getRelativeFilePath($traitUsageFile);
46+
}
47+
}
48+
49+
$traitUsagesToFiles = $usagesToFiles;
50+
51+
return new TraitSpottingResult($shortTraitNamesToLineCount, $traitUsagesToFiles);
52+
}
53+
54+
private function getRelativeFilePath(SplFileInfo $fileInfo): string
55+
{
56+
return substr($fileInfo->getRealPath(), strlen((string) getcwd()) + 1);
57+
}
58+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\SwissKnife\ValueObject\Traits;
6+
7+
final readonly class TraitSpottingResult
8+
{
9+
/**
10+
* @param array<string, int> $shortTraitNamesToLineCount
11+
* @param array<string, string[]> $traitUsagesToFiles
12+
*/
13+
public function __construct(
14+
private array $shortTraitNamesToLineCount,
15+
private array $traitUsagesToFiles
16+
) {
17+
}
18+
19+
public function getTraitCount(): int
20+
{
21+
return count($this->shortTraitNamesToLineCount);
22+
}
23+
24+
/**
25+
* @return TraitUsage[]
26+
*/
27+
public function getTraitMaximumUsedTimes(int $limit): array
28+
{
29+
$traitUsages = [];
30+
31+
foreach ($this->traitUsagesToFiles as $shortTraitName => $usingFiles) {
32+
// to many places
33+
if (count($usingFiles) > $limit) {
34+
continue;
35+
}
36+
37+
// probably external, nothing we can do about it
38+
if (! isset($this->shortTraitNamesToLineCount[$shortTraitName])) {
39+
continue;
40+
}
41+
42+
$traitUsages[] = new TraitUsage(
43+
$shortTraitName,
44+
$this->shortTraitNamesToLineCount[$shortTraitName],
45+
$usingFiles
46+
);
47+
}
48+
49+
return $traitUsages;
50+
}
51+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\SwissKnife\ValueObject\Traits;
6+
7+
final class TraitUsage
8+
{
9+
/**
10+
* @param string[] $usingFiles
11+
*/
12+
public function __construct(
13+
public string $shortTraitName,
14+
public int $lineCount,
15+
public array $usingFiles
16+
) {
17+
}
18+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Rector\SwissKnife\Tests\Traits\Fixture;
4+
5+
trait AnotherTrait
6+
{
7+
}

tests/Traits/Fixture/SomeTrait.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Rector\SwissKnife\Tests\Traits\Fixture;
4+
5+
trait SomeTrait
6+
{
7+
}

tests/Traits/Fixture/TraitUser.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Rector\SwissKnife\Tests\Traits\Fixture;
4+
5+
final class TraitUser
6+
{
7+
use SomeTrait;
8+
}

tests/Traits/TraitSpotterTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\SwissKnife\Tests\Traits;
6+
7+
use Rector\SwissKnife\Tests\AbstractTestCase;
8+
use Rector\SwissKnife\Traits\TraitSpotter;
9+
use Rector\SwissKnife\ValueObject\Traits\TraitUsage;
10+
11+
final class TraitSpotterTest extends AbstractTestCase
12+
{
13+
public function test(): void
14+
{
15+
$traitSpotter = $this->make(TraitSpotter::class);
16+
17+
$traitSpottingResult = $traitSpotter->analyse([__DIR__ . '/Fixture/']);
18+
$this->assertSame(2, $traitSpottingResult->getTraitCount());
19+
20+
$onceUsedTraits = $traitSpottingResult->getTraitMaximumUsedTimes(1);
21+
$this->assertCount(1, $onceUsedTraits);
22+
23+
$onceTraitUsage = $onceUsedTraits[0];
24+
$this->assertInstanceOf(TraitUsage::class, $onceTraitUsage);
25+
26+
$this->assertSame('SomeTrait', $onceTraitUsage->shortTraitName);
27+
$this->assertSame(7, $onceTraitUsage->lineCount);
28+
$this->assertSame(['tests/Traits/Fixture/TraitUser.php'], $onceTraitUsage->usingFiles);
29+
}
30+
}

0 commit comments

Comments
 (0)