diff --git a/README.md b/README.md index b64b38a..17cf50f 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ final class CreateUserCommand extends InvokableServiceCommand { use ConfigureWithDocblocks, RunsCommands, RunsProcesses; - public function __invoke(IO $io, UserRepository $repo): void + public function __invoke(IO $io, UserRepository $repo, string $email, string $password, array $roles): void { - $repo->createUser($io->argument('email'), $io->argument('password'), $io->option('role')); + $repo->createUser($email, $password, $roles); $this->runCommand('another:command'); $this->runProcess('/some/script'); @@ -71,27 +71,43 @@ On its own, it isn't very special, but it can be auto-injected into [`Invokable` ### `Invokable` -Use this trait to remove the need for extending `Command::execute()` and just inject what your need (ie [`IO`](#io)) -into your command's `__invoke()` method. +Use this trait to remove the need for extending `Command::execute()` and just inject what your need +into your command's `__invoke()` method. The following are parameters that can be auto-injected: + +- [`Zenstruck\Console\IO`](#io) +- `Symfony\Component\Console\Style\StyleInterface` +- `Symfony\Component\Console\Input\InputInterface` +- `Symfony\Component\Console\Input\OutputInterface` +- *arguments* (parameter name much match argument name) +- *options* (parameter name much match option name) ```php use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; use Zenstruck\Console\Invokable; use Zenstruck\Console\IO; class MyCommand extends \Symfony\Component\Console\Command\Command { - use Invokable; + use Invokable; // enables this feature - public function __invoke(IO $io) + // $username/$roles are the argument/option defined below + public function __invoke(IO $io, string $username, array $roles) { - $role = $io->option('role'); - $io->success('created.'); // even if you don't inject IO, it's available as a method: $this->io(); // IO } + + public function configure(): void + { + $this + ->addArgument('username', InputArgument::REQUIRED) + ->addOption('roles', mode: InputOption::VALUE_IS_ARRAY) + ; + } } ``` diff --git a/src/Invokable.php b/src/Invokable.php index c3ff2bb..d75a683 100644 --- a/src/Invokable.php +++ b/src/Invokable.php @@ -47,28 +47,42 @@ protected function initialize(InputInterface $input, OutputInterface $output): v protected function execute(InputInterface $input, OutputInterface $output): int { - self::invokeParameters(); - - $parameters = \array_merge( - \array_map( - static function(callable $factory, ?string $type) use ($input, $output) { - $factory = Parameter::factory(fn() => $factory($input, $output)); - - return $type ? Parameter::typed($type, $factory, Argument::EXACT) : Parameter::untyped($factory); - }, - $this->argumentFactories, - \array_keys($this->argumentFactories) - ), - [ - Parameter::untyped($this->io()), - Parameter::typed(InputInterface::class, $input, Argument::EXACT), - Parameter::typed(OutputInterface::class, $output, Argument::EXACT), - Parameter::typed(IO::class, $this->io(), Argument::COVARIANCE), - Parameter::typed(IO::class, Parameter::factory(fn($class) => new $class($input, $output))), - ] + $parameters = \array_map( + function(\ReflectionParameter $parameter) use ($input, $output) { + $type = $parameter->getType(); + + if (null !== $type && !$type instanceof \ReflectionNamedType) { + throw new \LogicException("Union/Intersection types not yet supported for \"{$parameter}\"."); + } + + if ($type instanceof \ReflectionNamedType && isset($this->argumentFactories[$type->getName()])) { + return Parameter::typed( + $type->getName(), + Parameter::factory(fn() => $this->argumentFactories[$type->getName()]($input, $output)), + Argument::EXACT + ); + } + + if ((!$type || $type->isBuiltin()) && $input->hasArgument($parameter->name)) { + return $input->getArgument($parameter->name); + } + + if ((!$type || $type->isBuiltin()) && $input->hasOption($parameter->name)) { + return $input->getOption($parameter->name); + } + + return Parameter::union( + Parameter::untyped($this->io()), + Parameter::typed(InputInterface::class, $input, Argument::EXACT), + Parameter::typed(OutputInterface::class, $output, Argument::EXACT), + Parameter::typed(IO::class, $this->io(), Argument::COVARIANCE), + Parameter::typed(IO::class, Parameter::factory(fn($class) => new $class($input, $output))) + ); + }, + self::invokeParameters(), ); - $return = Callback::createFor($this)->invokeAll(Parameter::union(...$parameters)); + $return = Callback::createFor($this)->invoke(...$parameters); if (null === $return) { $return = 0; // assume 0 diff --git a/src/InvokableServiceCommand.php b/src/InvokableServiceCommand.php index 9f164c9..7c53d27 100644 --- a/src/InvokableServiceCommand.php +++ b/src/InvokableServiceCommand.php @@ -36,16 +36,12 @@ static function(\ReflectionParameter $parameter): ?string { return null; } - if (!$type instanceof \ReflectionNamedType) { + if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) { return null; } $name = $type->getName(); - if ($type->isBuiltin()) { - throw new \LogicException(\sprintf('"%s::__invoke()" cannot accept built-in parameter: $%s (%s).', static::class, $parameter->getName(), $name)); - } - if (\is_a($name, InputInterface::class, true)) { return null; } diff --git a/tests/Fixture/Command/ServiceCommand.php b/tests/Fixture/Command/ServiceCommand.php index db35625..3afb326 100644 --- a/tests/Fixture/Command/ServiceCommand.php +++ b/tests/Fixture/Command/ServiceCommand.php @@ -4,6 +4,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\StyleInterface; @@ -16,8 +17,21 @@ */ final class ServiceCommand extends InvokableServiceCommand { - public function __invoke(IO $io, InputInterface $input, OutputInterface $output, StyleInterface $style, $none, LoggerInterface $logger, ?RouterInterface $router = null, ?Table $optional = null): void - { + public function __invoke( + IO $io, + InputInterface $input, + OutputInterface $output, + StyleInterface $style, + $none, + $arg1, + string $arg2, + $opt1, + bool $opt2, + string $env, + LoggerInterface $logger, + ?RouterInterface $router = null, + ?Table $optional = null + ): void { $io->comment(\sprintf('IO: %s', \get_debug_type($io))); $io->comment(\sprintf('InputInterface: %s', \get_debug_type($input))); $io->comment(\sprintf('OutputInterface: %s', \get_debug_type($output))); @@ -27,6 +41,11 @@ public function __invoke(IO $io, InputInterface $input, OutputInterface $output, $io->comment(\sprintf('RouterInterface: %s', \get_debug_type($router))); $io->comment(\sprintf('Table: %s', \get_debug_type($optional))); $io->comment(\sprintf('Parameter environment: %s', $this->parameter('kernel.environment'))); + $io->comment(\sprintf('arg1: %s', \var_export($arg1, true))); + $io->comment(\sprintf('arg2: %s', \var_export($arg2, true))); + $io->comment(\sprintf('opt1: %s', \var_export($opt1, true))); + $io->comment(\sprintf('opt2: %s', \var_export($opt2, true))); + $io->comment(\sprintf('env: %s', \var_export($env, true))); $io->success('done!'); } @@ -35,4 +54,14 @@ public static function getDefaultName(): string { return 'service-command'; } + + protected function configure(): void + { + $this + ->addArgument('arg1', InputArgument::REQUIRED) + ->addArgument('arg2', InputArgument::REQUIRED) + ->addOption('opt1') + ->addOption('opt2') + ; + } } diff --git a/tests/Integration/EventListener/CommandSummarySubscriberTest.php b/tests/Integration/EventListener/CommandSummarySubscriberTest.php index f2cf6d9..9f41f1d 100644 --- a/tests/Integration/EventListener/CommandSummarySubscriberTest.php +++ b/tests/Integration/EventListener/CommandSummarySubscriberTest.php @@ -5,7 +5,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Console\EventListener\CommandSummarySubscriber; use Zenstruck\Console\Test\InteractsWithConsole; -use Zenstruck\Console\Tests\Fixture\Command\ServiceCommand; use Zenstruck\Console\Tests\Fixture\EventListener\CustomCommandSummarySubscriber; /** @@ -24,7 +23,7 @@ public function can_add_summary_subscriber(): void self::$kernel->getContainer()->get('event_dispatcher')->addSubscriber(new CommandSummarySubscriber()); - $this->executeConsoleCommand(ServiceCommand::class) + $this->executeConsoleCommand('service-command foo bar') ->assertSuccessful() ->assertOutputContains('IO: ') ->assertOutputContains('Duration: ') @@ -41,7 +40,7 @@ public function can_disable_summary_with_custom_subscriber(): void self::$kernel->getContainer()->get('event_dispatcher')->addSubscriber(new CustomCommandSummarySubscriber()); - $this->executeConsoleCommand(ServiceCommand::class) + $this->executeConsoleCommand('service-command foo bar') ->assertSuccessful() ->assertOutputContains('IO: ') ->assertOutputNotContains('Duration: ') diff --git a/tests/Integration/InvokableServiceCommandTest.php b/tests/Integration/InvokableServiceCommandTest.php index 4dcdcbc..483930e 100644 --- a/tests/Integration/InvokableServiceCommandTest.php +++ b/tests/Integration/InvokableServiceCommandTest.php @@ -9,7 +9,6 @@ use Zenstruck\Console\Test\InteractsWithConsole; use Zenstruck\Console\Test\TestInput; use Zenstruck\Console\Test\TestOutput; -use Zenstruck\Console\Tests\Fixture\Command\ServiceCommand; use Zenstruck\Console\Tests\Fixture\Command\ServiceSubscriberTraitCommand; /** @@ -24,7 +23,7 @@ final class InvokableServiceCommandTest extends KernelTestCase */ public function can_auto_inject_services_into_invoke(): void { - $this->executeConsoleCommand(ServiceCommand::class) + $this->executeConsoleCommand('service-command foo bar --opt2') ->assertSuccessful() ->assertOutputContains(\sprintf('IO: %s', IO::class)) ->assertOutputContains(\sprintf('InputInterface: %s', TestInput::class)) @@ -35,6 +34,11 @@ public function can_auto_inject_services_into_invoke(): void ->assertOutputContains(\sprintf('RouterInterface: %s', Router::class)) ->assertOutputContains('Table: null') ->assertOutputContains('Parameter environment: test') + ->assertOutputContains("arg1: 'foo'") + ->assertOutputContains("arg2: 'bar'") + ->assertOutputContains("env: 'test'") + ->assertOutputContains('opt1: false') + ->assertOutputContains('opt2: true') ; } diff --git a/tests/Unit/InvokableTest.php b/tests/Unit/InvokableTest.php index 554c6b4..f3df589 100644 --- a/tests/Unit/InvokableTest.php +++ b/tests/Unit/InvokableTest.php @@ -31,19 +31,45 @@ public function invoke_auto_injects_proper_objects(): void { TestCommand::for( new class() extends InvokableCommand { - public function __invoke(IO $io, InputInterface $input, OutputInterface $output, StyleInterface $style, $none, ?string $optional = null) - { + public function __invoke( + IO $io, + InputInterface $input, + OutputInterface $output, + StyleInterface $style, + $none, + $arg1, + string $arg2, + $opt1, + bool $opt2, + ?string $optional = null + ) { $io->comment(\sprintf('IO: %s', \get_class($io))); $io->comment(\sprintf('$this->io(): %s', \get_class($this->io()))); $io->comment(\sprintf('InputInterface: %s', \get_class($input))); $io->comment(\sprintf('OutputInterface: %s', \get_class($output))); $io->comment(\sprintf('StyleInterface: %s', \get_class($style))); $io->comment(\sprintf('none: %s', \get_class($none))); + $io->comment(\sprintf('arg1: %s', \var_export($arg1, true))); + $io->comment(\sprintf('arg2: %s', \var_export($arg2, true))); + $io->comment(\sprintf('opt1: %s', \var_export($opt1, true))); + $io->comment(\sprintf('opt2: %s', \var_export($opt2, true))); $io->success('done!'); } + + protected function configure(): void + { + parent::configure(); + + $this + ->addArgument('arg1') + ->addArgument('arg2') + ->addOption('opt1') + ->addOption('opt2') + ; + } }) - ->execute() + ->execute('foo bar --opt2') ->assertSuccessful() ->assertOutputContains(\sprintf('IO: %s', IO::class)) ->assertOutputContains(\sprintf('$this->io(): %s', IO::class)) @@ -51,6 +77,10 @@ public function __invoke(IO $io, InputInterface $input, OutputInterface $output, ->assertOutputContains(\sprintf('OutputInterface: %s', TestOutput::class)) ->assertOutputContains(\sprintf('StyleInterface: %s', IO::class)) ->assertOutputContains(\sprintf('none: %s', IO::class)) + ->assertOutputContains("arg1: 'foo'") + ->assertOutputContains("arg2: 'bar'") + ->assertOutputContains('opt1: false') + ->assertOutputContains('opt2: true') ; }