diff --git a/doc/03-cli.md b/doc/03-cli.md index 9fd8256b610b..30d21c673b5d 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -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: diff --git a/src/Composer/Cache.php b/src/Composer/Cache.php index c6e2585aaa31..27fc282179cb 100644 --- a/src/Composer/Cache.php +++ b/src/Composer/Cache.php @@ -248,6 +248,10 @@ public function gcIsNecessary() return false; } + if (Platform::isInputCompletionProcess()) { + return false; + } + return !random_int(0, 50); } diff --git a/src/Composer/Command/ArchiveCommand.php b/src/Composer/Command/ArchiveCommand.php index a35f3aecf00a..dcd37f1ea58f 100644 --- a/src/Composer/Command/ArchiveCommand.php +++ b/src/Composer/Command/ArchiveCommand.php @@ -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; /** @@ -39,6 +39,10 @@ */ class ArchiveCommand extends BaseCommand { + use CompletionTrait; + + private const FORMATS = ['tar', 'tar.gz', 'tar.bz2', 'zip']; + /** * @return void */ @@ -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.'), diff --git a/src/Composer/Command/BaseCommand.php b/src/Composer/Command/BaseCommand.php index c4f2ace111fb..89c3996247c3 100644 --- a/src/Composer/Command/BaseCommand.php +++ b/src/Composer/Command/BaseCommand.php @@ -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; @@ -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; @@ -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 * diff --git a/src/Composer/Command/CompletionTrait.php b/src/Composer/Command/CompletionTrait.php new file mode 100644 index 000000000000..5134aac3a1e8 --- /dev/null +++ b/src/Composer/Command/CompletionTrait.php @@ -0,0 +1,197 @@ + + * Jordi Boggiano + * + * 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); + }); + }; + } +} diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index 1e98f836df96..e04c0fdcf335 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -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; @@ -55,6 +55,8 @@ */ class CreateProjectCommand extends BaseCommand { + use CompletionTrait; + /** * @var SuggestedPackagesReporter */ @@ -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.'), @@ -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'), @@ -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); @@ -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 diff --git a/src/Composer/Command/DependsCommand.php b/src/Composer/Command/DependsCommand.php index 0dbbb03f2cf7..1b353807aad4 100644 --- a/src/Composer/Command/DependsCommand.php +++ b/src/Composer/Command/DependsCommand.php @@ -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 */ class DependsCommand extends BaseDependencyCommand { + use CompletionTrait; + /** * Configure command metadata. * @@ -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'), )) diff --git a/src/Composer/Command/ExecCommand.php b/src/Composer/Command/ExecCommand.php index 049f6b687a05..f30467bd8100 100644 --- a/src/Composer/Command/ExecCommand.php +++ b/src/Composer/Command/ExecCommand.php @@ -13,9 +13,9 @@ namespace Composer\Command; 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\Console\Input\InputArgument; +use Composer\Console\Input\InputArgument; /** * @author Davey Shafik @@ -32,7 +32,9 @@ protected function configure() ->setDescription('Executes a vendored binary/script.') ->setDefinition(array( new InputOption('list', 'l', InputOption::VALUE_NONE), - new InputArgument('binary', InputArgument::OPTIONAL, 'The binary to run, e.g. phpunit'), + new InputArgument('binary', InputArgument::OPTIONAL, 'The binary to run, e.g. phpunit', null, function () { + return $this->getBinaries(false); + }), new InputArgument( 'args', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, @@ -52,14 +54,11 @@ protected function configure() 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())); + $bins = $this->getBinaries(true); + if ([] === $bins) { + $binDir = $composer->getConfig()->get('bin-dir'); - if (!$bins) { throw new \RuntimeException("No binaries found in composer.json or in bin-dir ($binDir)"); } @@ -70,13 +69,6 @@ protected function execute(InputInterface $input, OutputInterface $output) ); 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 +97,34 @@ protected function execute(InputInterface $input, OutputInterface $output) return $dispatcher->dispatchScript('__exec_command', true, $input->getArgument('args')); } + + /** + * @param bool $forDisplay + * @return string[] + */ + private function getBinaries(bool $forDisplay): array + { + $composer = $this->requireComposer(); + $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/FundCommand.php b/src/Composer/Command/FundCommand.php index 057fea5d6eab..15960d294523 100644 --- a/src/Composer/Command/FundCommand.php +++ b/src/Composer/Command/FundCommand.php @@ -21,7 +21,7 @@ use Composer\Semver\Constraint\MatchAllConstraint; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; +use Composer\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -38,7 +38,7 @@ protected function configure(): void $this->setName('fund') ->setDescription('Discover how to help fund the maintenance of your dependencies.') ->setDefinition(array( - new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['text', 'json']), )) ; } diff --git a/src/Composer/Command/GlobalCommand.php b/src/Composer/Command/GlobalCommand.php index 3ec1254271f0..e9195ca1e72a 100644 --- a/src/Composer/Command/GlobalCommand.php +++ b/src/Composer/Command/GlobalCommand.php @@ -16,8 +16,11 @@ 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 Composer\Console\Input\InputArgument; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\OutputInterface; @@ -26,6 +29,28 @@ */ 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; + } + + if ($application->has($commandName = $input->getArgument('command-name'))) { + $input = $this->prepareSubcommandInput($input, true); + $input = CompletionInput::fromString($input->__toString(), 2); + $command = $application->find($commandName); + $command->mergeApplicationDefinition(); + + $input->bind($command->getDefinition()); + $command->complete($input, $suggestions); + } + } + /** * @return void */ @@ -87,6 +112,17 @@ public function run(InputInterface $input, OutputInterface $output): int return parent::run($input, $output); } + $input = $this->prepareSubcommandInput($input); + + return $this->getApplication()->run($input, $output); + } + + private function prepareSubcommandInput(InputInterface $input, bool $quiet = false): StringInput + { + if (!method_exists($input, '__toString')) { + throw new \LogicException('Expected an Input instance that is stringable, got '.get_class($input)); + } + // The COMPOSER env var should not apply to the global execution scope if (Platform::getEnv('COMPOSER')) { Platform::clearEnv('COMPOSER'); @@ -109,13 +145,15 @@ public function run(InputInterface $input, OutputInterface $output): int } catch (\Exception $e) { throw new \RuntimeException('Could not switch to home directory "'.$home.'"', 0, $e); } - $this->getIO()->writeError('Changed current directory to '.$home.''); + if (!$quiet) { + $this->getIO()->writeError('Changed current directory to '.$home.''); + } // create new input without "global" command prefix $input = new StringInput(Preg::replace('{\bg(?:l(?:o(?:b(?:a(?:l)?)?)?)?)?\b}', '', $input->__toString(), 1)); $this->getApplication()->resetComposer(); - return $this->getApplication()->run($input, $output); + return $input; } /** diff --git a/src/Composer/Command/HomeCommand.php b/src/Composer/Command/HomeCommand.php index c4457ac4c54a..8fc4bc9dbf2a 100644 --- a/src/Composer/Command/HomeCommand.php +++ b/src/Composer/Command/HomeCommand.php @@ -18,8 +18,8 @@ use Composer\Repository\RepositoryFactory; use Composer\Util\Platform; use Composer\Util\ProcessExecutor; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -28,6 +28,8 @@ */ class HomeCommand extends BaseCommand { + use CompletionTrait; + /** * @inheritDoc * @@ -40,7 +42,7 @@ protected function configure(): void ->setAliases(array('home')) ->setDescription('Opens the package\'s repository URL or homepage in your browser.') ->setDefinition(array( - new InputArgument('packages', InputArgument::IS_ARRAY, 'Package(s) to browse to.'), + new InputArgument('packages', InputArgument::IS_ARRAY, 'Package(s) to browse to.', null, $this->suggestInstalledPackage()), new InputOption('homepage', 'H', InputOption::VALUE_NONE, 'Open the homepage instead of the repository URL.'), new InputOption('show', 's', InputOption::VALUE_NONE, 'Only show the homepage or repository URL.'), )) diff --git a/src/Composer/Command/InitCommand.php b/src/Composer/Command/InitCommand.php index ae8bc6c9bf70..173b77def55b 100644 --- a/src/Composer/Command/InitCommand.php +++ b/src/Composer/Command/InitCommand.php @@ -25,7 +25,7 @@ use Composer\Util\Silencer; use Symfony\Component\Console\Input\ArrayInput; 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\Process\ExecutableFinder; use Symfony\Component\Process\Process; @@ -37,6 +37,7 @@ */ class InitCommand extends BaseCommand { + use CompletionTrait; use PackageDiscoveryTrait; /** @var array */ @@ -58,8 +59,8 @@ protected function configure() new InputOption('author', null, InputOption::VALUE_REQUIRED, 'Author name of package'), new InputOption('type', null, InputOption::VALUE_OPTIONAL, 'Type of package (e.g. library, project, metapackage, composer-plugin)'), new InputOption('homepage', null, InputOption::VALUE_REQUIRED, 'Homepage of package'), - new InputOption('require', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'), - new InputOption('require-dev', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require for development with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'), + new InputOption('require', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"', null, $this->suggestAvailablePackageInclPlatform()), + new InputOption('require-dev', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require for development with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"', null, $this->suggestAvailablePackageInclPlatform()), new InputOption('stability', 's', InputOption::VALUE_REQUIRED, 'Minimum stability (empty or one of: '.implode(', ', array_keys(BasePackage::$stabilities)).')'), new InputOption('license', 'l', InputOption::VALUE_REQUIRED, 'License of package'), new InputOption('repository', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Add custom repositories, either by URL or using JSON arrays'), diff --git a/src/Composer/Command/InstallCommand.php b/src/Composer/Command/InstallCommand.php index 13b6ff400628..6e27a5815b3a 100644 --- a/src/Composer/Command/InstallCommand.php +++ b/src/Composer/Command/InstallCommand.php @@ -17,8 +17,8 @@ use Composer\Plugin\PluginEvents; use Composer\Util\HttpDownloader; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; /** @@ -29,6 +29,8 @@ */ class InstallCommand extends BaseCommand { + use CompletionTrait; + /** * @return void */ @@ -41,7 +43,7 @@ protected function configure() ->setDefinition(array( 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('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'), new InputOption('dev', null, InputOption::VALUE_NONE, 'DEPRECATED: Enables installation of require-dev packages (enabled by default, only present for BC).'), new InputOption('no-suggest', null, InputOption::VALUE_NONE, 'DEPRECATED: This flag does not exist anymore.'), diff --git a/src/Composer/Command/LicensesCommand.php b/src/Composer/Command/LicensesCommand.php index 748b5b85d4fd..52d0d0122959 100644 --- a/src/Composer/Command/LicensesCommand.php +++ b/src/Composer/Command/LicensesCommand.php @@ -12,6 +12,7 @@ namespace Composer\Command; +use Composer\Console\Input\InputOption; use Composer\Json\JsonFile; use Composer\Package\CompletePackageInterface; use Composer\Plugin\CommandEvent; @@ -22,7 +23,6 @@ use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -40,7 +40,7 @@ protected function configure(): void ->setName('licenses') ->setDescription('Shows information about licenses of dependencies.') ->setDefinition(array( - new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text, json or summary', 'text'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text, json or summary', 'text', ['text', 'json', 'summary']), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables search in require-dev packages.'), )) ->setHelp( diff --git a/src/Composer/Command/OutdatedCommand.php b/src/Composer/Command/OutdatedCommand.php index def190c04d7b..ffa9ba3af731 100644 --- a/src/Composer/Command/OutdatedCommand.php +++ b/src/Composer/Command/OutdatedCommand.php @@ -13,9 +13,9 @@ namespace Composer\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Input\InputOption; +use Composer\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** @@ -23,6 +23,8 @@ */ class OutdatedCommand extends BaseCommand { + use CompletionTrait; + /** * @return void */ @@ -32,7 +34,7 @@ protected function configure(): void ->setName('outdated') ->setDescription('Shows a list of installed packages that have updates available, including their latest version.') ->setDefinition(array( - new InputArgument('package', InputArgument::OPTIONAL, 'Package to inspect. Or a name including a wildcard (*) to filter lists of packages instead.'), + new InputArgument('package', InputArgument::OPTIONAL, 'Package to inspect. Or a name including a wildcard (*) to filter lists of packages instead.', null, $this->suggestInstalledPackage()), new InputOption('outdated', 'o', InputOption::VALUE_NONE, 'Show only packages that are outdated (this is the default, but present here for compat with `show`'), new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show all installed packages with their latest versions'), new InputOption('locked', null, InputOption::VALUE_NONE, 'Shows updates for packages from the lock file, regardless of what is currently in vendor dir'), @@ -40,8 +42,8 @@ protected function configure(): void new InputOption('strict', null, InputOption::VALUE_NONE, 'Return a non-zero exit code when there are outdated packages'), new InputOption('minor-only', 'm', InputOption::VALUE_NONE, 'Show only packages that have minor SemVer-compatible updates. Use with the --outdated option.'), new InputOption('patch-only', 'p', InputOption::VALUE_NONE, 'Show only packages that have patch SemVer-compatible updates. Use with the --outdated option.'), - new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text'), - new InputOption('ignore', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore specified package(s). Use it with the --outdated option if you don\'t want to be informed about new versions of some packages.'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), + new InputOption('ignore', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore specified package(s). Use it with the --outdated option if you don\'t want to be informed about new versions of some packages.', null, $this->suggestInstalledPackage()), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables search in require-dev packages.'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages). Use with the --outdated option'), new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages). Use with the --outdated option'), diff --git a/src/Composer/Command/PackageDiscoveryTrait.php b/src/Composer/Command/PackageDiscoveryTrait.php index 5e210bd7c763..e13abaf49155 100644 --- a/src/Composer/Command/PackageDiscoveryTrait.php +++ b/src/Composer/Command/PackageDiscoveryTrait.php @@ -99,7 +99,7 @@ final protected function determineRequirements(InputInterface $input, OutputInte foreach ($requires as $requirement) { if (!isset($requirement['version'])) { // determine the best version automatically - list($name, $version) = $this->findBestVersionAndNameForPackage($input, $requirement['name'], $platformRepo, $preferredStability, null, null, $fixed); + list($name, $version) = $this->findBestVersionAndNameForPackage($input, $requirement['name'], $platformRepo, $preferredStability, $fixed); $requirement['version'] = $version; // replace package name from packagist.org @@ -268,7 +268,7 @@ final protected function determineRequirements(InputInterface $input, OutputInte * @throws \InvalidArgumentException * @return array{string, string} name version */ - private function findBestVersionAndNameForPackage(InputInterface $input, string $name, ?PlatformRepository $platformRepo = null, string $preferredStability = 'stable', ?string $requiredVersion = null, ?string $minimumStability = null, bool $fixed = false): array + private function findBestVersionAndNameForPackage(InputInterface $input, string $name, ?PlatformRepository $platformRepo = null, string $preferredStability = 'stable', bool $fixed = false): array { // handle ignore-platform-reqs flag if present if ($input->hasOption('ignore-platform-reqs') && $input->hasOption('ignore-platform-req')) { @@ -278,17 +278,17 @@ private function findBestVersionAndNameForPackage(InputInterface $input, string } // find the latest version allowed in this repo set - $repoSet = $this->getRepositorySet($input, $minimumStability); + $repoSet = $this->getRepositorySet($input); $versionSelector = new VersionSelector($repoSet, $platformRepo); - $effectiveMinimumStability = $minimumStability ?? $this->getMinimumStability($input); + $effectiveMinimumStability = $this->getMinimumStability($input); - $package = $versionSelector->findBestCandidate($name, $requiredVersion, $preferredStability, $platformRequirementFilter); + $package = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter); if (false === $package) { // platform packages can not be found in the pool in versions other than the local platform's has // so if platform reqs are ignored we just take the user's word for it if ($platformRequirementFilter->isIgnored($name)) { - return array($name, $requiredVersion ?: '*'); + return array($name, '*'); } // Check if it is a virtual package provided by others @@ -308,17 +308,16 @@ private function findBestVersionAndNameForPackage(InputInterface $input, string } // Check whether the package requirements were the problem - if (!($platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter) && false !== ($candidate = $versionSelector->findBestCandidate($name, $requiredVersion, $preferredStability, PlatformRequirementFilterFactory::ignoreAll()))) { + if (!($platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter) && false !== ($candidate = $versionSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll()))) { throw new \InvalidArgumentException(sprintf( - 'Package %s%s has requirements incompatible with your PHP version, PHP extensions and Composer version' . $this->getPlatformExceptionDetails($candidate, $platformRepo), - $name, - is_string($requiredVersion) ? ' at version '.$requiredVersion : '' + 'Package %s has requirements incompatible with your PHP version, PHP extensions and Composer version' . $this->getPlatformExceptionDetails($candidate, $platformRepo), + $name )); } // Check whether the minimum stability was the problem but the package exists - if (false !== ($package = $versionSelector->findBestCandidate($name, $requiredVersion, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { + if (false !== ($package = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { // we must first verify if a valid package would be found in a lower priority repository - if (false !== ($allReposPackage = $versionSelector->findBestCandidate($name, $requiredVersion, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_SHADOWED_REPOSITORIES))) { + if (false !== ($allReposPackage = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter, RepositorySet::ALLOW_SHADOWED_REPOSITORIES))) { throw new \InvalidArgumentException( 'Package '.$name.' exists in '.$allReposPackage->getRepository()->getRepoName().' and '.$package->getRepository()->getRepoName().' which has a higher repository priority. The packages from the higher priority repository do not match your minimum-stability and are therefore not installable. That repository is canonical so the lower priority repo\'s packages are not installable. See https://getcomposer.org/repoprio for details and assistance.' ); @@ -330,21 +329,6 @@ private function findBestVersionAndNameForPackage(InputInterface $input, string $effectiveMinimumStability )); } - // Check whether the required version was the problem - if (is_string($requiredVersion) && false !== ($package = $versionSelector->findBestCandidate($name, null, $preferredStability, $platformRequirementFilter))) { - // we must first verify if a valid package would be found in a lower priority repository - if (false !== ($allReposPackage = $versionSelector->findBestCandidate($name, $requiredVersion, $preferredStability, PlatformRequirementFilterFactory::ignoreNothing(), RepositorySet::ALLOW_SHADOWED_REPOSITORIES))) { - throw new \InvalidArgumentException( - 'Package '.$name.' exists in '.$allReposPackage->getRepository()->getRepoName().' and '.$package->getRepository()->getRepoName().' which has a higher repository priority. The packages from the higher priority repository do not match your constraint and are therefore not installable. That repository is canonical so the lower priority repo\'s packages are not installable. See https://getcomposer.org/repoprio for details and assistance.' - ); - } - - throw new \InvalidArgumentException(sprintf( - 'Could not find package %s in a version matching "%s" and a stability matching "'.$effectiveMinimumStability.'".', - $name, - $requiredVersion - )); - } // Check whether the PHP version was the problem for all versions if (!$platformRequirementFilter instanceof IgnoreAllPlatformRequirementFilter && false !== ($candidate = $versionSelector->findBestCandidate($name, null, $preferredStability, PlatformRequirementFilterFactory::ignoreAll(), RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES))) { $additional = ''; diff --git a/src/Composer/Command/ProhibitsCommand.php b/src/Composer/Command/ProhibitsCommand.php index 6da462084f7b..d536a314161a 100644 --- a/src/Composer/Command/ProhibitsCommand.php +++ b/src/Composer/Command/ProhibitsCommand.php @@ -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 */ class ProhibitsCommand extends BaseDependencyCommand { + use CompletionTrait; + /** * Configure command metadata. * @@ -34,7 +36,7 @@ protected function configure(): void ->setAliases(array('why-not')) ->setDescription('Shows which packages prevent the given package from being 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->suggestAvailablePackage()), new InputArgument(self::ARGUMENT_CONSTRAINT, InputArgument::REQUIRED, 'Version constraint, which version you expected to be installed'), 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'), diff --git a/src/Composer/Command/ReinstallCommand.php b/src/Composer/Command/ReinstallCommand.php index 8150bfa22736..40a376134bdf 100644 --- a/src/Composer/Command/ReinstallCommand.php +++ b/src/Composer/Command/ReinstallCommand.php @@ -23,8 +23,8 @@ use Composer\Script\ScriptEvents; use Composer\Util\Platform; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; /** @@ -32,6 +32,8 @@ */ class ReinstallCommand extends BaseCommand { + use CompletionTrait; + /** * @return void */ @@ -43,7 +45,7 @@ protected function configure(): void ->setDefinition(array( 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('no-autoloader', null, InputOption::VALUE_NONE, 'Skips autoloader generation'), new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'), new InputOption('optimize-autoloader', 'o', InputOption::VALUE_NONE, 'Optimize autoloader during autoloader dump'), @@ -52,7 +54,7 @@ protected function configure(): void new InputOption('apcu-autoloader-prefix', null, InputOption::VALUE_REQUIRED, 'Use a custom prefix for the APCu autoloader cache. Implicitly enables --apcu-autoloader'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'), new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'), - new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'List of package names to reinstall, can include a wildcard (*) to match any substring.'), + new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'List of package names to reinstall, can include a wildcard (*) to match any substring.', null, $this->suggestInstalledPackage()), )) ->setHelp( <<setName('remove') ->setDescription('Removes a package from the require or require-dev.') ->setDefinition(array( - new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Packages that should be removed.'), + new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Packages that should be removed.', null, $this->suggestInstalledPackage(true)), new InputOption('dev', null, InputOption::VALUE_NONE, 'Removes a package from the require-dev section.'), new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'), new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'), diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php index 230e449c6ef4..3feb98e6c77d 100644 --- a/src/Composer/Command/RequireCommand.php +++ b/src/Composer/Command/RequireCommand.php @@ -15,8 +15,8 @@ use Composer\DependencyResolver\Request; use Composer\Util\Filesystem; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Composer\Factory; use Composer\Installer; @@ -39,6 +39,7 @@ */ class RequireCommand extends BaseCommand { + use CompletionTrait; use PackageDiscoveryTrait; /** @var bool */ @@ -67,12 +68,12 @@ protected function configure() ->setName('require') ->setDescription('Adds required packages to your composer.json and installs them.') ->setDefinition(array( - new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Optional package name can also include a version constraint, e.g. foo/bar or foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'), + new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Optional package name can also include a version constraint, e.g. foo/bar or foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"', null, $this->suggestAvailablePackageInclPlatform()), new InputOption('dev', null, InputOption::VALUE_NONE, 'Add requirement to require-dev.'), new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'), 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('fixed', null, InputOption::VALUE_NONE, 'Write fixed version to the composer.json.'), new InputOption('no-suggest', null, InputOption::VALUE_NONE, 'DEPRECATED: This flag does not exist anymore.'), new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'), diff --git a/src/Composer/Command/RunScriptCommand.php b/src/Composer/Command/RunScriptCommand.php index c626d9ccf04f..cdc262b1af3b 100644 --- a/src/Composer/Command/RunScriptCommand.php +++ b/src/Composer/Command/RunScriptCommand.php @@ -17,8 +17,8 @@ use Composer\Util\ProcessExecutor; use Composer\Util\Platform; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; /** @@ -54,7 +54,9 @@ protected function configure(): void ->setAliases(array('run')) ->setDescription('Runs the scripts defined in composer.json.') ->setDefinition(array( - new InputArgument('script', InputArgument::OPTIONAL, 'Script name to run.'), + new InputArgument('script', InputArgument::OPTIONAL, 'Script name to run.', null, function () { + return array_keys($this->requireComposer()->getPackage()->getScripts()); + }), new InputArgument('args', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, ''), new InputOption('timeout', null, InputOption::VALUE_REQUIRED, 'Sets script timeout in seconds, or 0 for never.'), new InputOption('dev', null, InputOption::VALUE_NONE, 'Sets the dev mode.'), diff --git a/src/Composer/Command/ScriptAliasCommand.php b/src/Composer/Command/ScriptAliasCommand.php index 4c34368ad237..603ea1059010 100644 --- a/src/Composer/Command/ScriptAliasCommand.php +++ b/src/Composer/Command/ScriptAliasCommand.php @@ -13,8 +13,8 @@ namespace Composer\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; /** diff --git a/src/Composer/Command/SearchCommand.php b/src/Composer/Command/SearchCommand.php index f701e40d91d4..32ceaa9bc9eb 100644 --- a/src/Composer/Command/SearchCommand.php +++ b/src/Composer/Command/SearchCommand.php @@ -16,8 +16,8 @@ use Composer\Json\JsonFile; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; @@ -42,7 +42,7 @@ protected function configure(): void new InputOption('only-name', 'N', InputOption::VALUE_NONE, 'Search only in package names'), new InputOption('only-vendor', 'O', InputOption::VALUE_NONE, 'Search only for vendor / organization names, returns only "vendor" as result'), new InputOption('type', 't', InputOption::VALUE_REQUIRED, 'Search for a specific package type'), - new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), new InputArgument('tokens', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'tokens to search for'), )) ->setHelp( diff --git a/src/Composer/Command/SelfUpdateCommand.php b/src/Composer/Command/SelfUpdateCommand.php index 5e025b486e68..f8291f133907 100644 --- a/src/Composer/Command/SelfUpdateCommand.php +++ b/src/Composer/Command/SelfUpdateCommand.php @@ -24,8 +24,8 @@ use Composer\Downloader\FilesystemException; use Composer\Downloader\TransportException; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Finder\Finder; diff --git a/src/Composer/Command/ShowCommand.php b/src/Composer/Command/ShowCommand.php index 72242073b205..18291cc29caf 100644 --- a/src/Composer/Command/ShowCommand.php +++ b/src/Composer/Command/ShowCommand.php @@ -20,7 +20,6 @@ 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,11 +40,12 @@ use Composer\Semver\Semver; use Composer\Spdx\SpdxLicenses; use Composer\Util\PackageInfo; +use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Formatter\OutputFormatterStyle; -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; /** @@ -56,6 +56,8 @@ */ class ShowCommand extends BaseCommand { + use CompletionTrait; + /** @var VersionParser */ protected $versionParser; /** @var string[] */ @@ -74,7 +76,7 @@ protected function configure() ->setAliases(array('info')) ->setDescription('Shows information about packages.') ->setDefinition(array( - new InputArgument('package', InputArgument::OPTIONAL, 'Package to inspect. Or a name including a wildcard (*) to filter lists of packages instead.'), + new InputArgument('package', InputArgument::OPTIONAL, 'Package to inspect. Or a name including a wildcard (*) to filter lists of packages instead.', null, $this->suggestPackageBasedOnMode()), new InputArgument('version', InputArgument::OPTIONAL, 'Version or version constraint to inspect'), new InputOption('all', null, InputOption::VALUE_NONE, 'List all packages'), new InputOption('locked', null, InputOption::VALUE_NONE, 'List all locked packages'), @@ -87,12 +89,12 @@ protected function configure() new InputOption('tree', 't', InputOption::VALUE_NONE, 'List the dependencies as a tree'), new InputOption('latest', 'l', InputOption::VALUE_NONE, 'Show the latest version'), new InputOption('outdated', 'o', InputOption::VALUE_NONE, 'Show the latest version but only for packages that are outdated'), - new InputOption('ignore', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore specified package(s). Use it with the --outdated option if you don\'t want to be informed about new versions of some packages.'), + new InputOption('ignore', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore specified package(s). Use it with the --outdated option if you don\'t want to be informed about new versions of some packages.', null, $this->suggestInstalledPackage()), new InputOption('minor-only', 'm', InputOption::VALUE_NONE, 'Show only packages that have minor SemVer-compatible updates. Use with the --outdated option.'), new InputOption('patch-only', null, InputOption::VALUE_NONE, 'Show only packages that have patch SemVer-compatible updates. Use with the --outdated option.'), new InputOption('direct', 'D', InputOption::VALUE_NONE, 'Shows only packages that are directly required by the root package'), new InputOption('strict', null, InputOption::VALUE_NONE, 'Return a non-zero exit code when there are outdated packages'), - new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text'), + new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Format of the output: text or json', 'text', ['json', 'text']), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables search in require-dev packages.'), new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages). Use with the --outdated option'), new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages). Use with the --outdated option'), @@ -108,6 +110,21 @@ protected function configure() ; } + protected function suggestPackageBasedOnMode(): \Closure + { + return function (CompletionInput $input) { + if ($input->getOption('available') || $input->getOption('all')) { + return $this->suggestAvailablePackageInclPlatform()($input); + } + + if ($input->getOption('platform')) { + return $this->suggestPlatformPackage()($input); + } + + return $this->suggestInstalledPackage()($input); + }; + } + protected function execute(InputInterface $input, OutputInterface $output) { $this->versionParser = new VersionParser; @@ -170,7 +187,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // init repos $platformOverrides = array(); if ($composer) { - $platformOverrides = $composer->getConfig()->get('platform') ?: array(); + $platformOverrides = $composer->getConfig()->get('platform'); } $platformRepo = new PlatformRepository(array(), $platformOverrides); $lockedRepo = null; diff --git a/src/Composer/Command/StatusCommand.php b/src/Composer/Command/StatusCommand.php index c749294d97a0..0f8e2b530a9e 100644 --- a/src/Composer/Command/StatusCommand.php +++ b/src/Composer/Command/StatusCommand.php @@ -13,7 +13,7 @@ namespace Composer\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; +use Composer\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Composer\Downloader\ChangeReportInterface; use Composer\Downloader\DvcsDownloaderInterface; diff --git a/src/Composer/Command/SuggestsCommand.php b/src/Composer/Command/SuggestsCommand.php index 23776abb5ba2..0e28b6089da7 100644 --- a/src/Composer/Command/SuggestsCommand.php +++ b/src/Composer/Command/SuggestsCommand.php @@ -16,13 +16,15 @@ use Composer\Repository\RootPackageRepository; use Composer\Repository\InstalledRepository; use Composer\Installer\SuggestedPackagesReporter; -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; class SuggestsCommand extends BaseCommand { + use CompletionTrait; + /** * @return void */ @@ -37,7 +39,7 @@ protected function configure(): void new InputOption('all', 'a', InputOption::VALUE_NONE, 'Show suggestions from all dependencies, including transitive ones'), new InputOption('list', null, InputOption::VALUE_NONE, 'Show only list of suggested package names'), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Exclude suggestions from require-dev packages'), - new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Packages that you want to list suggestions from.'), + new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Packages that you want to list suggestions from.', null, $this->suggestInstalledPackage()), )) ->setHelp( <<getPlatformOverrides()); $installedRepos[] = $locker->getLockedRepository(!$input->getOption('no-dev')); } else { - $installedRepos[] = new PlatformRepository(array(), $composer->getConfig()->get('platform') ?: array()); + $installedRepos[] = new PlatformRepository(array(), $composer->getConfig()->get('platform')); $installedRepos[] = $composer->getRepositoryManager()->getLocalRepository(); } diff --git a/src/Composer/Command/UpdateCommand.php b/src/Composer/Command/UpdateCommand.php index 2bd15c9f550c..7927bc2c8489 100644 --- a/src/Composer/Command/UpdateCommand.php +++ b/src/Composer/Command/UpdateCommand.php @@ -27,8 +27,8 @@ use Composer\Package\Link; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Input\InputArgument; +use Composer\Console\Input\InputOption; +use Composer\Console\Input\InputArgument; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; @@ -38,6 +38,8 @@ */ class UpdateCommand extends BaseCommand { + use CompletionTrait; + /** * @return void */ @@ -48,11 +50,11 @@ protected function configure() ->setAliases(array('u', 'upgrade')) ->setDescription('Updates your dependencies to the latest version according to composer.json, and updates the composer.lock file.') ->setDefinition(array( - new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Packages that should be updated, if not provided all packages are.'), + new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Packages that should be updated, if not provided all packages are.', null, $this->suggestInstalledPackage()), new InputOption('with', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Temporary version constraint to add, e.g. foo/bar:1.0.0 or foo/bar=1.0.0'), 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('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'), new InputOption('dev', null, InputOption::VALUE_NONE, 'DEPRECATED: Enables installation of require-dev packages (enabled by default, only present for BC).'), new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables installation of require-dev packages.'), diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index dac353a94cc1..26b3066753ed 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -467,7 +467,7 @@ public function getComposer(bool $required = true, ?bool $disablePlugins = null, if (null === $this->composer) { try { - $this->composer = Factory::create($this->io, null, $disablePlugins, $disableScripts); + $this->composer = Factory::create(Platform::isInputCompletionProcess() ? new NullIO() : $this->io, null, $disablePlugins, $disableScripts); } catch (\InvalidArgumentException $e) { if ($required) { $this->io->writeError($e->getMessage()); diff --git a/src/Composer/Console/Input/InputArgument.php b/src/Composer/Console/Input/InputArgument.php new file mode 100644 index 000000000000..69c6dfb02c23 --- /dev/null +++ b/src/Composer/Console/Input/InputArgument.php @@ -0,0 +1,69 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console\Input; + +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputArgument as BaseInputArgument; + +/** + * Backport suggested values definition from symfony/console 6.1+ + * + * @author Jérôme Tamarelle + * + * @internal + * + * TODO drop when PHP 8.1 / symfony 6.1+ can be required + */ +class InputArgument extends BaseInputArgument +{ + /** + * @var string[]|\Closure(CompletionInput,CompletionSuggestions):list + */ + private $suggestedValues; + + /** + * @param string $name The argument name + * @param int|null $mode The argument mode: self::REQUIRED or self::OPTIONAL + * @param string $description A description text + * @param string|bool|int|float|string[]|null $default The default value (for self::OPTIONAL mode only) + * @param string[]|\Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The values used for input completion + * + * @throws InvalidArgumentException When argument mode is not valid + */ + public function __construct(string $name, int $mode = null, string $description = '', $default = null, $suggestedValues = []) + { + parent::__construct($name, $mode, $description, $default); + + $this->suggestedValues = $suggestedValues; + } + + /** + * Adds suggestions to $suggestions for the current completion input. + * + * @see Command::complete() + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $values = $this->suggestedValues; + if ($values instanceof \Closure && !\is_array($values = $values($input, $suggestions))) { // @phpstan-ignore-line + throw new LogicException(sprintf('Closure for option "%s" must return an array. Got "%s".', $this->getName(), get_debug_type($values))); + } + if ([] !== $values) { + $suggestions->suggestValues($values); + } + } +} diff --git a/src/Composer/Console/Input/InputOption.php b/src/Composer/Console/Input/InputOption.php new file mode 100644 index 000000000000..2d370ba5135e --- /dev/null +++ b/src/Composer/Console/Input/InputOption.php @@ -0,0 +1,72 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Console\Input; + +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputOption as BaseInputOption; + +/** + * Backport suggested values definition from symfony/console 6.1+ + * + * @author Jérôme Tamarelle + * + * @internal + * + * TODO drop when PHP 8.1 / symfony 6.1+ can be required + */ +class InputOption extends BaseInputOption +{ + /** + * @var string[]|\Closure(CompletionInput,CompletionSuggestions):list + */ + private $suggestedValues; + + /** + * @param string|string[]|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param int|null $mode The option mode: One of the VALUE_* constants + * @param string|bool|int|float|string[]|null $default The default value (must be null for self::VALUE_NONE) + * @param string[]|\Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The values used for input completionnull for self::VALUE_NONE) + * + * @throws InvalidArgumentException If option mode is invalid or incompatible + */ + public function __construct(string $name, $shortcut = null, int $mode = null, string $description = '', $default = null, $suggestedValues = []) + { + parent::__construct($name, $shortcut, $mode, $description, $default); + + $this->suggestedValues = $suggestedValues; + + if ([] !== $suggestedValues && !$this->acceptValue()) { + throw new LogicException('Cannot set suggested values if the option does not accept a value.'); + } + } + + /** + * Adds suggestions to $suggestions for the current completion input. + * + * @see Command::complete() + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + $values = $this->suggestedValues; + if ($values instanceof \Closure && !\is_array($values = $values($input, $suggestions))) { // @phpstan-ignore-line + throw new LogicException(sprintf('Closure for argument "%s" must return an array. Got "%s".', $this->getName(), get_debug_type($values))); + } + if ([] !== $values) { + $suggestions->suggestValues($values); + } + } +} diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 93253b0b35a3..ee7e045868f9 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -309,12 +309,14 @@ public function createComposer(IOInterface $io, $localConfig = null, bool $disab throw new \InvalidArgumentException($message.PHP_EOL.$instructions); } - try { - $file->validateSchema(JsonFile::LAX_SCHEMA); - } catch (JsonValidationException $e) { - $errors = ' - ' . implode(PHP_EOL . ' - ', $e->getErrors()); - $message = $e->getMessage() . ':' . PHP_EOL . $errors; - throw new JsonValidationException($message); + if (!Platform::isInputCompletionProcess()) { + try { + $file->validateSchema(JsonFile::LAX_SCHEMA); + } catch (JsonValidationException $e) { + $errors = ' - ' . implode(PHP_EOL . ' - ', $e->getErrors()); + $message = $e->getMessage() . ':' . PHP_EOL . $errors; + throw new JsonValidationException($message); + } } $localConfig = $file->read(); @@ -702,6 +704,10 @@ private static function getUserDir(): string */ private static function validateJsonSchema(?IOInterface $io, $fileOrData, int $schema = JsonFile::LAX_SCHEMA, ?string $source = null): void { + if (Platform::isInputCompletionProcess()) { + return; + } + try { if ($fileOrData instanceof JsonFile) { $fileOrData->validateSchema($schema); diff --git a/src/Composer/Package/Version/VersionGuesser.php b/src/Composer/Package/Version/VersionGuesser.php index bc2610a63bed..d768f3247c49 100644 --- a/src/Composer/Package/Version/VersionGuesser.php +++ b/src/Composer/Package/Version/VersionGuesser.php @@ -19,6 +19,7 @@ use Composer\Semver\VersionParser as SemverVersionParser; use Composer\Util\Git as GitUtil; use Composer\Util\HttpDownloader; +use Composer\Util\Platform; use Composer\Util\ProcessExecutor; use Composer\Util\Svn as SvnUtil; use React\Promise\CancellablePromiseInterface; @@ -74,6 +75,12 @@ public function guessVersion(array $packageConfig, string $path): ?array 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 (Platform::isInputCompletionProcess()) { + return null; + } + $versionData = $this->guessGitVersion($packageConfig, $path); if (null !== $versionData && null !== $versionData['version']) { return $this->postprocess($versionData); diff --git a/src/Composer/Util/Platform.php b/src/Composer/Util/Platform.php index d27bda08c387..f7b1129b7a39 100644 --- a/src/Composer/Util/Platform.php +++ b/src/Composer/Util/Platform.php @@ -228,6 +228,14 @@ public static function isTty($fd = null): bool return $stat ? 0020000 === ($stat['mode'] & 0170000) : false; } + /** + * @return bool Whether the current command is for bash completion + */ + public static function isInputCompletionProcess(): bool + { + return '_complete' === ($_SERVER['argv'][1] ?? null); + } + /** * @return void */ diff --git a/tests/Composer/Test/CompletionFunctionalTest.php b/tests/Composer/Test/CompletionFunctionalTest.php new file mode 100644 index 000000000000..32baa33ce315 --- /dev/null +++ b/tests/Composer/Test/CompletionFunctionalTest.php @@ -0,0 +1,112 @@ + + * 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 +{ + /** + * @return iterable> + */ + public function getCommandSuggestions(): iterable + { + $randomVendor = 'a/'; + $installedPackages = ['composer/semver', 'psr/log']; + $preferInstall = ['dist', 'source', 'auto']; + + yield ['archive ', [$randomVendor]]; + yield ['archive symfony/http-', ['symfony/http-kernel', 'symfony/http-foundation']]; + yield ['archive --format ', ['tar', 'zip']]; + + yield ['create-project ', [$randomVendor]]; + yield ['create-project symfony/skeleton --prefer-install ', $preferInstall]; + + yield ['depends ', $installedPackages]; + yield ['why ', $installedPackages]; + + yield ['exec ', ['composer', 'jsonlint', 'phpstan', 'phpstan.phar', 'simple-phpunit', 'validate-json']]; + + yield ['browse ', $installedPackages]; + yield ['home -H ', $installedPackages]; + + yield ['init --require ', [$randomVendor]]; + yield ['init --require-dev foo/bar --require-dev ', [$randomVendor]]; + + yield ['install --prefer-install ', $preferInstall]; + yield ['install ', null]; + + yield ['outdated ', $installedPackages]; + + yield ['prohibits ', [$randomVendor]]; + 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 ', [$randomVendor]]; + 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(); + } +}