Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit cf8a997

Browse files
committed
feature #42251 [Console] Bash completion integration (wouterj)
This PR was squashed before being merged into the 5.4 branch. Discussion ---------- [Console] Bash completion integration | Q | A | ------------- | --- | Branch? | 5.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #38275 | License | MIT | Doc PR | - This is a first shot at implementing interactive bash completion support in the Console component. Besides completing option and command names, commands can override `Command::complete()` to implement completing values. I've added an example code to the `secrets:remove` command, which now supports autocompletion quite nicely: ![render1630315116886](https://user-images.githubusercontent.com/749025/136708284-bf2e4c12-7cb7-4d5e-9c8d-68bcdca6fd7c.gif) And support for other applications using `symfony/console` is automatically included (if the autocompletion script is installed for that specific application): ![render1630314403752](https://user-images.githubusercontent.com/749025/136708323-dfbccb77-dcbd-4d1e-8bb5-85b88f0b358b.gif) This PR only implements Bash completion. Zsh and Fish have much more sophisticated completion systems, but I propose to keep those for a future PR if there is a need for this. ### How it works 1. A bash completion function (`_console`) is defined by `bin/console completion bash` for the `console` command (name of the "binary" file) 2. This completion function calls the local `bin/console _complete` command to come up with suggestions 3. Bash parses these suggestions and shows them to the user. This has one drawback: the `_console` function is defined globally only once. This means we cannot easily change it, as it would break if you run different Symfony versions. We should probably add versioning (e.g. `bin/console _complete --version=1.0`) and don't suggest anything if the version doesn't match. <s> **Maybe it would be safer to mark this feature as experimental and target 6.0, to allow us to fine tune the shell related sides over the lifespan of 6.x?** </s> #42251 (comment) ### Steps to test yourself Load this PR in your project, open a bash shell and run this command to "install" completion for this project: ``` bin/console completion bash > /etc/bash_completion.d/console ```` Then reload the bash shell and enjoy autocompletion. ### TODO * [x] Autocompleting in the middle of the input doesn't work yet (i.e. `bin/console --en<TAB> cache:clear`) * [x] Better error handling * [x] Add a `bin/console completion` command to dump the `_console` file, so users can install this locally * [x] Add some versioning, to allow us to change the `_console` file in the future * [x] <s>See how we can better support standalone usage (e.g. Composer)</s> Tested on Laravel's artisan, works flawlessly Commits ------- e0a174f [FrameworkBundle] Add CLI completion to secrets:remove 82ef399 [Console] Bash completion integration
2 parents 5d13cf1 + e0a174f commit cf8a997

30 files changed

+2177
-76
lines changed

src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
1515
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Completion\CompletionInput;
17+
use Symfony\Component\Console\Completion\CompletionInterface;
18+
use Symfony\Component\Console\Completion\CompletionSuggestions;
1619
use Symfony\Component\Console\Input\InputArgument;
1720
use Symfony\Component\Console\Input\InputInterface;
1821
use Symfony\Component\Console\Input\InputOption;
@@ -26,7 +29,7 @@
2629
*
2730
* @internal
2831
*/
29-
final class SecretsRemoveCommand extends Command
32+
final class SecretsRemoveCommand extends Command implements CompletionInterface
3033
{
3134
protected static $defaultName = 'secrets:remove';
3235
protected static $defaultDescription = 'Remove a secret from the vault';
@@ -80,4 +83,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8083

8184
return 0;
8285
}
86+
87+
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
88+
{
89+
if (!$input->mustSuggestArgumentValuesFor('name')) {
90+
return;
91+
}
92+
93+
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
94+
$vaultKeys = array_keys($this->vault->list(false));
95+
$suggestions->suggestValues(array_intersect($vaultKeys, array_keys($vault->list(false))));
96+
}
8397
}

src/Symfony/Component/Console/Application.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@
1212
namespace Symfony\Component\Console;
1313

1414
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Command\CompleteCommand;
16+
use Symfony\Component\Console\Command\DumpCompletionCommand;
1517
use Symfony\Component\Console\Command\HelpCommand;
1618
use Symfony\Component\Console\Command\LazyCommand;
1719
use Symfony\Component\Console\Command\ListCommand;
1820
use Symfony\Component\Console\Command\SignalableCommandInterface;
1921
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
22+
use Symfony\Component\Console\Completion\CompletionInput;
23+
use Symfony\Component\Console\Completion\CompletionInterface;
24+
use Symfony\Component\Console\Completion\CompletionSuggestions;
2025
use Symfony\Component\Console\Event\ConsoleCommandEvent;
2126
use Symfony\Component\Console\Event\ConsoleErrorEvent;
2227
use Symfony\Component\Console\Event\ConsoleSignalEvent;
@@ -64,7 +69,7 @@
6469
*
6570
* @author Fabien Potencier <[email protected]>
6671
*/
67-
class Application implements ResetInterface
72+
class Application implements ResetInterface, CompletionInterface
6873
{
6974
private $commands = [];
7075
private $wantHelps = false;
@@ -350,6 +355,29 @@ public function getDefinition()
350355
return $this->definition;
351356
}
352357

