diff --git a/doc/07-runtime.md b/doc/07-runtime.md index 933897732..17aa6bce6 100644 --- a/doc/07-runtime.md +++ b/doc/07-runtime.md @@ -27,6 +27,14 @@ The main use cases for this class are the following: \Composer\InstalledVersions::isInstalled('psr/log-implementation'); // returns bool ``` +As of Composer 2.1, you may also check if something was installed via require-dev or not by +passing false as second argument: + +```php +\Composer\InstalledVersions::isInstalled('vendor/package'); // returns true assuming this package is installed +\Composer\InstalledVersions::isInstalled('vendor/package', false); // returns true if vendor/package is in require, false if in require-dev +``` + Note that this can not be used to check whether platform packages are installed. ### Knowing whether package X is installed in version Y @@ -89,6 +97,35 @@ possible for safety. A few other methods are available for more complex usages, please refer to the source/docblocks of [the class itself](https://github.com/composer/composer/blob/master/src/Composer/InstalledVersions.php). +### Knowing the path in which a package is installed + +The `getInstallPath` method to retrieve a package's absolute install path. + +```php +// returns an absolute path to the package installation location if vendor/package is installed, +// or null if it is provided/replaced, or the package is a metapackage +// or throws OutOfBoundsException if the package is not installed at all +\Composer\InstalledVersions::getInstallPath('vendor/package'); +``` + +> Available as of Composer 2.1 (i.e. `composer-runtime-api ^2.1`) + +### Knowing which packages of a given type are installed + +The `getInstalledPackagesByType` method accepts a package type (e.g. foo-plugin) and lists +the packages of that type which are installed. You can then use the methods above to retrieve +more information about each package if needed. + +This method should alleviate the need for custom installers placing plugins in a specific path +instead of leaving them in the vendor dir. You can then find plugins to initialize at runtime +via InstalledVersions, including their paths via getInstallPath if needed. + +```php +\Composer\InstalledVersions::getInstalledPackagesByType('foo-plugin'); +``` + +> Available as of Composer 2.1 (i.e. `composer-runtime-api ^2.1`) + ## Platform check composer-runtime-api 2.0 introduced a new `vendor/composer/platform_check.php` file, which diff --git a/src/Composer/Composer.php b/src/Composer/Composer.php index 725c47a8a..80a3ea0c5 100644 --- a/src/Composer/Composer.php +++ b/src/Composer/Composer.php @@ -65,7 +65,7 @@ class Composer * * @var string */ - const RUNTIME_API_VERSION = '2.0.0'; + const RUNTIME_API_VERSION = '2.1.0'; public static function getVersion() { diff --git a/src/Composer/InstalledVersions.php b/src/Composer/InstalledVersions.php index e6df5a5a2..2c3bcd1bf 100644 --- a/src/Composer/InstalledVersions.php +++ b/src/Composer/InstalledVersions.php @@ -48,6 +48,28 @@ class InstalledVersions return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); } + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + /** * Checks whether the given package is installed * @@ -61,7 +83,7 @@ class InstalledVersions { foreach (self::getInstalled() as $installed) { if (isset($installed['versions'][$packageName])) { - return $includeDevRequirements || empty($installed['versions'][$packageName]['dev-requirement']); + return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']); } } @@ -187,9 +209,26 @@ class InstalledVersions throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); } + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + /** * @return array - * @psalm-return array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool} + * @psalm-return array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string} */ public static function getRootPackage() { @@ -203,12 +242,16 @@ class InstalledVersions * * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. * @return array[] - * @psalm-return array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool}, versions: array} + * @psalm-return array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string}, versions: array} */ public static function getRawData() { @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + if (null === self::$installed) { + self::$installed = include __DIR__ . '/installed.php'; + } + return self::$installed; } @@ -216,7 +259,7 @@ class InstalledVersions * Returns the raw data of all installed.php which are currently loaded for custom implementations * * @return array[] - * @psalm-return list}> + * @psalm-return list}> */ public static function getAllRawData() { @@ -239,7 +282,7 @@ class InstalledVersions * @param array[] $data A vendor/composer/installed.php data set * @return void * - * @psalm-param array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool}, versions: array} $data + * @psalm-param array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string}, versions: array} $data */ public static function reload($data) { @@ -249,7 +292,7 @@ class InstalledVersions /** * @return array[] - * @psalm-return list}> + * @psalm-return list}> */ private static function getInstalled() { @@ -265,10 +308,16 @@ class InstalledVersions $installed[] = self::$installedByVendor[$vendorDir]; } elseif (is_file($vendorDir.'/composer/installed.php')) { $installed[] = self::$installedByVendor[$vendorDir] = require $vendorDir.'/composer/installed.php'; + if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { + self::$installed = $installed[count($installed) - 1]; + } } } } + if (null === self::$installed) { + self::$installed = require __DIR__ . '/installed.php'; + } $installed[] = self::$installed; return $installed; diff --git a/src/Composer/Repository/FilesystemRepository.php b/src/Composer/Repository/FilesystemRepository.php index fefc52dfe..a80777033 100644 --- a/src/Composer/Repository/FilesystemRepository.php +++ b/src/Composer/Repository/FilesystemRepository.php @@ -14,6 +14,7 @@ namespace Composer\Repository; use Composer\Json\JsonFile; use Composer\Package\Loader\ArrayLoader; +use Composer\Package\PackageInterface; use Composer\Package\RootPackageInterface; use Composer\Package\AliasPackage; use Composer\Package\Dumper\ArrayDumper; @@ -102,11 +103,15 @@ class FilesystemRepository extends WritableArrayRepository $dumper = new ArrayDumper(); $fs = new Filesystem(); $repoDir = dirname($fs->normalizePath($this->file->getPath())); + $installPaths = array(); foreach ($this->getCanonicalPackages() as $package) { $pkgArray = $dumper->dump($package); $path = $installationManager->getInstallPath($package); - $pkgArray['install-path'] = ('' !== $path && null !== $path) ? $fs->findShortestPath($repoDir, $fs->isAbsolutePath($path) ? $path : getcwd() . '/' . $path, true) : null; + $installPath = ('' !== $path && null !== $path) ? $fs->findShortestPath($repoDir, $fs->isAbsolutePath($path) ? $path : getcwd() . '/' . $path, true) : null; + $installPaths[$package->getName()] = $installPath; + + $pkgArray['install-path'] = $installPath; $data['packages'][] = $pkgArray; // only write to the files the names which are really installed, as we receive the full list @@ -124,24 +129,52 @@ class FilesystemRepository extends WritableArrayRepository $this->file->write($data); if ($this->dumpVersions) { - $versions = $this->generateInstalledVersions($installationManager, $devMode); + $versions = $this->generateInstalledVersions($installationManager, $installPaths, $devMode, $repoDir); - $fs->filePutContentsIfModified($repoDir.'/installed.php', 'filePutContentsIfModified($repoDir.'/installed.php', 'dumpToPhpCode($versions) . ';'."\n"); $installedVersionsClass = file_get_contents(__DIR__.'/../InstalledVersions.php'); - // while not strictly needed since https://github.com/composer/composer/pull/9635 - we keep this for BC - // and overall broader compatibility with people that may not use Composer's ClassLoader. They can - // simply include InstalledVersions.php manually and have it working in a basic way. - $installedVersionsClass = str_replace('private static $installed;', 'private static $installed = '.var_export($versions, true).';', $installedVersionsClass); $fs->filePutContentsIfModified($repoDir.'/InstalledVersions.php', $installedVersionsClass); \Composer\InstalledVersions::reload($versions); } } + private function dumpToPhpCode(array $array = array(), $level = 0) + { + $lines = "array(\n"; + $level++; + + foreach ($array as $key => $value) { + $lines .= str_repeat(' ', $level); + $lines .= is_int($key) ? $key . ' => ' : '\'' . $key . '\' => '; + + if (is_array($value)) { + if (!empty($value)) { + $lines .= $this->dumpToPhpCode($value, $level); + } else { + $lines .= "array(),\n"; + } + } elseif ($key === 'install_path' && is_string($value)) { + $fs = new Filesystem(); + if ($fs->isAbsolutePath($value)) { + $lines .= var_export($value, true) . ",\n"; + } else { + $lines .= "__DIR__ . " . var_export('/' . $value, true) . ",\n"; + } + } else { + $lines .= var_export($value, true) . ",\n"; + } + } + + $lines .= str_repeat(' ', $level - 1) . ')' . ($level - 1 == 0 ? '' : ",\n"); + + return $lines; + } + /** * @return ?array */ - private function generateInstalledVersions(InstallationManager $installationManager, $devMode) + private function generateInstalledVersions(InstallationManager $installationManager, array $installPaths, $devMode, $repoDir) { if (!$this->dumpVersions) { return null; @@ -170,16 +203,26 @@ class FilesystemRepository extends WritableArrayRepository $reference = ($package->getSourceReference() ?: $package->getDistReference()) ?: null; } + if ($package instanceof RootPackageInterface) { + $fs = new Filesystem(); + $to = getcwd(); + $installPath = $fs->findShortestPath($repoDir, $to, true); + } else { + $installPath = $installPaths[$package->getName()]; + } + $versions['versions'][$package->getName()] = array( 'pretty_version' => $package->getPrettyVersion(), 'version' => $package->getVersion(), + 'type' => $package->getType(), + 'install_path' => $installPath, 'aliases' => array(), 'reference' => $reference, - 'dev-requirement' => isset($devPackages[$package->getName()]), + 'dev_requirement' => isset($devPackages[$package->getName()]), ); if ($package instanceof RootPackageInterface) { $versions['root'] = $versions['versions'][$package->getName()]; - unset($versions['root']['dev-requirement']); + unset($versions['root']['dev_requirement']); $versions['root']['name'] = $package->getName(); $versions['root']['dev'] = $devMode; } @@ -193,10 +236,10 @@ class FilesystemRepository extends WritableArrayRepository if (PlatformRepository::isPlatformPackage($replace->getTarget())) { continue; } - if (!isset($versions['versions'][$replace->getTarget()]['dev-requirement'])) { - $versions['versions'][$replace->getTarget()]['dev-requirement'] = $isDevPackage; + if (!isset($versions['versions'][$replace->getTarget()]['dev_requirement'])) { + $versions['versions'][$replace->getTarget()]['dev_requirement'] = $isDevPackage; } elseif (!$isDevPackage) { - $versions['versions'][$replace->getTarget()]['dev-requirement'] = false; + $versions['versions'][$replace->getTarget()]['dev_requirement'] = false; } $replaced = $replace->getPrettyConstraint(); if ($replaced === 'self.version') { @@ -211,10 +254,10 @@ class FilesystemRepository extends WritableArrayRepository if (PlatformRepository::isPlatformPackage($provide->getTarget())) { continue; } - if (!isset($versions['versions'][$provide->getTarget()]['dev-requirement'])) { - $versions['versions'][$provide->getTarget()]['dev-requirement'] = $isDevPackage; + if (!isset($versions['versions'][$provide->getTarget()]['dev_requirement'])) { + $versions['versions'][$provide->getTarget()]['dev_requirement'] = $isDevPackage; } elseif (!$isDevPackage) { - $versions['versions'][$provide->getTarget()]['dev-requirement'] = false; + $versions['versions'][$provide->getTarget()]['dev_requirement'] = false; } $provided = $provide->getPrettyConstraint(); if ($provided === 'self.version') { diff --git a/tests/Composer/Test/InstalledVersionsTest.php b/tests/Composer/Test/InstalledVersionsTest.php index 7896548e2..d48cdf09a 100644 --- a/tests/Composer/Test/InstalledVersionsTest.php +++ b/tests/Composer/Test/InstalledVersionsTest.php @@ -17,6 +17,8 @@ use Composer\Semver\VersionParser; class InstalledVersionsTest extends TestCase { + private $root; + public static function setUpBeforeClass() { // disable multiple-ClassLoader-based checks of InstalledVersions by making it seem like no @@ -33,6 +35,9 @@ class InstalledVersionsTest extends TestCase public function setUp() { + $this->root = $this->getUniqueTmpDirectory(); + + $dir = $this->root; InstalledVersions::reload(require __DIR__.'/Repository/Fixtures/installed.php'); } @@ -47,6 +52,7 @@ class InstalledVersionsTest extends TestCase 'foo/impl', 'foo/impl2', 'foo/replaced', + 'meta/package', ); $this->assertSame($names, InstalledVersions::getInstalledPackages()); } @@ -69,6 +75,7 @@ class InstalledVersionsTest extends TestCase array(true, '__root__'), array(true, 'b/replacer'), array(false, 'not/there'), + array(true, 'meta/package'), ); } @@ -187,6 +194,8 @@ class InstalledVersionsTest extends TestCase $this->assertSame(array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', + 'type' => 'library', + 'install_path' => $this->root . '/./', 'aliases' => array( '1.10.x-dev', ), @@ -198,6 +207,7 @@ class InstalledVersionsTest extends TestCase public function testGetRawData() { + $dir = $this->root; $this->assertSame(require __DIR__.'/Repository/Fixtures/installed.php', InstalledVersions::getRawData()); } @@ -222,4 +232,24 @@ class InstalledVersionsTest extends TestCase array(null, 'c/c'), ); } + + public function testGetInstalledPackagesByType() + { + $names = array( + '__root__', + 'a/provider', + 'a/provider2', + 'b/replacer', + 'c/c', + ); + + $this->assertSame($names, \Composer\InstalledVersions::getInstalledPackagesByType('library')); + } + + public function testGetInstallPath() + { + $this->assertSame(realpath($this->root), realpath(\Composer\InstalledVersions::getInstallPath('__root__'))); + $this->assertSame('/foo/bar/vendor/c/c', \Composer\InstalledVersions::getInstallPath('c/c')); + $this->assertNull(\Composer\InstalledVersions::getInstallPath('foo/impl')); + } } diff --git a/tests/Composer/Test/Mock/InstallationManagerMock.php b/tests/Composer/Test/Mock/InstallationManagerMock.php index 4642a4800..7898250cc 100644 --- a/tests/Composer/Test/Mock/InstallationManagerMock.php +++ b/tests/Composer/Test/Mock/InstallationManagerMock.php @@ -119,4 +119,9 @@ class InstallationManagerMock extends InstallationManager { // noop } + + public function getInstalledPackagesByType() + { + return $this->installed; + } } diff --git a/tests/Composer/Test/Repository/FilesystemRepositoryTest.php b/tests/Composer/Test/Repository/FilesystemRepositoryTest.php index 72e7adfa3..da754661b 100644 --- a/tests/Composer/Test/Repository/FilesystemRepositoryTest.php +++ b/tests/Composer/Test/Repository/FilesystemRepositoryTest.php @@ -12,12 +12,15 @@ namespace Composer\Test\Repository; +use Composer\Package\RootPackageInterface; use Composer\Repository\FilesystemRepository; use Composer\Test\TestCase; use Composer\Json\JsonFile; class FilesystemRepositoryTest extends TestCase { + private $root; + public function testRepositoryRead() { $json = $this->createJsonFileMock(); @@ -121,6 +124,9 @@ class FilesystemRepositoryTest extends TestCase public function testRepositoryWritesInstalledPhp() { $dir = $this->getUniqueTmpDirectory(); + $this->root = $dir; + chdir($dir); + $json = new JsonFile($dir.'/installed.json'); $rootPackage = $this->getPackage('__root__', 'dev-master', 'Composer\Package\RootPackage'); @@ -152,12 +158,34 @@ class FilesystemRepositoryTest extends TestCase $pkg = $this->getPackage('c/c', '3.0'); $repository->addPackage($pkg); + $pkg = $this->getPackage('meta/package', '3.0'); + $pkg->setType('metapackage'); + $repository->addPackage($pkg); + $im = $this->getMockBuilder('Composer\Installer\InstallationManager') ->disableOriginalConstructor() ->getMock(); $im->expects($this->any()) ->method('getInstallPath') - ->will($this->returnValue('/foo/bar/vendor/woop/woop')); + ->will($this->returnCallback(function ($package) use ($dir) { + // check for empty paths handling + if ($package->getType() === 'metapackage') { + return ''; + } + + if ($package->getName() === 'c/c') { + // check for absolute paths + return '/foo/bar/vendor/c/c'; + } + + // check for cwd + if ($package instanceof RootPackageInterface) { + return $dir; + } + + // check for relative paths + return 'vendor/'.$package->getName(); + })); $repository->write(true, $im); $this->assertSame(require __DIR__.'/Fixtures/installed.php', require $dir.'/installed.php'); diff --git a/tests/Composer/Test/Repository/Fixtures/installed.php b/tests/Composer/Test/Repository/Fixtures/installed.php index 8c6c148ae..fb9870def 100644 --- a/tests/Composer/Test/Repository/Fixtures/installed.php +++ b/tests/Composer/Test/Repository/Fixtures/installed.php @@ -14,6 +14,9 @@ return array( 'root' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', + 'type' => 'library', + // @phpstan-ignore-next-line + 'install_path' => $dir . '/./', 'aliases' => array( '1.10.x-dev', ), @@ -25,44 +28,58 @@ return array( '__root__' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', + 'type' => 'library', + // @phpstan-ignore-next-line + 'install_path' => $dir . '/./', 'aliases' => array( '1.10.x-dev', ), 'reference' => 'sourceref-by-default', - 'dev-requirement' => false, + 'dev_requirement' => false, ), 'a/provider' => array( 'pretty_version' => '1.1', 'version' => '1.1.0.0', + 'type' => 'library', + // @phpstan-ignore-next-line + 'install_path' => $dir . '/vendor/a/provider', 'aliases' => array(), 'reference' => 'distref-as-no-source', - 'dev-requirement' => false, + 'dev_requirement' => false, ), 'a/provider2' => array( 'pretty_version' => '1.2', 'version' => '1.2.0.0', + 'type' => 'library', + // @phpstan-ignore-next-line + 'install_path' => $dir . '/vendor/a/provider2', 'aliases' => array( '1.4', ), 'reference' => 'distref-as-installed-from-dist', - 'dev-requirement' => false, + 'dev_requirement' => false, ), 'b/replacer' => array( 'pretty_version' => '2.2', 'version' => '2.2.0.0', + 'type' => 'library', + // @phpstan-ignore-next-line + 'install_path' => $dir . '/vendor/b/replacer', 'aliases' => array(), 'reference' => null, - 'dev-requirement' => false, + 'dev_requirement' => false, ), 'c/c' => array( 'pretty_version' => '3.0', 'version' => '3.0.0.0', + 'type' => 'library', + 'install_path' => '/foo/bar/vendor/c/c', 'aliases' => array(), 'reference' => null, - 'dev-requirement' => true, + 'dev_requirement' => true, ), 'foo/impl' => array( - 'dev-requirement' => false, + 'dev_requirement' => false, 'provided' => array( '^1.1', '1.2', @@ -71,7 +88,7 @@ return array( ), ), 'foo/impl2' => array( - 'dev-requirement' => false, + 'dev_requirement' => false, 'provided' => array( '2.0', ), @@ -80,10 +97,19 @@ return array( ), ), 'foo/replaced' => array( - 'dev-requirement' => false, + 'dev_requirement' => false, 'replaced' => array( '^3.0', ), ), + 'meta/package' => array( + 'pretty_version' => '3.0', + 'version' => '3.0.0.0', + 'type' => 'metapackage', + 'install_path' => null, + 'aliases' => array(), + 'reference' => null, + 'dev_requirement' => false, + ) ), );