Skip to content

Commit

Permalink
feat: configure auto-completion suggestions with attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond committed Feb 15, 2024
1 parent 58dce00 commit 40fe882
Show file tree
Hide file tree
Showing 7 changed files with 122 additions and 15 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
}
```

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 19 additions & 4 deletions src/Attribute/Argument.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = [],
) {
}

Expand All @@ -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;
Expand Down Expand Up @@ -74,20 +79,30 @@ final public static function parseParameter(\ReflectionParameter $parameter): ?a
$value->default = $parameter->getDefaultValue();
}

return $value->values();
return $value->values($command);
}

/**
* @internal
*
* @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];
}
}
23 changes: 19 additions & 4 deletions src/Attribute/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,13 +25,16 @@ class Option
{
/**
* @see InputOption::__construct()
*
* @param string[]|string $suggestions
*/
public function __construct(
public ?string $name = null,
private string|array|null $shortcut = null,
private ?int $mode = null,
private string $description = '',
private string|bool|int|float|array|null $default = null,
private array|string $suggestions = [],
) {
}

Expand All @@ -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;
Expand Down Expand Up @@ -77,20 +82,30 @@ final public static function parseParameter(\ReflectionParameter $parameter): ?a
$value->default = $parameter->getDefaultValue();
}

return $value->values();
return $value->values($command);
}

/**
* @internal
*
* @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];
}
}
10 changes: 5 additions & 5 deletions src/ConfigureWithAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Invokable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down
63 changes: 63 additions & 0 deletions tests/Unit/ConfigureWithAttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')]
Expand Down

0 comments on commit 40fe882

Please sign in to comment.