Skip to content

Commit 16f0668

Browse files
committed
feature symfony#44948 [Console] Add completion values to input definition (GromNaN)
This PR was squashed before being merged into the 6.1 branch. Discussion ---------- [Console] Add completion values to input definition | Q | A | ------------- | --- | Branch? | 6.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | n/a | License | MIT | Doc PR | symfony/symfony-docs#... During implementation of bash completion to core commands, I found the code quite verbose. The completion for all options and arguments are mixed into the same method. <details> <summary>Example of current code</summary> https://github.com/symfony/symfony/blob/098ff6277a398ac91cc6731bdadf207bf82fa40c/src/Symfony/Component/Console/Command/HelpCommand.php#L85-L98 </details This PR adds the possibility to attach values to each option and argument in their definition. It provides a default implementation of `Command::complete` that uses this values. In the constructor of the `InputOption` and `InputArgument` classes: https://github.com/symfony/symfony/blob/091027ac8c361e98d94f3c889d39a17fffaaa3ba/src/Symfony/Component/Console/Command/HelpCommand.php#L40-L45 Or using `Command::addOption` and `Command::addArgument`: https://github.com/symfony/symfony/blob/091027ac8c361e98d94f3c889d39a17fffaaa3ba/src/Symfony/Component/Console/Command/DumpCompletionCommand.php#L78 Additional benefits: - Command defined without a class can add completion. - Descriptor can show if an option/argument has completion (and values could be shown). - Easier to share suggestions when the same options are defined on several commands (for composer/composer#10320) Todo: - [x] Add tests - [ ] Add info to descriptor Commits ------- 9951124 [Console] Add completion values to input definition
2 parents 912ceb7 + 9951124 commit 16f0668

File tree

12 files changed

+216
-62
lines changed

12 files changed

+216
-62
lines changed

UPGRADE-6.1.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Console
1212
-------
1313

1414
* Deprecate `Command::$defaultName` and `Command::$defaultDescription`, use the `AsCommand` attribute instead
15+
* Add argument `$suggestedValues` to `Command::addArgument` and `Command::addOption`
16+
* Add argument `$suggestedValues` to `InputArgument` and `InputOption` constructors
1517

1618
HttpKernel
1719
----------

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Add method `__toString()` to `InputInterface`
88
* Deprecate `Command::$defaultName` and `Command::$defaultDescription`, use the `AsCommand` attribute instead
9+
* Add suggested values for arguments and options in input definition, for input completion
910

1011
6.0
1112
---

src/Symfony/Component/Console/Command/Command.php

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Component\Console\Attribute\AsCommand;
1616
use Symfony\Component\Console\Completion\CompletionInput;
1717
use Symfony\Component\Console\Completion\CompletionSuggestions;
18+
use Symfony\Component\Console\Completion\Suggestion;
1819
use Symfony\Component\Console\Exception\ExceptionInterface;
1920
use Symfony\Component\Console\Exception\InvalidArgumentException;
2021
use Symfony\Component\Console\Exception\LogicException;
@@ -319,6 +320,12 @@ public function run(InputInterface $input, OutputInterface $output): int
319320
*/
320321
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
321322
{
323+
$definition = $this->getDefinition();
324+
if (CompletionInput::TYPE_OPTION_VALUE === $input->getCompletionType() && $definition->hasOption($input->getCompletionName())) {
325+
$definition->getOption($input->getCompletionName())->complete($input, $suggestions);
326+
} elseif (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType() && $definition->hasArgument($input->getCompletionName())) {
327+
$definition->getArgument($input->getCompletionName())->complete($input, $suggestions);
328+
}
322329
}
323330

