From fe6be142b1a84a1e935b8953be18626336a2bf36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20TAMARELLE?= Date: Tue, 30 Nov 2021 01:03:23 +0100 Subject: [PATCH] Add completion on commands --- src/Composer/Command/ArchiveCommand.php | 13 +++ src/Composer/Command/BaseCommand.php | 100 +++++++++++++++- src/Composer/Command/CreateProjectCommand.php | 7 ++ src/Composer/Command/DependsCommand.php | 7 ++ src/Composer/Command/ExecCommand.php | 53 ++++++--- src/Composer/Command/GlobalCommand.php | 13 +++ src/Composer/Command/HomeCommand.php | 7 ++ src/Composer/Command/InitCommand.php | 10 ++ src/Composer/Command/InstallCommand.php | 7 ++ src/Composer/Command/OutdatedCommand.php | 7 ++ src/Composer/Command/ProhibitsCommand.php | 7 ++ src/Composer/Command/ReinstallCommand.php | 7 ++ src/Composer/Command/RemoveCommand.php | 7 ++ src/Composer/Command/RequireCommand.php | 7 ++ src/Composer/Command/RunScriptCommand.php | 9 ++ src/Composer/Command/SearchCommand.php | 9 ++ src/Composer/Command/ShowCommand.php | 14 ++- src/Composer/Command/SuggestsCommand.php | 7 ++ src/Composer/Command/UpdateCommand.php | 7 ++ .../Package/Version/VersionGuesser.php | 6 + src/Composer/Util/HttpDownloader.php | 38 ++++-- .../Test/CompletionFunctionalTest.php | 109 ++++++++++++++++++ 22 files changed, 423 insertions(+), 28 deletions(-) create mode 100644 tests/Composer/Test/CompletionFunctionalTest.php diff --git a/src/Composer/Command/ArchiveCommand.php b/src/Composer/Command/ArchiveCommand.php index fa450a6da..2629db7e9 100644 --- a/src/Composer/Command/ArchiveCommand.php +++ b/src/Composer/Command/ArchiveCommand.php @@ -27,6 +27,8 @@ use Composer\Util\Filesystem; use Composer\Util\Loop; use Composer\Util\Platform; use Composer\Util\ProcessExecutor; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -39,6 +41,17 @@ use Symfony\Component\Console\Output\OutputInterface; */ class ArchiveCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($this->completeAvailablePackage($input, $suggestions)) { + return; + } + + if ($input->mustSuggestOptionValuesFor('format')) { + $suggestions->suggestValues(['tar', 'tar.gz', 'tar.bz2', 'zip']); + } + } + /** * @return void */ diff --git a/src/Composer/Command/BaseCommand.php b/src/Composer/Command/BaseCommand.php index c4f2ace11..dc88c5e17 100644 --- a/src/Composer/Command/BaseCommand.php +++ b/src/Composer/Command/BaseCommand.php @@ -20,10 +20,19 @@ use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory; use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface; use Composer\IO\IOInterface; use Composer\IO\NullIO; +use Composer\Package\Package; +use Composer\Package\PackageInterface; use Composer\Plugin\PreCommandRunEvent; use Composer\Package\Version\VersionParser; use Composer\Plugin\PluginEvents; +use Composer\Repository\CompositeRepository; +use Composer\Repository\InstalledRepository; +use Composer\Repository\PlatformRepository; +use Composer\Repository\RepositoryInterface; +use Composer\Repository\RootPackageRepository; 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; @@ -317,7 +326,96 @@ abstract class BaseCommand extends Command } /** - * @param array $requirements + * Suggestion values for "prefer-install" option + */ + protected function completePreferInstall(CompletionInput $input, CompletionSuggestions $suggestions): bool + { + if ($input->mustSuggestOptionValuesFor('prefer-install')) { + $suggestions->suggestValues(['dist', 'source', 'auto']); + + return true; + } + + return false; + } + + /** + * Suggest package names from installed ones. + */ + protected function completeInstalledPackage(CompletionInput $input, CompletionSuggestions $suggestions): bool + { + if (!$input->mustSuggestArgumentValuesFor('packages') && + !$input->mustSuggestArgumentValuesFor('package') && + !$input->mustSuggestOptionValuesFor('ignore') + ) { + return false; + } + + $composer = $this->getComposer(); + $installedRepos = [new RootPackageRepository(clone $composer->getPackage())]; + + $locker = $composer->getLocker(); + if ($locker->isLocked()) { + $installedRepos[] = $locker->getLockedRepository(true); + } else { + $installedRepos[] = $composer->getRepositoryManager()->getLocalRepository(); + } + + $installedRepo = new InstalledRepository($installedRepos); + $suggestions->suggestValues(array_map(function (PackageInterface $package) { + return $package->getName(); + }, $installedRepo->getPackages())); + + return true; + } + + /** + * Suggest package names available on all configured repositories. + */ + protected function completeAvailablePackage(CompletionInput $input, CompletionSuggestions $suggestions): bool + { + if (!$input->mustSuggestArgumentValuesFor('packages') && + !$input->mustSuggestArgumentValuesFor('package') && + !$input->mustSuggestOptionValuesFor('require') && + !$input->mustSuggestOptionValuesFor('require-dev') + ) { + return false; + } + + $composer = $this->getComposer(); + $repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories()); + + $packages = $repos->search('^'.preg_quote($input->getCompletionValue()), RepositoryInterface::SEARCH_NAME); + + foreach (array_slice($packages, 0, 150) as $package) { + $suggestions->suggestValue($package['name']); + } + + return true; + } + + /** + * Suggests ext- packages from the ones available on the currently-running PHP + */ + protected function completePlatformPackage(CompletionInput $input, CompletionSuggestions $suggestions): bool + { + if (!$input->mustSuggestOptionValuesFor('require') && + !$input->mustSuggestOptionValuesFor('require-dev') && + !str_starts_with($input->getCompletionValue(), 'ext-') + ) { + return false; + } + + $repos = new PlatformRepository([], $this->getComposer()->getConfig()->get('platform') ?? []); + $suggestions->suggestValues(array_map(function (PackageInterface $package) { + return $package->getName(); + }, $repos->getPackages())); + + return true; + } + + /** + * @param array $requirements * * @return array */ diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index 1e98f836d..341caaaa8 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -33,6 +33,8 @@ use Composer\Repository\InstalledArrayRepository; use Composer\Repository\RepositorySet; use Composer\Script\ScriptEvents; use Composer\Util\Silencer; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -55,6 +57,11 @@ use Composer\Package\Version\VersionParser; */ class CreateProjectCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeAvailablePackage($input, $suggestions) || $this->completePreferInstall($input, $suggestions); + } + /** * @var SuggestedPackagesReporter */ diff --git a/src/Composer/Command/DependsCommand.php b/src/Composer/Command/DependsCommand.php index 0dbbb03f2..d5c65ed46 100644 --- a/src/Composer/Command/DependsCommand.php +++ b/src/Composer/Command/DependsCommand.php @@ -12,6 +12,8 @@ namespace Composer\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Input\InputArgument; @@ -22,6 +24,11 @@ use Symfony\Component\Console\Input\InputOption; */ class DependsCommand extends BaseDependencyCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions); + } + /** * Configure command metadata. * diff --git a/src/Composer/Command/ExecCommand.php b/src/Composer/Command/ExecCommand.php index 049f6b687..1afdb6e8d 100644 --- a/src/Composer/Command/ExecCommand.php +++ b/src/Composer/Command/ExecCommand.php @@ -12,6 +12,8 @@ namespace Composer\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -22,6 +24,13 @@ use Symfony\Component\Console\Input\InputArgument; */ class ExecCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('binary')) { + $suggestions->suggestValues($this->getBinaries(false)); + } + } + /** * @return void */ @@ -52,14 +61,11 @@ EOT protected function execute(InputInterface $input, OutputInterface $output) { $composer = $this->requireComposer(); - $binDir = $composer->getConfig()->get('bin-dir'); - if ($input->getOption('list') || null === $input->getArgument('binary')) { - $bins = glob($binDir . '/*'); - $bins = array_merge($bins, array_map(function ($e) { - return "$e (local)"; - }, $composer->getPackage()->getBinaries())); + if ($input->getOption('list') || !$input->getArgument('binary')) { + $bins = $this->getBinaries(true); + if (count($bins) > 0) { + $binDir = $composer->getConfig()->get('bin-dir'); - if (!$bins) { throw new \RuntimeException("No binaries found in composer.json or in bin-dir ($binDir)"); } @@ -70,13 +76,6 @@ EOT ); foreach ($bins as $bin) { - // skip .bat copies - if (isset($previousBin) && $bin === $previousBin.'.bat') { - continue; - } - - $previousBin = $bin; - $bin = basename($bin); $this->getIO()->write( <<- $bin @@ -105,4 +104,30 @@ EOT return $dispatcher->dispatchScript('__exec_command', true, $input->getArgument('args')); } + + private function getBinaries(bool $forDisplay): array + { + $composer = $this->getComposer(); + $binDir = $composer->getConfig()->get('bin-dir'); + $bins = glob($binDir . '/*'); + $localBins = $composer->getPackage()->getBinaries(); + if ($forDisplay) { + $localBins = array_map(function ($e) { + return "$e (local)"; + }, $localBins); + } + + $binaries = []; + foreach (array_merge($bins, $localBins) as $bin) { + // skip .bat copies + if (isset($previousBin) && $bin === $previousBin.'.bat') { + continue; + } + + $previousBin = $bin; + $binaries[] = basename($bin); + } + + return $binaries; + } } diff --git a/src/Composer/Command/GlobalCommand.php b/src/Composer/Command/GlobalCommand.php index 3ec125427..ea9b496e3 100644 --- a/src/Composer/Command/GlobalCommand.php +++ b/src/Composer/Command/GlobalCommand.php @@ -16,6 +16,9 @@ use Composer\Factory; use Composer\Pcre\Preg; use Composer\Util\Filesystem; use Composer\Util\Platform; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\StringInput; @@ -26,6 +29,16 @@ use Symfony\Component\Console\Output\OutputInterface; */ class GlobalCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $application = $this->getApplication(); + if ($input->mustSuggestArgumentValuesFor('command-name')) { + $suggestions->suggestValues(array_filter(array_map(function (Command $command) { + return $command->isHidden() ? null : $command->getName(); + }, $application->all()))); + } + } + /** * @return void */ diff --git a/src/Composer/Command/HomeCommand.php b/src/Composer/Command/HomeCommand.php index dad4e3a75..db715617c 100644 --- a/src/Composer/Command/HomeCommand.php +++ b/src/Composer/Command/HomeCommand.php @@ -18,6 +18,8 @@ use Composer\Repository\RootPackageRepository; use Composer\Repository\RepositoryFactory; use Composer\Util\Platform; use Composer\Util\ProcessExecutor; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; @@ -28,6 +30,11 @@ use Symfony\Component\Console\Output\OutputInterface; */ class HomeCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions); + } + /** * @inheritDoc * diff --git a/src/Composer/Command/InitCommand.php b/src/Composer/Command/InitCommand.php index f923f6929..8af6719c4 100644 --- a/src/Composer/Command/InitCommand.php +++ b/src/Composer/Command/InitCommand.php @@ -23,6 +23,8 @@ use Composer\Repository\PlatformRepository; use Composer\Repository\RepositoryFactory; use Composer\Util\Filesystem; use Composer\Util\Silencer; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -42,6 +44,14 @@ class InitCommand extends BaseCommand /** @var array */ private $gitConfig; + /** @var RepositorySet[] */ + private $repositorySets; + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeAvailablePackage($input, $suggestions); + } + /** * @inheritDoc * diff --git a/src/Composer/Command/InstallCommand.php b/src/Composer/Command/InstallCommand.php index 13b6ff400..cbc51042d 100644 --- a/src/Composer/Command/InstallCommand.php +++ b/src/Composer/Command/InstallCommand.php @@ -16,6 +16,8 @@ use Composer\Installer; use Composer\Plugin\CommandEvent; use Composer\Plugin\PluginEvents; use Composer\Util\HttpDownloader; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputArgument; @@ -29,6 +31,11 @@ use Symfony\Component\Console\Output\OutputInterface; */ class InstallCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completePreferInstall($input, $suggestions) || $this->completeInstalledPackage($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Command/OutdatedCommand.php b/src/Composer/Command/OutdatedCommand.php index def190c04..120291b6a 100644 --- a/src/Composer/Command/OutdatedCommand.php +++ b/src/Composer/Command/OutdatedCommand.php @@ -12,6 +12,8 @@ namespace Composer\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\ArrayInput; @@ -23,6 +25,11 @@ use Symfony\Component\Console\Output\OutputInterface; */ class OutdatedCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Command/ProhibitsCommand.php b/src/Composer/Command/ProhibitsCommand.php index 6da462084..45908ec40 100644 --- a/src/Composer/Command/ProhibitsCommand.php +++ b/src/Composer/Command/ProhibitsCommand.php @@ -12,6 +12,8 @@ namespace Composer\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Input\InputArgument; @@ -22,6 +24,11 @@ use Symfony\Component\Console\Input\InputOption; */ class ProhibitsCommand extends BaseDependencyCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeAvailablePackage($input, $suggestions); + } + /** * Configure command metadata. * diff --git a/src/Composer/Command/ReinstallCommand.php b/src/Composer/Command/ReinstallCommand.php index 8150bfa22..c845fc3f5 100644 --- a/src/Composer/Command/ReinstallCommand.php +++ b/src/Composer/Command/ReinstallCommand.php @@ -22,6 +22,8 @@ use Composer\Plugin\CommandEvent; use Composer\Plugin\PluginEvents; use Composer\Script\ScriptEvents; use Composer\Util\Platform; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputArgument; @@ -32,6 +34,11 @@ use Symfony\Component\Console\Output\OutputInterface; */ class ReinstallCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions) || $this->completePreferInstall($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Command/RemoveCommand.php b/src/Composer/Command/RemoveCommand.php index 9d1fa4965..d558e4c0c 100644 --- a/src/Composer/Command/RemoveCommand.php +++ b/src/Composer/Command/RemoveCommand.php @@ -20,6 +20,8 @@ use Composer\Plugin\CommandEvent; use Composer\Plugin\PluginEvents; use Composer\Json\JsonFile; use Composer\Factory; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputArgument; @@ -32,6 +34,11 @@ use Composer\Package\BasePackage; */ class RemoveCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php index 230e449c6..ff8d39976 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -14,6 +14,8 @@ namespace Composer\Command; use Composer\DependencyResolver\Request; use Composer\Util\Filesystem; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -58,6 +60,11 @@ class RequireCommand extends BaseCommand /** @var bool */ private $dependencyResolutionCompleted = false; + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completePlatformPackage($input, $suggestions) || $this->completeAvailablePackage($input, $suggestions) || $this->completePreferInstall($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Command/RunScriptCommand.php b/src/Composer/Command/RunScriptCommand.php index c626d9ccf..2b035cab2 100644 --- a/src/Composer/Command/RunScriptCommand.php +++ b/src/Composer/Command/RunScriptCommand.php @@ -16,6 +16,8 @@ use Composer\Script\Event as ScriptEvent; use Composer\Script\ScriptEvents; use Composer\Util\ProcessExecutor; use Composer\Util\Platform; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputArgument; @@ -44,6 +46,13 @@ class RunScriptCommand extends BaseCommand ScriptEvents::POST_AUTOLOAD_DUMP, ); + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('script')) { + $suggestions->suggestValues(array_keys($this->getComposer()->getPackage()->getScripts())); + } + } + /** * @return void */ diff --git a/src/Composer/Command/SearchCommand.php b/src/Composer/Command/SearchCommand.php index f701e40d9..f7dfa2001 100644 --- a/src/Composer/Command/SearchCommand.php +++ b/src/Composer/Command/SearchCommand.php @@ -14,6 +14,8 @@ namespace Composer\Command; use Composer\Factory; use Composer\Json\JsonFile; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; @@ -30,6 +32,13 @@ use Composer\Plugin\PluginEvents; */ class SearchCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('format')) { + $suggestions->suggestValues(['json', 'text']); + } + } + /** * @return void */ diff --git a/src/Composer/Command/ShowCommand.php b/src/Composer/Command/ShowCommand.php index 0194662b7..c128295c4 100644 --- a/src/Composer/Command/ShowCommand.php +++ b/src/Composer/Command/ShowCommand.php @@ -20,7 +20,6 @@ use Composer\Package\BasePackage; use Composer\Package\CompletePackageInterface; use Composer\Package\Link; use Composer\Package\AliasPackage; -use Composer\Package\Package; use Composer\Package\PackageInterface; use Composer\Package\Version\VersionParser; use Composer\Package\Version\VersionSelector; @@ -41,6 +40,8 @@ use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\Semver; use Composer\Spdx\SpdxLicenses; use Composer\Util\PackageInfo; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Input\InputArgument; @@ -64,6 +65,17 @@ class ShowCommand extends BaseCommand /** @var ?RepositorySet */ private $repositorySet; + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($this->completeInstalledPackage($input, $suggestions)) { + return; + } + + if ($input->mustSuggestOptionValuesFor('format')) { + $suggestions->suggestValues(['json', 'text']); + } + } + /** * @return void */ diff --git a/src/Composer/Command/SuggestsCommand.php b/src/Composer/Command/SuggestsCommand.php index 23776abb5..173337691 100644 --- a/src/Composer/Command/SuggestsCommand.php +++ b/src/Composer/Command/SuggestsCommand.php @@ -16,6 +16,8 @@ use Composer\Repository\PlatformRepository; use Composer\Repository\RootPackageRepository; use Composer\Repository\InstalledRepository; use Composer\Installer\SuggestedPackagesReporter; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -23,6 +25,11 @@ use Symfony\Component\Console\Output\OutputInterface; class SuggestsCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index 8bd3a9bfe..c89f1a58a 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -24,6 +24,8 @@ use Composer\Package\Version\VersionParser; use Composer\Util\HttpDownloader; use Composer\Semver\Constraint\MultiConstraint; use Composer\Package\Link; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -37,6 +39,11 @@ use Symfony\Component\Console\Question\Question; */ class UpdateCommand extends BaseCommand { + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $this->completeInstalledPackage($input, $suggestions) || $this->completePreferInstall($input, $suggestions); + } + /** * @return void */ diff --git a/src/Composer/Package/Version/VersionGuesser.php b/src/Composer/Package/Version/VersionGuesser.php index bc2610a63..96384b128 100644 --- a/src/Composer/Package/Version/VersionGuesser.php +++ b/src/Composer/Package/Version/VersionGuesser.php @@ -74,6 +74,12 @@ class VersionGuesser return null; } + // bypass version guessing in bash completions as it takes time to create + // new processes and the root version is usually not that important + if (isset($_SERVER['argv'][1]) && $_SERVER['argv'][1] === '_complete') { + return null; + } + $versionData = $this->guessGitVersion($packageConfig, $path); if (null !== $versionData && null !== $versionData['version']) { return $this->postprocess($versionData); diff --git a/src/Composer/Util/HttpDownloader.php b/src/Composer/Util/HttpDownloader.php index 581e17226..4fb6b9f76 100644 --- a/src/Composer/Util/HttpDownloader.php +++ b/src/Composer/Util/HttpDownloader.php @@ -44,8 +44,12 @@ class HttpDownloader private $config; /** @var array */ private $jobs = array(); + /** @var bool */ + private $disableTls; /** @var mixed[] */ private $options = array(); + /** @var mixed[]|null */ + private $tlsDefaultOptions = null; /** @var int */ private $runningJobs = 0; /** @var int */ @@ -73,22 +77,19 @@ class HttpDownloader $this->disabled = (bool) Platform::getEnv('COMPOSER_DISABLE_NETWORK'); - // Setup TLS options - // The cafile option can be set via config.json - if ($disableTls === false) { - $this->options = StreamContextFactory::getTlsDefaults($options, $io); + if ($disableTls === true) { + // make sure the tlsDefaultOptions are not loaded later + $this->tlsDefaultOptions = []; } - // handle the other externally set options normally. - $this->options = array_replace_recursive($this->options, $options); + $this->disableTls = $disableTls; + $this->options = $options; $this->config = $config; if (self::isCurlEnabled()) { $this->curl = new CurlDownloader($io, $config, $options, $disableTls); } - $this->rfs = new RemoteFilesystem($io, $config, $options, $disableTls); - if (is_numeric($maxJobs = Platform::getEnv('COMPOSER_MAX_PARALLEL_HTTP'))) { $this->maxJobs = max(1, min(50, (int) $maxJobs)); } @@ -171,7 +172,13 @@ class HttpDownloader */ public function getOptions() { - return $this->options; + if ($this->tlsDefaultOptions === null) { + // Setup TLS options + // The cafile option can be set via config.json + $this->tlsDefaultOptions = StreamContextFactory::getTlsDefaults($this->options, $this->io); + } + + return array_replace_recursive($this->tlsDefaultOptions, $this->options); } /** @@ -191,7 +198,7 @@ class HttpDownloader */ private function addJob(array $request, bool $sync = false): array { - $request['options'] = array_replace_recursive($this->options, $request['options']); + $request['options'] = array_replace_recursive($this->getOptions(), $request['options']); /** @var Job */ $job = array( @@ -211,8 +218,6 @@ class HttpDownloader $this->io->setAuthentication($job['origin'], rawurldecode($match[1]), rawurldecode($match[2])); } - $rfs = $this->rfs; - if ($this->canUseCurl($job)) { $resolver = function ($resolve, $reject) use (&$job): void { $job['status'] = HttpDownloader::STATUS_QUEUED; @@ -285,6 +290,15 @@ class HttpDownloader return array($job, $promise); } + private function getRFS(): RemoteFilesystem + { + if (null === $this->rfs) { + $this->rfs = new RemoteFilesystem($this->io, $this->config, $this->options, $this->disableTls); + } + + return $this->rfs; + } + /** * @param int $id * @return void diff --git a/tests/Composer/Test/CompletionFunctionalTest.php b/tests/Composer/Test/CompletionFunctionalTest.php new file mode 100644 index 000000000..a2f1ce8c0 --- /dev/null +++ b/tests/Composer/Test/CompletionFunctionalTest.php @@ -0,0 +1,109 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test; + +use Composer\Console\Application; +use Symfony\Component\Console\Tester\CommandCompletionTester; + +/** + * Validate autocompletion for all commands. + * + * @author Jérôme Tamarelle + */ +class CompletionFunctionalTest extends TestCase +{ + public function getCommandSuggestions(): iterable + { + $randomProject = '104corp/cache'; + $installedPackages = ['composer/semver', 'psr/log']; + $preferInstall = ['dist', 'source', 'auto']; + + yield ['archive ', [$randomProject]]; + yield ['archive symfony/http-', ['symfony/http-kernel', 'symfony/http-foundation']]; + yield ['archive --format ', ['tar', 'zip']]; + + yield ['create-project ', [$randomProject]]; + yield ['create-project symfony/skeleton --prefer-install ', $preferInstall]; + + yield ['depends ', $installedPackages]; + yield ['why ', $installedPackages]; + + yield ['exec ', ['composer', 'compile']]; + + yield ['browse ', $installedPackages]; + yield ['home -H ', $installedPackages]; + + yield ['init --require ', [$randomProject]]; + yield ['init --require-dev foo/bar --require-dev ', [$randomProject]]; + + yield ['install --prefer-install ', $preferInstall]; + yield ['install ', $installedPackages]; + + yield ['outdated ', $installedPackages]; + + yield ['prohibits ', [$randomProject]]; + yield ['why-not symfony/http-ker', ['symfony/http-kernel']]; + + yield ['reinstall --prefer-install ', $preferInstall]; + yield ['reinstall ', $installedPackages]; + + yield ['remove ', $installedPackages]; + + yield ['require --prefer-install ', $preferInstall]; + yield ['require ', [$randomProject]]; + yield ['require --dev symfony/http-', ['symfony/http-kernel', 'symfony/http-foundation']]; + + yield ['run-script ', ['compile', 'test', 'phpstan']]; + yield ['run-script test ', null]; + + yield ['search --format ', ['text', 'json']]; + + yield ['show --format ', ['text', 'json']]; + yield ['info ', $installedPackages]; + + yield ['suggests ', $installedPackages]; + + yield ['update --prefer-install ', $preferInstall]; + yield ['update ', $installedPackages]; + } + + /** + * @dataProvider getCommandSuggestions + * + * @param string $input The command that is typed + * @param string[]|null $expectedSuggestions Sample expected suggestions. Null if nothing is expected. + */ + public function testComplete(string $input, ?array $expectedSuggestions): void + { + $input = explode(' ', $input); + $commandName = array_shift($input); + $command = $this->getApplication()->get($commandName); + + $tester = new CommandCompletionTester($command); + $suggestions = $tester->complete($input); + + if (null === $expectedSuggestions) { + $this->assertEmpty($suggestions); + + return; + } + + $diff = array_diff($expectedSuggestions, $suggestions); + $this->assertEmpty($diff, sprintf('Suggestions must contain "%s". Got "%s".', implode('", "', $diff), implode('", "', $suggestions))); + } + + private function getApplication(): Application + { + return new Application(); + } +}