8000 [Console] Bash completion integration by wouterj · Pull Request #42251 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Console] Bash completion integration #42251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 19, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
[Console] Bash completion integration
  • Loading branch information
wouterj committed Oct 19, 2021
commit 82ef399de3ff94305feb03924751ec7c5e8b40b7
32 changes: 30 additions & 2 deletions src/Symfony/Component/Console/Application.php
8000
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
namespace Symfony\Component\Console;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\CompleteCommand;
use Symfony\Component\Console\Command\DumpCompletionCommand;
use Symfony\Component\Console\Command\HelpCommand;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Command\ListCommand;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionInterface;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\Console\Event\ConsoleSignalEvent;
Expand Down Expand Up @@ -64,7 +69,7 @@
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Application implements ResetInterface
class Application implements ResetInterface, CompletionInterface
{
private $commands = [];
private $wantHelps = false;
Expand Down Expand Up @@ -350,6 +355,29 @@ public function getDefinition()
return $this->definition;
}

/**
* {@inheritdoc}
*/
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if (
CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType()
&& 'command' === $input->getCompletionName()
) {
$suggestions->suggestValues(array_filter(array_map(function (Command $command) {
return $command->isHidden() ? null : $command->getName();
}, $this->all())));

return;
}

if (CompletionInput::TYPE_OPTION_NAME === $input->getCompletionType()) {
$suggestions->suggestOptions($this->getDefinition()->getOptions());

return;
}
}

/**
* Gets the help message.
*
Expand Down Expand Up @@ -1052,7 +1080,7 @@ protected function getDefaultInputDefinition()
*/
protected function getDefaultCommands()
{
return [new HelpCommand(), new ListCommand()];
return [new HelpCommand(), new ListCommand(), new CompleteCommand(), new DumpCompletionCommand()];
}