324331
/**
@@ -427,17 +434,22 @@ public function getNativeDefinition(): InputDefinition
427434
/**
428435
* Adds an argument.
429436
*
430-
* @param int|null $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL
431-
* @param mixed $default The default value (for InputArgument::OPTIONAL mode only)
437+
* @param $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL
438+
* @param $default The default value (for InputArgument::OPTIONAL mode only)
439+
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
432440
*
433441
* @throws InvalidArgumentException When argument mode is not valid
434442
*
435443
* @return $this
436444
*/
437-
public function addArgument(string $name, int $mode = null, string $description = '', mixed $default = null): static
445+
public function addArgument(string $name, int $mode = null, string $description = '', mixed $default = null, /*array|\Closure $suggestedValues = null*/): static
438446
{
439-
$this->definition->addArgument(new InputArgument($name, $mode, $description, $default));
440-
$this->fullDefinition?->addArgument(new InputArgument($name, $mode, $description, $default));
447+
$suggestedValues = 5 <= \func_num_args() ? func_get_arg(4) : [];
448+
if (!\is_array($suggestedValues) && !$suggestedValues instanceof \Closure) {
449+
throw new \TypeError(sprintf('Argument 5 passed to "%s()" must be array or \Closure, "%s" given.', __METHOD__, get_debug_type($suggestedValues)));
450+
}
451+
$this->definition->addArgument(new InputArgument($name, $mode, $description, $default, $suggestedValues));
452+
$this->fullDefinition?->addArgument(new InputArgument($name, $mode, $description, $default, $suggestedValues));
441453

442454
return $this;
443455
}
@@ -448,15 +460,20 @@ public function addArgument(string $name, int $mode = null, string $description
448460
* @param $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
449461
* @param $mode The option mode: One of the InputOption::VALUE_* constants
450462
* @param $default The default value (must be null for InputOption::VALUE_NONE)
463+
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
451464
*
452465
* @throws InvalidArgumentException If option mode is invalid or incompatible
453466
*
454467
* @return $this
455468
*/
456-
public function addOption(string $name, string|array $shortcut = null, int $mode = null, string $description = '', mixed $default = null): static
469+
public function addOption(string $name, string|array $shortcut = null, int $mode = null, string $description = '', mixed $default = null, /*array|\Closure $suggestedValues = []*/): static
457470
{
458-
$this->definition->addOption(new InputOption($name, $shortcut, $mode, $description, $default));
459-
$this->fullDefinition?->addOption(new InputOption($name, $shortcut, $mode, $description, $default));
471+
$suggestedValues = 6 <= \func_num_args() ? func_get_arg(5) : [];
472+
if (!\is_array($suggestedValues) && !$suggestedValues instanceof \Closure) {
473+
throw new \TypeError(sprintf('Argument 5 passed to "%s()" must be array or \Closure, "%s" given.', __METHOD__, get_debug_type($suggestedValues)));
474+
}
475+
$this->definition->addOption(new InputOption($name, $shortcut, $mode, $description, $default, $suggestedValues));
476+
$this->fullDefinition?->addOption(new InputOption($name, $shortcut, $mode, $description, $default, $suggestedValues));
460477

461478
return $this;
462479
}

src/Symfony/Component/Console/Command/DumpCompletionCommand.php

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
namespace Symfony\Component\Console\Command;
1313

1414
use Symfony\Component\Console\Attribute\AsCommand;
15-
use Symfony\Component\Console\Completion\CompletionInput;
16-
use Symfony\Component\Console\Completion\CompletionSuggestions;
1715
use Symfony\Component\Console\Input\InputArgument;
1816
use Symfony\Component\Console\Input\InputInterface;
1917
use Symfony\Component\Console\Input\InputOption;
@@ -39,12 +37,7 @@ final class DumpCompletionCommand extends Command
3937
*/
4038
protected static $defaultDescription = 'Dump the shell completion script';
4139

42-
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
43-
{
44-
if ($input->mustSuggestArgumentValuesFor('shell')) {
45-
$suggestions->suggestValues($this->getSupportedShells());
46-
}
47-
}
40+
private array $supportedShells;
4841

4942
protected function configure()
5043
{
@@ -82,7 +75,7 @@ protected function configure()
8275
<info>eval "$(${fullCommand} completion bash)"</>
8376
EOH
8477
)
85-
->addArgument('shell', InputArgument::OPTIONAL, 'The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given')
78+
->addArgument('shell', InputArgument::OPTIONAL, 'The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given', null, $this->getSupportedShells(...))
8679
->addOption('debug', null, InputOption::VALUE_NONE, 'Tail the completion debug log')
8780
;
8881
}
@@ -135,7 +128,7 @@ private function tailDebugLog(string $commandName, OutputInterface $output): voi
135128
*/
136129
private function getSupportedShells(): array
137130
{
138-
return array_map(function ($f) {
131+
return $this->supportedShells ??= array_map(function ($f) {
139132
return pathinfo($f, \PATHINFO_EXTENSION);
140133
}, glob(__DIR__.'/../Resources/completion.*'));
141134
}

src/Symfony/Component/Console/Command/HelpCommand.php

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111

1212
namespace Symfony\Component\Console\Command;
1313

14-
use Symfony\Component\Console\Completion\CompletionInput;
15-
use Symfony\Component\Console\Completion\CompletionSuggestions;
1614
use Symfony\Component\Console\Descriptor\ApplicationDescription;
1715
use Symfony\Component\Console\Helper\DescriptorHelper;
1816
use Symfony\Component\Console\Input\InputArgument;
@@ -39,8 +37,12 @@ protected function configure()
3937
$this
4038
->setName('help')
4139
->setDefinition([
42-
new InputArgument('command_name', InputArgument::OPTIONAL, 'The command name', 'help'),
43-
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt'),
40+
new InputArgument('command_name', InputArgument::OPTIONAL, 'The command name', 'help', function () {
41+
return array_keys((new ApplicationDescription($this->getApplication()))->getCommands());
42+
}),
43+
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt', function () {
44+
return (new DescriptorHelper())->getFormats();
45+
}),
4446
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command help'),
4547
])
4648
->setDescription('Display help for a command')
@@ -81,19 +83,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8183

