From 449c64aa43cfe56a1aa874c473f480ce6450b9c6 Mon Sep 17 00:00:00 2001 From: Marcus Pettersen Irgens Date: Wed, 8 Jan 2020 10:44:14 +0100 Subject: [PATCH 1/3] Add support for autocomplete descriptions --- README.md | 19 ++++ src/Completion.php | 10 +-- src/Completion/CompletionInterface.php | 2 +- src/Completion/CompletionResult.php | 46 ++++++++++ src/Completion/CompletionResultInterface.php | 21 +++++ src/Completion/DescriptiveCompletion.php | 25 ++++++ src/CompletionCommand.php | 86 +++++++++++-------- src/CompletionHandler.php | 72 ++++++++-------- src/HookFactory.php | 5 ++ .../Common/CompletionHandlerTestCase.php | 8 +- .../BashCompletion/CompletionCommandTest.php | 2 + .../BashCompletion/CompletionHandlerTest.php | 6 +- .../BashCompletion/HookFactoryTest.php | 3 + 13 files changed, 219 insertions(+), 86 deletions(-) create mode 100644 src/Completion/CompletionResult.php create mode 100644 src/Completion/CompletionResultInterface.php create mode 100644 src/Completion/DescriptiveCompletion.php diff --git a/README.md b/README.md index 15ce85b..a1cfe6e 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,25 @@ $handler->addHandler( ); ``` +##### Option descriptions (zsh) + +You can add custom descriptions for options by returning a `DescriptiveCompletion`: + +```php +$handler->addHandler( + new DescriptiveCompletion( + Completion::ALL_COMMANDS, + 'weather', + Completion::TYPE_ARGUMENT, + [ + 'gale' => 'winds exceeding 34 kts', + 'storm' => 'winds exceeding 48 kts', + 'hurricane' => 'winds exceeding 64 kts' + ] + ) +); +``` + ##### Completing the for both arguments and options To have a completion run for both options and arguments matching the specified name, you can use the type `Completion::ALL_TYPES`. Combining this with `Completion::ALL_COMMANDS` and consistent option/argument naming throughout your application, it's easy to share completion behaviour between commands, options and arguments: diff --git a/src/Completion.php b/src/Completion.php index f5adb45..bd2d990 100644 --- a/src/Completion.php +++ b/src/Completion.php @@ -1,9 +1,9 @@ isCallable()) { - return call_user_func($this->completion); + return new CompletionResult(call_user_func($this->completion)); } - return $this->completion; + return new CompletionResult($this->completion); } /** diff --git a/src/Completion/CompletionInterface.php b/src/Completion/CompletionInterface.php index 4f1ca05..a96f185 100644 --- a/src/Completion/CompletionInterface.php +++ b/src/Completion/CompletionInterface.php @@ -42,7 +42,7 @@ public function getTargetName(); /** * Execute the completion * - * @return string[] - an array of possible completion values + * @return CompletionResultInterface */ public function run(); } diff --git a/src/Completion/CompletionResult.php b/src/Completion/CompletionResult.php new file mode 100644 index 0000000..f5b34da --- /dev/null +++ b/src/Completion/CompletionResult.php @@ -0,0 +1,46 @@ + + */ + +namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; + + +class CompletionResult implements CompletionResultInterface +{ + /** + * @var string[] + */ + private $values; + + /** + * @var bool + */ + private $descriptive; + + public function __construct( + $values, + $descriptive = false + ) { + $this->values = $values; + $this->descriptive = $descriptive; + } + + /** + * @return bool + */ + public function isDescriptive() + { + return $this->descriptive; + } + + /** + * @return string[] + */ + public function getValues() + { + return $this->values; + } +} diff --git a/src/Completion/CompletionResultInterface.php b/src/Completion/CompletionResultInterface.php new file mode 100644 index 0000000..8d47a54 --- /dev/null +++ b/src/Completion/CompletionResultInterface.php @@ -0,0 +1,21 @@ + + */ + +namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; + +interface CompletionResultInterface +{ + /** + * @return bool + */ + public function isDescriptive(); + + /** + * @return string[] + */ + public function getValues(); +} diff --git a/src/Completion/DescriptiveCompletion.php b/src/Completion/DescriptiveCompletion.php new file mode 100644 index 0000000..0ce7889 --- /dev/null +++ b/src/Completion/DescriptiveCompletion.php @@ -0,0 +1,25 @@ +isCallable()) { + return new CompletionResult(call_user_func($this->completion), true); + } + + return new CompletionResult($this->completion, true); + } +} diff --git a/src/CompletionCommand.php b/src/CompletionCommand.php index f694c41..69c7fa5 100644 --- a/src/CompletionCommand.php +++ b/src/CompletionCommand.php @@ -123,16 +123,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $handler->setContext(new EnvironmentCompletionContext()); // Get completion results - $results = $this->runCompletion(); + $this->configureCompletion($handler); + $results = $this->handler->runCompletion(); // Escape results for the current shell $shellType = $input->getOption('shell-type') ?: $this->getShellType(); - foreach ($results as &$result) { - $result = $this->escapeForShell($result, $shellType); - } - - $output->write($results, true); + return $this->writeForShell($results, $shellType, $output); } return 0; @@ -141,56 +138,71 @@ protected function execute(InputInterface $input, OutputInterface $output) /** * Escape each completion result for the specified shell * - * @param string $result - Completion results that should appear in the shell + * @param Completion\CompletionResultInterface $result - Completion results that should appear in the shell * @param string $shellType - Valid shell type from HookFactory - * @return string + * @param OutputInterface $output + * @return int */ - protected function escapeForShell($result, $shellType) + protected function writeForShell($result, $shellType, $output) { + $desc = $result->isDescriptive(); + $values = $result->getValues(); switch ($shellType) { // BASH requires special escaping for multi-word and special character results // This emulates registering completion with`-o filenames`, without side-effects like dir name slashes case 'bash': - $context = $this->handler->getContext(); - $wordStart = substr($context->getRawCurrentWord(), 0, 1); - - if ($wordStart == "'") { - // If the current word is single-quoted, escape any single quotes in the result - $result = str_replace("'", "\\'", $result); - } else if ($wordStart == '"') { - // If the current word is double-quoted, escape any double quotes in the result - $result = str_replace('"', '\\"', $result); - } else { - // Otherwise assume the string is unquoted and word breaks should be escaped - $result = preg_replace('/([\s\'"\\\\])/', '\\\\$1', $result); + if ($desc) { + // BASH does not support autocompletion descriptions, so we just want the actual suggestions + $values = array_keys($values); + } + foreach ($values as &$value) { + $context = $this->handler->getContext(); + $wordStart = substr($context->getRawCurrentWord(), 0, 1); + + if ($wordStart == "'") { + // If the current word is single-quoted, escape any single quotes in the result + $value = str_replace("'", "\\'", $value); + } else if ($wordStart == '"') { + // If the current word is double-quoted, escape any double quotes in the result + $value = str_replace('"', '\\"', $value); + } else { + // Otherwise assume the string is unquoted and word breaks should be escaped + $value = preg_replace('/([\s\'"\\\\])/', '\\\\$1', $value); + } + + // Escape output to prevent special characters being lost when passing results to compgen + $value = escapeshellarg($value); } + $output->write($values, true); - // Escape output to prevent special characters being lost when passing results to compgen - return escapeshellarg($result); + return 0; + + case 'zsh': + if ($desc) { + $out = array(); + foreach ($values as $cmd => $description) { + $out[] = sprintf("'%s:%s'", $cmd, $description); + } + + $output->write(sprintf("(%s)", implode(" ", $out)), true); + return 100; + } // No transformation by default default: - return $result; + if ($desc) { + $values = array_keys($values); + } + $output->write($values, true); + return 0; } } - /** - * Run the completion handler and return a filtered list of results - * - * @deprecated - This will be removed in 1.0.0 in favour of CompletionCommand::configureCompletion - * - * @return string[] - */ - protected function runCompletion() - { - $this->configureCompletion($this->handler); - return $this->handler->runCompletion(); - } - /** * Configure the CompletionHandler instance before it is run * * @param CompletionHandler $handler + * @return void */ protected function configureCompletion(CompletionHandler $handler) { diff --git a/src/CompletionHandler.php b/src/CompletionHandler.php index abd0b0b..5e22fb1 100644 --- a/src/CompletionHandler.php +++ b/src/CompletionHandler.php @@ -4,6 +4,9 @@ use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface; use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResult; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResultInterface; +use Stecman\Component\Symfony\Console\BashCompletion\Completion\DescriptiveCompletion; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArrayInput; @@ -14,6 +17,7 @@ class CompletionHandler { /** * Application to complete for + * * @var \Symfony\Component\Console\Application */ protected $application; @@ -30,12 +34,14 @@ class CompletionHandler /** * Array of completion helpers. + * * @var CompletionInterface[] */ protected $helpers = array(); /** * Index the command name was detected at + * * @var int */ private $commandWordIndex; @@ -47,11 +53,11 @@ public function __construct(Application $application, CompletionContext $context // Set up completions for commands that are built-into Application $this->addHandler( - new Completion( + new \Stecman\Component\Symfony\Console\BashCompletion\Completion\DescriptiveCompletion( 'help', 'command_name', Completion::TYPE_ARGUMENT, - $this->getCommandNames() + $this->getCommands() ) ); @@ -97,8 +103,8 @@ public function addHandler(CompletionInterface $helper) /** * Do the actual completion, returning an array of strings to provide to the parent shell's completion system * + * @return CompletionResultInterface * @throws \RuntimeException - * @return string[] */ public function runCompletion() { @@ -122,30 +128,14 @@ public function runCompletion() $result = $this->{$methodName}(); if (false !== $result) { - // Return the result of the first completion mode that matches - return $this->filterResults((array) $result); + if (!$result instanceof CompletionResultInterface) { + $result = new CompletionResult((array)$result); + } + return $this->filterResult($result); } } - return array(); - } - - /** - * Get an InputInterface representation of the completion context - * - * @deprecated Incorrectly uses the ArrayInput API and is no longer needed. - * This will be removed in the next major version. - * - * @return ArrayInput - */ - public function getInput() - { - // Filter the command line content to suit ArrayInput - $words = $this->context->getWords(); - array_shift($words); - $words = array_filter($words); - - return new ArrayInput($words); + return new CompletionResult(array()); } /** @@ -161,7 +151,7 @@ protected function completeForOptions() $options = array(); foreach ($this->getAllOptions() as $opt) { - $options[] = '--'.$opt->getName(); + $options[] = '--' . $opt->getName(); } return $options; @@ -256,12 +246,12 @@ protected function completeForOptionValues() /** * Attempt to complete the current word as a command name * - * @return array|false + * @return \Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResultInterface|false */ protected function completeForCommandName() { - if (!$this->command || $this->context->getWordIndex() == $this->commandWordIndex) { - return $this->getCommandNames(); + if (!$this->command || ($this->context->getWordIndex() == $this->commandWordIndex)) { + return new CompletionResult($this->getCommands(), true); } return false; @@ -270,8 +260,8 @@ protected function completeForCommandName() /** * Attempt to complete the current word as a command argument value * + * @return CompletionResultInterface|array|false * @see Symfony\Component\Console\Input\InputArgument - * @return array|false */ protected function completeForCommandArguments() { @@ -349,7 +339,8 @@ protected function completeOption(InputOption $option) * Step through the command line to determine which word positions represent which argument values * * The word indexes of argument values are found by eliminating words that are known to not be arguments (options, - * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument value, + * option values, and command names). Any word that doesn't match for elimination is assumed to be an argument + * value, * * @param InputArgument[] $argumentDefinitions * @return array as [argument name => word index on command line] @@ -413,16 +404,21 @@ protected function getOptionWordsWithValues() /** * Filter out results that don't match the current word on the command line * - * @param string[] $array - * @return string[] + * @param CompletionResultInterface $result + * @return CompletionResultInterface */ - protected function filterResults(array $array) + protected function filterResult($result) { $curWord = $this->context->getCurrentWord(); - return array_filter($array, function($val) use ($curWord) { - return fnmatch($curWord.'*', $val); - }); + $values = $result->getValues(); + $desc = $result->isDescriptive(); + + $values = array_filter($values, function ($val) use ($curWord) { + return fnmatch($curWord . '*', $val); + }, $result->isDescriptive() ? ARRAY_FILTER_USE_KEY : 0); + + return new CompletionResult($values, $desc); } /** @@ -449,7 +445,7 @@ protected function getAllOptions() * * @return string[] */ - protected function getCommandNames() + protected function getCommands() { // Command::Hidden isn't supported before Symfony Console 3.2.0 // We don't complete hidden command names as these are intended to be private @@ -458,7 +454,7 @@ protected function getCommandNames() foreach ($this->application->all() as $name => $command) { if (!$command->isHidden()) { - $commands[] = $name; + $commands[$name] = $command->getDescription(); } } diff --git a/src/HookFactory.php b/src/HookFactory.php index d86f9b2..6c4c23e 100644 --- a/src/HookFactory.php +++ b/src/HookFactory.php @@ -100,6 +100,11 @@ function %%function_name%% { _path_files; return 0; + # Allow descriptive suggestions + elif [ $STATUS -eq 100 ]; then + _describe -- "$RESULT"; + return 0; + # Bail out if PHP didn't exit cleanly elif [ $STATUS -ne 0 ]; then echo -e "$RESULT"; diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/Common/CompletionHandlerTestCase.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/Common/CompletionHandlerTestCase.php index af4e743..72ff172 100644 --- a/tests/Stecman/Component/Symfony/Console/BashCompletion/Common/CompletionHandlerTestCase.php +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/Common/CompletionHandlerTestCase.php @@ -59,11 +59,15 @@ protected function createHandler($commandLine, $cursorIndex = null) * Get the list of terms from the output of CompletionHandler * The array index needs to be reset so that PHPUnit's array equality assertions match correctly. * - * @param string $handlerOutput + * @param \Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionResultInterface $handlerOutput * @return string[] */ protected function getTerms($handlerOutput) { - return array_values($handlerOutput); + if ($handlerOutput->isDescriptive()) { + return array_keys($handlerOutput->getValues()); + } else { + return array_values($handlerOutput->getValues()); + } } } diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionCommandTest.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionCommandTest.php index 78b634d..6d62b15 100644 --- a/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionCommandTest.php +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionCommandTest.php @@ -13,6 +13,8 @@ class CompletionCommandTest extends TestCase { /** * Ensure conflicting options names and shortcuts from the application do not break the completion command + * + * @doesNotPerformAssertions */ public function testConflictingGlobalOptions() { diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionHandlerTest.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionHandlerTest.php index f5b5935..f2a378d 100644 --- a/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionHandlerTest.php +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/CompletionHandlerTest.php @@ -14,7 +14,7 @@ public function testCompleteAppName() $handler = $this->createHandler('app'); // It's not valid to complete the application name, so this should return nothing - $this->assertEmpty($handler->runCompletion()); + $this->assertEmpty($handler->runCompletion()->getValues()); } public function testCompleteCommandNames() @@ -29,7 +29,7 @@ public function testCompleteCommandNames() public function testCompleteCommandNameNonMatch() { $handler = $this->createHandler('app br'); - $this->assertEmpty($handler->runCompletion()); + $this->assertEmpty($handler->runCompletion()->getValues()); } public function testCompleteCommandNamePartialTwoMatches() @@ -57,7 +57,7 @@ public function testCompleteSingleDash() $handler = $this->createHandler('app wave -'); // Short options are not given as suggestions - $this->assertEmpty($handler->runCompletion()); + $this->assertEmpty($handler->runCompletion()->getValues()); } public function testCompleteOptionShortcut() diff --git a/tests/Stecman/Component/Symfony/Console/BashCompletion/HookFactoryTest.php b/tests/Stecman/Component/Symfony/Console/BashCompletion/HookFactoryTest.php index 13559ec..aaeb616 100644 --- a/tests/Stecman/Component/Symfony/Console/BashCompletion/HookFactoryTest.php +++ b/tests/Stecman/Component/Symfony/Console/BashCompletion/HookFactoryTest.php @@ -54,6 +54,9 @@ public function generateHookDataProvider() ); } + /** + * @doesNotPerformAssertions + */ public function testForMissingSemiColons() { $class = new \ReflectionClass('Stecman\Component\Symfony\Console\BashCompletion\HookFactory'); From 9e31bc493642a61aef536a09bc0471b04bb80ccc Mon Sep 17 00:00:00 2001 From: Marcus Pettersen Irgens Date: Sat, 18 Jan 2020 17:39:42 +0100 Subject: [PATCH 2/3] Fix invalid hook, remove invalid docblocks --- src/Completion/CompletionResult.php | 6 ------ src/Completion/CompletionResultInterface.php | 5 ----- src/HookFactory.php | 2 +- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/Completion/CompletionResult.php b/src/Completion/CompletionResult.php index f5b34da..0ec82fd 100644 --- a/src/Completion/CompletionResult.php +++ b/src/Completion/CompletionResult.php @@ -1,13 +1,7 @@ - */ namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; - class CompletionResult implements CompletionResultInterface { /** diff --git a/src/Completion/CompletionResultInterface.php b/src/Completion/CompletionResultInterface.php index 8d47a54..bc71a7e 100644 --- a/src/Completion/CompletionResultInterface.php +++ b/src/Completion/CompletionResultInterface.php @@ -1,9 +1,4 @@ - */ namespace Stecman\Component\Symfony\Console\BashCompletion\Completion; diff --git a/src/HookFactory.php b/src/HookFactory.php index 6c4c23e..54d3617 100644 --- a/src/HookFactory.php +++ b/src/HookFactory.php @@ -102,7 +102,7 @@ function %%function_name%% { # Allow descriptive suggestions elif [ $STATUS -eq 100 ]; then - _describe -- "$RESULT"; + _describe 'command' "$RESULT"; return 0; # Bail out if PHP didn't exit cleanly From 3e6d874509e9962707660ddef8e1b13883fe05b6 Mon Sep 17 00:00:00 2001 From: Marcus Pettersen Irgens Date: Sat, 18 Jan 2020 18:15:26 +0100 Subject: [PATCH 3/3] Allow CompletionResultInterface as return types in CompletionAwareInterface --- src/Completion/CompletionAwareInterface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Completion/CompletionAwareInterface.php b/src/Completion/CompletionAwareInterface.php index 20963cb..7e63b85 100644 --- a/src/Completion/CompletionAwareInterface.php +++ b/src/Completion/CompletionAwareInterface.php @@ -12,7 +12,7 @@ interface CompletionAwareInterface * * @param string $optionName * @param CompletionContext $context - * @return array + * @return array|CompletionResultInterface */ public function completeOptionValues($optionName, CompletionContext $context); @@ -21,7 +21,7 @@ public function completeOptionValues($optionName, CompletionContext $context); * * @param string $argumentName * @param CompletionContext $context - * @return array + * @return array|CompletionResultInterface */ public function completeArgumentValues($argumentName, CompletionContext $context); }