Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,30 @@ 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`.

### Implementing `CompletionAwareInterface`
### 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:

```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` (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:

Expand Down Expand Up @@ -104,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:

Expand Down
5 changes: 5 additions & 0 deletions src/Completion/CompletionAwareInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{

Expand Down
3 changes: 3 additions & 0 deletions src/CompletionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
146 changes: 139 additions & 7 deletions src/CompletionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;

/**
* Command mixing this library's CompletionAwareInterface with symfony/console's own completion API
*
* This covers commands that were written against the old API and later extended using the new one.
*/
class NativeCompleteCommand extends Command implements CompletionAwareInterface
{
protected function configure(): void
{
$this->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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;

/**
* Command using symfony/console's native $suggestedValues parameter for completion
*/
class SuggestedValuesCommand extends Command
{
protected function configure(): void
{
$this->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');
}
);
}
}
Loading
Loading