From c74acc0aca0a25b787e99e248290e861ee2c0699 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 30 Jun 2022 16:52:31 -0400 Subject: [PATCH] [feature] define opts/args on `__invoke()` params with `Argument|Option` --- src/Attribute/Argument.php | 44 ++++++- src/Attribute/Option.php | 46 ++++++- src/ConfigureWithAttributes.php | 22 ++++ tests/Unit/AttributesConfigureTest.php | 169 ++++++++++++++++++++++++- 4 files changed, 274 insertions(+), 7 deletions(-) diff --git a/src/Attribute/Argument.php b/src/Attribute/Argument.php index c452948..2c37cc5 100644 --- a/src/Attribute/Argument.php +++ b/src/Attribute/Argument.php @@ -7,20 +7,56 @@ /** * @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, + private ?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 (!$attribute = ($parameter->getAttributes(self::class)[0] ?? null)) { + return null; + } + + /** @var self $value */ + $value = $attribute->newInstance(); + $value->name = $value->name ?? $parameter->name; + + if ($value->mode) { + throw new \LogicException(); // todo + } + + if ($value->default) { + throw new \LogicException(); // todo + } + + $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 +64,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..9c37468 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, + private ?string $name = null, private string|array|null $shortcut = null, private ?int $mode = null, private string $description = '', @@ -22,6 +22,44 @@ public function __construct( ) { } + /** + * @internal + * + * @return mixed[]|null + */ + public static function parseParameter(\ReflectionParameter $parameter): ?array + { + if (!$attribute = ($parameter->getAttributes(self::class)[0] ?? null)) { + return null; + } + + /** @var self $value */ + $value = $attribute->newInstance(); + $value->name = $value->name ?? $parameter->name; + + if ($value->mode) { + throw new \LogicException(); // todo + } + + if ($value->default) { + throw new \LogicException(); // todo + } + + $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 +67,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/tests/Unit/AttributesConfigureTest.php b/tests/Unit/AttributesConfigureTest.php index c173d5d..65e8d66 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,134 @@ 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->markTestIncomplete(); + } + + /** + * @test + */ + public function option_attribute_name_required_when_using_on_class(): void + { + $this->markTestIncomplete(); + } + + /** + * @test + */ + public function negatable_parameter_attribute_options(): void + { + $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->markTestIncomplete(); + } + + /** + * @test + */ + public function cannot_use_default_with_argument_as_parameter_attribute(): void + { + $this->markTestIncomplete(); + } + + /** + * @test + */ + public function cannot_use_mode_with_option_as_parameter_attribute(): void + { + $this->markTestIncomplete(); + } + + /** + * @test + */ + public function cannot_use_default_with_option_as_parameter_attribute(): void + { + $this->markTestIncomplete(); + } } #[Argument('arg1', InputArgument::REQUIRED, 'First argument is required')] @@ -87,7 +218,39 @@ 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 = [] + ) { + } +}