From 40fe882a6d899e5d2a96e6e8aff3eb4d7db82898 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 15 Feb 2024 16:28:56 -0500 Subject: [PATCH] feat: configure auto-completion suggestions with attributes --- README.md | 14 +++++ composer.json | 2 +- src/Attribute/Argument.php | 23 ++++++-- src/Attribute/Option.php | 23 ++++++-- src/ConfigureWithAttributes.php | 10 ++-- src/Invokable.php | 2 +- tests/Unit/ConfigureWithAttributesTest.php | 63 ++++++++++++++++++++++ 7 files changed, 122 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 377c73e..c4cefac 100644 --- a/README.md +++ b/README.md @@ -206,10 +206,24 @@ use Zenstruck\Console\InvokableCommand; #[Argument('arg1', description: 'Argument 1 description', mode: InputArgument::REQUIRED)] #[Argument('arg2', description: 'Argument 1 description')] +#[Argument('arg3', suggestions: ['suggestion1', 'suggestion2'])] // for auto-completion +#[Argument('arg4', suggestions: 'suggestionsForArg4')] // use a method on the command to get suggestions #[Option('option1', description: 'Option 1 description')] +#[Option('option2', suggestions: ['suggestion1', 'suggestion2'])] // for auto-completion +#[Option('option3', suggestions: 'suggestionsForOption3')] // use a method on the command to get suggestions class MyCommand extends InvokableCommand { // ... + + private function suggestionsForArg4(): array + { + return ['suggestion3', 'suggestion4']; + } + + private function suggestionsForOption3(): array + { + return ['suggestion3', 'suggestion4']; + } } ``` diff --git a/composer.json b/composer.json index d0efd7e..b67a367 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "symfony/phpunit-bridge": "^6.2|^7.0", "symfony/process": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", - "zenstruck/console-test": "^1.3" + "zenstruck/console-test": "^1.4" }, "conflict": { "symfony/service-contracts": "<3.2" diff --git a/src/Attribute/Argument.php b/src/Attribute/Argument.php index 8c87fde..9613072 100644 --- a/src/Attribute/Argument.php +++ b/src/Attribute/Argument.php @@ -11,6 +11,8 @@ namespace Zenstruck\Console\Attribute; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Input\InputArgument; use function Symfony\Component\String\s; @@ -23,12 +25,15 @@ class Argument { /** * @see InputArgument::__construct() + * + * @param string[]|string $suggestions */ public function __construct( public ?string $name = null, private ?int $mode = null, private string $description = '', private string|bool|int|float|array|null $default = null, + private array|string $suggestions = [], ) { } @@ -37,7 +42,7 @@ public function __construct( * * @return mixed[]|null */ - final public static function parseParameter(\ReflectionParameter $parameter): ?array + final public static function parseParameter(\ReflectionParameter $parameter, Command $command): ?array { if (!$attributes = $parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)) { return null; @@ -74,7 +79,7 @@ final public static function parseParameter(\ReflectionParameter $parameter): ?a $value->default = $parameter->getDefaultValue(); } - return $value->values(); + return $value->values($command); } /** @@ -82,12 +87,22 @@ final public static function parseParameter(\ReflectionParameter $parameter): ?a * * @return mixed[] */ - final public function values(): array + final public function values(Command $command): 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]; + $suggestions = $this->suggestions; + + if (\is_string($suggestions)) { + $suggestions = \Closure::bind( + fn(CompletionInput $i) => $this->{$suggestions}($i), + $command, + $command, + ); + } + + return [$this->name, $this->mode, $this->description, $this->default, $suggestions]; } } diff --git a/src/Attribute/Option.php b/src/Attribute/Option.php index 07058f6..7ab229d 100644 --- a/src/Attribute/Option.php +++ b/src/Attribute/Option.php @@ -11,6 +11,8 @@ namespace Zenstruck\Console\Attribute; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Input\InputOption; use function Symfony\Component\String\s; @@ -23,6 +25,8 @@ class Option { /** * @see InputOption::__construct() + * + * @param string[]|string $suggestions */ public function __construct( public ?string $name = null, @@ -30,6 +34,7 @@ public function __construct( private ?int $mode = null, private string $description = '', private string|bool|int|float|array|null $default = null, + private array|string $suggestions = [], ) { } @@ -38,7 +43,7 @@ public function __construct( * * @return mixed[]|null */ - final public static function parseParameter(\ReflectionParameter $parameter): ?array + final public static function parseParameter(\ReflectionParameter $parameter, Command $command): ?array { if (!$attributes = $parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)) { return null; @@ -77,7 +82,7 @@ final public static function parseParameter(\ReflectionParameter $parameter): ?a $value->default = $parameter->getDefaultValue(); } - return $value->values(); + return $value->values($command); } /** @@ -85,12 +90,22 @@ final public static function parseParameter(\ReflectionParameter $parameter): ?a * * @return mixed[] */ - final public function values(): array + final public function values(Command $command): 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]; + $suggestions = $this->suggestions; + + if (\is_string($suggestions)) { + $suggestions = \Closure::bind( + fn(CompletionInput $i) => $this->{$suggestions}($i), + $command, + $command, + ); + } + + return [$this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestions]; } } diff --git a/src/ConfigureWithAttributes.php b/src/ConfigureWithAttributes.php index bad06d1..2bb33d1 100644 --- a/src/ConfigureWithAttributes.php +++ b/src/ConfigureWithAttributes.php @@ -28,27 +28,27 @@ protected function configure(): void $class = new \ReflectionClass($this); foreach ($class->getAttributes(Argument::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $this->addArgument(...$attribute->newInstance()->values()); + $this->addArgument(...$attribute->newInstance()->values($this)); } foreach ($class->getAttributes(Option::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { - $this->addOption(...$attribute->newInstance()->values()); + $this->addOption(...$attribute->newInstance()->values($this)); } try { $parameters = (new \ReflectionClass(static::class))->getMethod('__invoke')->getParameters(); - } catch (\ReflectionException $e) { + } catch (\ReflectionException) { return; // not using Invokable } foreach ($parameters as $parameter) { - if ($args = Argument::parseParameter($parameter)) { + if ($args = Argument::parseParameter($parameter, $this)) { $this->addArgument(...$args); continue; } - if ($args = Option::parseParameter($parameter)) { + if ($args = Option::parseParameter($parameter, $this)) { $this->addOption(...$args); } } diff --git a/src/Invokable.php b/src/Invokable.php index 1dcc55e..29f51ee 100644 --- a/src/Invokable.php +++ b/src/Invokable.php @@ -132,7 +132,7 @@ protected static function invokeParameters(): array { try { return (new \ReflectionClass(static::class))->getMethod('__invoke')->getParameters(); - } catch (\ReflectionException $e) { + } catch (\ReflectionException) { throw new \LogicException(\sprintf('"%s" must implement __invoke() to use %s.', static::class, Invokable::class)); } } diff --git a/tests/Unit/ConfigureWithAttributesTest.php b/tests/Unit/ConfigureWithAttributesTest.php index 66afa2c..0fbb52d 100644 --- a/tests/Unit/ConfigureWithAttributesTest.php +++ b/tests/Unit/ConfigureWithAttributesTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Zenstruck\Console\Attribute\Argument; @@ -360,6 +361,68 @@ public function __invoke( $this->assertSame('arg description', $command->getDefinition()->getArgument('foo')->getDescription()); $this->assertSame('opt description', $command->getDefinition()->getOption('bar')->getDescription()); } + + /** + * @test + */ + public function configure_argument_suggestions(): void + { + $command = TestCommand::for( + new class('command') extends Command { + use ConfigureWithAttributes; + + public function __invoke( + #[Argument(suggestions: ['foo', 'bar'])] + string $arg1, + + #[Argument(suggestions: 'arg2Suggestions')] + string $arg2, + ): void { + } + + private function arg2Suggestions(CompletionInput $input): array + { + return ['baz', 'qux']; + } + }, + ); + + $command + ->complete('')->is(['foo', 'bar'])->back() + ->complete('first ')->is(['baz', 'qux'])->back() + ; + } + + /** + * @test + */ + public function configure_option_suggestions(): void + { + $command = TestCommand::for( + new class('command') extends Command { + use ConfigureWithAttributes; + + public function __invoke( + #[Option(suggestions: ['foo', 'bar'])] + string $first, + + #[Option(suggestions: 'secondSuggestions')] + string $second, + ): void { + } + + private function secondSuggestions(CompletionInput $input): array + { + return ['baz', 'qux']; + } + }, + ); + + $command + ->complete('--first=')->is(['foo', 'bar'])->back() + ->complete('--second=')->is(['baz', 'qux'])->back() + ; + } } #[Argument('arg1', InputArgument::REQUIRED, 'First argument is required')]