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
210 changes: 28 additions & 182 deletions src/Console/Command/CustomRuleCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@

namespace Rector\Console\Command;

use DOMDocument;
use DOMElement;
use DOMXPath;
use Generator;
use Nette\Utils\FileSystem;
use Nette\Utils\Strings;
use PHPStan\Reflection\ReflectionProvider;
use Rector\Enum\ClassName;
use Rector\Exception\ShouldNotHappenException;
use Rector\FileSystem\JsonFileSystem;
use Symfony\Component\Console\Command\Command;
Expand All @@ -21,14 +19,9 @@

final class CustomRuleCommand extends Command
{
/**
* @see https://regex101.com/r/2eP4rw/1
* @var string
*/
private const START_WITH_10_REGEX = '#(\^10\.|>=10\.|10\.)#';

public function __construct(
private readonly SymfonyStyle $symfonyStyle,
private readonly ReflectionProvider $reflectionProvider
) {
parent::__construct();
}
Expand All @@ -43,10 +36,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
// ask for rule name
$rectorName = $this->symfonyStyle->ask(
'What is the name of the rule class (e.g. "LegacyCallToDbalMethodCall")?',
'What is the rule class name? (e.g. "LegacyCallToDbalMethodCall")?',
null,
static function (string $answer): string {
if ($answer === '') {
static function (?string $answer): string {
if ($answer === '' || $answer === null) {
throw new ShouldNotHappenException('Rector name cannot be empty');
}

Expand All @@ -65,27 +58,25 @@ static function (string $answer): string {
$finder = Finder::create()
->files()
->in(__DIR__ . '/../../../templates/custom-rule')
->notName('__Name__Test.php');
->notName('__Name__Test.php.phtml');

// 0. resolve if local phpunit is at least PHPUnit 10 (which supports #[DataProvider])
// to provide annotation if not
$arePHPUnitAttributesSupported = $this->detectPHPUnitAttributeSupport();

if ($arePHPUnitAttributesSupported) {
if ($this->isPHPUnitAttributeSupported()) {
$finder->append([
new SplFileInfo(
__DIR__ . '/../../../templates/custom-rule/utils/rector/tests/Rector/__Name__/__Name__Test.php',
__DIR__ . '/../../../templates/custom-rule/utils/rector/tests/Rector/__Name__/__Name__Test.php.phtml',
'utils/rector/tests/Rector/__Name__',
'utils/rector/tests/Rector/__Name__/__Name__Test.php',
'utils/rector/tests/Rector/__Name__/__Name__Test.php.phtml',
),
]);
} else {
// use @annotations for PHPUnit 9 and bellow
$finder->append([
new SplFileInfo(
__DIR__ . '/../../../templates/custom-rules-annotations/utils/rector/tests/Rector/__Name__/__Name__Test.php',
__DIR__ . '/../../../templates/custom-rules-annotations/utils/rector/tests/Rector/__Name__/__Name__Test.php.phtml',
'utils/rector/tests/Rector/__Name__',
'utils/rector/tests/Rector/__Name__/__Name__Test.php',
'utils/rector/tests/Rector/__Name__/__Name__Test.php.phtml',
),
]);
}
Expand All @@ -97,20 +88,21 @@ static function (string $answer): string {
$fileInfos = iterator_to_array($finder->getIterator());

foreach ($fileInfos as $fileInfo) {
// replace __Name__ with $rectorName
// replace "__Name__" with $rectorName
$newContent = $this->replaceNameVariable($rectorName, $fileInfo->getContents());
$newFilePath = $this->replaceNameVariable($rectorName, $fileInfo->getRelativePathname());

// remove "phtml" suffix
$newFilePath = Strings::substring($newFilePath, 0, -strlen('.phtml'));

FileSystem::write($currentDirectory . '/' . $newFilePath, $newContent, null);

$generatedFilePaths[] = $newFilePath;
}

$this->symfonyStyle->title('Generated files');
$title = sprintf('Skeleton for "%s" rule was created. Now write rule logic to solve your problem', $rectorName);
$this->symfonyStyle->title($title);
$this->symfonyStyle->listing($generatedFilePaths);
$this->symfonyStyle->success(
sprintf('Base for the "%s" rule was created. Now you can fill the missing parts', $rectorName)
);

// 2. update autoload-dev in composer.json
$composerJsonFilePath = $currentDirectory . '/composer.json';
Expand All @@ -125,176 +117,30 @@ static function (string $answer): string {
}

if ($hasChanged) {
$this->symfonyStyle->success(
'We also update composer.json autoload-dev, to load Rector rules. Now run "composer dump-autoload" to update paths'
);
$this->symfonyStyle->writeln('We updated "composer.json" autoload-dev to load Rector rules.');
$this->symfonyStyle->writeln('Now run "composer dump-autoload" to update paths');
JsonFileSystem::writeFile($composerJsonFilePath, $composerJson);
}
}

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

// 3. update phpunit.xml(.dist) to include rector test suite
$this->setupRectorTestSuite($currentDirectory);
$this->symfonyStyle->writeln('<fg=green>Run Rector tests via PHPUnit:</>');
$this->symfonyStyle->newLine(1);
$this->symfonyStyle->writeln(' vendor/bin/phpunit utils/rector/tests');
$this->symfonyStyle->newLine(1);

return Command::SUCCESS;
}

private function setupRectorTestSuite(string $currentDirectory): void
{
if (! extension_loaded('dom')) {
$this->symfonyStyle->warning(
'The "dom" extension is not loaded. Rector could not add the rector test suite to phpunit.xml'
);

return;
}

$phpunitXmlExists = file_exists($currentDirectory . '/phpunit.xml');
$phpunitXmlDistExists = file_exists($currentDirectory . '/phpunit.xml.dist');

if (! $phpunitXmlExists && ! $phpunitXmlDistExists) {
$this->symfonyStyle->warning(
'No phpunit.xml or phpunit.xml.dist found. Rector could not add the rector test suite to it'
);

return;
}

$phpunitFile = $phpunitXmlExists ? 'phpunit.xml' : 'phpunit.xml.dist';

$phpunitFilePath = $currentDirectory . '/' . $phpunitFile;

$domDocument = new DOMDocument('1.0');
$domDocument->preserveWhiteSpace = false;
$domDocument->formatOutput = true;
$domDocument->loadXML(FileSystem::read($phpunitFilePath));

if ($this->hasRectorTestSuite($domDocument)) {
$this->symfonyStyle->success(
'The rector test suite already exists in ' . $phpunitFilePath . ". No changes were made.\n You can run the rector tests by running: phpunit --testsuite rector"
);

return;
}

$testsuitesElement = $domDocument->getElementsByTagName('testsuites')
->item(0);

if (! $testsuitesElement instanceof DOMElement) {
$this->symfonyStyle->warning(
'No <testsuites> element found in ' . $phpunitFilePath . '. Rector could not add the rector test suite to it'
);

return;
}

$phpunitXML = $this->updatePHPUnitXMLFile($domDocument, $testsuitesElement);

FileSystem::write($phpunitFilePath, $phpunitXML, null);

$this->symfonyStyle->success(
'We also update ' . $phpunitFilePath . ", to add a rector test suite.\n You can run the rector tests by running: phpunit --testsuite rector"
);
}

private function hasRectorTestSuite(DOMDocument $domDocument): bool
{
foreach ($this->getTestSuiteElements($domDocument) as $testSuiteElement) {
foreach ($testSuiteElement->getElementsByTagName('directory') as $directoryNode) {
if (! $directoryNode instanceof DOMElement) {
continue;
}

$name = $testSuiteElement->getAttribute('name');
if ($name !== 'rector') {
continue;
}

$directory = $directoryNode->textContent;
if ($directory === 'utils/rector/tests') {
return true;
}
}
}

return false;
}

private function updatePHPUnitXMLFile(DOMDocument $domDocument, DOMElement $testsuitesElement): string
{
$domElement = $domDocument->createElement('testsuite');
$domElement->setAttribute('name', 'rector');

$rectorTestSuiteDirectory = $domDocument->createElement('directory', 'utils/rector/tests');
$domElement->appendChild($rectorTestSuiteDirectory);

$testsuitesElement->appendChild($domElement);

$phpunitXML = $domDocument->saveXML();
if ($phpunitXML === false) {
throw new ShouldNotHappenException('Could not save XML');
}

return $phpunitXML;
}

/**
* @return Generator<DOMElement>
*/
private function getTestSuiteElements(DOMDocument $domDocument): Generator
{
$domxPath = new DOMXPath($domDocument);
$testSuiteNodes = $domxPath->query('testsuites/testsuite');
if ($testSuiteNodes === false) {
return;
}

if ($testSuiteNodes->length === 0) {
$testSuiteNodes = $domxPath->query('testsuite');
if ($testSuiteNodes === false) {
return;
}
}

if ($testSuiteNodes->length === 1) {
$element = $testSuiteNodes->item(0);

if ($element instanceof DOMElement) {
yield $element;
}

return;
}

foreach ($testSuiteNodes as $testSuiteNode) {
if (! $testSuiteNode instanceof DOMElement) {
continue;
}

yield $testSuiteNode;
}
}

private function replaceNameVariable(string $rectorName, string $contents): string
{
return str_replace('__Name__', $rectorName, $contents);
}

private function detectPHPUnitAttributeSupport(): bool
private function isPHPUnitAttributeSupported(): bool
{
$composerJsonFilePath = getcwd() . '/composer.json';

if (! file_exists($composerJsonFilePath)) {
// be safe
return false;
}

$composerJson = JsonFileSystem::readFilePath($composerJsonFilePath);
$phpunitVersion = $composerJson['require-dev']['phpunit/phpunit'] ?? null;

if ($phpunitVersion === null) {
return false;
}

return (bool) Strings::match($phpunitVersion, self::START_WITH_10_REGEX);
return $this->reflectionProvider->hasClass(ClassName::DATA_PROVIDER);
}
}
5 changes: 5 additions & 0 deletions src/Enum/ClassName.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ final class ClassName
* @var string
*/
public const DOCTRINE_ENTITY = 'Doctrine\ORM\Mapping\Entity';

/**
* @var string
*/
public const DATA_PROVIDER = 'PHPUnit\Framework\Attributes\DataProvider';
}