Skip to content
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

Add completion to commands options and arguments #10320

Merged
merged 15 commits into from Jun 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/03-cli.md
Expand Up @@ -13,6 +13,12 @@ php composer.phar dump
```
calls `composer dump-autoload`.

## Bash Completions

To install bash completions you can run `composer completion bash > completion.bash` (put the file
in /etc/bash_completion.d/composer to make it load automatically in new terminals) and then
`source completion.bash` to enable it in the current terminal session.

## Global Options

The following options are available with every command:
Expand Down
4 changes: 4 additions & 0 deletions src/Composer/Cache.php
Expand Up @@ -248,6 +248,10 @@ public function gcIsNecessary()
return false;
}

if (Platform::isInputCompletionProcess()) {
return false;
}

return !random_int(0, 50);
}

Expand Down
12 changes: 8 additions & 4 deletions src/Composer/Command/ArchiveCommand.php
Expand Up @@ -27,9 +27,9 @@
use Composer\Util\Loop;
use Composer\Util\Platform;
use Composer\Util\ProcessExecutor;
use Symfony\Component\Console\Input\InputArgument;
use Composer\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Composer\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
Expand All @@ -39,6 +39,10 @@
*/
class ArchiveCommand extends BaseCommand
{
use CompletionTrait;

private const FORMATS = ['tar', 'tar.gz', 'tar.bz2', 'zip'];

/**
* @return void
*/
Expand All @@ -48,9 +52,9 @@ protected function configure(): void
->setName('archive')
->setDescription('Creates an archive of this composer package.')
->setDefinition(array(
new InputArgument('package', InputArgument::OPTIONAL, 'The package to archive instead of the current project'),
new InputArgument('package', InputArgument::OPTIONAL, 'The package to archive instead of the current project', null, $this->suggestAvailablePackage()),
new InputArgument('version', InputArgument::OPTIONAL, 'A version constraint to find the package to archive'),
new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the resulting archive: tar, tar.gz, tar.bz2 or zip (default tar)'),
new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the resulting archive: tar, tar.gz, tar.bz2 or zip (default tar)', null, self::FORMATS),
new InputOption('dir', null, InputOption::VALUE_REQUIRED, 'Write the archive to this directory'),
new InputOption('file', null, InputOption::VALUE_REQUIRED, 'Write the archive with the given file name.'
.' Note that the format will be appended.'),
Expand Down
30 changes: 30 additions & 0 deletions src/Composer/Command/BaseCommand.php
Expand Up @@ -15,6 +15,8 @@
use Composer\Composer;
use Composer\Config;
use Composer\Console\Application;
use Composer\Console\Input\InputArgument;
use Composer\Console\Input\InputOption;
use Composer\Factory;
use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory;
use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface;
Expand All @@ -24,6 +26,8 @@
use Composer\Package\Version\VersionParser;
use Composer\Plugin\PluginEvents;
use Composer\Util\Platform;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputInterface;
Expand Down Expand Up @@ -182,6 +186,32 @@ public function setIO(IOInterface $io)
$this->io = $io;
}

/**
* @inheritdoc
*
* Backport suggested values definition from symfony/console 6.1+
*
* TODO drop when PHP 8.1 / symfony 6.1+ can be required
*/
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
$definition = $this->getDefinition();
$name = (string) $input->getCompletionName();
if (CompletionInput::TYPE_OPTION_VALUE === $input->getCompletionType()
&& $definition->hasOption($name)
&& ($option = $definition->getOption($name)) instanceof InputOption
) {
$option->complete($input, $suggestions);
} elseif (CompletionInput::TYPE_ARGUMENT_VALUE === $input->getCompletionType()
&& $definition->hasArgument($name)
&& ($argument = $definition->getArgument($name)) instanceof InputArgument
) {
$argument->complete($input, $suggestions);
} else {
parent::complete($input, $suggestions);
}
}

/**
* @inheritDoc
*
Expand Down
197 changes: 197 additions & 0 deletions src/Composer/Command/CompletionTrait.php
@@ -0,0 +1,197 @@
<?php declare(strict_types=1);

/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Composer\Command;

use Composer\Composer;
use Composer\Package\BasePackage;
use Composer\Package\PackageInterface;
use Composer\Pcre\Preg;
use Composer\Repository\CompositeRepository;
use Composer\Repository\InstalledRepository;
use Composer\Repository\PlatformRepository;
use Composer\Repository\RepositoryInterface;
use Composer\Repository\RootPackageRepository;
use Symfony\Component\Console\Completion\CompletionInput;

/**
* Adds completion to arguments and options.
*
* @internal
*/
trait CompletionTrait
{
/**
* @see BaseCommand::requireComposer()
*/
abstract public function requireComposer(bool $disablePlugins = null, bool $disableScripts = null): Composer;

/**
* Suggestion values for "prefer-install" option
*
* @return string[]
*/
private function suggestPreferInstall(): array
{
return ['dist', 'source', 'auto'];
}

/**
* Suggest package names from installed.
*/
private function suggestInstalledPackage(bool $includePlatformPackages = false): \Closure
{
return function (CompletionInput $input) use ($includePlatformPackages): array {
$composer = $this->requireComposer();
$installedRepos = [new RootPackageRepository(clone $composer->getPackage())];

$locker = $composer->getLocker();
if ($locker->isLocked()) {
$installedRepos[] = $locker->getLockedRepository(true);
} else {
$installedRepos[] = $composer->getRepositoryManager()->getLocalRepository();
}

$platformHint = [];
if ($includePlatformPackages) {
if ($locker->isLocked()) {
$platformRepo = new PlatformRepository(array(), $locker->getPlatformOverrides());
} else {
$platformRepo = new PlatformRepository(array(), $composer->getConfig()->get('platform'));
}
if ($input->getCompletionValue() === '') {
// to reduce noise, when no text is yet entered we list only two entries for ext- and lib- prefixes
$hintsToFind = ['ext-' => 0, 'lib-' => 0, 'php' => 99, 'composer' => 99];
foreach ($platformRepo->getPackages() as $pkg) {
foreach ($hintsToFind as $hintPrefix => $hintCount) {
if (str_starts_with($pkg->getName(), $hintPrefix)) {
if ($hintCount === 0 || $hintCount >= 99) {
$platformHint[] = $pkg->getName();
$hintsToFind[$hintPrefix]++;
} elseif ($hintCount === 1) {
unset($hintsToFind[$hintPrefix]);
$platformHint[] = substr($pkg->getName(), 0, max(strlen($pkg->getName()) - 3, strlen($hintPrefix) + 1)).'...';
}
continue 2;
}
}
}
} else {
$installedRepos[] = $platformRepo;
}
}

$installedRepo = new InstalledRepository($installedRepos);

return array_merge(
array_map(function (PackageInterface $package) {
return $package->getName();
}, $installedRepo->getPackages()),
$platformHint
);
};
}

/**
* Suggest package names available on all configured repositories.
*/
private function suggestAvailablePackage(int $max = 99): \Closure
{
return function (CompletionInput $input) use ($max): array {
if ($max < 1) {
return [];
}

$composer = $this->requireComposer();
$repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories());

$results = [];
$showVendors = false;
if (!str_contains($input->getCompletionValue(), '/')) {
$results = $repos->search('^' . preg_quote($input->getCompletionValue()), RepositoryInterface::SEARCH_VENDOR);
$showVendors = true;
}

// if we get a single vendor, we expand it into its contents already
if (\count($results) <= 1) {
$results = $repos->search('^'.preg_quote($input->getCompletionValue()), RepositoryInterface::SEARCH_NAME);
$showVendors = false;
}

$results = array_column($results, 'name');

if ($showVendors) {
$results = array_map(function (string $name): string {
return $name.'/';
}, $results);

// sort shorter results first to avoid auto-expanding the completion to a longer string than needed
usort($results, function (string $a, string $b) {
$lenA = \strlen($a);
$lenB = \strlen($b);
if ($lenA === $lenB) {
return $a <=> $b;
}

return $lenA - $lenB;
});

$pinned = [];

// ensure if the input is an exact match that it is always in the result set
$completionInput = $input->getCompletionValue().'/';
if (false !== ($exactIndex = array_search($completionInput, $results, true))) {
$pinned[] = $completionInput;
array_splice($results, $exactIndex, 1);
}

return array_merge($pinned, array_slice($results, 0, $max - \count($pinned)));
}

return array_slice($results, 0, $max);
};
}

/**
* Suggest package names available on all configured repositories or
* platform packages from the ones available on the currently-running PHP
*/
private function suggestAvailablePackageInclPlatform(): \Closure
{
return function (CompletionInput $input): array {
if (Preg::isMatch('{^(ext|lib|php)(-|$)|^com}', $input->getCompletionValue())) {
$matches = $this->suggestPlatformPackage()($input);
} else {
$matches = [];
}

return array_merge($matches, $this->suggestAvailablePackage(99 - \count($matches))($input));
};
}

/**
* Suggest platform packages from the ones available on the currently-running PHP
*/
private function suggestPlatformPackage(): \Closure
{
return function (CompletionInput $input): array {
$repos = new PlatformRepository([], $this->requireComposer()->getConfig()->get('platform'));

$pattern = BasePackage::packageNameToRegexp($input->getCompletionValue().'*');
return array_filter(array_map(function (PackageInterface $package) {
return $package->getName();
}, $repos->getPackages()), function (string $name) use ($pattern): bool {
return Preg::isMatch($pattern, $name);
});
};
}
}
16 changes: 9 additions & 7 deletions src/Composer/Command/CreateProjectCommand.php
Expand Up @@ -33,9 +33,9 @@
use Composer\Repository\RepositorySet;
use Composer\Script\ScriptEvents;
use Composer\Util\Silencer;
use Symfony\Component\Console\Input\InputArgument;
use Composer\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Composer\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use Composer\Json\JsonFile;
Expand All @@ -55,6 +55,8 @@
*/
class CreateProjectCommand extends BaseCommand
{
use CompletionTrait;

/**
* @var SuggestedPackagesReporter
*/
Expand All @@ -69,13 +71,13 @@ protected function configure(): void
->setName('create-project')
->setDescription('Creates new project from a package into given directory.')
->setDefinition(array(
new InputArgument('package', InputArgument::OPTIONAL, 'Package name to be installed'),
new InputArgument('package', InputArgument::OPTIONAL, 'Package name to be installed', null, $this->suggestAvailablePackage()),
new InputArgument('directory', InputArgument::OPTIONAL, 'Directory where the files should be created'),
new InputArgument('version', InputArgument::OPTIONAL, 'Version, will default to latest'),
new InputOption('stability', 's', InputOption::VALUE_REQUIRED, 'Minimum-stability allowed (unless a version is specified).'),
new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
new InputOption('prefer-dist', null, InputOption::VALUE_NONE, 'Forces installation from package dist (default behavior).'),
new InputOption('prefer-install', null, InputOption::VALUE_REQUIRED, 'Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).'),
new InputOption('prefer-install', null, InputOption::VALUE_REQUIRED, 'Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).', null, $this->suggestPreferInstall()),
new InputOption('repository', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Add custom repositories to look the package up, either by URL or using JSON arrays'),
new InputOption('repository-url', null, InputOption::VALUE_REQUIRED, 'DEPRECATED: Use --repository instead.'),
new InputOption('add-repository', null, InputOption::VALUE_NONE, 'Add the custom repository in the composer.json. If a lock file is present it will be deleted and an update will be run instead of install.'),
Expand Down Expand Up @@ -157,7 +159,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$preferSource,
$preferDist,
!$input->getOption('no-dev'),
$input->getOption('repository') ?: $input->getOption('repository-url'),
\count($input->getOption('repository')) > 0 ? $input->getOption('repository') : $input->getOption('repository-url'),
$input->getOption('no-plugins'),
$input->getOption('no-scripts'),
$input->getOption('no-progress'),
Expand Down Expand Up @@ -195,7 +197,7 @@ public function installProject(IOInterface $io, Config $config, InputInterface $
$repositories = (array) $repositories;
}

$platformRequirementFilter = $platformRequirementFilter ?: PlatformRequirementFilterFactory::ignoreNothing();
$platformRequirementFilter = $platformRequirementFilter ?? PlatformRequirementFilterFactory::ignoreNothing();

// we need to manually load the configuration to pass the auth credentials to the io interface!
$io->loadConfiguration($config);
Expand Down Expand Up @@ -422,7 +424,7 @@ protected function installRootPackage(IOInterface $io, Config $config, string $p
}
}

$platformOverrides = $config->get('platform') ?: array();
$platformOverrides = $config->get('platform');
$platformRepo = new PlatformRepository(array(), $platformOverrides);

// find the latest version if there are multiple
Expand Down
8 changes: 5 additions & 3 deletions src/Composer/Command/DependsCommand.php
Expand Up @@ -14,14 +14,16 @@

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

/**
* @author Niels Keurentjes <niels.keurentjes@omines.com>
*/
class DependsCommand extends BaseDependencyCommand
{
use CompletionTrait;

/**
* Configure command metadata.
*
Expand All @@ -34,7 +36,7 @@ protected function configure(): void
->setAliases(array('why'))
->setDescription('Shows which packages cause the given package to be installed.')
->setDefinition(array(
new InputArgument(self::ARGUMENT_PACKAGE, InputArgument::REQUIRED, 'Package to inspect'),
new InputArgument(self::ARGUMENT_PACKAGE, InputArgument::REQUIRED, 'Package to inspect', null, $this->suggestInstalledPackage(true)),
new InputOption(self::OPTION_RECURSIVE, 'r', InputOption::VALUE_NONE, 'Recursively resolves up to the root package'),
new InputOption(self::OPTION_TREE, 't', InputOption::VALUE_NONE, 'Prints the results as a nested tree'),
))
Expand Down