/**
Expand Down
195 changes: 195 additions & 0 deletions src/Symfony/Component/Console/Command/CompleteCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Command;

use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionInterface;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Completion\Output\BashCompletionOutput;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Responsible for providing the values to the shell completion.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class CompleteCommand extends Command
{
protected static $defaultName = '|_complete';
protected static $defaultDescription = 'Internal command to provide shell completion suggestions';

private static $completionOutputs = [
'bash' => BashCompletionOutput::class,
];

private $isDebug = false;

protected function configure(): void
{
$this
->addOption('shell', 's', InputOption::VALUE_REQUIRED, 'The shell type (e.g. "bash")')
->addOption('input', 'i', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'An array of input tokens (e.g. COMP_WORDS or argv)')
->addOption('current', 'c', InputOption::VALUE_REQUIRED, 'The index of the "input" array that the cursor is in (e.g. COMP_CWORD)')
->addOption('symfony', 'S', InputOption::VALUE_REQUIRED, 'The version of the completion script')
;
}

protected function initialize(InputInterface $input, OutputInterface $output)
{
$this->isDebug = filter_var(getenv('SYMFONY_COMPLETION_DEBUG'), \FILTER_VALIDATE_BOOLEAN);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
// uncomment when a bugfix or BC break has been introduced in the shell completion scripts
//$version = $input->getOption('symfony');
//if ($version && version_compare($version, 'x.y', '>=')) {
// $message = sprintf('Completion script version is not supported ("%s" given, ">=x.y" required).', $version);
// $this->log($message);

// $output->writeln($message.' Install the Symfony completion script again by using the "completion" command.');

// return 126;
//}

$shell = $input->getOption('shell');
if (!$shell) {
throw new \RuntimeException('The "--shell" option must be set.');
}

if (!$completionOutput = self::$completionOutputs[$shell] ?? false) {
throw new \RuntimeException(sprintf('Shell completion is not supported for your shell: "%s" (supported: "%s").', $shell, implode('", "', array_keys(self::$completionOutputs))));
}

$completionInput = $this->createCompletionInput($input);
$suggestions = new CompletionSuggestions();

$this->log([
'',
'<comment>'.date('Y-m-d H:i:s').'</>',
'<info>Input:</> <comment>("|" indicates the cursor position)</>',
' '.(string) $completionInput,
'<info>Messages:</>',
]);

$command = $this->findCommand($completionInput, $output);
if (null === $command) {
$this->log(' No command found, completing using the Application class.');

$this->getApplication()->complete($completionInput, $suggestions);
} elseif (
$completionInput->mustSuggestArgumentValuesFor('command')
&& $command->getName() !== $completionInput->getCompletionValue()
) {
$this->log(' No command found, completing using the Application class.');

// expand shortcut names ("cache:cl<TAB>") into their full name ("cache:clear")
$suggestions->suggestValue($command->getName());
} else {
$command->mergeApplicationDefinition();
$completionInput->bind($command->getDefinition());

if (CompletionInput::TYPE_OPTION_NAME === $completionInput->getCompletionType()) {
$this->log(' Completing option names for the <comment>'.\get_class($command instanceof LazyCommand ? $command->getCommand() : $command).'</> command.');

$suggestions->suggestOptions($command->getDefinition()->getOptions());
} elseif ($command instanceof CompletionInterface) {
$this->log([
' Completing using the <comment>'.\get_class($command).'</> class.',
' Completing <comment>'.$completionInput->getCompletionType().'</> for <comment>'.$completionInput->getCompletionName().'</>',
]);
if (null !== $compval = $completionInput->getCompletionValue()) {
$this->log(' Current value: <comment>'.$compval.'</>');
}

$command->complete($completionInput, $suggestions);
}
}

$completionOutput = new $completionOutput();

$this->log('<info>Suggestions:</>');
if ($options = $suggestions->getOptionSuggestions()) {
$this->log(' --'.implode(' --', array_map(function ($o) { return $o->getName(); }, $options)));
} elseif ($values = $suggestions->getValueSuggestions()) {
$this->log(' '.implode(' ', $values));
} else {
$this->log(' <comment>No suggestions were provided</>');
}

< F438 span class='blob-code-inner blob-code-marker ' data-code-marker="+"> $completionOutput->write($suggestions, $output);
} catch (\Throwable $e) {
$this->log([
'<error>Error!</error>',
(string) $e,
]);

if ($output->isDebug()) {
throw $e;
}

return self::FAILURE;
}

return self::SUCCESS;
}

private function createCompletionInput(InputInterface $input): CompletionInput
{
$currentIndex = $input->getOption('current');
if (!$currentIndex || !ctype_digit($currentIndex)) {
throw new \RuntimeException('The "--current" option must be set and it must be an integer.');
}

$completionInput = CompletionInput::fromTokens(array_map(
function (string $i): string { return trim($i, "'"); },
$input->getOption('input')
), (int) $currentIndex);

try {
$completionInput->bind($this->getApplication()->getDefinition());
} catch (ExceptionInterface $e) {
}

return $completionInput;
}

private function findCommand(CompletionInput $completionInput, OutputInterface $output): ?Command
{
try {
$inputName = $completionInput->getFirstArgument();
if (null === $inputName) {
return null;
}

return $this->getApplication()->find($inputName);
} catch (CommandNotFoundException $e) {
}

return null;
}

private function log($messages): void
{
if (!$this->isDebug) {
return;
}

$commandName = basename($_SERVER['argv'][0]);
file_put_contents(sys_get_temp_dir().'/sf_'.$commandName.'.log', implode(\PHP_EOL, (array) $messages).\PHP_EOL, \FILE_APPEND);
}
}
116 changes: 116 additions & 0 deletions src/Symfony/Component/Console/Command/DumpCompletionCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Command;

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

/**
* Dumps the completion script for the current shell.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class DumpCompletionCommand extends Command
{
protected static $defaultName = 'completion';
protected static $defaultDescription = 'Dump the shell completion script';

protected function configure()
{
$fullCommand = $_SERVER['PHP_SELF'];
$commandName = basename($fullCommand);
$fullCommand = realpath($fullCommand) ?: $fullCommand;

$this
->setHelp(<<<EOH
The <info>%command.name%</> command dumps the shell completion script required
to use shell autocompletion (currently only bash completion is supported).

<comment>Static installation
-------------------</>

Dump the script to a global completion file and restart your shell:

<info>%command.full_name% bash | sudo tee /etc/bash_completion.d/${commandName}</>

Or dump the script to a local file and source it:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to always generate this script as part of a cache warmer?
Could that ease with installing/updating the completion script?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to always generate this script as part of a cache warmer?

No, because installing the completion script is system-dependent, the same completion script is used for multiple Symfony apps on your system. Unless the completion is bundled with the app like I've shown in the kubectl example, the user needs to enable it for themselves in some way. The dynamic of updating this script will not be the same as the dynamic of building the DIC.

Also, note that, if the script is indeed installed in this manner (system-wide), cache warmer will not have permissions to update it. On some systems, a user-localized completion is supported by default, here's the snippet from Fedora:

if [ "x${BASH_VERSION-}" != x -a "x${PS1-}" != x -a "x${BASH_COMPLETION_VERSINFO-}" = x ]; then

    # Check for recent enough version of bash.
    if [ "${BASH_VERSINFO[0]}" -gt 4 ] ||
        [ "${BASH_VERSINFO[0]}" -eq 4 -a "${BASH_VERSINFO[1]}" -ge 2 ]; then
        [ -r "${XDG_CONFIG_HOME:-$HOME/.config}/bash_completion" ] &&
            . "${XDG_CONFIG_HOME:-$HOME/.config}/bash_completion"
        if shopt -q progcomp && [ -r /usr/share/bash-completion/bash_completion ]; then
            # Source completion code.
            . /usr/share/bash-completion/bash_completion
        fi
    fi

fi

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about generating the file during cache warming and put in a dedicated folder of the app (with gitignore) and propose a script on the side (maybe through symfony cli) that could watch current working directory changes and auto source / unsource the file ? So it stays app specific and a good DX.


<info>%command.full_name% bash > completion.sh</>

<comment># source the file whenever you use the project</>
<info>source completion.sh</>

<comment># or add this line at the end of your "~/.bashrc" file:</>
<info>source /path/to/completion.sh</>

<comment>Dynamic installation
--------------------</>

Add this add the end of your shell configuration file (e.g. <info>"~/.bashrc"</>):

<info>eval "$(${fullCommand} completion bash)"</>
EOH
)
->addArgument('shell', InputArgument::OPTIONAL, 'The shell type (e.g. "bash"), the value of the "$SHELL" env var will be used if this is not given')
->addOption('debug', null, InputOption::VALUE_NONE, 'Tail the completion debug log')
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$commandName = basename($_SERVER['argv'][0]);

if ($input->getOption('debug')) {
$this->tailDebugLog($commandName, $output);

return self::SUCCESS;
}

$shell = $input->getArgument('shell') ?? self::guessShell();
$completionFile = __DIR__.'/../Resources/completion.'.$shell;
if (!file_exists($completionFile)) {
$supportedShells = array_map(function ($f) {
return pathinfo($f, \PATHINFO_EXTENSION);
}, glob(__DIR__.'/../Resources/completion.*'));

($output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output)
->writeln(sprintf('<error>Detected shell "%s", which is not supported by Symfony shell completion (supported shells: "%s").</>', $shell, implode('", "', $supportedShells)));

return self::INVALID;
}

$output->write(str_replace(['{{ COMMAND_NAME }}', '{{ VERSION }}'], [$commandName, $this->getApplication()->getVersion()], file_get_contents($completionFile)));

return self::SUCCESS;
}

private static function guessShell(): string
{
return basename($_SERVER['SHELL'] ?? '');
}

private function tailDebugLog(string $commandName, OutputInterface $output): void
{
$debugFile = sys_get_temp_dir().'/sf_'.$commandName.'.log';
if (!file_exists($debugFile)) {
touch($debugFile);
}
$process = new Process(['tail', '-f', $debugFile], null, null, null, 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens on windows (and yes, bash can be used on windows, which does not imply that tail is available in the PATH AFAICT) ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs some more special casing. For example, you might not have symfony/process installed.

$process->run(function (string $type, string $line) use ($output): void {
$output->write($line);
});
}
}
Loading
0