diff --git a/.docs/README.md b/.docs/README.md index 66dca73..ac231cf 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -11,6 +11,7 @@ Integration of [Symfony Console](https://symfony.com/doc/current/console.html) i - [Commands](#commands) - [Example command](#example-command) - [Invokable commands](#invokable-commands) + - [Commands without extending Command](#commands-without-extending-command) - [UI](#ui) - [Styled output](#styled-output) - [Cursor control](#cursor-control) @@ -280,6 +281,58 @@ The `#[Argument]` and `#[Option]` attributes support these parameters: > See [Console Input](https://symfony.com/doc/current/console/input.html) in Symfony docs. +## Commands without extending Command + +Since Symfony Console 7.3, a service doesn't have to extend Symfony's `Command` class at all - a plain class with a public `__invoke()` method is enough. Behind the scenes, the extension wraps it into a synthetic `Command` instance via `Command::setCode()`, the same way Symfony's own `AddConsoleCommandPass` does for the framework bundle. + +```php +namespace App\Console; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'app:greet', + description: 'Greets the given name', + usages: ['John', '--yell John'], +)] +final class GreetCommand +{ + + public function __invoke(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Hello!'); + + return Command::SUCCESS; + } + +} +``` + +```neon +services: + - App\Console\GreetCommand +``` + +The command can also be discovered purely via the `console.command` tag, without the attribute at all: + +```neon +services: + greet: + class: App\Console\GreetCommand + tags: [console.command: app:greet] +``` + +A service is registered as a console command if it meets at least one of these conditions: + +- it extends `Symfony\Component\Console\Command\Command` +- it carries the `console.command` tag +- it's annotated with `#[AsCommand]` + +> See [Invokable Commands](https://symfony.com/blog/new-in-symfony-7-3-invokable-commands-and-input-attributes) blog post (Symfony 7.3+). + --- # UI diff --git a/composer.json b/composer.json index 2a028f6..d6b3aa1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=8.2", "nette/di": "^3.1.8", - "symfony/console": "^7.2.0 || ^8.0.0" + "symfony/console": "^7.4.0 || ^8.0.0" }, "require-dev": { "nette/http": "^3.2.3", diff --git a/src/CommandLoader/ContainerCommandLoader.php b/src/CommandLoader/ContainerCommandLoader.php index a895058..8225eab 100644 --- a/src/CommandLoader/ContainerCommandLoader.php +++ b/src/CommandLoader/ContainerCommandLoader.php @@ -11,7 +11,7 @@ class ContainerCommandLoader implements CommandLoaderInterface { /** - * @param array $commandMap + * @param array $commandMap */ public function __construct( private readonly Container $container, diff --git a/src/DI/ConsoleExtension.php b/src/DI/ConsoleExtension.php index 538eeb2..636720e 100644 --- a/src/DI/ConsoleExtension.php +++ b/src/DI/ConsoleExtension.php @@ -6,6 +6,8 @@ use Contributte\Console\CommandLoader\ContainerCommandLoader; use Contributte\Console\Http\ConsoleRequestFactory; use Nette\DI\CompilerExtension; +use Nette\DI\ContainerBuilder; +use Nette\DI\Definitions\Definition; use Nette\DI\Definitions\ServiceDefinition; use Nette\DI\Definitions\Statement; use Nette\DI\MissingServiceException; @@ -13,9 +15,7 @@ use Nette\Http\RequestFactory; use Nette\Schema\Expect; use Nette\Schema\Schema; -use Nette\Utils\Arrays; use ReflectionClass; -use ReflectionProperty; use stdClass; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -146,62 +146,10 @@ public function beforeCompile(): void } // Add all commands to map for command loader - $commands = $builder->findByType(Command::class); $commandMap = []; - // Iterate over all commands and build commandMap - foreach ($commands as $serviceName => $service) { - $tags = $service->getTags(); - $commandName = null; - - // Try to use console.command tag - if (isset($tags['console.command'])) { - if (is_string($tags['console.command'])) { - $commandName = $tags['console.command']; - } elseif (is_array($tags['console.command'])) { - $commandName = Arrays::get($tags['console.command'], 'name', null); - } - } - - $aliases = []; - // Try to detect command name from Command::getDefaultName() or Command::defaultName property - if (!is_string($commandName) || $commandName === '') { - /** @var class-string $className */ - $className = $service->getType(); - $reflection = new ReflectionClass($className); - $attributes = $reflection->getAttributes(AsCommand::class); - - if ($attributes !== []) { - $commandName = $attributes[0]->newInstance()->name; - } elseif (method_exists($className, 'getDefaultName')) { - $commandName = call_user_func([$service->getType(), 'getDefaultName']); // @phpstan-ignore-line - } elseif (property_exists($className, 'defaultName')) { - $rp = new ReflectionProperty($className, 'defaultName'); - $commandName = $rp->getValue(); - } - - if (!is_string($commandName) || $commandName === '') { - throw new ServiceCreationException( - sprintf( - 'Command "%s" missing #[AsCommand] attribute', - $service->getType(), - ) - ); - } - - $aliases = explode('|', $commandName); - $commandName = array_shift($aliases); - if ($commandName === '') { - $commandName = array_shift($aliases); - } - } - - // Append service to command map - $commandMap[$commandName] = $serviceName; - - foreach ($aliases as $alias) { - $commandMap[$alias] = $serviceName; - } + foreach ($builder->getDefinitions() as $service) { + $commandMap = array_replace($commandMap, $this->resolveCommandServices($builder, $service)); } /** @var ServiceDefinition $commandLoaderDef */ @@ -212,9 +160,150 @@ public function beforeCompile(): void try { $dispatcherDef = $builder->getDefinitionByType(EventDispatcherInterface::class); $applicationDef->addSetup('setDispatcher', [$dispatcherDef]); - } catch (MissingServiceException $e) { + } catch (MissingServiceException) { // Event dispatcher is not installed, ignore } } + /** + * Returns the "name => service" map for a console command (including aliases), or an empty array otherwise. + * Commands are detected via Command inheritance, the "console.command" tag, or the #[AsCommand] attribute. + * + * @return array + */ + private function resolveCommandServices(ContainerBuilder $builder, Definition $service): array + { + $serviceName = $service->getName(); + $type = $service->getType(); + + if ($serviceName === null || $type === null) { + return []; + } + + $tag = $service->getTag('console.command'); + $reflectionClass = new ReflectionClass($type); + $extendsCommand = $reflectionClass->isSubclassOf(Command::class); + $asCommandAttribute = ($reflectionClass->getAttributes(AsCommand::class)[0] ?? null)?->newInstance(); + + if (!$extendsCommand) { + if ($tag === null && $asCommandAttribute === null) { + return []; + } + + if (!$reflectionClass->hasMethod('__invoke')) { + throw new ServiceCreationException(sprintf( + 'Service "%s" of type "%s" is registered as a console command (via "console.command" tag or #[AsCommand] attribute), but is neither a subclass of "%s" nor has an __invoke()" method.', + $serviceName, + $type, + Command::class, + )); + } + } + + // The resolved name may be pipe-separated (name|alias1|alias2). + // A leading pipe marks a hidden command, as in Symfony's Command constructor. + $aliases = explode('|', $this->resolveCommandName($type, $tag, $asCommandAttribute)); + /** @var string $commandName */ + $commandName = array_shift($aliases); + $isHidden = $commandName === ''; + + if ($isHidden) { + /** @var string $commandName */ + $commandName = array_shift($aliases); + } + + $commandServiceName = $serviceName; + + if (!$extendsCommand) { + $commandServiceName = $this->registerInvokableCommand( + $builder, + $serviceName, + $commandName, + $aliases, + $isHidden, + $asCommandAttribute, + ); + } + + return array_fill_keys([$commandName, ...$aliases], $commandServiceName); + } + + /** + * @param class-string $type + */ + private function resolveCommandName(string $type, mixed $tag, ?AsCommand $asCommandAttribute): string + { + $commandName = null; + + if (is_string($tag)) { + $commandName = $tag; + } elseif (is_array($tag)) { + $commandName = $tag['name'] ?? null; + } + + if (is_string($commandName) && $commandName !== '') { + return $commandName; + } + + if ($asCommandAttribute !== null) { + $commandName = $asCommandAttribute->name; + } elseif (is_callable([$type, 'getDefaultName'])) { + $commandName = $type::getDefaultName(); + } + + if (!is_string($commandName) || $commandName === '') { + throw new ServiceCreationException(sprintf('Command "%s" missing #[AsCommand] attribute', $type)); + } + + return $commandName; + } + + /** + * Registers a new Command wrapper service for an invokable command. + * + * @param string[] $aliases + */ + private function registerInvokableCommand( + ContainerBuilder $builder, + string $invokableServiceName, + string $commandName, + array $aliases, + bool $isHidden, + ?AsCommand $asCommandAttribute, + ): string + { + $commandServiceName = $this->prefix($invokableServiceName . '.command'); + $commandDef = $builder->addDefinition($commandServiceName) + ->setFactory(Command::class) + ->setAutowired(false) + ->addSetup('setCode', ['@' . $invokableServiceName]) + ->addSetup('setName', [$commandName]); + + if ($aliases !== []) { + $commandDef->addSetup('setAliases', [$aliases]); + } + + if ($isHidden) { + $commandDef->addSetup('setHidden', [true]); + } + + if ($asCommandAttribute === null) { + return $commandServiceName; + } + + if (($asCommandAttribute->description ?? '') !== '') { + $commandDef->addSetup('setDescription', [$asCommandAttribute->description]); + } + + if (($asCommandAttribute->help ?? '') !== '') { + $commandDef->addSetup('setHelp', [$asCommandAttribute->help]); + } + + foreach ($asCommandAttribute->usages as $usage) { + $commandDef->addSetup('addUsage', [$usage]); + } + + return $commandServiceName; + } + } diff --git a/tests/Cases/DI/ConsoleExtension.invokable.phpt b/tests/Cases/DI/ConsoleExtension.invokable.phpt new file mode 100644 index 0000000..e3eb170 --- /dev/null +++ b/tests/Cases/DI/ConsoleExtension.invokable.phpt @@ -0,0 +1,122 @@ +withCompiler(function (Compiler $compiler): void { + $compiler->addExtension('console', new ConsoleExtension(true)); + $compiler->addConfig(Neonkit::load(<<<'NEON' + console: + services: + invokable: Tests\Fixtures\InvokableCommand + NEON)); + })->build(); + + $application = $container->getByType(Application::class); + assert($application instanceof Application); + + // A synthetic "console.invokable.command" Command service wraps the invokable service. + // The wrapper is the container's only Command service, as the original doesn't extend Command. + Assert::count(1, $container->findByType(Command::class)); + Assert::false($container->isCreated('invokable')); + + // Application::has() triggers lazy loading through the command loader, + // instantiating the invokable service in the process. + Assert::true($application->has('app:invokable')); + Assert::true($container->isCreated('invokable')); + + $command = $application->find('app:invokable'); + Assert::type(Command::class, $command); + Assert::same('app:invokable', $command->getName()); + Assert::same('Invokable command', $command->getDescription()); + Assert::same(['app:invokable-alias'], $command->getAliases()); + Assert::same('Invokable command help', $command->getHelp()); + Assert::same(['app:invokable --foo', 'app:invokable --bar'], $command->getUsages()); + + // The alias resolves to the very same wrapped command + Assert::same($command, $application->find('app:invokable-alias')); + + $tester = new CommandTester($command); + $tester->execute([]); + Assert::same('invoked', $tester->getDisplay()); + Assert::same(Command::SUCCESS, $tester->getStatusCode()); +}); + +// Invokable command with the hidden flag set via #[AsCommand]. +// A plain, unrelated service ("other") is registered alongside it to verify it's skipped, not treated as a command. +Toolkit::test(function (): void { + $container = ContainerBuilder::of() + ->withCompiler(function (Compiler $compiler): void { + $compiler->addExtension('console', new ConsoleExtension(true)); + $compiler->addConfig(Neonkit::load(<<<'NEON' + console: + services: + invokable: Tests\Fixtures\InvokableHiddenCommand + other: stdClass + NEON)); + })->build(); + + $application = $container->getByType(Application::class); + assert($application instanceof Application); + + Assert::count(1, $container->findByType(Command::class)); + + $command = $application->find('app:invokable-hidden'); + Assert::same('app:invokable-hidden', $command->getName()); + Assert::true($command->isHidden()); +}); + +// Invokable command discovered purely via the "console.command" tag (no attribute, does not extend Command) +Toolkit::test(function (): void { + $container = ContainerBuilder::of() + ->withCompiler(function (Compiler $compiler): void { + $compiler->addExtension('console', new ConsoleExtension(true)); + $compiler->addConfig(Neonkit::load(<<<'NEON' + console: + services: + invokable: + class: Tests\Fixtures\InvokableTaggedCommand + tags: [console.command: app:invokable-tagged] + NEON)); + })->build(); + + $application = $container->getByType(Application::class); + assert($application instanceof Application); + + Assert::true($application->has('app:invokable-tagged')); + + $command = $application->find('app:invokable-tagged'); + $tester = new CommandTester($command); + $tester->execute([]); + Assert::same('invoked-tagged', $tester->getDisplay()); +}); + +// Tagged as console command, but neither extends Command nor has __invoke() +Toolkit::test(function (): void { + Assert::exception(function (): void { + ContainerBuilder::of() + ->withCompiler(function (Compiler $compiler): void { + $compiler->addExtension('console', new ConsoleExtension(true)); + $compiler->addConfig(Neonkit::load(<<<'NEON' + console: + services: + invokable: + class: stdClass + tags: [console.command: app:invalid] + NEON)); + })->build(); + }, ServiceCreationException::class); +}); diff --git a/tests/Fixtures/BarCommand.php b/tests/Fixtures/BarCommand.php index fda07f3..9c34bff 100644 --- a/tests/Fixtures/BarCommand.php +++ b/tests/Fixtures/BarCommand.php @@ -16,7 +16,7 @@ final class BarCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - return 0; + return self::SUCCESS; } } diff --git a/tests/Fixtures/FooAliasCommand.php b/tests/Fixtures/FooAliasCommand.php index da4dc4a..bdeedec 100644 --- a/tests/Fixtures/FooAliasCommand.php +++ b/tests/Fixtures/FooAliasCommand.php @@ -17,7 +17,7 @@ final class FooAliasCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - return 0; + return self::SUCCESS; } } diff --git a/tests/Fixtures/FooCommand.php b/tests/Fixtures/FooCommand.php index 413543d..4d3c06f 100644 --- a/tests/Fixtures/FooCommand.php +++ b/tests/Fixtures/FooCommand.php @@ -16,7 +16,7 @@ final class FooCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - return 0; + return self::SUCCESS; } } diff --git a/tests/Fixtures/FooHiddenCommand.php b/tests/Fixtures/FooHiddenCommand.php index 7879a72..2aba963 100644 --- a/tests/Fixtures/FooHiddenCommand.php +++ b/tests/Fixtures/FooHiddenCommand.php @@ -17,7 +17,7 @@ final class FooHiddenCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - return 0; + return self::SUCCESS; } } diff --git a/tests/Fixtures/HelperSetCommand.php b/tests/Fixtures/HelperSetCommand.php index 3cf6fc3..187c1ac 100644 --- a/tests/Fixtures/HelperSetCommand.php +++ b/tests/Fixtures/HelperSetCommand.php @@ -20,7 +20,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - return 0; + return self::SUCCESS; } } diff --git a/tests/Fixtures/InvokableCommand.php b/tests/Fixtures/InvokableCommand.php new file mode 100644 index 0000000..24becd6 --- /dev/null +++ b/tests/Fixtures/InvokableCommand.php @@ -0,0 +1,31 @@ +write('invoked'); + + return Command::SUCCESS; + } + +} diff --git a/tests/Fixtures/InvokableHiddenCommand.php b/tests/Fixtures/InvokableHiddenCommand.php new file mode 100644 index 0000000..a95463a --- /dev/null +++ b/tests/Fixtures/InvokableHiddenCommand.php @@ -0,0 +1,28 @@ +write('invoked-hidden'); + + return Command::SUCCESS; + } + +} diff --git a/tests/Fixtures/InvokableTaggedCommand.php b/tests/Fixtures/InvokableTaggedCommand.php new file mode 100644 index 0000000..498b220 --- /dev/null +++ b/tests/Fixtures/InvokableTaggedCommand.php @@ -0,0 +1,23 @@ +write('invoked-tagged'); + + return Command::SUCCESS; + } + +}