diff --git a/README.md b/README.md index 17cf50f..ca5cfa4 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,28 @@ [![CI Status](https://github.com/zenstruck/console-extra/workflows/CI/badge.svg)](https://github.com/zenstruck/console-extra/actions?query=workflow%3ACI) [![codecov](https://codecov.io/gh/zenstruck/console-extra/branch/1.x/graph/badge.svg?token=OEFPA53TDM)](https://codecov.io/gh/zenstruck/console-extra) -A modular set of features to reduce configuration boilerplate for your commands: +A modular set of features to reduce configuration boilerplate for your Symfony commands: ```php -/** - * Creates a user in the database. - * - * @command create:user email password --r|role[] - */ +#[AsCommand('create:user', 'Creates a user in the database.')] final class CreateUserCommand extends InvokableServiceCommand { - use ConfigureWithDocblocks, RunsCommands, RunsProcesses; + use ConfigureWithAttributes, RunsCommands, RunsProcesses; - public function __invoke(IO $io, UserRepository $repo, string $email, string $password, array $roles): void - { + public function __invoke( + IO $io, + + UserRepository $repo, + + #[Argument] + string $email, + + #[Argument] + string $password, + + #[Option(name: 'role', shortcut: 'r')] + array $roles, + ): void { $repo->createUser($email, $password, $roles); $this->runCommand('another:command'); @@ -45,8 +53,9 @@ composer require zenstruck/console-extra This library is a set of modular features that can be used separately or in combination. -**TIP**: To reduce command boilerplate even further, it is recommended to create an abstract base command for your -app that enables all the features you desire. Then have all your app's commands extend this. +> **Note** +> To reduce command boilerplate even further, it is recommended to create an abstract base command for your +> app that enables all the features you desire. Then have all your app's commands extend this. ### `IO` @@ -78,8 +87,8 @@ into your command's `__invoke()` method. The following are parameters that can b - `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) +- *arguments* (parameter name must match argument name or use the `Zenstruck\Console\Attribute\Argument` attribute) +- *options* (parameter name must match option name or use the `Zenstruck\Console\Attribute\Option` attribute) ```php use Symfony\Component\Console\Command\Command; @@ -153,6 +162,76 @@ class CreateUserCommand extends InvokableServiceCommand } ``` +### `ConfigureWithAttributes` + +Use this trait to use the `Argument` and `Option` attributes to configure your command's +arguments and options (_PHP 8+ required_): + +```php +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Zenstruck\Console\Attribute\Argument; +use Zenstruck\Console\Attribute\Option; +use Zenstruck\Console\ConfigureWithAttributes; + +#[Argument('arg1', description: 'Argument 1 description', mode: InputArgument::REQUIRED)] +#[Argument('arg2', description: 'Argument 1 description')] +#[Option('option1', description: 'Option 1 description')] +class MyCommand extends Command +{ + use ConfigureWithAttributes; +} +``` + +> **Note** +> This trait is incompatible with [`ConfigureWithDocblocks`](#configurewithdocblocks). + +#### Invokable Attributes + +If using `ConfigureWithAttributes` and [`Invokable`](#invokable) together, you can add the +`Option`/`Argument` attributes to your `__invoke()` parameters to define and inject arguments/options: + +```php +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Zenstruck\Console\Attribute\Argument; +use Zenstruck\Console\Attribute\Option; +use Zenstruck\Console\ConfigureWithAttributes; +use Zenstruck\Console\Invokable; + +#[AsCommand('my:command')] +class MyCommand extends Command +{ + use ConfigureWithAttributes, Invokable; + + public function __invoke( + #[Argument] + string $username, // defined as a required argument (username) + + #[Argument] + string $password = 'p4ssw0rd', // defined as an optional argument (password) with a default (p4ssw0rd) + + #[Option(name: 'role', shortcut: 'r')] + array $roles = [], // defined as an array option that requires values (--r|role[]) + + #[Option(name: 'super-admin')] + bool $superAdmin = false, // defined as a "value-less" option (--super-admin) + + #[Option] + ?bool $force = null, // defined as a "negatable" option (--force/--no-force) + + #[Option] + ?string $name = null, // defined as an option that requires a value (--name=) + ): void { + // ... + } +} +``` + +> **Note** +> Option/Argument _modes_ and _defaults_ are detected from the parameter's type-hint/default value +> and cannot be defined on the attribute. + ### `AutoName` Use this trait to have your command's name auto-generated from the class name: @@ -186,29 +265,6 @@ class CreateUserCommand extends Command } ``` -### `ConfigureWithAttributes` - -Use this trait to use the `Argument` and `Option` attributes to configure your command's -arguments and options (_PHP 8+ required_): - -```php -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Zenstruck\Console\Attribute\Argument; -use Zenstruck\Console\Attribute\Option; -use Zenstruck\Console\ConfigureWithAttributes; - -#[Argument('arg1', description: 'Argument 1 description', mode: InputArgument::REQUIRED)] -#[Argument('arg2', description: 'Argument 1 description')] -#[Option('option1', description: 'Option 1 description')] -class MyCommand extends Command -{ - use ConfigureWithAttributes; -} -``` - -**NOTE:** This trait is incompatible with [`ConfigureWithDocblocks`](#configurewithdocblocks). - ### `ConfigureWithDocblocks` Use this trait to allow your command to be configured by your command class' docblock. @@ -253,12 +309,12 @@ class MyCommand extends Command } ``` -**NOTES**: -1. If the `@command` tag is absent, [AutoName](#autoname) is used. -2. All the configuration can be disabled by using the traditional methods of configuring your command. -3. Command's are still [lazy](https://symfony.com/blog/new-in-symfony-3-4-lazy-commands) using this method of - configuration but there is overhead in parsing the docblocks so be aware of this. -4. This trait is incompatible with [`ConfigureWithAttributes`](#configurewithattributes). +> **Note** +> 1. If the `@command` tag is absent, [AutoName](#autoname) is used. +> 2. All the configuration can be disabled by using the traditional methods of configuring your command. +> 3. Command's are still [lazy](https://symfony.com/blog/new-in-symfony-3-4-lazy-commands) using this method of +> configuration but there is overhead in parsing the docblocks so be aware of this. +> 4. This trait is incompatible with [`ConfigureWithAttributes`](#configurewithattributes). #### `@command` Tag @@ -274,11 +330,10 @@ class MyCommand extends Command } ``` -**NOTES**: -1. The `|` prefix makes the command hidden. -2. Argument/Option descriptions are not allowed. - -**TIP**: It is recommended to only do this for very simple commands as it isn't as explicit as splitting the tags out. +> **Note** +> 1. The `|` prefix makes the command hidden. +> 2. Argument/Option descriptions are not allowed. +> 3. It is recommended to only do this for very simple commands as it isn't as explicit as splitting the tags out. ### `CommandRunner` @@ -420,4 +475,5 @@ services: autoconfigure: true ``` -**NOTE**: This will display a summary after every registered command runs. +> **Note** +> This will display a summary after every registered command runs. diff --git a/composer.json b/composer.json index 21b0e60..e0f4d8c 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "zenstruck/console-extra", - "description": "Supercharge your Symfony console commands!", + "description": "A modular set of features to reduce configuration boilerplate for your Symfony commands.", "homepage": "https://github.com/zenstruck/console-extra", "type": "library", "license": "MIT", diff --git a/src/Attribute/Argument.php b/src/Attribute/Argument.php index c452948..76d1762 100644 --- a/src/Attribute/Argument.php +++ b/src/Attribute/Argument.php @@ -7,20 +7,60 @@ /** * @author Kevin Bond */ -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] final class Argument { /** * @see InputArgument::__construct() */ public function __construct( - private string $name, + public ?string $name = null, private ?int $mode = null, private string $description = '', private string|bool|int|float|array|null $default = null, ) { } + /** + * @internal + * + * @return mixed[]|null + */ + public static function parseParameter(\ReflectionParameter $parameter): ?array + { + if (!$attributes = $parameter->getAttributes(self::class)) { + return null; + } + + if (\count($attributes) > 1) { + throw new \LogicException(\sprintf('%s cannot be repeated when used as a parameter attribute.', self::class)); + } + + /** @var self $value */ + $value = $attributes[0]->newInstance(); + $value->name = $value->name ?? $parameter->name; + + if ($value->mode) { + throw new \LogicException(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', self::class)); + } + + if ($value->default) { + throw new \LogicException(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', self::class)); + } + + $value->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; + + if ($parameter->getType() instanceof \ReflectionNamedType && 'array' === $parameter->getType()->getName()) { + $value->mode |= InputArgument::IS_ARRAY; + } + + if ($parameter->isDefaultValueAvailable()) { + $value->default = $parameter->getDefaultValue(); + } + + return $value->values(); + } + /** * @internal * @@ -28,6 +68,10 @@ public function __construct( */ public function values(): array { + if (!$this->name) { + throw new \LogicException(\sprintf('A $name is required when using %s as a command class attribute.', self::class)); + } + return [$this->name, $this->mode, $this->description, $this->default]; } } diff --git a/src/Attribute/Option.php b/src/Attribute/Option.php index 0695b97..dff04b7 100644 --- a/src/Attribute/Option.php +++ b/src/Attribute/Option.php @@ -7,14 +7,14 @@ /** * @author Kevin Bond */ -#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)] final class Option { /** * @see InputOption::__construct() */ public function __construct( - private string $name, + public ?string $name = null, private string|array|null $shortcut = null, private ?int $mode = null, private string $description = '', @@ -22,6 +22,48 @@ public function __construct( ) { } + /** + * @internal + * + * @return mixed[]|null + */ + public static function parseParameter(\ReflectionParameter $parameter): ?array + { + if (!$attributes = $parameter->getAttributes(self::class)) { + return null; + } + + if (\count($attributes) > 1) { + throw new \LogicException(\sprintf('%s cannot be repeated when used as a parameter attribute.', self::class)); + } + + /** @var self $value */ + $value = $attributes[0]->newInstance(); + $value->name = $value->name ?? $parameter->name; + + if ($value->mode) { + throw new \LogicException(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', self::class)); + } + + if ($value->default) { + throw new \LogicException(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', self::class)); + } + + $name = $parameter->getType() instanceof \ReflectionNamedType ? $parameter->getType()->getName() : null; + + $value->mode = match ($name) { + 'array' => InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, + 'bool' => \defined(InputOption::class.'::VALUE_NEGATABLE') && $parameter->allowsNull() ? InputOption::VALUE_NEGATABLE : InputOption::VALUE_NONE, + default => InputOption::VALUE_REQUIRED, + }; + + if ($value->mode ^ InputOption::VALUE_NONE && $parameter->isDefaultValueAvailable()) { + $value->default = $parameter->getDefaultValue(); + } + + return $value->values(); + } + /** * @internal * @@ -29,6 +71,10 @@ public function __construct( */ public function values(): array { + if (!$this->name) { + throw new \LogicException(\sprintf('A $name is required when using %s as a command class attribute.', self::class)); + } + return [$this->name, $this->shortcut, $this->mode, $this->description, $this->default]; } } diff --git a/src/ConfigureWithAttributes.php b/src/ConfigureWithAttributes.php index ea8cb5b..8843ab8 100644 --- a/src/ConfigureWithAttributes.php +++ b/src/ConfigureWithAttributes.php @@ -12,6 +12,10 @@ trait ConfigureWithAttributes { protected function configure(): void { + if (\PHP_VERSION_ID < 80000) { + throw new \LogicException(\sprintf('PHP 8+ required to use %s.', ConfigureWithAttributes::class)); + } + $class = new \ReflectionClass($this); foreach ($class->getAttributes(Argument::class) as $attribute) { @@ -21,5 +25,23 @@ protected function configure(): void foreach ($class->getAttributes(Option::class) as $attribute) { $this->addOption(...$attribute->newInstance()->values()); } + + try { + $parameters = (new \ReflectionClass(static::class))->getMethod('__invoke')->getParameters(); + } catch (\ReflectionException $e) { + return; // not using Invokable + } + + foreach ($parameters as $parameter) { + if ($args = Argument::parseParameter($parameter)) { + $this->addArgument(...$args); + + continue; + } + + if ($args = Option::parseParameter($parameter)) { + $this->addOption(...$args); + } + } } } diff --git a/src/Invokable.php b/src/Invokable.php index d75a683..52da68b 100644 --- a/src/Invokable.php +++ b/src/Invokable.php @@ -7,6 +7,8 @@ use Zenstruck\Callback; use Zenstruck\Callback\Argument; use Zenstruck\Callback\Parameter; +use Zenstruck\Console\Attribute\Argument as ConsoleArgument; +use Zenstruck\Console\Attribute\Option; /** * Makes your command "invokable" to reduce boilerplate. @@ -63,12 +65,20 @@ function(\ReflectionParameter $parameter) use ($input, $output) { ); } - if ((!$type || $type->isBuiltin()) && $input->hasArgument($parameter->name)) { - return $input->getArgument($parameter->name); - } + if (!$type || $type->isBuiltin()) { + $name = $parameter->name; + + if (\PHP_VERSION_ID >= 80000 && $attr = $parameter->getAttributes(ConsoleArgument::class)[0] ?? $parameter->getAttributes(Option::class)[0] ?? null) { + $name = $attr->newInstance()->name ?? $name; + } + + if ($input->hasArgument($name)) { + return $input->getArgument($name); + } - if ((!$type || $type->isBuiltin()) && $input->hasOption($parameter->name)) { - return $input->getOption($parameter->name); + if ($input->hasOption($name)) { + return $input->getOption($name); + } } return Parameter::union( diff --git a/tests/Unit/AttributesConfigureTest.php b/tests/Unit/AttributesConfigureTest.php index c173d5d..07f9572 100644 --- a/tests/Unit/AttributesConfigureTest.php +++ b/tests/Unit/AttributesConfigureTest.php @@ -9,6 +9,8 @@ use Zenstruck\Console\Attribute\Argument; use Zenstruck\Console\Attribute\Option; use Zenstruck\Console\ConfigureWithAttributes; +use Zenstruck\Console\Invokable; +use Zenstruck\Console\Test\TestCommand; /** * @author Kevin Bond @@ -19,10 +21,11 @@ final class AttributesConfigureTest extends TestCase { /** * @test + * @dataProvider attributeCommandProvider */ - public function parse_arguments_and_options(): void + public function parse_arguments_and_options(string $class): void { - $command = new WithAttributesCommand(); + $command = new $class(); $definition = $command->getDefinition(); $arg = $definition->getArgument('arg1'); @@ -77,6 +80,255 @@ public function parse_arguments_and_options(): void $this->assertSame('o', $option->getShortcut()); $this->assertTrue($option->isValueRequired()); } + + public static function attributeCommandProvider(): iterable + { + yield [WithClassAttributesCommand::class]; + yield [WithParameterAttributesCommand::class]; + } + + /** + * @test + */ + public function argument_attribute_name_required_when_using_on_class(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('A $name is required when using %s as a command class attribute.', Argument::class)); + + new ArgumentClassAttributeMissingName(); + } + + /** + * @test + */ + public function option_attribute_name_required_when_using_on_class(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('A $name is required when using %s as a command class attribute.', Option::class)); + + new OptionClassAttributeMissingName(); + } + + /** + * @test + */ + public function negatable_parameter_attribute_options(): void + { + if (!\defined(InputOption::class.'::VALUE_NEGATABLE')) { + $this->markTestSkipped('Negatable arguments not available.'); + } + + $command = TestCommand::for( + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Option] ?bool $foo + ): void { + $this->io()->writeln(\sprintf('foo: %s', \var_export($foo, true))); + } + } + ); + + $command->execute() + ->assertSuccessful() + ->assertOutputContains('foo: NULL') + ; + + $command->execute('--foo') + ->assertSuccessful() + ->assertOutputContains('foo: true') + ; + + $command->execute('--no-foo') + ->assertSuccessful() + ->assertOutputContains('foo: false') + ; + } + + /** + * @test + */ + public function can_customize_argument_and_option_names_via_parameter_attribute(): void + { + $command = TestCommand::for( + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Argument('custom-foo')] ?string $foo, + #[Option('custom-bar')] bool $bar + ): void { + $this->io()->writeln(\sprintf('foo: %s', \var_export($foo, true))); + $this->io()->writeln(\sprintf('bar: %s', \var_export($bar, true))); + } + } + ); + + $command->execute() + ->assertSuccessful() + ->assertOutputContains('foo: NULL') + ->assertOutputContains('bar: false') + ; + + $command->execute('value --custom-bar') + ->assertSuccessful() + ->assertOutputContains("foo: 'value'") + ->assertOutputContains('bar: true') + ; + } + + /** + * @test + */ + public function cannot_use_mode_with_argument_as_parameter_attribute(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', Argument::class)); + + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Argument(mode: InputArgument::REQUIRED)] $foo + ): void { + } + }; + } + + /** + * @test + */ + public function cannot_use_default_with_argument_as_parameter_attribute(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', Argument::class)); + + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Argument(default: true)] $foo + ): void { + } + }; + } + + /** + * @test + */ + public function cannot_use_mode_with_option_as_parameter_attribute(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('Cannot use $mode when using %s as a parameter attribute, this is inferred from the parameter\'s type.', Option::class)); + + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Option(mode: InputArgument::REQUIRED)] $foo + ): void { + } + }; + } + + /** + * @test + */ + public function cannot_use_default_with_option_as_parameter_attribute(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('Cannot use $default when using %s as a parameter attribute, this is inferred from the parameter\'s default value.', Option::class)); + + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Option(default: true)] $foo + ): void { + } + }; + } + + /** + * @test + */ + public function option_not_repeatable_when_used_on_parameter(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('%s cannot be repeated when used as a parameter attribute.', Option::class)); + + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Option] + #[Option] + $foo + ): void { + } + }; + } + + /** + * @test + */ + public function argument_not_repeatable_when_used_on_parameter(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('%s cannot be repeated when used as a parameter attribute.', Argument::class)); + + new class() extends Command { + use ConfigureWithAttributes, Invokable; + + public static function getDefaultName(): ?string + { + return 'command'; + } + + public function __invoke( + #[Argument] + #[Argument] + $foo + ): void { + } + }; + } } #[Argument('arg1', InputArgument::REQUIRED, 'First argument is required')] @@ -87,7 +339,51 @@ public function parse_arguments_and_options(): void #[Option('option2', null, InputOption::VALUE_REQUIRED, 'Second option (value required)')] #[Option('option3', null, InputOption::VALUE_REQUIRED, 'Third option with default value', 'default')] #[Option('option4', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Fourth option is an array with a shortcut (-o)')] -class WithAttributesCommand extends Command +class WithClassAttributesCommand extends Command +{ + use ConfigureWithAttributes; +} + +class WithParameterAttributesCommand extends Command +{ + use ConfigureWithAttributes, Invokable; + + public function __invoke( + #[Argument(description: 'First argument is required')] + string $arg1, + + #[Argument(description: 'Second argument is optional')] + ?string $arg2 = null, + + #[Argument(description: 'Third argument is optional with a default value')] + string $arg3 = 'default', + + #[Argument('arg4', description: 'Fourth argument is an optional array')] + array $foo = [], + + #[Option(description: 'First option (no value)')] + bool $option1 = false, + + #[Option(description: 'Second option (value required)')] + ?string $option2 = null, + + #[Option(description: 'Third option with default value')] + string $option3 = 'default', + + #[Option('option4', shortcut: 'o', description: 'Fourth option is an array with a shortcut (-o)')] + array $bar = [] + ) { + } +} + +#[Argument] +class ArgumentClassAttributeMissingName extends Command +{ + use ConfigureWithAttributes; +} + +#[Option] +class OptionClassAttributeMissingName extends Command { use ConfigureWithAttributes; }