8284
return 0;
8385
}
84-
85-
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
86-
{
87-
if ($input->mustSuggestArgumentValuesFor('command_name')) {
88-
$descriptor = new ApplicationDescription($this->getApplication());
89-
$suggestions->suggestValues(array_keys($descriptor->getCommands()));
90-
91-
return;
92-
}
93-
94-
if ($input->mustSuggestOptionValuesFor('format')) {
95-
$helper = new DescriptorHelper();
96-
$suggestions->suggestValues($helper->getFormats());
97-
}
98-
}
9986
}

src/Symfony/Component/Console/Command/LazyCommand.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Console\Application;
1515
use Symfony\Component\Console\Completion\CompletionInput;
1616
use Symfony\Component\Console\Completion\CompletionSuggestions;
17+
use Symfony\Component\Console\Completion\Suggestion;
1718
use Symfony\Component\Console\Helper\HelperSet;
1819
use Symfony\Component\Console\Input\InputDefinition;
1920
use Symfony\Component\Console\Input\InputInterface;
@@ -108,16 +109,28 @@ public function getNativeDefinition(): InputDefinition
108109
return $this->getCommand()->getNativeDefinition();
109110
}
110111

111-
public function addArgument(string $name, int $mode = null, string $description = '', mixed $default = null): static
112+
/**
113+
* {@inheritdoc}
114+
*
115+
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
116+
*/
117+
public function addArgument(string $name, int $mode = null, string $description = '', mixed $default = null, /*array|\Closure $suggestedValues = []*/): static
112118
{
113-
$this->getCommand()->addArgument($name, $mode, $description, $default);
119+
$suggestedValues = 5 <= \func_num_args() ? func_get_arg(4) : [];
120+
$this->getCommand()->addArgument($name, $mode, $description, $default, $suggestedValues);
114121

115122
return $this;
116123
}
117124

