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