8000 [Console] Bash completion integration · symfony/symfony@ee3ee65 · GitHub
[go: up one dir, main page]

Skip to content

Commit ee3ee65

Browse files
committed
[Console] Bash completion integration
1 parent 732acf5 commit ee3ee65

29 files changed

+2163
-75
lines changed

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 <fabien@symfony.com>
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 <fabien@symfony.com>
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 <wouter@wouterj.nl>
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" or "zsh")')
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+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Input\InputArgument;
15+
use Symfony\Component\Console\Input\InputInterface;
16+
use Symfony\Component\Console\Input\InputOption;
17+
use Symfony\Component\Console\Output\ConsoleOutputInterface;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
use Symfony\Component\Process\Process;
20+
21+
/**
22+
* Dumps the completion script for the current shell.
23+
*
24+
* @author Wouter de Jong <wouter@wouterj.nl>
25+
*/
26+
final class DumpCompletionCommand extends Command
27+
{
28+
protected static $defaultName = 'completion';
29+
protected static $defaultDescription = 'Dump the shell completion script';
30+
31+
protected function configure()
32+
{
33+
$fullCommand = $_SERVER['PHP_SELF'];
34+
$commandName = basename($fullCommand);
35+
$fullCommand = realpath($fullCommand) ?: $fullCommand;
36+
$shell = self::guessShell();
37+
38+
$this
39+
->setHelp(<<<EOH
40+
The <info>%command.name%</info> command dumps the shell completion script required
41+
to use shell autocompletion.
42+
43+
<comment>Static installation
44+
-------------------</comment>
45+
46+
Dump the script to a global completion file and restart your shell:
47+
48+
<info>%command.full_name% ${shell} | sudo tee /etc/bash_completion.d/${commandName}</info>
49+
50+
Or dump the script to a local file and source it:
51+
52+
<info>%command.full_name% ${shell} > completion.sh</info>
53+
54+
<comment># source the file whenever you use the project</comment>
55+
<info>source completion.sh</info>
56+
57+
<comment># or add this line at the end of your "~/.bashrc" file:</comment>
58+
<info>source /path/to/completion.sh</info>
59+
60+
<comment>Dynamic installation
61+
--------------------</comment>
62+
63+
Add this add the end of your shell configuration file (e.g. <info>"~/.bashrc"</info>):
64+
65+
<info>eval "$(${fullCommand} completion ${shell})"</info>
66+
EOH
67+
)
68+
->addArgument('shell', InputArgument::OPTIONAL, 'The shell type (e.g. "bash" or "zsh"), the value of the "$SHELL" env var will be used if this is not given')
69+
->addOption('debug', null, InputOption::VALUE_NONE, 'Tail the completion debug log')
70+
;
71+
}
72+
73+
protected function execute(InputInterface $input, OutputInterface $output): int
74+
{
75+
$commandName = basename($_SERVER['argv'][0]);
76+
77+
if ($input->getOption('debug')) {
78+
$this->tailDebugLog($commandName, $output);
79+
80+
return self::SUCCESS;
81+
}
82+
83+
$shell = $input->getArgument('shell') ?? self::guessShell();
84+
$completionFile = __DIR__.'/../Resources/completion.'.$shell;
85+
if (!file_exists($completionFile)) {
86+
$supportedShells = array_map(function ($f) {
87+
return pathinfo($f, \PATHINFO_EXTENSION);
88+
}, glob(__DIR__.'/../Resources/completion.*'));
89+
90+
($output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output)
91+
->writeln(sprintf('<error>Detected shell "%s", which is not supported by Symfony shell completion (supported shells: "%s").</>', $shell, implode('", "', $supportedShells)));
92+
93+
return self::INVALID;
94+
}
95+
96+
$output->write(str_replace(['{{ COMMAND_NAME }}', '{{ VERSION }}'], [$commandName, $this->getApplication()->getVersion()], file_get_contents($completionFile)));
97+
98+
return self::SUCCESS;
99+
}
100+
101+
private static function guessShell(): string
102+
{
103+
return basename($_SERVER['SHELL'] ?? '');
104+
}
105+
106+
private function tailDebugLog(string $commandName, OutputInterface $output): void
107+
{
108+
$debugFile = sys_get_temp_dir().'/sf_'.$commandName.'.log';
109+
if (!file_exists($debugFile)) {
110+
touch($debugFile);
111+
}
112+
$process = new Process(['tail', '-f', $debugFile], null, null, null, 0);
113+
$process->run(function (string $type, string $line) use ($output): void {
114+
$output->write($line);
115+
});
116+
}
117+
}

0 commit comments

Comments
 (0)
0