From 405f207203b0348ab3a08c095d31bfaf7a37840f Mon Sep 17 00:00:00 2001 From: Jitendra Adhikari Date: Tue, 9 Nov 2021 16:26:48 +0700 Subject: [PATCH 1/2] Add zsh completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérôme Tamarelle --- src/Symfony/Component/Console/Application.php | 7 +- .../Console/Command/CompleteCommand.php | 4 + .../Console/Completion/CompletionInput.php | 12 +++ .../Completion/Output/ZshCompletionOutput.php | 35 ++++++++ .../Console/Completion/Suggestion.php | 14 ++-- .../Console/Resources/completion.zsh | 80 +++++++++++++++++++ .../Tests/Command/CompleteCommandTest.php | 54 ++++++++++++- .../Command/DumpCompletionCommandTest.php | 2 +- .../Tests/Completion/CompletionInputTest.php | 8 ++ .../Console/Tests/Fixtures/application_1.json | 2 +- .../Console/Tests/Fixtures/application_1.xml | 2 +- .../Console/Tests/Fixtures/application_2.json | 2 +- .../Console/Tests/Fixtures/application_2.xml | 2 +- 13 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 src/Symfony/Component/Console/Completion/Output/ZshCompletionOutput.php create mode 100644 src/Symfony/Component/Console/Resources/completion.zsh diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 64068fcc23b4..ade7820e12c9 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Event\ConsoleSignalEvent; @@ -353,18 +354,16 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType() && 'command' === $input->getCompletionName() ) { - $commandNames = []; foreach ($this->all() as $name => $command) { // skip hidden commands and aliased commands as they already get added below if ($command->isHidden() || $command->getName() !== $name) { continue; } - $commandNames[] = $command->getName(); + $suggestions->suggestValue(new Suggestion($command->getName(), $command->getDescription())); foreach ($command->getAliases() as $name) { - $commandNames[] = $name; + $suggestions->suggestValue(new Suggestion($name, $command->getDescription())); } } - $suggestions->suggestValues(array_filter($commandNames)); return; } diff --git a/src/Symfony/Component/Console/Command/CompleteCommand.php b/src/Symfony/Component/Console/Command/CompleteCommand.php index 8c8d62f860c6..74eaca43c4cd 100644 --- a/src/Symfony/Component/Console/Command/CompleteCommand.php +++ b/src/Symfony/Component/Console/Command/CompleteCommand.php @@ -17,6 +17,7 @@ use Symfony\Component\Console\Completion\Output\BashCompletionOutput; use Symfony\Component\Console\Completion\Output\CompletionOutputInterface; use Symfony\Component\Console\Completion\Output\FishCompletionOutput; +use Symfony\Component\Console\Completion\Output\ZshCompletionOutput; use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Input\InputInterface; @@ -27,6 +28,7 @@ * Responsible for providing the values to the shell completion. * * @author Wouter de Jong + * @author Jitendra A */ #[AsCommand(name: '|_complete', description: 'Internal command to provide shell completion suggestions')] final class CompleteCommand extends Command @@ -56,6 +58,7 @@ public function __construct(array $completionOutputs = []) $this->completionOutputs = $completionOutputs + [ 'bash' => BashCompletionOutput::class, 'fish' => FishCompletionOutput::class, + 'zsh' => ZshCompletionOutput::class, ]; parent::__construct(); @@ -185,6 +188,7 @@ private function createCompletionInput(InputInterface $input): CompletionInput } $completionInput = CompletionInput::fromTokens($input->getOption('input'), (int) $currentIndex); + $completionInput->setShell($input->getOption('shell')); try { $completionInput->bind($this->getApplication()->getDefinition()); diff --git a/src/Symfony/Component/Console/Completion/CompletionInput.php b/src/Symfony/Component/Console/Completion/CompletionInput.php index 872f919127d5..0fb38533ae9d 100644 --- a/src/Symfony/Component/Console/Completion/CompletionInput.php +++ b/src/Symfony/Component/Console/Completion/CompletionInput.php @@ -23,6 +23,7 @@ * completion is expected. * * @author Wouter de Jong + * @author Jitendra A */ final class CompletionInput extends ArgvInput { @@ -32,6 +33,7 @@ final class CompletionInput extends ArgvInput public const TYPE_NONE = 'none'; private $tokens; + private $shell = ''; private $currentIndex; private $completionType; private $completionName = null; @@ -179,6 +181,16 @@ public function mustSuggestArgumentValuesFor(string $argumentName): bool return self::TYPE_ARGUMENT_VALUE === $this->getCompletionType() && $argumentName === $this->getCompletionName(); } + public function setShell(string $shell): void + { + $this->shell = $shell; + } + + public function isShell(string $shell): bool + { + return $this->shell === $shell; + } + protected function parseToken(string $token, bool $parseOptions): bool { try { diff --git a/src/Symfony/Component/Console/Completion/Output/ZshCompletionOutput.php b/src/Symfony/Component/Console/Completion/Output/ZshCompletionOutput.php new file mode 100644 index 000000000000..280601d9294a --- /dev/null +++ b/src/Symfony/Component/Console/Completion/Output/ZshCompletionOutput.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Completion\Output; + +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @author Jitendra A + */ +class ZshCompletionOutput implements CompletionOutputInterface +{ + public function write(CompletionSuggestions $suggestions, OutputInterface $output): void + { + foreach ($suggestions->getValueSuggestions() as $value) { + $values[] = $value->getValue().($value->getDescription() ? "\t".$value->getDescription() : ''); + } + foreach ($suggestions->getOptionSuggestions() as $option) { + $values[] = '--'.$option->getName().($option->getDescription() ? "\t".$option->getDescription() : ''); + if ($option->isNegatable()) { + $values[] = '--no-'.$option->getName().($option->getDescription() ? "\t".$option->getDescription() : ''); + } + } + $output->write(implode("\n", $values)."\n"); + } +} diff --git a/src/Symfony/Component/Console/Completion/Suggestion.php b/src/Symfony/Component/Console/Completion/Suggestion.php index ff156f84ce7e..3020c847b6d4 100644 --- a/src/Symfony/Component/Console/Completion/Suggestion.php +++ b/src/Symfony/Component/Console/Completion/Suggestion.php @@ -18,11 +18,10 @@ */ class Suggestion { - private string $value; - - public function __construct(string $value) - { - $this->value = $value; + public function __construct( + private readonly string $value, + private readonly string $description = '' + ) { } public function getValue(): string @@ -30,6 +29,11 @@ public function getValue(): string return $this->value; } + public function getDescription(): string + { + return $this->description; + } + public function __toString(): string { return $this->getValue(); diff --git a/src/Symfony/Component/Console/Resources/completion.zsh b/src/Symfony/Component/Console/Resources/completion.zsh new file mode 100644 index 000000000000..fed761c162c0 --- /dev/null +++ b/src/Symfony/Component/Console/Resources/completion.zsh @@ -0,0 +1,80 @@ +# This file is part of the Symfony package. +# +# (c) Fabien Potencier +# +# For the full copyright and license information, please view +# https://symfony.com/doc/current/contributing/code/license.html + +# +# zsh completions for {{ COMMAND_NAME }} +# +# References: +# - https://github.com/spf13/cobra/blob/master/zsh_completions.go +# - https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Console/Resources/completion.bash +# +_sf_{{ COMMAND_NAME }}() { + local lastParam flagPrefix requestComp out comp + local -a completions + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") lastParam=${words[-1]} + + # For zsh, when completing a flag with an = (e.g., {{ COMMAND_NAME }} -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[0]} ${words[1]} _complete -szsh -S{{ VERSION }} -c$((CURRENT-1))" i="" + for w in ${words[@]}; do + w=$(printf -- '%b' "$w") + # remove quotes from typed values + quote="${w:0:1}" + if [ "$quote" = \' ]; then + w="${w%\'}" + w="${w#\'}" + elif [ "$quote" = \" ]; then + w="${w%\"}" + w="${w#\"}" + fi + # empty values are ignored + if [ ! -z "$w" ]; then + i="${i}-i${w} " + fi + done + + # Ensure atleast 1 input + if [ "${i}" = "" ]; then + requestComp="${requestComp} -i\" \"" + else + requestComp="${requestComp} ${i}" + fi + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + + while IFS='\n' read -r comp; do + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + local tab=$(printf '\t') + comp=${comp//$tab/:} + completions+=${comp} + fi + done < <(printf "%s\n" "${out[@]}") + + # Let inbuilt _describe handle completions + eval _describe "completions" completions $flagPrefix + return $? +} + +compdef _sf_{{ COMMAND_NAME }} {{ COMMAND_NAME }} diff --git a/src/Symfony/Component/Console/Tests/Command/CompleteCommandTest.php b/src/Symfony/Component/Console/Tests/Command/CompleteCommandTest.php index fe0cf12767a0..3c19a18f4511 100644 --- a/src/Symfony/Component/Console/Tests/Command/CompleteCommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/CompleteCommandTest.php @@ -47,7 +47,7 @@ public function testRequiredShellOption() public function testUnsupportedShellOption() { - $this->expectExceptionMessage('Shell completion is not supported for your shell: "unsupported" (supported: "bash", "fish").'); + $this->expectExceptionMessage('Shell completion is not supported for your shell: "unsupported" (supported: "bash", "fish", "zsh").'); $this->execute(['--shell' => 'unsupported']); } @@ -125,6 +125,57 @@ public function provideCompleteCommandInputDefinitionInputs() yield 'custom-aliased' => [['bin/console', 'ahoy'], ['Fabien', 'Robin', 'Wouter']]; } + /** + * @dataProvider provideZshCompleteCommandNameInputs + */ + public function testZshCompleteCommandName(array $input, array $suggestions) + { + $this->execute(['--current' => '1', '--input' => $input, '--shell' => 'zsh']); + $this->assertEquals(implode("\n", $suggestions).\PHP_EOL, $this->tester->getDisplay()); + } + + public function provideZshCompleteCommandNameInputs() + { + yield 'empty' => [['bin/console'], [ + 'help'."\t".'Display help for a command', + 'list'."\t".'List commands', + 'completion'."\t".'Dump the shell completion script', + 'hello'."\t".'Hello test command', + 'ahoy'."\t".'Hello test command', + ]]; + yield 'partial' => [['bin/console', 'he'], [ + 'help'."\t".'Display help for a command', + 'list'."\t".'List commands', + 'completion'."\t".'Dump the shell completion script', + 'hello'."\t".'Hello test command', + 'ahoy'."\t".'Hello test command', + ]]; + yield 'complete-shortcut-name' => [['bin/console', 'hell'], ['hello', 'ahoy']]; + } + + /** + * @dataProvider provideZshCompleteCommandInputDefinitionInputs + */ + public function testZshCompleteCommandInputDefinition(array $input, array $suggestions) + { + $this->execute(['--current' => '2', '--input' => $input, '--shell' => 'zsh']); + $this->assertEquals(implode("\n", $suggestions).\PHP_EOL, $this->tester->getDisplay()); + } + + public function provideZshCompleteCommandInputDefinitionInputs() + { + yield 'definition' => [['bin/console', 'hello', '-'], [ + '--help'."\t".'Display help for the given command. When no command is given display help for the list command', + '--quiet'."\t".'Do not output any message', + '--verbose'."\t".'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug', + '--version'."\t".'Display this application version', + '--ansi'."\t".'Force (or disable --no-ansi) ANSI output', + '--no-ansi'."\t".'Force (or disable --no-ansi) ANSI output', + '--no-interaction'."\t".'Do not ask any interactive question', + ]]; + yield 'custom' => [['bin/console', 'hello'], ['Fabien', 'Robin', 'Wouter']]; + } + private function execute(array $input) { // run in verbose mode to assert exceptions @@ -138,6 +189,7 @@ public function configure(): void { $this->setName('hello') ->setAliases(['ahoy']) + ->setDescription('Hello test command') ->addArgument('name', InputArgument::REQUIRED) ; } diff --git a/src/Symfony/Component/Console/Tests/Command/DumpCompletionCommandTest.php b/src/Symfony/Component/Console/Tests/Command/DumpCompletionCommandTest.php index 65a758ed1b58..0c955a0f626f 100644 --- a/src/Symfony/Component/Console/Tests/Command/DumpCompletionCommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/DumpCompletionCommandTest.php @@ -32,7 +32,7 @@ public function provideCompletionSuggestions() { yield 'shell' => [ [''], - ['bash', 'fish'], + ['bash', 'fish', 'zsh'], ]; } } diff --git a/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php b/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php index 946e51d20d26..ab7cacdebfa8 100644 --- a/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php +++ b/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php @@ -132,4 +132,12 @@ public function provideFromStringData() yield ['bin/console cache:clear "multi word string"', ['bin/console', 'cache:clear', '"multi word string"']]; yield ['bin/console cache:clear \'multi word string\'', ['bin/console', 'cache:clear', '\'multi word string\'']]; } + + public function testShell() + { + $input = CompletionInput::fromString('bin/console cache:clear \'multi word string\'', 1); + $input->setShell('zsh'); + + $this->assertTrue($input->isShell('zsh')); + } } diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_1.json b/src/Symfony/Component/Console/Tests/Fixtures/application_1.json index 0d655c39a7e8..bd0bd94c7eed 100644 --- a/src/Symfony/Component/Console/Tests/Fixtures/application_1.json +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_1.json @@ -89,7 +89,7 @@ "accept_value": true, "is_value_required": true, "is_multiple": false, - "description": "The shell type (\"bash\", \"fish\")", + "description": "The shell type (\"bash\", \"fish\", \"zsh\")", "default": null }, "current": { diff --git a/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml b/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml index 617786cdae19..d109e055f45c 100644 --- a/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml +++ b/src/Symfony/Component/Console/Tests/Fixtures/application_1.xml @@ -10,7 +10,7 @@