diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 91403c275c9b0..334f115f38b58 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -363,8 +363,8 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType() && 'command' === $input->getCompletionName() ) { - $suggestions->suggestValues(array_filter(array_map(function (Command $command) { - return $command->isHidden() ? null : $command->getName(); + $suggestions->suggestValues(array_filter(array_map(function (Command $command) use ($input) { + return $command->isHidden() ? null : $command->getName().($input->isShell('zsh') ? "\t".$command->getDescription() : ''); }, $this->all()))); return; diff --git a/src/Symfony/Component/Console/Command/CompleteCommand.php b/src/Symfony/Component/Console/Command/CompleteCommand.php index 97357d6737ed3..50c634b0adc84 100644 --- a/src/Symfony/Component/Console/Command/CompleteCommand.php +++ b/src/Symfony/Component/Console/Command/CompleteCommand.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Output\BashCompletionOutput; use Symfony\Component\Console\Completion\Output\CompletionOutputInterface; +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; @@ -25,6 +26,7 @@ * Responsible for providing the values to the shell completion. * * @author Wouter de Jong + * @author Jitendra A */ final class CompleteCommand extends Command { @@ -41,7 +43,10 @@ final class CompleteCommand extends Command public function __construct(array $completionOutputs = []) { // must be set before the parent constructor, as the property value is used in configure() - $this->completionOutputs = $completionOutputs + ['bash' => BashCompletionOutput::class]; + $this->completionOutputs = $completionOutputs + [ + 'bash' => BashCompletionOutput::class, + 'zsh' => ZshCompletionOutput::class, + ]; parent::__construct(); } @@ -168,6 +173,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 eda95bef55468..eddaa81d2fd63 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 0000000000000..5828e4d2b2627 --- /dev/null +++ b/src/Symfony/Component/Console/Completion/Output/ZshCompletionOutput.php @@ -0,0 +1,30 @@ + + * + * 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 + { + $options = $suggestions->getValueSuggestions(); + foreach ($suggestions->getOptionSuggestions() as $option) { + $options[] = '--'.$option->getName()."\t".$option->getDescription(); + } + $output->writeln(implode("\n", $options)); + } +} diff --git a/src/Symfony/Component/Console/Resources/completion.zsh b/src/Symfony/Component/Console/Resources/completion.zsh new file mode 100644 index 0000000000000..fed761c162c08 --- /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 189928897cc7c..ddb24929e342b 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").'); + $this->expectExceptionMessage('Shell completion is not supported for your shell: "unsupported" (supported: "bash", "zsh").'); $this->execute(['--shell' => 'unsupported']); } @@ -122,6 +122,54 @@ public function provideCompleteCommandInputDefinitionInputs() yield 'custom' => [['bin/console', 'hello'], ['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', + ]]; + 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', + ]]; + yield 'complete-shortcut-name' => [['bin/console', 'hell'], ['hello']]; + } + + /** + * @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-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 @@ -134,6 +182,7 @@ class CompleteCommandTest_HelloCommand extends Command public function configure(): void { $this->setName('hello') + ->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 de8a3d4a60a3a..cd2c56c5fced4 100644 --- a/src/Symfony/Component/Console/Tests/Command/DumpCompletionCommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/DumpCompletionCommandTest.php @@ -23,7 +23,7 @@ public function provideCompletionSuggestions() { yield 'shell' => [ [''], - ['bash'], + ['bash', 'zsh'], ]; } } diff --git a/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php b/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php index f83a0f89893aa..2b7af606e9722 100644 --- a/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php +++ b/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php @@ -119,4 +119,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 8c8ba2285f59a..e14f72297781c 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\")", + "description": "The shell type (\"bash\", \"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 5a17229343fcf..5c9c51f83e197 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 @@