[DX] Add Interactive Mode to Generate command (#4931)

* Add Interactive Mode to Generate command

* CS Fixer

* Unify names

* Unify names

* Fixes

* Fixes

* Fixes

* Check generated tests against the "special hack for PHPUnit"

* Re-use the same input/output in the Generate Command

* Add test for Interactive Mode of Generate Command

* Rename test file

* Use ::class instead of string

* Create Finder instance where it's used

* Create RectorRecipeInteractiveProvider service

* Create RectorRecipeInteractiveProvider service

* Bring back SymfonyStyle as DI service
This commit is contained in:
Krystian Marcisz 2020-12-28 18:00:51 +01:00 committed by GitHub
parent 8ed61a7560
commit 94a4bb7777
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 366 additions and 5 deletions

View File

@ -9,7 +9,24 @@ How can we **remove repeated work** and let us focus only on `refactor()` method
It creates a bare structured Rule.
Don't worry, also generates a test case, which is required to contribute.
## How to Generate Rector rule in 3 steps?
## How to Generate Rector rule?
There are two possibilities to Generate a Rector rule.
### Generate using Interactive Mode
**Important**: using this approach will generate Rector rule with placeholder Code Samples, which should be changed
by hand to reflect what the rule does
1. Run Generate command in Interactive Mode
```bash
vendor/bin/rector generate --interactive
```
2. Provide an answer to questions asked by the command
### Generate using configuration file
1. Initialize `rector-recipe.php` config

View File

@ -11,10 +11,14 @@ use Rector\RectorGenerator\Config\ConfigFilesystem;
use Rector\RectorGenerator\Finder\TemplateFinder;
use Rector\RectorGenerator\Generator\FileGenerator;
use Rector\RectorGenerator\Guard\OverrideGuard;
use Rector\RectorGenerator\Provider\RectorRecipeInteractiveProvider;
use Rector\RectorGenerator\Provider\RectorRecipeProvider;
use Rector\RectorGenerator\TemplateVariablesFactory;
use Rector\RectorGenerator\ValueObject\RectorRecipe;
use Rector\Testing\PHPUnit\StaticPHPUnitEnvironment;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symplify\PackageBuilder\Console\ShellCode;
@ -22,6 +26,8 @@ use Symplify\SmartFileSystem\SmartFileInfo;
final class GenerateCommand extends Command
{
private const INTERACTIVE_MODE_NAME = 'interactive';
/**
* @var SymfonyStyle
*/
@ -62,6 +68,11 @@ final class GenerateCommand extends Command
*/
private $rectorRecipeProvider;
/**
* @var RectorRecipeInteractiveProvider
*/
private $rectorRecipeInteractiveProvider;
public function __construct(
ComposerPackageAutoloadUpdater $composerPackageAutoloadUpdater,
ConfigFilesystem $configFilesystem,
@ -70,29 +81,37 @@ final class GenerateCommand extends Command
SymfonyStyle $symfonyStyle,
TemplateFinder $templateFinder,
TemplateVariablesFactory $templateVariablesFactory,
RectorRecipeProvider $rectorRecipeProvider
RectorRecipeProvider $rectorRecipeProvider,
RectorRecipeInteractiveProvider $rectorRecipeInteractiveProvider
) {
parent::__construct();
$this->symfonyStyle = $symfonyStyle;
$this->templateVariablesFactory = $templateVariablesFactory;
$this->composerPackageAutoloadUpdater = $composerPackageAutoloadUpdater;
$this->templateFinder = $templateFinder;
$this->configFilesystem = $configFilesystem;
$this->overrideGuard = $overrideGuard;
$this->symfonyStyle = $symfonyStyle;
$this->fileGenerator = $fileGenerator;
$this->rectorRecipeProvider = $rectorRecipeProvider;
$this->rectorRecipeInteractiveProvider = $rectorRecipeInteractiveProvider;
}
protected function configure(): void
{
$this->setAliases(['c', 'create', 'g']);
$this->setDescription('[DEV] Create a new Rector, in a proper location, with new tests');
$this->addOption(
self::INTERACTIVE_MODE_NAME,
'i',
InputOption::VALUE_NONE,
'Turns on Interactive Mode - Rector will be generated based on responses to questions instead of using rector-recipe.php',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$rectorRecipe = $this->rectorRecipeProvider->provide();
$rectorRecipe = $this->getRectorRecipe($input);
$templateVariables = $this->templateVariablesFactory->createFromRectorRecipe($rectorRecipe);
@ -136,7 +155,7 @@ final class GenerateCommand extends Command
private function resolveTestCaseDirectoryPath(array $generatedFilePaths): string
{
foreach ($generatedFilePaths as $generatedFilePath) {
if (! Strings::endsWith($generatedFilePath, 'Test.php')) {
if (! $this->isGeneratedFilePathTestCase($generatedFilePath)) {
continue;
}
@ -147,6 +166,16 @@ final class GenerateCommand extends Command
throw new ShouldNotHappenException();
}
private function isGeneratedFilePathTestCase(string $generatedFilePath): bool
{
if (Strings::endsWith($generatedFilePath, 'Test.php')) {
return true;
}
return Strings::endsWith($generatedFilePath, 'Test.php.inc') && StaticPHPUnitEnvironment::isPHPUnitRun();
}
/**
* @param string[] $generatedFilePaths
*/
@ -167,4 +196,14 @@ final class GenerateCommand extends Command
$this->symfonyStyle->success($message);
}
private function getRectorRecipe(InputInterface $input): RectorRecipe
{
$isInteractive = $input->getOption(self::INTERACTIVE_MODE_NAME);
if (! $isInteractive) {
return $this->rectorRecipeProvider->provide();
}
return $this->rectorRecipeInteractiveProvider->provide($this->symfonyStyle);
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Rector\RectorGenerator\Provider;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
final class NodeTypesProvider
{
private const PHP_PARSER_NODES_PATH = __DIR__ . '/../../../../vendor/nikic/php-parser/lib/PhpParser/Node';
private const PHP_PARSER_NAMESPACE = '\PhpParser\Node\\';
/**
* @return array<string, string>
*/
public function provide(): array
{
$finder = new Finder();
$filesList = $finder
->files()
->in(self::PHP_PARSER_NODES_PATH)
->getIterator()
;
$names = [];
foreach ($filesList as $splFileInfo) {
$name = str_replace(['.php', '/'], ['', '\\'], $splFileInfo->getRelativePathname());
$reflection = new ReflectionClass(self::PHP_PARSER_NAMESPACE . $name);
if ($reflection->isAbstract() || $reflection->isInterface()) {
continue;
}
$names[$name] = $name;
}
return $names;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Rector\RectorGenerator\Provider;
use Rector\Core\Util\StaticRectorStrings;
use Symfony\Component\Finder\Finder;
final class PackageNamesProvider
{
/**
* @return array<int, string>
*/
public function provide(): array
{
$finder = new Finder();
$directoriesList = $finder
->directories()
->depth(0)
->in(__DIR__ . '/../../../../rules/')
->getIterator()
;
$names = [];
foreach ($directoriesList as $directory) {
$names[] = StaticRectorStrings::dashesToCamelCase($directory->getFilename());
}
return $names;
}
}

View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Rector\RectorGenerator\Provider;
use Rector\RectorGenerator\ValueObject\RectorRecipe;
use Rector\Set\ValueObject\SetList;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
final class RectorRecipeInteractiveProvider
{
/**
* @var SymfonyStyle
*/
private $symfonyStyle;
/**
* @var PackageNamesProvider
*/
private $packageNamesProvider;
/**
* @var NodeTypesProvider
*/
private $nodeTypesProvider;
/**
* @var SetsListProvider
*/
private $setsListProvider;
public function __construct(
PackageNamesProvider $packageNamesProvider,
NodeTypesProvider $nodeTypesProvider,
SetsListProvider $setsListProvider
) {
$this->packageNamesProvider = $packageNamesProvider;
$this->nodeTypesProvider = $nodeTypesProvider;
$this->setsListProvider = $setsListProvider;
}
public function provide(SymfonyStyle $symfonyStyle): RectorRecipe
{
$this->symfonyStyle = $symfonyStyle;
$rectorRecipe = new RectorRecipe(
$this->askForPackageName(),
$this->askForRectorName(),
$this->askForNodeTypes(),
$this->askForRectorDescription(),
$this->getExampleCodeBefore(),
$this->getExampleCodeAfter(),
);
$rectorRecipe->setResources($this->askForResources());
$set = $this->askForSet();
if ($set !== null) {
$rectorRecipe->setSet($set);
}
return $rectorRecipe;
}
private function askForPackageName(): string
{
$question = new Question(sprintf(
'Package name for which Rector should be created (e.g. <fg=yellow>%s</>)',
'Naming'
));
$question->setAutocompleterValues($this->packageNamesProvider->provide());
$packageName = $this->symfonyStyle->askQuestion($question);
return $packageName ?? $this->askForPackageName();
}
private function askForRectorName(): string
{
$question = sprintf(
'Class name of the Rector to create (e.g. <fg=yellow>%s</>)',
'RenameMethodCallRector',
);
$rectorName = $this->symfonyStyle->ask($question);
return $rectorName ?? $this->askForRectorName();
}
/**
* @return array<int, class-string>
*/
private function askForNodeTypes(): array
{
$question = new ChoiceQuestion(sprintf(
'For what Nodes should the Rector be run (e.g. <fg=yellow>%s</>)',
'Expr/MethodCall',
), $this->nodeTypesProvider->provide());
$question->setMultiselect(true);
$nodeTypes = $this->symfonyStyle->askQuestion($question);
$classes = [];
foreach ($nodeTypes as $nodeType) {
/** @var class-string $class */
$class = 'PhpParser\Node\\' . $nodeType;
$classes[] = $class;
}
return $classes;
}
private function askForRectorDescription(): string
{
$description = $this->symfonyStyle->ask('Short description of new Rector');
return $description ?? $this->askForRectorDescription();
}
private function getExampleCodeBefore(): string
{
return <<<'CODE_SAMPLE'
<?php
class SomeClass
{
public function run()
{
$this->something();
}
}
CODE_SAMPLE;
}
private function getExampleCodeAfter(): string
{
return <<<'CODE_SAMPLE'
<?php
class SomeClass
{
public function run()
{
$this->somethingElse();
}
}
CODE_SAMPLE;
}
/**
* @return array<string>
*/
private function askForResources(): array
{
$resources = [];
while (true) {
$question = sprintf(
'Link to resource that explains why the change is needed (e.g. <fg=yellow>%s</>)',
'https://github.com/symfony/symfony/blob/704c648ba53be38ef2b0105c97c6497744fef8d8/UPGRADE-6.0.md',
);
$resource = $this->symfonyStyle->ask($question);
if ($resource === null) {
break;
}
$resources[] = $resource;
}
return $resources;
}
private function askForSet(): ?string
{
$question = new Question(sprintf('Set to which Rector should be added (e.g. <fg=yellow>%s</>)', 'SYMFONY_52'));
$question->setAutocompleterValues($this->setsListProvider->provide());
$setName = $this->symfonyStyle->askQuestion($question);
if ($setName === null) {
return null;
}
return constant(SetList::class . $setName);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Rector\RectorGenerator\Provider;
use Rector\Set\ValueObject\SetList;
use ReflectionClass;
final class SetsListProvider
{
/**
* @return array<int, string>
*/
public function provide(): array
{
$setListReflection = new ReflectionClass(SetList::class);
$constants = $setListReflection->getConstants();
return array_keys($constants);
}
}

View File

@ -37,6 +37,13 @@ final class StaticRectorStrings
return false;
}
public static function dashesToCamelCase(string $input): string
{
$tokens = explode('-', $input);
return implode('', array_map('ucfirst', $tokens));
}
public static function camelCaseToDashes(string $input): string
{
return self::camelCaseToGlue($input, '-');

View File

@ -10,6 +10,20 @@ use Rector\Core\Util\StaticRectorStrings;
final class StaticRectorStringsTest extends TestCase
{
/**
* @dataProvider provideDataForDashesToCamelCase()
*/
public function testDashesToCamelCase(string $content, string $expected): void
{
$this->assertSame($expected, StaticRectorStrings::dashesToCamelCase($content));
}
public function provideDataForDashesToCamelCase(): Iterator
{
yield ['simple-test', 'SimpleTest'];
yield ['easy', 'Easy'];
}
/**
* @dataProvider provideDataForCamelCaseToUnderscore()
*/