diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index fe20f2caa..0bf7ddfae 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -36,6 +36,7 @@
./src/Composer/Autoload/ClassLoader.php
+ ./src/Composer/PHPStan/
diff --git a/src/Composer/Command/RemoveCommand.php b/src/Composer/Command/RemoveCommand.php
index f4717439e..3b8ed88af 100644
--- a/src/Composer/Command/RemoveCommand.php
+++ b/src/Composer/Command/RemoveCommand.php
@@ -85,7 +85,7 @@ EOT
protected function execute(InputInterface $input, OutputInterface $output)
{
if ($input->getArgument('packages') === [] && !$input->getOption('unused')) {
- throw new InvalidArgumentException('Not enough arguments (missing: "packages")');
+ throw new InvalidArgumentException('Not enough arguments (missing: "packages").');
}
$packages = $input->getArgument('packages');
diff --git a/tests/Composer/Test/Command/AuditCommandTest.php b/tests/Composer/Test/Command/AuditCommandTest.php
index 1b820b479..53041f33d 100644
--- a/tests/Composer/Test/Command/AuditCommandTest.php
+++ b/tests/Composer/Test/Command/AuditCommandTest.php
@@ -13,6 +13,7 @@
namespace Composer\Test\Command;
use Composer\Test\TestCase;
+use UnexpectedValueException;
class AuditCommandTest extends TestCase
{
@@ -24,5 +25,20 @@ class AuditCommandTest extends TestCase
$appTester->run(['command' => 'audit']);
$appTester->assertCommandIsSuccessful();
+ self::assertEquals('No packages - skipping audit.', trim($appTester->getDisplay(true)));
+ }
+
+ public function testErrorAuditingLockFileWhenItIsMissing(): void
+ {
+ $this->initTempComposer();
+ $this->createInstalledJson([self::getPackage()]);
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage(
+ "Valid composer.json and composer.lock files are required to run this command with --locked"
+ );
+
+ $appTester = $this->getApplicationTester();
+ $appTester->run(['command' => 'audit', '--locked' => true]);
}
}
diff --git a/tests/Composer/Test/Command/RemoveCommandTest.php b/tests/Composer/Test/Command/RemoveCommandTest.php
new file mode 100644
index 000000000..f34b0c761
--- /dev/null
+++ b/tests/Composer/Test/Command/RemoveCommandTest.php
@@ -0,0 +1,470 @@
+
+ * Jordi Boggiano
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Test\Command;
+
+use Composer\Json\JsonFile;
+use Composer\Package\Link;
+use Composer\Semver\Constraint\MatchAllConstraint;
+use Composer\Test\TestCase;
+use InvalidArgumentException;
+use Symfony\Component\Console\Command\Command;
+use UnexpectedValueException;
+
+class RemoveCommandTest extends TestCase
+{
+ public function testExceptionRunningWithNoRemovePackages(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Not enough arguments (missing: "packages").');
+
+ $appTester = $this->getApplicationTester();
+ $this->assertEquals(Command::FAILURE, $appTester->run(['command' => 'remove']));
+ }
+
+ public function testExceptionWhenRunningUnusedWithoutLockFile(): void
+ {
+ $this->initTempComposer();
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('A valid composer.lock file is required to run this command with --unused');
+
+ $appTester = $this->getApplicationTester();
+ $this->assertEquals(Command::FAILURE, $appTester->run(['command' => 'remove', '--unused' => true]));
+ }
+
+ public function testWarningWhenRemovingNonExistentPackage(): void
+ {
+ $this->initTempComposer();
+ $this->createInstalledJson();
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['vendor1/package1']]));
+ self::assertStringStartsWith('vendor1/package1 is not required in your composer.json and has not been removed', trim($appTester->getDisplay(true)));
+ }
+
+ public function testWarningWhenRemovingPackageFromWrongType(): void
+ {
+ $this->initTempComposer([
+ 'require' => [
+ 'root/req' => '1.*',
+ ],
+ ]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--dev' => true, '--no-update' => true, '--no-interaction' => true]));
+ self::assertSame('root/req could not be found in require-dev but it is present in require
+./composer.json has been updated', trim($appTester->getDisplay(true)));
+ $this->assertEquals(['require' => ['root/req' => '1.*']], (new JsonFile('./composer.json'))->read());
+ }
+
+ public function testWarningWhenRemovingPackageWithDeprecatedDependenciesFlag(): void
+ {
+ $this->initTempComposer([
+ 'require' => [
+ 'root/req' => '1.*',
+ ],
+ ]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--update-with-dependencies' => true, '--no-update' => true, '--no-interaction' => true]));
+ self::assertSame('You are using the deprecated option "update-with-dependencies". This is now default behaviour. The --no-update-with-dependencies option can be used to remove a package without its dependencies.
+./composer.json has been updated', trim($appTester->getDisplay(true)));
+ $this->assertEmpty((new JsonFile('./composer.json'))->read());
+ }
+
+ public function testMessageOutputWhenNoUnusedPackagesToRemove(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0', 'require' => ['nested/req' => '^1']],
+ ['name' => 'nested/req', 'version' => '1.1.0'],
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ ],
+ ]);
+
+ $requiredPackage = self::getPackage('root/req');
+ $requiredPackage->setRequires([
+ 'nested/req' => new Link(
+ 'root/req',
+ 'nested/req',
+ new MatchAllConstraint(),
+ Link::TYPE_REQUIRE,
+ '^1'
+ )
+ ]);
+ $nestedPackage = self::getPackage('nested/req', '1.1.0');
+
+ $this->createInstalledJson([$requiredPackage, $nestedPackage]);
+ $this->createComposerLock([$requiredPackage, $nestedPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', '--unused' => true, '--no-audit' => true, '--no-interaction' => true]));
+ self::assertSame('No unused packages to remove', trim($appTester->getDisplay(true)));
+ }
+
+ public function testRemoveUnusedPackage(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0'],
+ ['name' => 'not/req', 'version' => '1.0.0'],
+ ],
+ ]
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ ],
+ ]);
+
+ $requiredPackage = self::getPackage('root/req');
+ $extraneousPackage = self::getPackage('not/req');
+
+ $this->createInstalledJson([$requiredPackage]);
+ $this->createComposerLock([$requiredPackage, $extraneousPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', '--unused' => true, '--no-audit' => true, '--no-interaction' => true]));
+ self::assertStringStartsWith('not/req is not required in your composer.json and has not been removed', $appTester->getDisplay(true));
+ self::assertStringContainsString('Running composer update not/req', $appTester->getDisplay(true));
+ self::assertStringContainsString('- Removing not/req (1.0.0)', $appTester->getDisplay(true));
+ }
+
+ public function testRemovePackageByName(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'metapackage'],
+ ['name' => 'root/another', 'version' => '1.0.0', 'type' => 'metapackage']
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootAnotherPackage = self::getPackage('root/another');
+ // Set as a metapackage so that we can do the whole post-remove update & install process without Composer trying to download them (DownloadManager::getDownloaderForPackage).
+ $rootReqPackage->setType('metapackage');
+ $rootAnotherPackage->setType('metapackage');
+
+ $this->createInstalledJson([$rootReqPackage, $rootAnotherPackage]);
+ $this->createComposerLock([$rootReqPackage, $rootAnotherPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--no-audit' => true, '--no-interaction' => true]));
+ self::assertStringStartsWith('./composer.json has been updated', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Running composer update root/req', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Lock file operations: 0 installs, 0 updates, 1 removal', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('- Removing root/req (1.0.0)', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Package operations: 0 installs, 0 updates, 1 removal', trim($appTester->getDisplay(true)));
+ self::assertEquals(['root/another' => '1.*'], (new JsonFile('./composer.json'))->read()['require']);
+ self::assertEquals([['name' => 'root/another', 'version' => '1.0.0', 'type' => 'metapackage']], (new JsonFile('./composer.lock'))->read()['packages']);
+ }
+
+ public function testRemovePackageByNameWithDryRun(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'metapackage'],
+ ['name' => 'root/another', 'version' => '1.0.0', 'type' => 'metapackage']
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootAnotherPackage = self::getPackage('root/another');
+ // Set as a metapackage so that we can do the whole post-remove update & install process without Composer trying to download them (DownloadManager::getDownloaderForPackage).
+ $rootReqPackage->setType('metapackage');
+ $rootAnotherPackage->setType('metapackage');
+
+ $this->createInstalledJson([$rootReqPackage, $rootAnotherPackage]);
+ $this->createComposerLock([$rootReqPackage, $rootAnotherPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--dry-run' => true, '--no-audit' => true, '--no-interaction' => true]));
+ self::assertStringContainsString('./composer.json has been updated', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Running composer update root/req', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Lock file operations: 0 installs, 0 updates, 1 removal', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('- Removing root/req (1.0.0)', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Package operations: 0 installs, 0 updates, 1 removal', trim($appTester->getDisplay(true)));
+ self::assertEquals(['root/req' => '1.*', 'root/another' => '1.*'], (new JsonFile('./composer.json'))->read()['require']);
+ self::assertEquals([['name' => 'root/another', 'version' => '1.0.0', 'type' => 'metapackage'], ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'metapackage']], (new JsonFile('./composer.lock'))->read()['packages']);
+ }
+
+ public function testRemoveAllowedPluginPackageWithNoOtherAllowedPlugins(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'metapackage'],
+ ['name' => 'root/another', 'version' => '1.0.0', 'type' => 'metapackage']
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ ],
+ 'config' => [
+ 'allow-plugins' => [
+ 'root/req' => true,
+ ],
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootAnotherPackage = self::getPackage('root/another');
+ // Set as a metapackage so that we can do the whole post-remove update & install process without Composer trying to download them (DownloadManager::getDownloaderForPackage).
+ $rootReqPackage->setType('metapackage');
+ $rootAnotherPackage->setType('metapackage');
+
+ $this->createInstalledJson([$rootReqPackage, $rootAnotherPackage]);
+ $this->createComposerLock([$rootReqPackage, $rootAnotherPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--no-audit' => true, '--no-interaction' => true]));
+ self::assertEquals(['root/another' => '1.*'], (new JsonFile('./composer.json'))->read()['require']);
+ self::assertEmpty((new JsonFile('./composer.json'))->read()['config']);
+ }
+
+ public function testRemoveAllowedPluginPackageWithOtherAllowedPlugins(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'metapackage'],
+ ['name' => 'root/another', 'version' => '1.0.0', 'type' => 'metapackage']
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ ],
+ 'config' => [
+ 'allow-plugins' => [
+ 'root/another' => true,
+ 'root/req' => true,
+ ],
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootAnotherPackage = self::getPackage('root/another');
+ // Set as a metapackage so that we can do the whole post-remove update & install process without Composer trying to download them (DownloadManager::getDownloaderForPackage).
+ $rootReqPackage->setType('metapackage');
+ $rootAnotherPackage->setType('metapackage');
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--no-audit' => true, '--no-interaction' => true]));
+ self::assertEquals(['root/another' => '1.*'], (new JsonFile('./composer.json'))->read()['require']);
+ self::assertEquals(['allow-plugins' => ['root/another' => true]], (new JsonFile('./composer.json'))->read()['config']);
+ }
+
+ public function testRemovePackagesByVendor(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0'],
+ ['name' => 'root/another', 'version' => '1.0.0'],
+ ['name' => 'another/req', 'version' => '1.0.0'],
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ 'another/req' => '1.*',
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootAnotherPackage = self::getPackage('root/another');
+ $anotherReqPackage = self::getPackage('another/req');
+
+ $this->createInstalledJson([$rootReqPackage, $rootAnotherPackage, $anotherReqPackage]);
+ $this->createComposerLock([$rootReqPackage, $rootAnotherPackage, $anotherReqPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/*'], '--no-install' => true, '--no-audit' => true, '--no-interaction' => true]));
+ self::assertStringStartsWith('./composer.json has been updated', trim($appTester->getDisplay(true)));
+ self::assertStringContainsString('Running composer update root/*', $appTester->getDisplay(true));
+ self::assertStringContainsString('- Removing root/another (1.0.0)', $appTester->getDisplay(true));
+ self::assertStringContainsString('- Removing root/req (1.0.0)', $appTester->getDisplay(true));
+ self::assertStringContainsString('Writing lock file', $appTester->getDisplay(true));
+ self::assertEquals(['another/req' => '1.*'], (new JsonFile('./composer.json'))->read()['require']);
+ self::assertEquals([['name' => 'another/req', 'version' => '1.0.0', 'type' => 'library']], (new JsonFile('./composer.lock'))->read()['packages']);
+ }
+
+ public function testRemovePackagesByVendorWithDryRun(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0'],
+ ['name' => 'root/another', 'version' => '1.0.0'],
+ ['name' => 'another/req', 'version' => '1.0.0'],
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ 'another/req' => '1.*',
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootAnotherPackage = self::getPackage('root/another');
+ $anotherReqPackage = self::getPackage('another/req');
+
+ $this->createInstalledJson([$rootReqPackage, $rootAnotherPackage, $anotherReqPackage]);
+ $this->createComposerLock([$rootReqPackage, $rootAnotherPackage, $anotherReqPackage]);
+
+ $appTester = $this->getApplicationTester();
+ $appTester->run(['command' => 'remove', 'packages' => ['root/*'], '--dry-run' => true, '--no-install' => true, '--no-audit' => true, '--no-interaction' => true]);
+ self::assertEquals(Command::SUCCESS, $appTester->getStatusCode());
+ self::assertSame("./composer.json has been updated
+Running composer update root/*
+Loading composer repositories with package information
+Updating dependencies
+Lock file operations: 0 installs, 0 updates, 2 removals
+ - Removing root/another (1.0.0)
+ - Removing root/req (1.0.0)", trim($appTester->getDisplay(true)));
+ self::assertStringNotContainsString('Writing lock file', $appTester->getDisplay(true));
+ self::assertEquals(['root/req' => '1.*', 'root/another' => '1.*', 'another/req' => '1.*'], (new JsonFile('./composer.json'))->read()['require']);
+ self::assertEquals([['name' => 'another/req', 'version' => '1.0.0', 'type' => 'library'], ['name' => 'root/another', 'version' => '1.0.0', 'type' => 'library'], ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'library']], (new JsonFile('./composer.lock'))->read()['packages']);
+ }
+
+ public function testWarningWhenRemovingPackagesByVendorFromWrongType(): void
+ {
+ $this->initTempComposer([
+ 'require' => [
+ 'root/req' => '1.*',
+ 'root/another' => '1.*',
+ 'another/req' => '1.*',
+ ],
+ ]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/*'], '--dev' => true, '--no-interaction' => true, '--no-update' => true]));
+ self::assertSame("root/req could not be found in require-dev but it is present in require
+root/another could not be found in require-dev but it is present in require
+./composer.json has been updated", trim($appTester->getDisplay(true)));
+ self::assertEquals(['require' => ['root/req' => '1.*', 'root/another' => '1.*', 'another/req' => '1.*']], (new JsonFile('./composer.json'))->read());
+ }
+
+ public function testPackageStillPresentErrorWhenNoInstallFlagUsed(): void
+ {
+ $this->initTempComposer([
+ 'require' => [
+ 'root/req' => '1.*',
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+
+ $this->createInstalledJson([$rootReqPackage]);
+ $this->createComposerLock([$rootReqPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::INVALID, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], '--no-install' => true, '--no-audit' => true, '--no-interaction' => true]));
+ self::assertStringContainsString('./composer.json has been updated', $appTester->getDisplay(true));
+ self::assertStringContainsString('Lock file operations: 0 installs, 0 updates, 1 removal', $appTester->getDisplay(true));
+ self::assertStringContainsString('- Removing root/req (1.0.0)', $appTester->getDisplay(true));
+ self::assertStringContainsString('Writing lock file', $appTester->getDisplay(true));
+ self::assertStringContainsString('Removal failed, root/req is still present, it may be required by another package. See `composer why root/req`', $appTester->getDisplay(true));
+ self::assertEmpty((new JsonFile('./composer.json'))->read());
+ self::assertEmpty((new JsonFile('./composer.lock'))->read()['packages']);
+ self::assertEquals([['name' => 'root/req', 'version' => '1.0.0', 'version_normalized' => '1.0.0.0', 'type' => 'library', 'install-path' => '../root/req']], (new JsonFile('./vendor/composer/installed.json'))->read()['packages']);
+ }
+
+ /**
+ * @dataProvider provideInheritedDependenciesUpdateFlag
+ */
+ public function testUpdateInheritedDependenciesFlagIsPassedToPostRemoveInstaller(string $installFlagName, string $expectedComposerUpdateCommand): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'root/req', 'version' => '1.0.0', 'type' => 'metapackage'],
+ ],
+ ],
+ ],
+ 'require' => [
+ 'root/req' => '1.*',
+ ],
+ ]);
+ $rootReqPackage = self::getPackage('root/req');
+ $rootReqPackage->setType('metapackage');
+
+ $this->createInstalledJson([$rootReqPackage]);
+ $this->createComposerLock([$rootReqPackage]);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'remove', 'packages' => ['root/req'], $installFlagName => true, '--no-audit' => true, '--no-interaction' => true]));
+ self::assertStringContainsString('./composer.json has been updated', $appTester->getDisplay(true));
+ self::assertStringContainsString($expectedComposerUpdateCommand, $appTester->getDisplay(true));
+ self::assertStringContainsString('Package operations: 0 installs, 0 updates, 1 removal', $appTester->getDisplay(true));
+ self::assertStringContainsString('- Removing root/req (1.0.0)', $appTester->getDisplay(true));
+ self::assertStringContainsString('Writing lock file', $appTester->getDisplay(true));
+ self::assertStringContainsString('Lock file operations: 0 installs, 0 updates, 1 removal', $appTester->getDisplay(true));
+ self::assertEmpty((new JsonFile('./composer.lock'))->read()['packages']);
+ }
+
+ public static function provideInheritedDependenciesUpdateFlag(): \Generator
+ {
+ yield 'update with all dependencies' => [
+ '--update-with-all-dependencies',
+ 'Running composer update root/req --with-all-dependencies',
+ ];
+
+ yield 'with all dependencies' => [
+ '--with-all-dependencies',
+ 'Running composer update root/req --with-all-dependencies',
+ ];
+
+ yield 'no update with dependencies' => [
+ '--no-update-with-dependencies',
+ 'Running composer update root/req --with-dependencies',
+ ];
+ }
+}
diff --git a/tests/Composer/Test/Command/SuggestsCommandTest.php b/tests/Composer/Test/Command/SuggestsCommandTest.php
new file mode 100644
index 000000000..217f41157
--- /dev/null
+++ b/tests/Composer/Test/Command/SuggestsCommandTest.php
@@ -0,0 +1,489 @@
+
+ * Jordi Boggiano
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Test\Command;
+
+use Composer\Package\CompletePackage;
+use Composer\Package\Link;
+use Composer\Test\TestCase;
+use Symfony\Component\Console\Command\Command;
+
+class SuggestsCommandTest extends TestCase
+{
+ public function testInstalledPackagesWithNoSuggestions(): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'vendor1/package1', 'version' => '1.0.0'],
+ ['name' => 'vendor2/package2', 'version' => '1.0.0'],
+ ],
+ ],
+ ],
+ 'require' => [
+ 'vendor1/package1' => '1.*',
+ 'vendor2/package2' => '1.*',
+ ],
+ ]);
+
+ $packages = [
+ self::getPackage('vendor1/package1'),
+ self::getPackage('vendor2/package2'),
+ ];
+
+ $this->createInstalledJson($packages);
+ $this->createComposerLock($packages);
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(['command' => 'suggest']));
+ self::assertEmpty($appTester->getDisplay(true));
+ }
+
+ /**
+ * @dataProvider provideSuggest
+ * @param array> $command
+ */
+ public function testSuggest(bool $hasLockFile, array $command, string $expected): void
+ {
+ $this->initTempComposer([
+ 'repositories' => [
+ 'packages' => [
+ 'type' => 'package',
+ 'package' => [
+ ['name' => 'vendor1/package1', 'version' => '1.0.0', 'suggests' => ['vendor3/suggested' => 'helpful for vendor1/package1'], 'require' => ['vendor6/package6' => '^1.0'], 'require-dev' => ['vendor3/suggested' => '^1.0', 'vendor4/dev-suggested' => '^1.0']],
+ ['name' => 'vendor2/package2', 'version' => '1.0.0', 'suggests' => ['vendor4/dev-suggested' => 'helpful for vendor2/package2'], 'require' => ['vendor5/dev-package' => '^1.0']],
+ ['name' => 'vendor5/dev-package', 'version' => '1.0.0', 'suggests' => ['vendor8/dev-transitive' => 'helpful for vendor5/dev-package'], 'require-dev' => ['vendor8/dev-transitive' => '^1.0']],
+ ['name' => 'vendor6/package6', 'version' => '1.0.0', 'suggests' => ['vendor7/transitive' => 'helpful for vendor6/package6']],
+ ],
+ ],
+ ],
+ 'require' => ['vendor1/package1' => '^1'],
+ 'require-dev' => ['vendor2/package2' => '^1'],
+ ]);
+
+ $packages = [
+ self::getPackageWithSuggestAndRequires(
+ 'vendor1/package1',
+ '1.0.0',
+ [
+ 'vendor3/suggested' => 'helpful for vendor1/package1',
+ ],
+ [
+ 'vendor6/package6' => new Link('vendor1/package1', 'vendor6/package6', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE, '^1.0'),
+ ],
+ [
+ 'vendor4/dev-suggested' => new Link('vendor1/package1', 'vendor4/dev-suggested', self::getVersionConstraint('>=', '1.0'), Link::TYPE_DEV_REQUIRE, '^1.0'),
+ 'vendor3/suggested' => new Link('vendor1/package1', 'vendor3/suggested', self::getVersionConstraint('>=', '1.0'), Link::TYPE_DEV_REQUIRE, '^1.0'),
+ ]
+ ),
+ self::getPackageWithSuggestAndRequires(
+ 'vendor6/package6',
+ '1.0.0',
+ [
+ 'vendor7/transitive' => 'helpful for vendor6/package6',
+ ]
+ ),
+ ];
+ $devPackages = [
+ self::getPackageWithSuggestAndRequires(
+ 'vendor2/package2',
+ '1.0.0',
+ [
+ 'vendor4/dev-suggested' => 'helpful for vendor2/package2',
+ ],
+ [
+ 'vendor5/dev-package' => new Link('vendor2/package2', 'vendor5/dev-package', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE, '^1.0'),
+ ]
+ ),
+ self::getPackageWithSuggestAndRequires(
+ 'vendor5/dev-package',
+ '1.0.0',
+ [
+ 'vendor8/dev-transitive' => 'helpful for vendor5/dev-package',
+ ],
+ [],
+ [
+ 'vendor8/dev-transitive' => new Link('vendor5/dev-package', 'vendor8/dev-transitive', self::getVersionConstraint('>=', '1.0'), Link::TYPE_DEV_REQUIRE, '^1.0'),
+ ]
+ )
+ ];
+
+ $this->createInstalledJson($packages, $devPackages);
+ if ($hasLockFile) {
+ $this->createComposerLock($packages, $devPackages);
+ }
+
+ $appTester = $this->getApplicationTester();
+ self::assertEquals(Command::SUCCESS, $appTester->run(array_merge(['command' => 'suggest'], $command)));
+ self::assertSame(trim($expected), trim($appTester->getDisplay(true)));
+ }
+
+ public function provideSuggest(): \Generator
+ {
+ yield 'with lockfile, show suggested' => [
+ true,
+ [],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested' => [
+ false,
+ [],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested (excluding dev)' => [
+ true,
+ ['--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+1 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested (excluding dev)' => [
+ false,
+ ['--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show all suggested' => [
+ true,
+ ['--all' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+vendor5/dev-package suggests:
+ - vendor8/dev-transitive: helpful for vendor5/dev-package
+
+vendor6/package6 suggests:
+ - vendor7/transitive: helpful for vendor6/package6'
+ ];
+
+ yield 'without lockfile, show all suggested' => [
+ false,
+ ['--all' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+vendor5/dev-package suggests:
+ - vendor8/dev-transitive: helpful for vendor5/dev-package
+
+vendor6/package6 suggests:
+ - vendor7/transitive: helpful for vendor6/package6'
+ ];
+
+ yield 'with lockfile, show all suggested (excluding dev)' => [
+ true,
+ ['--all' => true, '--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor6/package6 suggests:
+ - vendor7/transitive: helpful for vendor6/package6'
+ ];
+
+ yield 'without lockfile, show all suggested (excluding dev)' => [
+ false,
+ ['--all' => true, '--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+vendor5/dev-package suggests:
+ - vendor8/dev-transitive: helpful for vendor5/dev-package
+
+vendor6/package6 suggests:
+ - vendor7/transitive: helpful for vendor6/package6'
+ ];
+
+ yield 'with lockfile, show suggested grouped by package' => [
+ true,
+ ['--by-package' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested grouped by package' => [
+ false,
+ ['--by-package' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested grouped by package (excluding dev)' => [
+ true,
+ ['--by-package' => true, '--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+1 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested grouped by package (excluding dev)' => [
+ false,
+ ['--by-package' => true, '--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested grouped by suggestion' => [
+ true,
+ ['--by-suggestion' => true],
+ 'vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+vendor4/dev-suggested is suggested by:
+ - vendor2/package2: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested grouped by suggestion' => [
+ false,
+ ['--by-suggestion' => true],
+ 'vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+vendor4/dev-suggested is suggested by:
+ - vendor2/package2: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested grouped by suggestion (excluding dev)' => [
+ true,
+ ['--by-suggestion' => true, '--no-dev' => true],
+ 'vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+1 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested grouped by suggestion (excluding dev)' => [
+ false,
+ ['--by-suggestion' => true, '--no-dev' => true],
+ 'vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+vendor4/dev-suggested is suggested by:
+ - vendor2/package2: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested grouped by package and suggestion' => [
+ true,
+ ['--by-package' => true, '--by-suggestion' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+------------------------------------------------------------------------------
+vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+vendor4/dev-suggested is suggested by:
+ - vendor2/package2: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested grouped by package and suggestion' => [
+ false,
+ ['--by-package' => true, '--by-suggestion' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+------------------------------------------------------------------------------
+vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+vendor4/dev-suggested is suggested by:
+ - vendor2/package2: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested grouped by package and suggestion (excluding dev)' => [
+ true,
+ ['--by-package' => true, '--by-suggestion' => true, '--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+------------------------------------------------------------------------------
+vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+1 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'without lockfile, show suggested grouped by package and suggestion (excluding dev)' => [
+ false,
+ ['--by-package' => true, '--by-suggestion' => true, '--no-dev' => true],
+ 'vendor1/package1 suggests:
+ - vendor3/suggested: helpful for vendor1/package1
+
+vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2
+
+------------------------------------------------------------------------------
+vendor3/suggested is suggested by:
+ - vendor1/package1: helpful for vendor1/package1
+
+vendor4/dev-suggested is suggested by:
+ - vendor2/package2: helpful for vendor2/package2
+
+2 additional suggestions by transitive dependencies can be shown with --all'
+ ];
+
+ yield 'with lockfile, show suggested for package' => [
+ true,
+ ['packages' => ['vendor2/package2']],
+ 'vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2'
+ ];
+
+ yield 'without lockfile, show suggested for package' => [
+ false,
+ ['packages' => ['vendor2/package2']],
+ 'vendor2/package2 suggests:
+ - vendor4/dev-suggested: helpful for vendor2/package2'
+ ];
+
+ yield 'with lockfile, list suggested' => [
+ true,
+ ['--list' => true],
+ 'vendor3/suggested
+vendor4/dev-suggested',
+ ];
+
+ yield 'without lockfile, list suggested' => [
+ false,
+ ['--list' => true],
+ 'vendor3/suggested
+vendor4/dev-suggested',
+ ];
+
+ yield 'with lockfile, list suggested with no transitive or no-dev dependencies' => [
+ true,
+ ['--list' => true, '--no-dev' => true],
+ 'vendor3/suggested',
+ ];
+
+ yield 'without lockfile, list suggested with no transitive or no-dev dependencies' => [
+ false,
+ ['--list' => true, '--no-dev' => true],
+ 'vendor3/suggested
+vendor4/dev-suggested',
+ ];
+
+ yield 'with lockfile, list suggested with all dependencies including transitive and dev dependencies' => [
+ true,
+ ['--list' => true, '--all' => true],
+ 'vendor3/suggested
+vendor4/dev-suggested
+vendor7/transitive
+vendor8/dev-transitive',
+ ];
+
+ yield 'without lockfile, list suggested with all dependencies including transitive and dev dependencies' => [
+ false,
+ ['--list' => true, '--all' => true],
+ 'vendor3/suggested
+vendor4/dev-suggested
+vendor7/transitive
+vendor8/dev-transitive',
+ ];
+
+ yield 'with lockfile, list all suggested (excluding dev)' => [
+ true,
+ ['--list' => true, '--all' => true, '--no-dev' => true],
+ 'vendor3/suggested
+vendor7/transitive',
+ ];
+
+ yield 'without lockfile, list all suggested (excluding dev)' => [
+ false,
+ ['--list' => true, '--all' => true, '--no-dev' => true],
+ 'vendor3/suggested
+vendor4/dev-suggested
+vendor7/transitive
+vendor8/dev-transitive',
+ ];
+ }
+
+ /**
+ * @param array $suggests
+ * @param array $requires
+ * @param array $requireDevs
+ */
+ private function getPackageWithSuggestAndRequires(string $name = 'dummy/pkg', string $version = '1.0.0', array $suggests = [], array $requires = [], array $requireDevs = []): CompletePackage
+ {
+ $normVersion = self::getVersionParser()->normalize($version);
+
+ $pkg = new CompletePackage($name, $normVersion, $version);
+ $pkg->setSuggests($suggests);
+ $pkg->setRequires($requires);
+ $pkg->setDevRequires($requireDevs);
+
+ return $pkg;
+ }
+}
diff --git a/tests/complete.phpunit.xml b/tests/complete.phpunit.xml
index b30398e96..e5e4fe0f1 100644
--- a/tests/complete.phpunit.xml
+++ b/tests/complete.phpunit.xml
@@ -35,6 +35,7 @@
../src/Composer/Autoload/ClassLoader.php
+ ../src/Composer/PHPStan/