From 2a19b9b372b9ebe64eec203a49ff6e051b46a3d7 Mon Sep 17 00:00:00 2001 From: HypeMC Date: Wed, 14 May 2025 00:47:44 +0200 Subject: [PATCH] [Console] Simplify using invokable commands when the component is used standalone --- src/Symfony/Component/Console/Application.php | 28 +++++++- src/Symfony/Component/Console/CHANGELOG.md | 1 + .../Console/Tests/ApplicationTest.php | 72 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index b4539fa1eeb5..b6a80b54ef76 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\CompleteCommand; use Symfony\Component\Console\Command\DumpCompletionCommand; @@ -28,6 +29,7 @@ use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\ExceptionInterface; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\NamespaceNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; @@ -520,12 +522,12 @@ public function register(string $name): Command * * If a Command is not enabled it will not be added. * - * @param Command[] $commands An array of commands + * @param callable|Command[] $commands An array of commands */ public function addCommands(array $commands): void { foreach ($commands as $command) { - $this->add($command); + $this->addCommand($command); } } @@ -565,6 +567,28 @@ public function add(Command $command): ?Command return $command; } + public function addCommand(callable|Command $command): ?Command + { + if ($command instanceof Command) { + return $this->add($command); + } + + if (!\is_object($command) || $command instanceof \Closure) { + throw new InvalidArgumentException(\sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)); + } + + /** @var AsCommand $attribute */ + $attribute = ((new \ReflectionObject($command))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance() + ?? throw new LogicException(\sprintf('The command must use the "%s" attribute.', AsCommand::class)); + + return $this->add( + (new Command($attribute->name)) + ->setDescription($attribute->description ?? '') + ->setHelp($attribute->help ?? '') + ->setCode($command) + ); + } + /** * Returns a registered command by name or alias. * diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 9f3ae3d7d232..5cb4c48d5452 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -15,6 +15,7 @@ CHANGELOG * Deprecate returning a non-integer value from a `\Closure` function set via `Command::setCode()` * Mark `#[AsCommand]` attribute as `@final` * Add support for `SignalableCommandInterface` with invokable commands + * Simplify using invokable commands when the component is used standalone 7.2 --- diff --git a/src/Symfony/Component/Console/Tests/ApplicationTest.php b/src/Symfony/Component/Console/Tests/ApplicationTest.php index 268f8ba501a9..2c8dc2eba45f 100644 --- a/src/Symfony/Component/Console/Tests/ApplicationTest.php +++ b/src/Symfony/Component/Console/Tests/ApplicationTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\HelpCommand; +use Symfony\Component\Console\Command\InvokableCommand; use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; @@ -28,6 +29,8 @@ use Symfony\Component\Console\Event\ConsoleSignalEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Exception\CommandNotFoundException; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\NamespaceNotFoundException; use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Helper\HelperSet; @@ -239,6 +242,59 @@ public function testAddCommandWithEmptyConstructor() (new Application())->add(new \Foo5Command()); } + public function testAddCommandWithExtendedCommand() + { + $application = new Application(); + $application->add($foo = new \FooCommand()); + $commands = $application->all(); + + $this->assertEquals($foo, $commands['foo:bar']); + } + + public function testAddCommandWithInvokableCommand() + { + $application = new Application(); + $application->addCommand($foo = new InvokableTestCommand()); + $commands = $application->all(); + + $this->assertInstanceOf(Command::class, $command = $commands['invokable']); + $this->assertEquals(new InvokableCommand($command, $foo), (new \ReflectionObject($command))->getProperty('code')->getValue($command)); + } + + public function testAddCommandWithInvokableExtendedCommand() + { + $application = new Application(); + $application->addCommand($foo = new InvokableExtendedTestCommand()); + $commands = $application->all(); + + $this->assertEquals($foo, $commands['invokable-extended']); + } + + /** + * @dataProvider provideInvalidInvokableCommands + */ + public function testAddCommandThrowsExceptionOnInvalidCommand(callable $command, string $expectedException, string $expectedExceptionMessage) + { + $application = new Application(); + + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $application->addCommand($command); + } + + public static function provideInvalidInvokableCommands(): iterable + { + yield 'a function' => ['strlen', InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)]; + yield 'a closure' => [function () { + }, InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)]; + yield 'without the #[AsCommand] attribute' => [new class { + public function __invoke() + { + } + }, LogicException::class, \sprintf('The command must use the "%s" attribute.', AsCommand::class)]; + } + public function testHasGet() { $application = new Application(); @@ -2514,6 +2570,22 @@ public function isEnabled(): bool } } +#[AsCommand(name: 'invokable')] +class InvokableTestCommand +{ + public function __invoke(): int + { + } +} + +#[AsCommand(name: 'invokable-extended')] +class InvokableExtendedTestCommand extends Command +{ + public function __invoke(): int + { + } +} + #[AsCommand(name: 'signal')] class BaseSignableCommand extends Command {