diff --git a/src/Maker/MakeCommand.php b/src/Maker/MakeCommand.php index 52d331c53..8d010ac2d 100644 --- a/src/Maker/MakeCommand.php +++ b/src/Maker/MakeCommand.php @@ -16,9 +16,12 @@ use Symfony\Bundle\MakerBundle\Generator; use Symfony\Bundle\MakerBundle\InputConfiguration; use Symfony\Bundle\MakerBundle\Str; +use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil; use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; +use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\Input\InputArgument; @@ -26,6 +29,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpKernel\Kernel; /** * @author Javier Eguiluz @@ -58,8 +62,9 @@ public function configureCommand(Command $command, InputConfiguration $inputConf { $command ->addArgument('name', InputArgument::OPTIONAL, \sprintf('Choose a command name (e.g. app:%s)', Str::asCommand(Str::getRandomTerm()))) - ->setHelp($this->getHelpFileContents('MakeCommand.txt')) - ; + ->setHelp($this->getHelpFileContents('MakeCommand.txt')); + + $command->addOption('invokable', 'i', InputOption::VALUE_NEGATABLE, 'Use this option to create an invokable command', default: $this->supportsInvokableCommand()); } public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void @@ -74,6 +79,13 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen \sprintf('The "%s" command name is not valid because it would be implemented by "%s" class, which is not valid as a PHP class name (it must start with a letter or underscore, followed by any number of letters, numbers, or underscores).', $commandName, Str::asClassName($commandName, 'Command')) ); + $this->supportsInvokableCommand() && $input->getOption('invokable') ? + $this->generateInvokableCommand($commandName, $commandClassNameDetails, $io, $generator) : + $this->generateInheritanceCommand($commandName, $commandClassNameDetails, $io, $generator); + } + + private function generateInheritanceCommand(string $commandName, ClassNameDetails $commandClassNameDetails, ConsoleStyle $io, Generator $generator): void + { $useStatements = new UseStatementGenerator([ Command::class, InputArgument::class, @@ -86,7 +98,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $generator->generateClass( $commandClassNameDetails->getFullName(), - 'command/Command.tpl.php', + 'command/InheritanceCommand.tpl.php', [ 'use_statements' => $useStatements, 'command_name' => $commandName, @@ -103,6 +115,260 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen ]); } + private function generateInvokableCommand(string $commandName, ClassNameDetails $commandClassNameDetails, ConsoleStyle $io, Generator $generator): void + { + if (class_exists($commandClassNameDetails->getFullName())) { + $io->error('This command already exists.'); + + return; + } + + $description = $io->ask('What is the command description?'); + if (false === \is_string($description)) { + $description = (string) $description; + } + + $arguments = $this->askForArguments($io); + $options = $this->askForOptions($io); + + $useStatements = new UseStatementGenerator([ + AsCommand::class, + Argument::class, + Command::class, + Option::class, + SymfonyStyle::class, + ]); + + $generator->generateClass( + $commandClassNameDetails->getFullName(), + 'command/InvokableCommand.tpl.php', + [ + 'use_statements' => $useStatements, + 'command_name' => $commandName, + 'command_description' => $description, + 'command_parameters' => $this->mergeAndSortParameters($arguments, $options), + ] + ); + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + $io->text([ + 'Next: open your new command class and customize it!', + 'Find the documentation at https://symfony.com/doc/current/console.html', + ]); + } + + /** + * @param array $arguments + * @param array $options + * + * @return array + */ + private function mergeAndSortParameters(array $arguments, array $options): array + { + // Merge arguments and options, marking each with its type + $parameters = []; + foreach ($arguments as $arg) { + $parameters[] = array_merge($arg, ['param_type' => 'argument']); + } + foreach ($options as $opt) { + $parameters[] = array_merge($opt, ['param_type' => 'option']); + } + + // Sort parameters: required parameters (no defaults) must come before optional ones (with defaults) + usort($parameters, function ($a, $b) { + $aHasDefault = $this->parameterHasDefault($a); + $bHasDefault = $this->parameterHasDefault($b); + + if ($aHasDefault === $bHasDefault) { + return 0; // Keep original order within same group (required with required, optional with optional) + } + + // Required (no default) comes before optional (has default) + return $aHasDefault ? 1 : -1; + }); + + return $parameters; + } + + /** @param array{default: mixed, param_type: string, nullable?: bool} $param */ + private function parameterHasDefault(array $param): bool + { + if ('argument' === $param['param_type']) { + // Arguments have defaults if explicitly set or if nullable + return null !== $param['default'] || ($param['nullable'] ?? false); + } + + // Options always have defaults + return true; + } + + /** + * @return array + */ + private function askForArguments(ConsoleStyle $io): array + { + $io->writeln('Now, let\'s add some arguments.'); + + $arguments = []; + $isFirst = true; + + while (true) { + $io->writeln(''); + + if ($isFirst) { + $questionText = 'Argument name? (press to stop adding arguments)'; + } else { + $questionText = 'Add another argument? Enter the argument name (or press to stop adding arguments)'; + } + + $name = $io->ask($questionText, null, function ($name) use ($arguments) { + // allow it to be empty + if (!$name) { + return $name; + } + + foreach ($arguments as $arg) { + if ($arg['name'] === $name) { + throw new \InvalidArgumentException(\sprintf('The "%s" argument already exists.', $name)); + } + } + + return Str::asLowerCamelCase(strtr($name, ['-' => ' '])); + }); + + if (!$name) { + break; + } + + $isFirst = false; + + $type = $io->choice( + 'What is the argument type?', + ['string', 'int', 'float', 'bool', 'array'], + 'string' + ); + + $arguments[] = [ + 'name' => $name, + 'type' => $type, + 'description' => $this->askForParameterDescription($io), + 'default' => $this->askForDefaults($io, $type), + 'nullable' => $io->confirm('Is this argument nullable?', false), + ]; + } + + return $arguments; + } + + /** + * @return array + */ + private function askForOptions(ConsoleStyle $io): array + { + $io->writeln('Now, let\'s add some options.'); + + $options = []; + $isFirst = true; + + while (true) { + $io->writeln(''); + + if ($isFirst) { + $questionText = 'What is the option name?'; + } else { + $questionText = 'What is the next option name?'; + } + + $name = $io->ask($questionText, null, function ($name) use ($options) { + // allow it to be empty + if (!$name) { + return $name; + } + + foreach ($options as $opt) { + if ($opt['name'] === $name) { + throw new \InvalidArgumentException(\sprintf('The "%s" option already exists.', $name)); + } + } + + return Str::asLowerCamelCase(strtr($name, ['-' => ' '])); + }); + + if (!$name) { + break; + } + + $isFirst = false; + + $shortcut = $io->ask('What is the option shortcut?'); + if (!\is_string($shortcut) && null !== $shortcut) { + $shortcut = (string) $shortcut; + } + + $type = $io->choice( + 'What is the option type?', + ['bool', 'string', 'int', 'float'], + 'bool' + ); + + $options[] = [ + 'name' => Str::asLowerCamelCase($name), + 'shortcut' => $shortcut, + 'type' => $type, + 'description' => $this->askForParameterDescription($io), + 'default' => $this->askForDefaults($io, $type, true), + ]; + } + + return $options; + } + + private function askForParameterDescription(ConsoleStyle $io): ?string + { + $description = $io->ask('What is the description?', null); + if (null !== $description && !\is_string($description)) { + $description = (string) $description; + } + + if (false === \is_scalar($description)) { + $description = null; + } + + return $description; + } + + private function askForDefaults(ConsoleStyle $io, string $type, bool $force = false): mixed + { + if (false === $force) { + $hasDefault = $io->confirm('Does it have a default value?', false); + + if (false === $hasDefault) { + return null; + } + } + + if ('bool' === $type) { + return $io->confirm('What is the default value?', false); + } elseif ('int' === $type) { + return (int) $io->ask('What is the default value?', '0'); + } elseif ('float' === $type) { + return (float) $io->ask('What is the default value?', '0.0'); + } elseif ('array' === $type) { + $defaultValue = $io->ask('What is the default value?', '[]'); + + return '[]' === $defaultValue ? [] : $defaultValue; + } else { + $default = $io->ask('What is the default value?', ''); + if (true === \is_scalar($default)) { + return (string) $default; + } + + return null; + } + } + public function configureDependencies(DependencyBuilder $dependencies): void { $dependencies->addClassDependency( @@ -110,4 +376,9 @@ public function configureDependencies(DependencyBuilder $dependencies): void 'console' ); } + + private function supportsInvokableCommand(): bool + { + return Kernel::VERSION_ID >= 70300; + } } diff --git a/templates/command/Command.tpl.php b/templates/command/InheritanceCommand.tpl.php similarity index 99% rename from templates/command/Command.tpl.php rename to templates/command/InheritanceCommand.tpl.php index 3050e1baa..dde939b0f 100644 --- a/templates/command/Command.tpl.php +++ b/templates/command/InheritanceCommand.tpl.php @@ -42,3 +42,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } } + diff --git a/templates/command/InvokableCommand.tpl.php b/templates/command/InvokableCommand.tpl.php new file mode 100644 index 000000000..7274642eb --- /dev/null +++ b/templates/command/InvokableCommand.tpl.php @@ -0,0 +1,107 @@ + + +namespace ; + + + +#[AsCommand( + name: '', + description: '', +)] +class +{ + public function __invoke( + SymfonyStyle $io, + ): int + { + + + if ($) { + $io->note(sprintf('You passed an argument: %s', $)); + } + + + if ($) { + $io->note(sprintf('You passed option: %s', $)); + } + + + + $io->success('You have a new command! Now make it your own! Pass --help to see your options.'); + + return Command::SUCCESS; + } +} diff --git a/tests/Maker/MakeCommandTest.php b/tests/Maker/MakeCommandTest.php index 1ff279c19..2f2b46bb2 100644 --- a/tests/Maker/MakeCommandTest.php +++ b/tests/Maker/MakeCommandTest.php @@ -14,6 +14,7 @@ use Symfony\Bundle\MakerBundle\Maker\MakeCommand; use Symfony\Bundle\MakerBundle\Test\MakerTestCase; use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Yaml\Yaml; class MakeCommandTest extends MakerTestCase @@ -27,10 +28,11 @@ public function getTestDetails(): \Generator { yield 'it_makes_a_command_no_attributes' => [$this->createMakerTest() ->run(function (MakerTestRunner $runner) { + $runner->runMaker([ // command name 'app:foo', - ]); + ], $runner->getSymfonyVersion() >= 70300 ? '--no-invokable' : ''); $this->runCommandTest($runner, 'it_makes_a_command.php'); }), @@ -41,7 +43,7 @@ public function getTestDetails(): \Generator $runner->runMaker([ // command name 'app:foo', - ]); + ], $runner->getSymfonyVersion() >= 70300 ? '--no-invokable' : ''); $this->runCommandTest($runner, 'it_makes_a_command.php'); @@ -63,11 +65,74 @@ public function getTestDetails(): \Generator $runner->runMaker([ // command name 'app:foo', - ]); + ], $runner->getSymfonyVersion() >= 70300 ? '--no-invokable' : ''); $this->runCommandTest($runner, 'it_makes_a_command_in_custom_namespace.php'); }), ]; + + yield 'it_makes_an_invokable_command_by_default' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + if ($runner->getSymfonyVersion() < 70300) { + $this->markTestSkipped('Symfony version does not support Invokable Commands'); + } + + $runner->runMaker([ + // command name + 'app:foo', + 'foo', + '', + '', + ]); + + $this->runCommandTest($runner, 'it_makes_a_command.php'); + + $commandFileContents = file_get_contents($runner->getPath('src/Command/FooCommand.php')); + + self::assertStringContainsString('use Symfony\Component\Console\Attribute\AsCommand;', $commandFileContents); + self::assertStringContainsString('#[AsCommand(', $commandFileContents); + self::assertStringNotContainsString('extends Command', $commandFileContents); + self::assertStringContainsString('__invoke(', $commandFileContents); + }), + ]; + + yield 'it_makes_an_invokable_and_configures_parameters' => [$this->createMakerTest() + ->run(function (MakerTestRunner $runner) { + if ($runner->getSymfonyVersion() < 70300) { + $this->markTestSkipped('Symfony version does not support Invokable Commands'); + } + + $runner->runMaker([ + // command name + 'app:foo', + 'foo', + 'bar', // Argument name + 0, // Argument type (string) + 'Adds a bar argument to your command', // Argument description + 'no', // Has no default value + 'yes', // Is nullable + 'baz', // Second argument, will be required + 1, // Second type (int) + 'How many bazzes do you need?', // Second argument description + 'no', // Second argument no default + 'no', // Second argument not nullable + '', // Stop Arguments + 'dry-run', // Option name + 'd', // Option shortcut + 0, // Option type (boolean) + 'Perform a dry run?', + 'no', // Default value (false) + '', // Stop option insertion + ]); + + $commandFileContents = file_get_contents($runner->getPath('src/Command/FooCommand.php')); + + self::assertStringContainsString('__invoke(', $commandFileContents); + self::assertStringContainsString('#[Argument(description: \'How many bazzes do you need?\')] int $baz,', $commandFileContents); + self::assertStringContainsString('#[Argument(description: \'Adds a bar argument to your command\')] ?string $bar = null', $commandFileContents); + self::assertStringContainsString('#[Option(description: \'Perform a dry run?\', shortcut: \'d\')] bool $dryRun = false', $commandFileContents); + }), + ]; } private function runCommandTest(MakerTestRunner $runner, string $filename): void