118-
public function addOption(string $name, string|array $shortcut = null, int $mode = null, string $description = '', mixed $default = null): static
125+
/**
126+
* {@inheritdoc}
127+
*
128+
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
129+
*/
130+
public function addOption(string $name, string|array $shortcut = null, int $mode = null, string $description = '', mixed $default = null, /*array|\Closure $suggestedValues = []*/): static
119131
{
120-
$this->getCommand()->addOption($name, $shortcut, $mode, $description, $default);
132+
$suggestedValues = 6 <= \func_num_args() ? func_get_arg(5) : [];
133+
$this->getCommand()->addOption($name, $shortcut, $mode, $description, $default, $suggestedValues);
121134

122135
return $this;
123136
}

src/Symfony/Component/Console/Command/ListCommand.php

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111

1212
namespace Symfony\Component\Console\Command;
1313

14-
use Symfony\Component\Console\Completion\CompletionInput;
15-
use Symfony\Component\Console\Completion\CompletionSuggestions;
1614
use Symfony\Component\Console\Descriptor\ApplicationDescription;
1715
use Symfony\Component\Console\Helper\DescriptorHelper;
1816
use Symfony\Component\Console\Input\InputArgument;
@@ -35,9 +33,13 @@ protected function configure()
3533
$this
3634
->setName('list')
3735
->setDefinition([
38-
new InputArgument('namespace', InputArgument::OPTIONAL, 'The namespace name'),
36+
new InputArgument('namespace', InputArgument::OPTIONAL, 'The namespace name', null, function () {
37+
return array_keys((new ApplicationDescription($this->getApplication()))->getNamespaces());
38+
}),
3939
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command list'),
40-
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt'),
40+
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt', function () {
41+
return (new DescriptorHelper())->getFormats();
42+
}),
4143
new InputOption('short', null, InputOption::VALUE_NONE, 'To skip describing commands\' arguments'),
4244
])
4345
->setDescription('List commands')
@@ -77,19 +79,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7779

7880
return 0;
7981
}
80-
81-
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
82-
{
83-
if ($input->mustSuggestArgumentValuesFor('namespace')) {
84-
$descriptor = new ApplicationDescription($this->getApplication());
85-
$suggestions->suggestValues(array_keys($descriptor->getNamespaces()));
86-
87-
return;
88-
}
89-
90-
if ($input->mustSuggestOptionValuesFor('format')) {
91-
$helper = new DescriptorHelper();
92-
$suggestions->suggestValues($helper->getFormats());
93-
}
94-
}
9582
}

src/Symfony/Component/Console/Input/InputArgument.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
namespace Symfony\Component\Console\Input;
1313

14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Completion\CompletionInput;
16+
use Symfony\Component\Console\Completion\CompletionSuggestions;
17+
use Symfony\Component\Console\Completion\Suggestion;
1418
use Symfony\Component\Console\Exception\InvalidArgumentException;
1519
use Symfony\Component\Console\Exception\LogicException;
1620

@@ -28,17 +32,19 @@ class InputArgument
2832
private string $name;
2933
private int $mode;
3034
private string|int|bool|array|null|float $default;
35+
private array|\Closure $suggestedValues;
3136
private string $description;
3237

