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..2bd33b21daac 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; @@ -56,6 +57,7 @@ public function __construct(array $completionOutputs = []) $this->completionOutputs = $completionOutputs + [ 'bash' => BashCompletionOutput::class, 'fish' => FishCompletionOutput::class, + 'zsh' => ZshCompletionOutput::class, ]; parent::__construct(); diff --git a/src/Symfony/Component/Console/Command/DumpCompletionCommand.php b/src/Symfony/Component/Console/Command/DumpCompletionCommand.php index 090f2601cd21..321641e1b28a 100644 --- a/src/Symfony/Component/Console/Command/DumpCompletionCommand.php +++ b/src/Symfony/Component/Console/Command/DumpCompletionCommand.php @@ -48,6 +48,7 @@ protected function configure() $shell = $this->guessShell(); [$rcFile, $completionFile] = match ($shell) { 'fish' => ['~/.config/fish/config.fish', "/etc/fish/completions/$commandName.fish"], + 'zsh' => ['~/.zshrc', '$fpath[1]/'.$commandName], default => ['~/.bashrc', "/etc/bash_completion.d/$commandName"], }; 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..7392965a2183 100644 --- a/src/Symfony/Component/Console/Completion/Suggestion.php +++ b/src/Symfony/Component/Console/Completion/Suggestion.php @@ -16,13 +16,12 @@ * * @author Wouter de Jong */ -class Suggestion +class Suggestion implements \Stringable { - 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..ef5a380cfc8b --- /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 -a{{ 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 at least 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..216bd130da66 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']); } @@ -138,6 +138,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/Output/CompletionOutputTestCase.php b/src/Symfony/Component/Console/Tests/Completion/Output/CompletionOutputTestCase.php index c4551e5b67b7..3ca7c15db9b6 100644 --- a/src/Symfony/Component/Console/Tests/Completion/Output/CompletionOutputTestCase.php +++ b/src/Symfony/Component/Console/Tests/Completion/Output/CompletionOutputTestCase.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Output\CompletionOutputInterface; +use Symfony\Component\Console\Completion\Suggestion; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\StreamOutput; @@ -28,8 +29,8 @@ abstract public function getExpectedValuesOutput(): string; public function testOptionsOutput() { $options = [ - new InputOption('option1', 'o', InputOption::VALUE_NONE), - new InputOption('negatable', null, InputOption::VALUE_NEGATABLE), + new InputOption('option1', 'o', InputOption::VALUE_NONE, 'First Option'), + new InputOption('negatable', null, InputOption::VALUE_NEGATABLE, 'Can be negative'), ]; $suggestions = new CompletionSuggestions(); $suggestions->suggestOptions($options); @@ -42,7 +43,11 @@ public function testOptionsOutput() public function testValuesOutput() { $suggestions = new CompletionSuggestions(); - $suggestions->suggestValues(['Green', 'Red', 'Yellow']); + $suggestions->suggestValues([ + new Suggestion('Green', 'Beans are green'), + new Suggestion('Red', 'Rose are red'), + new Suggestion('Yellow', 'Canaries are yellow'), + ]); $stream = fopen('php://memory', 'rw+'); $this->getCompletionOutput()->write($suggestions, new StreamOutput($stream)); fseek($stream, 0); diff --git a/src/Symfony/Component/Console/Tests/Completion/Output/ZshCompletionOutputTest.php b/src/Symfony/Component/Console/Tests/Completion/Output/ZshCompletionOutputTest.php new file mode 100644 index 000000000000..74dddb4b4ba8 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/Completion/Output/ZshCompletionOutputTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Completion\Output; + +use Symfony\Component\Console\Completion\Output\CompletionOutputInterface; +use Symfony\Component\Console\Completion\Output\ZshCompletionOutput; + +class ZshCompletionOutputTest extends CompletionOutputTestCase +{ + public function getCompletionOutput(): CompletionOutputInterface + { + return new ZshCompletionOutput(); + } + + public function getExpectedOptionsOutput(): string + { + return "--option1\tFirst Option\n--negatable\tCan be negative\n--no-negatable\tCan be negative\n"; + } + + public function getExpectedValuesOutput(): string + { + return "Green\tBeans are green\nRed\tRose are red\nYellow\tCanaries are yellow\n"; + } +} 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 @@