From 9fc3f81edd83da19fa827e3d966fbd3383832ad4 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Sat, 29 Aug 2026 16:55:05 +0200 Subject: [PATCH 1/3] fix: symfony/console $suggestedValues compatibility Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Joas Schilling --- README.md | 23 ++- src/CompletionHandler.php | 146 ++++++++++++++++- .../Fixtures/NativeCompleteCommand.php | 60 +++++++ .../Fixtures/SuggestedValuesCommand.php | 58 +++++++ .../BashCompletion/SuggestedValuesTest.php | 154 ++++++++++++++++++ 5 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php create mode 100644 tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php create mode 100644 tests/Stecman/Component/Symfony/Console/BashCompletion/SuggestedValuesTest.php diff --git a/README.md b/README.md index 26cb2e5..4778368 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,28 @@ The `--generate-hook` option of `CompletionCommand` generates a small shell scri ## Defining value completions -By default, no completion results will be returned for option and argument values. There are two ways of defining custom completion values for values: extend `CompletionCommand`, or implement `CompletionAwareInterface`. +By default, no completion results will be returned for option and argument values. There are three ways of defining custom completion values: use symfony/console's own `$suggestedValues` parameter, implement `CompletionAwareInterface`, or extend `CompletionCommand`. + +### Using symfony/console's `$suggestedValues` + +Since Symfony 5.4, `addArgument()` and `addOption()` accept a `$suggestedValues` parameter, and commands can override `Command::complete()`. Both are picked up automatically, so commands written against symfony/console's documented completion API complete correctly through this library too, with no extra work: + +```php +class MyCommand extends Command +{ + protected function configure() + { + $this->addArgument('package', InputArgument::REQUIRED, 'Package', null, ['first', 'second']) + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format', null, function (CompletionInput $input) { + return $this->getFormatsMatching($input->getCompletionValue()); + }); + } +} +``` + +Suggestions declared this way are used as a fallback: if a `CompletionInterface` handler is registered for the same option/argument, or the command implements `CompletionAwareInterface` and returns values for it, those win. This keeps existing completions working unchanged, so both APIs can be used side by side — including within a single command. + +Note that `Suggestion` descriptions are dropped, as this library emits plain values only. ### Implementing `CompletionAwareInterface` diff --git a/src/CompletionHandler.php b/src/CompletionHandler.php index 871838e..b5eaf45 100644 --- a/src/CompletionHandler.php +++ b/src/CompletionHandler.php @@ -6,6 +6,8 @@ use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -292,14 +294,18 @@ protected function completeForCommandArguments() } if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) { - return $helper->run(); + return $this->withNativeFallback($helper->run(), $name, Completion::TYPE_ARGUMENT); } if ($this->command instanceof CompletionAwareInterface) { - return $this->command->completeArgumentValues($name, $this->context); + return $this->withNativeFallback( + $this->command->completeArgumentValues($name, $this->context), + $name, + Completion::TYPE_ARGUMENT + ); } - return false; + return $this->completeUsingNativeApi($name, Completion::TYPE_ARGUMENT); } /** @@ -334,15 +340,141 @@ protected function getCompletionHelper($name, $type) */ protected function completeOption(InputOption $option) { - if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) { - return $helper->run(); + $name = $option->getName(); + + if ($helper = $this->getCompletionHelper($name, Completion::TYPE_OPTION)) { + return $this->withNativeFallback($helper->run(), $name, Completion::TYPE_OPTION); } if ($this->command instanceof CompletionAwareInterface) { - return $this->command->completeOptionValues($option->getName(), $this->context); + return $this->withNativeFallback( + $this->command->completeOptionValues($name, $this->context), + $name, + Completion::TYPE_OPTION + ); } - return false; + return $this->completeUsingNativeApi($name, Completion::TYPE_OPTION); + } + + /** + * Use symfony/console's native completion as a fallback when this library's completion produced nothing + * + * The result of this library's completion always wins, so that existing completion setups keep behaving + * exactly as they did. When it produced no values, the original result is still returned if the native + * API has nothing to offer either, to leave CompletionHandler::runCompletion's flow control untouched. + * + * @param array|false|null $result - result from a CompletionInterface or CompletionAwareInterface + * @param string $name - name of the option or argument being completed + * @param string $type - one of the Completion::TYPE_* constants + * @return array|false + */ + protected function withNativeFallback($result, $name, $type) + { + if (!empty($result)) { + return $result; + } + + $native = $this->completeUsingNativeApi($name, $type); + + return $native === false ? $result : $native; + } + + /** + * Complete an option or argument value using symfony/console's own completion API + * + * This picks up values declared through the $suggestedValues parameter of Command::addArgument() and + * Command::addOption(), as well as commands that implement Symfony's Command::complete() method. + * + * @see \Symfony\Component\Console\Command\Command::complete() + * @see \Symfony\Component\Console\Input\InputArgument::complete() + * @see \Symfony\Component\Console\Input\InputOption::complete() + * + * @param string $name - name of the option or argument being completed + * @param string $type - one of the Completion::TYPE_* constants + * @return string[]|false - false when the native API offered no suggestions + */ + protected function completeUsingNativeApi($name, $type) + { + if (!$this->command) { + return false; + } + + $input = $this->createCompletionInput(); + + if (!$input) { + return false; + } + + $suggestions = new CompletionSuggestions(); + $definition = $this->command->getDefinition(); + + $targetMatches = $type === Completion::TYPE_OPTION + ? $input->mustSuggestOptionValuesFor($name) + : $input->mustSuggestArgumentValuesFor($name); + + try { + if ($targetMatches) { + // Symfony's reading of the command line agrees with ours, so let the command resolve the + // completion itself. This also covers commands that override Command::complete(). + $this->command->complete($input, $suggestions); + } elseif ($type === Completion::TYPE_OPTION && $definition->hasOption($name)) { + // Fall back to asking the option directly, so declared values still work when Symfony's + // parsing of the command line differs from this library's. + $definition->getOption($name)->complete($input, $suggestions); + } elseif ($type === Completion::TYPE_ARGUMENT && $definition->hasArgument($name)) { + $definition->getArgument($name)->complete($input, $suggestions); + } + } catch (\Exception $e) { + // Never let a broken or unexpected completion definition break the user's shell + return false; + } + + $values = array(); + + foreach ($suggestions->getValueSuggestions() as $suggestion) { + // Suggestion descriptions are dropped as this library only emits plain values + $values[] = (string) $suggestion; + } + + return $values ? $values : false; + } + + /** + * Build a symfony/console CompletionInput bound to the detected command's definition + * + * @return CompletionInput|null - null if the context can't be represented as a CompletionInput + */ + protected function createCompletionInput() + { + $tokens = $this->context->getWords(); + $currentIndex = $this->context->getWordIndex(); + + // CompletionInput can't deal with an empty token under the cursor: it expects the cursor to be + // "free" instead, which is signalled by the index being one past the end of the token list. + if ('' === $this->context->getCurrentWord()) { + $tokens = array_slice($tokens, 0, $currentIndex); + $currentIndex = count($tokens); + } + + // A command has been detected, so there is always at least a program name and a command name. + // Bail out rather than tripping over CompletionInput's assumptions if that isn't the case. + if ($currentIndex < 1 || count($tokens) < 1) { + return null; + } + + try { + $input = CompletionInput::fromTokens(array_values($tokens), $currentIndex); + + // Application options and the command name argument need to be part of the definition for the + // token list to line up with it, as the tokens include both. + $this->command->mergeApplicationDefinition(); + $input->bind($this->command->getDefinition()); + } catch (\Exception $e) { + return null; + } + + return $input; } /** diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php new file mode 100644 index 0000000..6751193 --- /dev/null +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php @@ -0,0 +1,60 @@ +setName('native-complete') + ->addOption('legacy-option', null, InputOption::VALUE_REQUIRED) + ->addOption('native-option', null, InputOption::VALUE_REQUIRED) + ->addArgument('legacy-argument', InputArgument::OPTIONAL) + ->addArgument('native-argument', InputArgument::OPTIONAL); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('native-option')) { + $suggestions->suggestValues(array('native-opt-one', 'native-opt-two')); + } + + if ($input->mustSuggestArgumentValuesFor('native-argument')) { + $suggestions->suggestValues(array('native-arg-one', 'native-arg-two')); + } + + // Values the legacy API already provides, to check that the legacy API takes precedence + if ($input->mustSuggestOptionValuesFor('legacy-option')) { + $suggestions->suggestValue('native-should-not-win'); + } + } + + public function completeOptionValues($optionName, CompletionContext $context) + { + if ($optionName === 'legacy-option') { + return array('legacy-opt'); + } + + return array(); + } + + public function completeArgumentValues($argumentName, CompletionContext $context) + { + if ($argumentName === 'legacy-argument') { + return array('legacy-arg'); + } + + return array(); + } +} diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php new file mode 100644 index 0000000..19d5593 --- /dev/null +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php @@ -0,0 +1,58 @@ +setName('suggested-values') + ->addOption( + 'colour', + 'c', + InputOption::VALUE_REQUIRED, + 'Option with statically suggested values', + null, + array('red', 'green', 'blue') + ) + ->addOption( + 'described', + null, + InputOption::VALUE_REQUIRED, + 'Option suggesting Suggestion instances', + null, + array(new Suggestion('with-description', 'This description is not used')) + ) + ->addOption( + 'plain', + null, + InputOption::VALUE_REQUIRED, + 'Option without any suggested values' + ) + ->addArgument( + 'animal', + InputArgument::OPTIONAL, + 'Argument with statically suggested values', + null, + array('cat', 'cow', 'dog') + ) + ->addArgument( + 'sounds', + InputArgument::IS_ARRAY, + 'Array argument suggesting values based on the current input', + null, + function (CompletionInput $input) { + return $input->getCompletionValue() === 'me' + ? array('meow', 'mew') + : array('moo', 'woof'); + } + ); + } +} diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/SuggestedValuesTest.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/SuggestedValuesTest.php new file mode 100644 index 0000000..31b292a --- /dev/null +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/SuggestedValuesTest.php @@ -0,0 +1,154 @@ +application->addCommands(array( + new \SuggestedValuesCommand(), + new \NativeCompleteCommand(), + )); + } + + /** + * @dataProvider suggestedValuesDataProvider + */ + public function testSuggestedValues($commandLine, array $suggestions) + { + $handler = $this->createHandler($commandLine); + $this->assertSame($suggestions, $this->getTerms($handler->runCompletion())); + } + + public static function suggestedValuesDataProvider(): array + { + return array( + 'option values' => array( + 'app suggested-values --colour ', array('red', 'green', 'blue') + ), + 'option values partially typed' => array( + 'app suggested-values --colour g', array('green') + ), + 'option values after equals sign' => array( + 'app suggested-values --colour=', array('red', 'green', 'blue') + ), + 'option values after equals sign partially typed' => array( + 'app suggested-values --colour=b', array('blue') + ), + 'option values by shortcut' => array( + 'app suggested-values -c ', array('red', 'green', 'blue') + ), + 'option without suggested values' => array( + 'app suggested-values --plain ', array() + ), + 'suggestion objects are reduced to their value' => array( + 'app suggested-values --described ', array('with-description') + ), + 'argument values' => array( + 'app suggested-values ', array('cat', 'cow', 'dog') + ), + 'argument values partially typed' => array( + 'app suggested-values co', array('cow') + ), + 'argument values with an option in front' => array( + 'app suggested-values --colour red ', array('cat', 'cow', 'dog') + ), + 'array argument values' => array( + 'app suggested-values cat ', array('moo', 'woof') + ), + 'array argument repeats' => array( + 'app suggested-values cat moo ', array('moo', 'woof') + ), + 'closure receives the completion input' => array( + 'app suggested-values cat me', array('meow', 'mew') + ), + ); + } + + /** + * Values declared through symfony/console must not take precedence over this library's completion + * + * @dataProvider apiPrecedenceDataProvider + */ + public function testApiPrecedence($commandLine, array $suggestions) + { + $handler = $this->createHandler($commandLine); + $this->assertSame($suggestions, $this->getTerms($handler->runCompletion())); + } + + public static function apiPrecedenceDataProvider(): array + { + return array( + 'CompletionAwareInterface wins for options' => array( + 'app native-complete --legacy-option ', array('legacy-opt') + ), + 'CompletionAwareInterface wins for arguments' => array( + 'app native-complete ', array('legacy-arg') + ), + 'Command::complete() fills in for options' => array( + 'app native-complete --native-option ', array('native-opt-one', 'native-opt-two') + ), + 'Command::complete() fills in for arguments' => array( + 'app native-complete legacy ', array('native-arg-one', 'native-arg-two') + ), + ); + } + + /** + * A Completion handler registered with this library takes precedence over suggested values + */ + public function testRegisteredCompletionWins() + { + $handler = $this->createHandler('app suggested-values --colour '); + $handler->addHandler( + new Completion( + 'suggested-values', + 'colour', + Completion::TYPE_OPTION, + array('puce') + ) + ); + + $this->assertSame(array('puce'), $this->getTerms($handler->runCompletion())); + } + + /** + * An empty result from a registered handler still falls back to suggested values + */ + public function testRegisteredCompletionFallsBackWhenEmpty() + { + $handler = $this->createHandler('app suggested-values --colour '); + $handler->addHandler( + new Completion( + 'suggested-values', + 'colour', + Completion::TYPE_OPTION, + array() + ) + ); + + $this->assertSame(array('red', 'green', 'blue'), $this->getTerms($handler->runCompletion())); + } +} From f14f51d78c4c2dd6512745331d037b960773b2ef Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Sat, 29 Aug 2026 17:12:21 +0200 Subject: [PATCH 2/3] fixup! fix: symfony/console $suggestedValues compatibility --- .../Console/BashCompletion/Fixtures/NativeCompleteCommand.php | 2 +- .../Console/BashCompletion/Fixtures/SuggestedValuesCommand.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php index 6751193..a51b191 100644 --- a/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/NativeCompleteCommand.php @@ -15,7 +15,7 @@ */ class NativeCompleteCommand extends Command implements CompletionAwareInterface { - protected function configure() + protected function configure(): void { $this->setName('native-complete') ->addOption('legacy-option', null, InputOption::VALUE_REQUIRED) diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php index 19d5593..fb100f1 100644 --- a/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/Fixtures/SuggestedValuesCommand.php @@ -11,7 +11,7 @@ */ class SuggestedValuesCommand extends Command { - protected function configure() + protected function configure(): void { $this->setName('suggested-values') ->addOption( From 9a91dee7180965a61ddef88361d0df427991c886 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Sun, 30 Aug 2026 12:09:27 +0200 Subject: [PATCH 3/3] chore: Deprecate the old custom way and recommend the direct symfony/console way Signed-off-by: Joas Schilling --- README.md | 6 +++--- src/Completion/CompletionAwareInterface.php | 5 +++++ src/CompletionCommand.php | 3 +++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4778368..3d2242d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The `--generate-hook` option of `CompletionCommand` generates a small shell scri By default, no completion results will be returned for option and argument values. There are three ways of defining custom completion values: use symfony/console's own `$suggestedValues` parameter, implement `CompletionAwareInterface`, or extend `CompletionCommand`. -### Using symfony/console's `$suggestedValues` +### Using symfony/console's `$suggestedValues` (recommended) Since Symfony 5.4, `addArgument()` and `addOption()` accept a `$suggestedValues` parameter, and commands can override `Command::complete()`. Both are picked up automatically, so commands written against symfony/console's documented completion API complete correctly through this library too, with no extra work: @@ -97,7 +97,7 @@ Suggestions declared this way are used as a fallback: if a `CompletionInterface` Note that `Suggestion` descriptions are dropped, as this library emits plain values only. -### Implementing `CompletionAwareInterface` +### Implementing `CompletionAwareInterface` (deprecated) `CompletionAwareInterface` allows a command to be responsible for completing its own option and argument values. When completion is run with a command name specified (eg. `myapp mycommand ...`) and the named command implements this interface, the appropriate interface method is called automatically: @@ -125,7 +125,7 @@ class MyCommand extends Command implements CompletionAwareInterface This method of generating completions doesn't support use of `CompletionInterface` implementations at the moment, which make it easy to share completion behaviour between commands. To use this functionality, you'll need write your value completions by extending `CompletionCommand`. -### Extending `CompletionCommand` +### Extending `CompletionCommand` (deprecated) Argument and option value completions can also be defined by extending `CompletionCommand` and overriding the `configureCompletion` method: diff --git a/src/Completion/CompletionAwareInterface.php b/src/Completion/CompletionAwareInterface.php index 20963cb..321dbc4 100644 --- a/src/Completion/CompletionAwareInterface.php +++ b/src/Completion/CompletionAwareInterface.php @@ -4,6 +4,11 @@ use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +/** + * @deprecated 0.16.0 - It is recommended to use the symfony/console native $suggestedValues parameter + * on {@see \Symfony\Component\Console\Command\Command::addArgument} + * and {@see \Symfony\Component\Console\Command\Command::addOption()} instead + */ interface CompletionAwareInterface { diff --git a/src/CompletionCommand.php b/src/CompletionCommand.php index ef0b3e4..93a3cff 100644 --- a/src/CompletionCommand.php +++ b/src/CompletionCommand.php @@ -188,6 +188,9 @@ protected function runCompletion() * Configure the CompletionHandler instance before it is run * * @param CompletionHandler $handler + * @deprecated 0.16.0 - It is recommended to use the symfony/console native $suggestedValues parameter + * on {@see \Symfony\Component\Console\Command\Command::addArgument} + * and {@see \Symfony\Component\Console\Command\Command::addOption()} instead */ protected function configureCompletion(CompletionHandler $handler) {