From 10950a576104884ac1b29551351976d48e96a6af Mon Sep 17 00:00:00 2001 From: phenaproxima <phenaproxima@205645.no-reply.drupal.org> Date: Tue, 16 Aug 2022 19:48:24 +0000 Subject: [PATCH] Issue #3296261 by tedbow, phenaproxima, drumm: Add the ability to map package names to project names and vice-versa --- composer.json | 2 +- package_manager/src/ComposerUtility.php | 109 ++++++++++++++ .../project_package_conversion/composer.json | 1 + .../project_package_conversion/composer.lock | 1 + .../vendor/composer/installed.json | 29 ++++ .../vendor/composer/installed.php | 31 ++++ .../any_sub_folder/any_yml_file.info.yml.hide | 3 + .../custom_module/custom_module.info.yml.hide | 1 + .../not_match_project.info.yml.hide | 1 + .../other_project/other_project.info.yml.hide | 1 + .../packge_project_match.info.yml.hide | 1 + .../tests/src/Kernel/ComposerUtilityTest.php | 138 ++++++++++++++++++ .../src/Unit/InstalledPackagesDataTest.php | 42 ++++++ 13 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 package_manager/tests/fixtures/project_package_conversion/composer.json create mode 100644 package_manager/tests/fixtures/project_package_conversion/composer.lock create mode 100644 package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json create mode 100644 package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php create mode 100644 package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide create mode 100644 package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide create mode 100644 package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide create mode 100644 package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide create mode 100644 package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide create mode 100644 package_manager/tests/src/Unit/InstalledPackagesDataTest.php diff --git a/composer.json b/composer.json index 84e35583ea..ffde026090 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "drupal/core": "^9.3", "php-tuf/composer-stager": "^1.0.0-beta2", "composer/composer": "^2.2.12 || ^2.3.5", - "composer-runtime-api": "^2.0.9", + "composer-runtime-api": "^2.1", "symfony/config": "^4.4 || ^6.1", "php": ">=7.4.0" }, diff --git a/package_manager/src/ComposerUtility.php b/package_manager/src/ComposerUtility.php index b593bb0f7d..325d1fce52 100644 --- a/package_manager/src/ComposerUtility.php +++ b/package_manager/src/ComposerUtility.php @@ -8,6 +8,7 @@ use Composer\IO\NullIO; use Composer\Package\PackageInterface; use Composer\Semver\Comparator; use Drupal\Component\Serialization\Json; +use Drupal\Component\Serialization\Yaml; /** * Defines a utility object to get information from Composer's API. @@ -190,4 +191,112 @@ class ComposerUtility { return array_filter($packages, $filter, ARRAY_FILTER_USE_BOTH); } + /** + * Returns installed package data from Composer's `installed.php`. + * + * @return array + * The installed package data as represented in Composer's `installed.php`, + * keyed by package name. + */ + private function getInstalledPackagesData(): array { + $installed_php = implode(DIRECTORY_SEPARATOR, [ + // Composer returns the absolute path to the vendor directory by default. + $this->getComposer()->getConfig()->get('vendor-dir'), + 'composer', + 'installed.php', + ]); + $data = include $installed_php; + return $data['versions']; + } + + /** + * Returns the Drupal project name for a given Composer package. + * + * @param string $package_name + * The name of the package. + * + * @return string|null + * The name of the Drupal project installed by the package, or NULL if: + * - The package is not installed. + * - The package is not of a supported type (one of `drupal-module`, + * `drupal-theme`, or `drupal-profile`). + * - The package name does not begin with `drupal/`. + * - The project name could not otherwise be determined. + */ + public function getProjectForPackage(string $package_name): ?string { + $data = $this->getInstalledPackagesData(); + + if (array_key_exists($package_name, $data)) { + $package = $data[$package_name]; + + $supported_package_types = [ + 'drupal-module', + 'drupal-theme', + 'drupal-profile', + ]; + // Only consider packages which are packaged by drupal.org and will be + // known to the core Update module. + if (str_starts_with($package_name, 'drupal/') && in_array($package['type'], $supported_package_types, TRUE)) { + return $this->scanForProjectName($package['install_path']); + } + } + return NULL; + } + + /** + * Returns the package name for a given Drupal project. + * + * @param string $project_name + * The name of the project. + * + * @return string|null + * The name of the Composer package which installs the project, or NULL if + * it could not be determined. + */ + public function getPackageForProject(string $project_name): ?string { + $installed = $this->getInstalledPackagesData(); + + // If we're lucky, the package name is the project name, prefixed with + // `drupal/`. + if (array_key_exists("drupal/$project_name", $installed)) { + return "drupal/$project_name"; + } + + $installed = array_keys($installed); + foreach ($installed as $package_name) { + if ($this->getProjectForPackage($package_name) === $project_name) { + return $package_name; + } + } + return NULL; + } + + /** + * Scans a given path to determine the Drupal project name. + * + * The path will be scanned for `.info.yml` files containing a `project` key. + * + * @param string $path + * The path to scan. + * + * @return string|null + * The name of the project, as declared in the first found `.info.yml` which + * contains a `project` key, or NULL if none was found. + */ + private function scanForProjectName(string $path): ?string { + $iterator = new \RecursiveDirectoryIterator($path); + $iterator = new \RecursiveIteratorIterator($iterator); + $iterator = new \RegexIterator($iterator, '/.+\.info\.yml$/', \RecursiveRegexIterator::GET_MATCH); + + foreach ($iterator as $match) { + $info = file_get_contents($match[0]); + $info = Yaml::decode($info); + + if (is_string($info['project']) && !empty($info['project'])) { + return $info['project']; + } + } + return NULL; + } + } diff --git a/package_manager/tests/fixtures/project_package_conversion/composer.json b/package_manager/tests/fixtures/project_package_conversion/composer.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/composer.json @@ -0,0 +1 @@ +{} diff --git a/package_manager/tests/fixtures/project_package_conversion/composer.lock b/package_manager/tests/fixtures/project_package_conversion/composer.lock new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/composer.lock @@ -0,0 +1 @@ +{} diff --git a/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json b/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json new file mode 100644 index 0000000000..eecd41db82 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json @@ -0,0 +1,29 @@ +{ + "packages": [ + { + "name": "drupal/package_project_match", + "version": "6.1.3", + "type": "drupal-module" + }, + { + "name": "drupal/not_match_package", + "version": "6.1.3", + "type": "drupal-theme" + }, + { + "name": "non_drupal/other_project", + "version": "6.1.3", + "type": "drupal-module" + }, + { + "name": "drupal/nested_no_match_package", + "version": "6.1.3", + "type": "drupal-profile" + }, + { + "name": "drupal/custom_module", + "version": "6.1.3", + "type": "drupal-custom-module" + } + ] +} diff --git a/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php b/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php new file mode 100644 index 0000000000..303ef1a8e8 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php @@ -0,0 +1,31 @@ +<?php + +/** + * @file + */ + +$projects_dir = __DIR__ . '/../../web/projects'; +return [ + 'versions' => [ + 'drupal/package_project_match' => [ + 'type' => 'drupal-module', + 'install_path' => $projects_dir . '/package_project_match', + ], + 'drupal/not_match_package' => [ + 'type' => 'drupal-module', + 'install_path' => $projects_dir . '/not_match_project', + ], + 'drupal/nested_no_match_package' => [ + 'type' => 'drupal-module', + 'install_path' => $projects_dir . '/any_folder_name', + ], + 'non_drupal/other_project' => [ + 'type' => 'drupal-module', + 'install_path' => $projects_dir . '/other_project', + ], + 'drupal/custom_module' => [ + 'type' => 'drupal-custom-module', + 'install_path' => $projects_dir . '/custom_module', + ], + ], +]; diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide new file mode 100644 index 0000000000..5b69176b90 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide @@ -0,0 +1,3 @@ +# A test info.yml file where the folder names and info.yml file names do not match the project or package. +# Only the project key in this file need to match. +project: nested_no_match_project diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide new file mode 100644 index 0000000000..93021a1460 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide @@ -0,0 +1 @@ +project: custom_module diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide new file mode 100644 index 0000000000..7838d71de5 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide @@ -0,0 +1 @@ +project: not_match_project diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide new file mode 100644 index 0000000000..ca54a40db9 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide @@ -0,0 +1 @@ +project: other_project diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide new file mode 100644 index 0000000000..84896e4f27 --- /dev/null +++ b/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide @@ -0,0 +1 @@ +project: package_project_match diff --git a/package_manager/tests/src/Kernel/ComposerUtilityTest.php b/package_manager/tests/src/Kernel/ComposerUtilityTest.php index 5000902b34..bd1d7c16f9 100644 --- a/package_manager/tests/src/Kernel/ComposerUtilityTest.php +++ b/package_manager/tests/src/Kernel/ComposerUtilityTest.php @@ -5,6 +5,9 @@ namespace Drupal\Tests\package_manager\Kernel; use Drupal\KernelTests\KernelTestBase; use Drupal\package_manager\ComposerUtility; use org\bovigo\vfs\vfsStream; +use org\bovigo\vfs\vfsStreamDirectory; +use org\bovigo\vfs\vfsStreamFile; +use org\bovigo\vfs\visitor\vfsStreamAbstractVisitor; /** * @coversDefaultClass \Drupal\package_manager\ComposerUtility @@ -18,6 +21,45 @@ class ComposerUtilityTest extends KernelTestBase { */ protected static $modules = ['package_manager']; + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $fixture = vfsStream::newDirectory('fixture'); + vfsStream::copyFromFileSystem(__DIR__ . '/../../fixtures/project_package_conversion', $fixture); + $this->vfsRoot->addChild($fixture); + + // Strip the `.hide` suffix from all `.info.yml.hide` files. Drupal's coding + // standards don't allow info files to have the `project` key, but we need + // it to be present for testing. + vfsStream::inspect(new class () extends vfsStreamAbstractVisitor { + + /** + * {@inheritdoc} + */ + public function visitFile(vfsStreamFile $file) { + $name = $file->getName(); + + if (str_ends_with($name, '.info.yml.hide')) { + $new_name = basename($name, '.hide'); + $file->rename($new_name); + } + } + + /** + * {@inheritdoc} + */ + public function visitDirectory(vfsStreamDirectory $dir) { + foreach ($dir->getChildren() as $child) { + $this->visit($child); + } + } + + }); + } + /** * Tests that ComposerUtility disables automatic creation of .htaccess files. */ @@ -91,4 +133,100 @@ class ComposerUtilityTest extends KernelTestBase { $this->assertSame(['drupal/updated'], array_keys($updated)); } + /** + * @covers ::getProjectForPackage + * + * @param string $package + * The package name. + * @param string|null $expected_project + * The expected project if any, otherwise NULL. + * + * @dataProvider providerGetProjectForPackage + */ + public function testGetProjectForPackage(string $package, ?string $expected_project): void { + $dir = $this->vfsRoot->getChild('fixture')->url(); + $this->assertSame($expected_project, ComposerUtility::createForDirectory($dir)->getProjectForPackage($package)); + } + + /** + * Data provider for ::testGetProjectForPackage(). + * + * @return mixed[][] + * The test cases. + */ + public function providerGetProjectForPackage(): array { + return [ + 'package and project match' => [ + 'drupal/package_project_match', + 'package_project_match', + ], + 'package and project do not match' => [ + 'drupal/not_match_package', + 'not_match_project', + ], + 'vendor is not drupal' => [ + 'non_drupal/other_project', + NULL, + ], + 'missing package' => [ + 'drupal/missing', + NULL, + ], + 'nested_no_match' => [ + 'drupal/nested_no_match_package', + 'nested_no_match_project', + ], + 'unsupported package type' => [ + 'drupal/custom_module', + NULL, + ], + ]; + } + + /** + * @covers ::getPackageForProject + * + * @param string $project + * The project name. + * @param string|null $expected_package + * The expected package if any, otherwise NULL. + * + * @dataProvider providerGetPackageForProject + */ + public function testGetPackageForProject(string $project, ?string $expected_package): void { + $dir = $this->vfsRoot->getChild('fixture')->url(); + $this->assertSame($expected_package, ComposerUtility::createForDirectory($dir)->getPackageForProject($project)); + } + + /** + * Data provider for ::testGetPackageForProject(). + * + * @return mixed[][] + * The test cases. + */ + public function providerGetPackageForProject(): array { + return [ + 'package and project match' => [ + 'package_project_match', + 'drupal/package_project_match', + ], + 'package and project do not match' => [ + 'not_match_project', + 'drupal/not_match_package', + ], + 'vendor is not drupal' => [ + 'other_project', + NULL, + ], + 'missing package' => [ + 'missing', + NULL, + ], + 'nested_no_match' => [ + 'nested_no_match_project', + 'drupal/nested_no_match_package', + ], + ]; + } + } diff --git a/package_manager/tests/src/Unit/InstalledPackagesDataTest.php b/package_manager/tests/src/Unit/InstalledPackagesDataTest.php new file mode 100644 index 0000000000..606d2e2fa7 --- /dev/null +++ b/package_manager/tests/src/Unit/InstalledPackagesDataTest.php @@ -0,0 +1,42 @@ +<?php + +namespace Drupal\Tests\package_manager\Unit; + +use Composer\Autoload\ClassLoader; +use Drupal\Tests\UnitTestCase; + +/** + * Tests retrieval of package data from Composer's `installed.php`. + * + * ComposerUtility relies on the internal structure of `installed.php` for + * certain operations. This test is intended as an early warning if the file's + * internal structure changes in a way that would break our functionality. + * + * @group package_manager + */ +class InstalledPackagesDataTest extends UnitTestCase { + + /** + * Tests that Composer's `installed.php` file looks how we expect. + */ + public function testinstalledPackagesData(): void { + $loaders = ClassLoader::getRegisteredLoaders(); + $installed_php = key($loaders) . '/composer/installed.php'; + $this->assertFileIsReadable($installed_php); + $data = include $installed_php; + + // There should be a `versions` array whose keys are package names. + $this->assertIsArray($data['versions']); + $this->assertMatchesRegularExpression('|^[a-z0-9\-_]+/[a-z0-9\-_]+$|', key($data['versions'])); + + // The values of `versions` should be arrays of package information that + // includes a non-empty `install_path` string and a non-empty `type` string. + $package = reset($data['versions']); + $this->assertIsArray($package); + $this->assertNotEmpty($package['install_path']); + $this->assertIsString($package['install_path']); + $this->assertNotEmpty($package['type']); + $this->assertIsString($package['type']); + } + +} -- GitLab