358+
/**
359+
* {@inheritdoc}
360+
*/
361+
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
362+
{
363+
if (
364+
CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType()
365+
&& 'command' === $input->getCompletionName()
366+
) {
367+
$suggestions->suggestValues(array_filter(array_map(function (Command $command) {
368+
return $command->isHidden() ? null : $command->getName();
369+
}, $this->all())));
370+
371+
return;
372+
}
373+
374+
if (CompletionInput::TYPE_OPTION_NAME === $input->getCompletionType()) {
375+
$suggestions->suggestOptions($this->getDefinition()->getOptions());
376+
377+
return;
378+
}
379+
}
380+
353381
/**
354382
* Gets the help message.
355383
*
@@ -1052,7 +1080,7 @@ protected function getDefaultInputDefinition()
10521080
*/
10531081
protected function getDefaultCommands()
10541082
{
1055-
return [new HelpCommand(), new ListCommand()];
1083+
return [new HelpCommand(), new ListCommand(), new CompleteCommand(), new DumpCompletionCommand()];
10561084
}
10571085

10581086
/**
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Command;
13+
14+
use Symfony\Component\Console\Completion\CompletionInput;
15+
use Symfony\Component\Console\Completion\CompletionInterface;
16+
use Symfony\Component\Console\Completion\CompletionSuggestions;
17+
use Symfony\Component\Console\Completion\Output\BashCompletionOutput;
18+
use Symfony\Component\Console\Exception\CommandNotFoundException;
19+
use Symfony\Component\Console\Exception\ExceptionInterface;
20+
use Symfony\Component\Console\Input\InputInterface;
21+
use Symfony\Component\Console\Input\InputOption;
22+
use Symfony\Component\Console\Output\OutputInterface;
23+
24+
/**
25+
* Responsible for providing the values to the shell completion.
26+
*
27+
* @author Wouter de Jong <[email protected]>
28+
*/
29+
final class CompleteCommand extends Command
30+
{
31+
protected static $defaultName = '|_complete';
32+
protected static $defaultDescription = 'Internal command to provide shell completion suggestions';
33+
34+
private static $completionOutputs = [
35+
'bash' => BashCompletionOutput::class,
36+
];
37+
38+
private $isDebug = false;
39+
40+
protected function configure(): void
41+
{
42+
$this
43+
->addOption('shell', 's', InputOption::VALUE_REQUIRED, 'The shell type (e.g. "bash")')
44+
->addOption('input', 'i', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'An array of input tokens (e.g. COMP_WORDS or argv)')
45+
->addOption('current', 'c', InputOption::VALUE_REQUIRED, 'The index of the "input" array that the cursor is in (e.g. COMP_CWORD)')
46+
->addOption('symfony', 'S', InputOption::VALUE_REQUIRED, 'The version of the completion script')
47+
;
48+
}
49+
50+
protected function initialize(InputInterface $input, OutputInterface $output)
51+
{
52+
$this->isDebug = filter_var(getenv('SYMFONY_COMPLETION_DEBUG'), \FILTER_VALIDATE_BOOLEAN);
53+
}
54+
55+
protected function execute(InputInterface $input, OutputInterface $output): int
56+
{
57+
try {
58+
// uncomment when a bugfix or BC break has been introduced in the shell completion scripts
59+
//$version = $input->getOption('symfony');
60+
//if ($version && version_compare($version, 'x.y', '>=')) {
61+
// $message = sprintf('Completion script version is not supported ("%s" given, ">=x.y" required).', $version);
62+
// $this->log($message);
63+
64+
// $output->writeln($message.' Install the Symfony completion script again by using the "completion" command.');
65+
66+
// return 126;
67+
//}
68+
69+
$shell = $input->getOption('shell');
70+
if (!$shell) {
71+
throw new \RuntimeException('The "--shell" option must be set.');
72+
}
73+
74+
if (!$completionOutput = self::$completionOutputs[$shell] ?? false) {
75+
throw new \RuntimeException(sprintf('Shell completion is not supported for your shell: "%s" (supported: "%s").', $shell, implode('", "', array_keys(self::$completionOutputs))));
76+
}
77+
78+
$completionInput = $this->createCompletionInput($input);
79+
$suggestions = new CompletionSuggestions();
80+
81+
$this->log([
82+
'',
83+
'<comment>'.date('Y-m-d H:i:s').'</>',
84+
'<info>Input:</> <comment>("|" indicates the cursor position)</>',
85+
' '.(string) $completionInput,
86+
'<info>Messages:</>',
87+
]);
88+
89+
$command = $this->findCommand($completionInput, $output);
90+
if (null === $command) {
91+
$this->log(' No command found, completing using the Application class.');
92+
93+
$this->getApplication()->complete($completionInput, $suggestions);
94+
} elseif (
95+
$completionInput->mustSuggestArgumentValuesFor('command')
96+
&& $command->getName() !== $completionInput->getCompletionValue()
97+
) {
98+
$this->log(' No command found, completing using the Application class.');
99+
100+
// expand shortcut names ("cache:cl<TAB>") into their full name ("cache:clear")
101+
$suggestions->suggestValue($command->getName());
102+
} else {
103+
$command->mergeApplicationDefinition();
104+
$completionInput->bind($command->getDefinition());
105+
106+
if (CompletionInput::TYPE_OPTION_NAME === $completionInput->getCompletionType()) {
107+
$this->log(' Completing option names for the <comment>'.\get_class($command instanceof LazyCommand ? $command->getCommand() : $command).'</> command.');
108+
109+
$suggestions->suggestOptions($command->getDefinition()->getOptions());
110+
} elseif ($command instanceof CompletionInterface) {
111+
$this->log([
112+
' Completing using the <comment>'.\get_class($command).'</> class.',
113+
' Completing <comment>'.$completionInput->getCompletionType().'</> for <comment>'.$completionInput->getCompletionName().'</>',
114+
]);
115+
if (null !== $compval = $completionInput->getCompletionValue()) {
116+
$this->log(' Current value: <comment>'.$compval.'</>');
117+
}
118+
119+
$command->complete($completionInput, $suggestions);
120+
}
121+
}
122+
123+
$completionOutput = new $completionOutput();
124+
125+
$this->log('<info>Suggestions:</>');
126+
if ($options = $suggestions->getOptionSuggestions()) {
127+
$this->log(' --'.implode(' --', array_map(function ($o) { return $o->getName(); }, $options)));
128+
} elseif ($values = $suggestions->getValueSuggestions()) {
129+
$this->log(' '.implode(' ', $values));
130+
} else {
131+
$this->log(' <comment>No suggestions were provided</>');
132+
}
133+
134+
$completionOutput->write($suggestions, $output);
135+
} catch (\Throwable $e) {
136+
$this->log([
137+
'<error>Error!</error>',
138+
(string) $e,
139+
]);
140+
141+
if ($output->isDebug()) {
142+
throw $e;
143+
}
144+
145+
return self::FAILURE;
146+
}
147+
148+
return self::SUCCESS;
149+
}
150+
151+
private function createCompletionInput(InputInterface $input): CompletionInput
152+
{
153+
$currentIndex = $input->getOption('current');
154+
if (!$currentIndex || !ctype_digit($currentIndex)) {
155+
throw new \RuntimeException('The "--current" option must be set and it must be an integer.');
156+
}
157+
158+
$completionInput = CompletionInput::fromTokens(array_map(
159+
function (string $i): string { return trim($i, "'"); },
160+
$input->getOption('input')
161+
), (int) $currentIndex);
162+
163+
try {
164+
$completionInput->bind($this->getApplication()->getDefinition());
165+
} catch (ExceptionInterface $e) {
166+
}
167+
168+
return $completionInput;
169+
}
170+
171+
private function findCommand(CompletionInput $completionInput, OutputInterface $output): ?Command
172+
{
173+
try {
174+
$inputName = $completionInput->getFirstArgument();
175+
if (null === $inputName) {
176+
return null;
177+
}
178+
179+
return $this->getApplication()->find($inputName);
180+
} catch (CommandNotFoundException $e) {
181+
}
182+
183+
return null;
184+
}
185+
186+
private function log($messages): void
187+
{
188+
if (!$this->isDebug) {
189+
return;
190+
}
191+
192+
$commandName = basename($_SERVER['argv'][0]);
193+
file_put_contents(sys_get_temp_dir().'/sf_'.$commandName.'.log', implode(\PHP_EOL, (array) $messages).\PHP_EOL, \FILE_APPEND);
194+
}
195+
}

0 commit comments

Comments
 (0)