Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add completion to commands options and arguments #10320

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/Composer/Command/ArchiveCommand.php
Expand Up @@ -27,9 +27,9 @@
use Composer\Util\Loop;
use Composer\Util\Platform;
use Composer\Util\ProcessExecutor;
use Symfony\Component\Console\Input\InputArgument;
use Composer\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Composer\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

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

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

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

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

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

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

namespace Composer\Command;

use Composer\Composer;
use Composer\Package\PackageInterface;
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(): \Closure
{
return function (): 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();
}

$installedRepo = new InstalledRepository($installedRepos);

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

/**
* Suggest package names available on all configured repositories.
* @todo rework to list packages from cache
*/
private function suggestAvailablePackage(): \Closure
{
return function (CompletionInput $input) {
$composer = $this->requireComposer();
$repos = new CompositeRepository($composer->getRepositoryManager()->getRepositories());

$packages = $repos->search('^' . preg_quote($input->getCompletionValue()), RepositoryInterface::SEARCH_NAME);

return array_column(array_slice($packages, 0, 150), 'name');
};
}

/**
* Suggest package names available on all configured repositories or
* ext- packages from the ones available on the currently-running PHP
*/
private function suggestAvailablePackageOrExtension(): \Closure
{
return function (CompletionInput $input) {
if (!str_starts_with($input->getCompletionValue(), 'ext-')) {
Seldaek marked this conversation as resolved.
Show resolved Hide resolved
return $this->suggestAvailablePackage()($input);
}

$repos = new PlatformRepository([], $this->requireComposer()->getConfig()->get('platform') ?? []);

return array_map(function (PackageInterface $package) {
return $package->getName();
}, $repos->getPackages());
};
}
}
10 changes: 6 additions & 4 deletions src/Composer/Command/CreateProjectCommand.php
Expand Up @@ -33,9 +33,9 @@
use Composer\Repository\RepositorySet;
use Composer\Script\ScriptEvents;
use Composer\Util\Silencer;
use Symfony\Component\Console\Input\InputArgument;
use Composer\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Composer\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use Composer\Json\JsonFile;
Expand All @@ -55,6 +55,8 @@
*/
class CreateProjectCommand extends BaseCommand
{
use CompletionTrait;

/**
* @var SuggestedPackagesReporter
*/
Expand All @@ -69,13 +71,13 @@ protected function configure(): void
->setName('create-project')
->setDescription('Creates new project from a package into given directory.')
->setDefinition(array(
new InputArgument('package', InputArgument::OPTIONAL, 'Package name to be installed'),
new InputArgument('package', InputArgument::OPTIONAL, 'Package name to be installed', null, $this->suggestAvailablePackage()),
new InputArgument('directory', InputArgument::OPTIONAL, 'Directory where the files should be created'),
new InputArgument('version', InputArgument::OPTIONAL, 'Version, will default to latest'),
new InputOption('stability', 's', InputOption::VALUE_REQUIRED, 'Minimum-stability allowed (unless a version is specified).'),
new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
new InputOption('prefer-dist', null, InputOption::VALUE_NONE, 'Forces installation from package dist (default behavior).'),
new InputOption('prefer-install', null, InputOption::VALUE_REQUIRED, 'Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).'),
new InputOption('prefer-install', null, InputOption::VALUE_REQUIRED, 'Forces installation from package dist|source|auto (auto chooses source for dev versions, dist for the rest).', null, $this->suggestPreferInstall()),
new InputOption('repository', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Add custom repositories to look the package up, either by URL or using JSON arrays'),
new InputOption('repository-url', null, InputOption::VALUE_REQUIRED, 'DEPRECATED: Use --repository instead.'),
new InputOption('add-repository', null, InputOption::VALUE_NONE, 'Add the custom repository in the composer.json. If a lock file is present it will be deleted and an update will be run instead of install.'),
Expand Down
8 changes: 5 additions & 3 deletions src/Composer/Command/DependsCommand.php
Expand Up @@ -14,14 +14,16 @@

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

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

/**
* Configure command metadata.
*
Expand All @@ -34,7 +36,7 @@ protected function configure(): void
->setAliases(array('why'))
->setDescription('Shows which packages cause the given package to be installed.')
->setDefinition(array(
new InputArgument(self::ARGUMENT_PACKAGE, InputArgument::REQUIRED, 'Package to inspect'),
new InputArgument(self::ARGUMENT_PACKAGE, InputArgument::REQUIRED, 'Package to inspect', null, $this->suggestInstalledPackage()),
new InputOption(self::OPTION_RECURSIVE, 'r', InputOption::VALUE_NONE, 'Recursively resolves up to the root package'),
new InputOption(self::OPTION_TREE, 't', InputOption::VALUE_NONE, 'Prints the results as a nested tree'),
))
Expand Down
54 changes: 38 additions & 16 deletions src/Composer/Command/ExecCommand.php
Expand Up @@ -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 <me@daveyshafik.com>
Expand All @@ -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,
Expand All @@ -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)");
}

Expand All @@ -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(
<<<EOT
<info>- $bin</info>
Expand Down Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions src/Composer/Command/FundCommand.php
Expand Up @@ -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;

/**
Expand All @@ -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']),
))
;
}
Expand Down
15 changes: 14 additions & 1 deletion src/Composer/Command/GlobalCommand.php
Expand Up @@ -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;

Expand All @@ -26,6 +29,16 @@
*/
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())));
}
Copy link
Contributor Author

@GromNaN GromNaN Jan 3, 2022

Choose a reason for hiding this comment

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

Completion of subcommand is hard. I have this for beginning; but the global state of the application must be modified.

        if ($application->has($commandName = $input->getArgument('command-name'))) {
            $input = CompletionInput::fromString(preg_replace(['{\bg(?:l(?:o(?:b(?:a(?:l)?)?)?)?)?\b}', '{|$}'], ['', ''], $input->__toString(), 1), 3);
            $command = $this->getApplication()->find($commandName);
            $input->bind($command->getDefinition());
            $command->complete($input, $suggestions);
        }

Copy link
Member

Choose a reason for hiding this comment

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

Not sure what you mean by "the global state of the application must be modified"?

Copy link
Contributor Author

@GromNaN GromNaN Jan 6, 2022

Choose a reason for hiding this comment

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

All the things that are done in the run method.

// The COMPOSER env var should not apply to the global execution scope
if (Platform::getEnv('COMPOSER')) {
Platform::clearEnv('COMPOSER');
}
// change to global dir
$config = Factory::createConfig();
$home = $config->get('home');
if (!is_dir($home)) {
$fs = new Filesystem();
$fs->ensureDirectoryExists($home);
if (!is_dir($home)) {
throw new \RuntimeException('Could not create home directory');
}
}
try {
chdir($home);
} catch (\Exception $e) {
throw new \RuntimeException('Could not switch to home directory "'.$home.'"', 0, $e);
}
$this->getIO()->writeError('<info>Changed current directory to '.$home.'</info>');
// 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();

That said ... that's not so complex.

Copy link
Member

Choose a reason for hiding this comment

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

Ahh I see. Yes that should be easy to extract in a method to avoid duplication?

Copy link
Member

Choose a reason for hiding this comment

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

See dd2e2d7 - I tried doing it but it doesn't seem to work, I think I missed something, perhaps you can have a look?

}

/**
* @return void
*/
Expand Down