Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit ee3ee65

Browse filesBrowse files
committed
[Console] Bash completion integration
1 parent 732acf5 commit ee3ee65
Copy full SHA for ee3ee65

29 files changed

+2163
-75
lines changed

‎src/Symfony/Component/Console/Application.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Console/Application.php
+30-2Lines 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
/**
+195Lines 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+
}
+117Lines 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)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.