3338
/**
3439
* @param string $name The argument name
3540
* @param int|null $mode The argument mode: self::REQUIRED or self::OPTIONAL
3641
* @param string $description A description text
3742
* @param string|bool|int|float|array|null $default The default value (for self::OPTIONAL mode only)
43+
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
3844
*
3945
* @throws InvalidArgumentException When argument mode is not valid
4046
*/
41-
public function __construct(string $name, int $mode = null, string $description = '', string|bool|int|float|array $default = null)
47+
public function __construct(string $name, int $mode = null, string $description = '', string|bool|int|float|array $default = null, \Closure|array $suggestedValues = [])
4248
{
4349
if (null === $mode) {
4450
$mode = self::OPTIONAL;
@@ -49,6 +55,7 @@ public function __construct(string $name, int $mode = null, string $description
4955
$this->name = $name;
5056
$this->mode = $mode;
5157
$this->description = $description;
58+
$this->suggestedValues = $suggestedValues;
5259

5360
$this->setDefault($default);
5461
}
@@ -111,6 +118,27 @@ public function getDefault(): string|bool|int|float|array|null
111118
return $this->default;
112119
}
113120

121+
public function hasCompletion(): bool
122+
{
123+
return [] !== $this->suggestedValues;
124+
}
125+
126+
/**
127+
* Adds suggestions to $suggestions for the current completion input.
128+
*
129+
* @see Command::complete()
130+
*/
131+
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
132+
{
133+
$values = $this->suggestedValues;
134+
if ($values instanceof \Closure && !\is_array($values = $values($input))) {
135+
throw new LogicException(sprintf('Closure for argument "%s" must return an array. Got "%s".', $this->name, get_debug_type($values)));
136+
}
137+
if ($values) {
138+
$suggestions->suggestValues($values);
139+
}
140+
}
141+
114142
/**
115143
* Returns the description text.
116144
*/

src/Symfony/Component/Console/Input/InputOption.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
namespace Symfony\Component\Console\Input;
1313

14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Completion\CompletionInput;
16+
use Symfony\Component\Console\Completion\CompletionSuggestions;
17+
use Symfony\Component\Console\Completion\Suggestion;
1418
use Symfony\Component\Console\Exception\InvalidArgumentException;
1519
use Symfony\Component\Console\Exception\LogicException;
1620

@@ -50,16 +54,18 @@ class InputOption
5054
private string|array|null $shortcut;
5155
private int $mode;
5256
private string|int|bool|array|null|float $default;
57+
private array|\Closure $suggestedValues;
5358
private string $description;
5459

5560
/**
5661
* @param string|array|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
5762
* @param int|null $mode The option mode: One of the VALUE_* constants
5863
* @param string|bool|int|float|array|null $default The default value (must be null for self::VALUE_NONE)
64+
* @param array|\Closure(CompletionInput,CompletionSuggestions):list<string|Suggestion> $suggestedValues The values used for input completion
5965
*
6066
* @throws InvalidArgumentException If option mode is invalid or incompatible
6167
*/
62-
public function __construct(string $name, string|array $shortcut = null, int $mode = null, string $description = '', string|bool|int|float|array $default = null)
68+
public function __construct(string $name, string|array $shortcut = null, int $mode = null, string $description = '', string|bool|int|float|array $default = null, array|\Closure $suggestedValues = [])
6369
{
6470
if (str_starts_with($name, '--')) {
6571
$name = substr($name, 2);
@@ -96,7 +102,11 @@ public function __construct(string $name, string|array $shortcut = null, int $mo
96102
$this->shortcut = $shortcut;
97103
$this->mode = $mode;
98104
$this->description = $description;
105+
$this->suggestedValues = $suggestedValues;
99106

107+
if ($suggestedValues && !$this->acceptValue()) {
108+
throw new LogicException('Cannot set suggested values if the option does not accept a value.');
109+
}
100110
if ($this->isArray() && !$this->acceptValue()) {
101111
throw new InvalidArgumentException('Impossible to have an option mode VALUE_IS_ARRAY if the option does not accept a value.');
102112
}
@@ -201,6 +211,27 @@ public function getDescription(): string
201211
return $this->description;
202212
}
203213

214+
public function hasCompletion(): bool
215+
{
216+
return [] !== $this->suggestedValues;
217+
}
218+
219+
/**
220+
* Adds suggestions to $suggestions for the current completion input.
221+
*
222+
* @see Command::complete()
223+
*/
224+
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
225+
{
226+
$values = $this->suggestedValues;
227+
if ($values instanceof \Closure && !\is_array($values = $values($input))) {
228+
throw new LogicException(sprintf('Closure for option "%s" must return an array. Got "%s".', $this->name, get_debug_type($values)));
229+
}
230+
if ($values) {
231+
$suggestions->suggestValues($values);
232+
}
233+
}
234+
204235
/**
205236
* Checks whether the given option equals this one.
206237
*/

0 commit comments

Comments
 (0)