Repository: php/pie (php)

Issue:
If composer.lock available, build full list of extensions for a PHP project `pie install`
The initial implementation of `pie install` (when cwd is a PHP project) is to examine the root package and compile a list of extensions required. A more complete implementation should actually use `composer.lock` to compile the list of extensions required through transitive dependencies as well.

Example:

```
 - root package
   - requires: ext-foo
   - requires: a/b
     - requires: ext-baz
 ```

At the moment, `ext-baz` would NOT be picked for install (since we only look at the root package links).

Also note [this comment](https://github.com/php/pie/pull/213#issuecomment-2806269817) from Stof:

> It would be great to support identifying all extensions required by a project when a composer.lock file is present.
> 
> the heroku PHP buildpack has such logic looking at the lock file: https://github.com/heroku/heroku-buildpack-php/blob/6af5a55aab8a8f9417b8c8a14b0a6f91bf005aa0/bin/util/platform.php#L124

NOTE: this is work in progress already

Pull request description:
Check for all exts required by a PHP project
Fixes #222 

Patch:
diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php
index cf87564..e80f008 100644
--- a/src/Command/InstallExtensionsForProjectCommand.php
+++ b/src/Command/InstallExtensionsForProjectCommand.php
@@ -11,8 +11,9 @@ use Php\Pie\ComposerIntegration\PieComposerRequest;
 use Php\Pie\ComposerIntegration\PieJsonEditor;
 use Php\Pie\ExtensionName;
 use Php\Pie\ExtensionType;
+use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject;
+use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired;
 use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages;
-use Php\Pie\Installing\InstallForPhpProject\FindRootPackage;
 use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath;
 use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage;
 use Psr\Container\ContainerInterface;
@@ -26,7 +27,6 @@ use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Question\ChoiceQuestion;
 use Throwable;
 
-use function array_filter;
 use function array_keys;
 use function array_map;
 use function array_merge;
@@ -37,8 +37,6 @@ use function in_array;
 use function is_string;
 use function realpath;
 use function sprintf;
-use function str_starts_with;
-use function strlen;
 use function strpos;
 use function substr;
 
@@ -51,7 +49,8 @@ use const PHP_EOL;
 final class InstallExtensionsForProjectCommand extends Command
 {
     public function __construct(
-        private readonly FindRootPackage $findRootPackage,
+        private readonly ComposerFactoryForProject $composerFactoryForProject,
+        private readonly DetermineExtensionsRequired $determineExtensionsRequired,
         private readonly FindMatchingPackages $findMatchingPackages,
         private readonly InstallSelectedPackage $installSelectedPackage,
         private readonly InstallPiePackageFromPath $installPiePackageFromPath,
@@ -72,7 +71,7 @@ final class InstallExtensionsForProjectCommand extends Command
         $helper = $this->getHelper('question');
         assert($helper instanceof QuestionHelper);
 
-        $rootPackage = $this->findRootPackage->forCwd($input, $output);
+        $rootPackage = $this->composerFactoryForProject->rootPackage($input, $output);
 
         if (ExtensionType::isValid($rootPackage->getType())) {
             $cwd = realpath(getcwd());
@@ -100,17 +99,7 @@ final class InstallExtensionsForProjectCommand extends Command
             getcwd(),
         ));
 
-        $rootPackageExtensionsRequired = array_filter(
-            array_merge($rootPackage->getRequires(), $rootPackage->getDevRequires()),
-            static function (Link $link) {
-                $linkTarget = $link->getTarget();
-                if (! str_starts_with($linkTarget, 'ext-')) {
-                    return false;
-                }
-
-                return ExtensionName::isValidExtensionName(substr($linkTarget, strlen('ext-')));
-            },
-        );
+        $extensionsRequired = $this->determineExtensionsRequired->forProject($this->composerFactoryForProject->composer($input, $output));
 
         $pieComposer = PieComposerFactory::createPieComposer(
             $this->container,
@@ -125,7 +114,7 @@ final class InstallExtensionsForProjectCommand extends Command
         $anyErrorsHappened = false;
 
         array_walk(
-            $rootPackageExtensionsRequired,
+            $extensionsRequired,
             function (Link $link) use ($pieComposer, $phpEnabledExtensions, $input, $output, $helper, &$anyErrorsHappened): void {
                 $extension = ExtensionName::normaliseFromString($link->getTarget());
 
@@ -133,7 +122,7 @@ final class InstallExtensionsForProjectCommand extends Command
                     $output->writeln(sprintf(
                         '%s: <info>%s</info> ✅ Already installed',
                         $link->getDescription(),
-                        $extension->name(),
+                        $link,
                     ));
 
                     return;
@@ -142,7 +131,7 @@ final class InstallExtensionsForProjectCommand extends Command
                 $output->writeln(sprintf(
                     '%s: <comment>%s</comment> ⚠️  Missing',
                     $link->getDescription(),
-                    $extension->name(),
+                    $link,
                 ));
 
                 try {
diff --git a/src/Installing/InstallForPhpProject/ComposerFactoryForProject.php b/src/Installing/InstallForPhpProject/ComposerFactoryForProject.php
new file mode 100644
index 0000000..79fd486
--- /dev/null
+++ b/src/Installing/InstallForPhpProject/ComposerFactoryForProject.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\Installing\InstallForPhpProject;
+
+use Composer\Composer;
+use Composer\Factory as ComposerFactory;
+use Composer\IO\ConsoleIO;
+use Composer\Package\RootPackageInterface;
+use Symfony\Component\Console\Helper\HelperSet;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
+class ComposerFactoryForProject
+{
+    private Composer|null $memoizedComposer = null;
+
+    public function composer(InputInterface $input, OutputInterface $output): Composer
+    {
+        if ($this->memoizedComposer === null) {
+            $this->memoizedComposer = ComposerFactory::create(new ConsoleIO(
+                $input,
+                $output,
+                new HelperSet([]),
+            ));
+        }
+
+        return $this->memoizedComposer;
+    }
+
+    public function rootPackage(InputInterface $input, OutputInterface $output): RootPackageInterface
+    {
+        return $this->composer($input, $output)->getPackage();
+    }
+}
diff --git a/src/Installing/InstallForPhpProject/DetermineExtensionsRequired.php b/src/Installing/InstallForPhpProject/DetermineExtensionsRequired.php
new file mode 100644
index 0000000..7e14a61
--- /dev/null
+++ b/src/Installing/InstallForPhpProject/DetermineExtensionsRequired.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\Installing\InstallForPhpProject;
+
+use Composer\Composer;
+use Composer\Package\Link;
+use Composer\Repository\InstalledRepository;
+use Composer\Repository\RootPackageRepository;
+use Php\Pie\ExtensionName;
+
+use function array_filter;
+use function in_array;
+use function ksort;
+use function str_starts_with;
+use function strlen;
+use function substr;
+
+class DetermineExtensionsRequired
+{
+    public static function linkFilter(Link $link): bool
+    {
+        $linkTarget = $link->getTarget();
+        if (! str_starts_with($linkTarget, 'ext-')) {
+            return false;
+        }
+
+        return ExtensionName::isValidExtensionName(substr($linkTarget, strlen('ext-')));
+    }
+
+    /** @return array<string, Link> */
+    public function forProject(Composer $composer): array
+    {
+        $requires          = [];
+        $removeDevPackages = [];
+
+        /** {@see \Composer\Command\CheckPlatformReqsCommand::execute} */
+        $installedRepo = $composer->getRepositoryManager()->getLocalRepository();
+        if (! $installedRepo->getPackages()) {
+            $installedRepo = $composer->getLocker()->getLockedRepository();
+        } else {
+            $removeDevPackages = $installedRepo->getDevPackageNames();
+        }
+
+        foreach (array_filter($composer->getPackage()->getDevRequires(), [self::class, 'linkFilter']) as $require => $link) {
+            $requires[$require] = $link;
+        }
+
+        $installedRepo = new InstalledRepository([$installedRepo, new RootPackageRepository(clone $composer->getPackage())]);
+
+        foreach ($installedRepo->getPackages() as $package) {
+            if (in_array($package->getName(), $removeDevPackages, true)) {
+                continue;
+            }
+
+            foreach (array_filter($package->getRequires(), [self::class, 'linkFilter']) as $require => $link) {
+                $requires[$require] = $link;
+            }
+        }
+
+        ksort($requires);
+
+        return $requires;
+    }
+}
diff --git a/src/Installing/InstallForPhpProject/FindRootPackage.php b/src/Installing/InstallForPhpProject/FindRootPackage.php
deleted file mode 100644
index 3e26db9..0000000
--- a/src/Installing/InstallForPhpProject/FindRootPackage.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Php\Pie\Installing\InstallForPhpProject;
-
-use Composer\Factory as ComposerFactory;
-use Composer\IO\ConsoleIO;
-use Composer\Package\RootPackageInterface;
-use Symfony\Component\Console\Helper\HelperSet;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-
-/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
-class FindRootPackage
-{
-    public function forCwd(InputInterface $input, OutputInterface $output): RootPackageInterface
-    {
-        $io = new ConsoleIO($input, $output, new HelperSet([]));
-
-        return ComposerFactory::create($io)->getPackage();
-    }
-}


Test patch:
diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php
index 5376f8c..009fa2f 100644
--- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php
+++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php
@@ -4,16 +4,20 @@ declare(strict_types=1);
 
 namespace Php\PieIntegrationTest\Command;
 
+use Composer\Composer;
 use Composer\Package\Link;
 use Composer\Package\RootPackage;
+use Composer\Repository\InstalledArrayRepository;
+use Composer\Repository\RepositoryManager;
 use Composer\Semver\Constraint\Constraint;
 use Php\Pie\Command\InstallExtensionsForProjectCommand;
 use Php\Pie\ComposerIntegration\MinimalHelperSet;
 use Php\Pie\ComposerIntegration\PieJsonEditor;
 use Php\Pie\ComposerIntegration\QuieterConsoleIO;
 use Php\Pie\ExtensionType;
+use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject;
+use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired;
 use Php\Pie\Installing\InstallForPhpProject\FindMatchingPackages;
-use Php\Pie\Installing\InstallForPhpProject\FindRootPackage;
 use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath;
 use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage;
 use PHPUnit\Framework\Attributes\CoversClass;
@@ -35,7 +39,7 @@ use function getcwd;
 final class InstallExtensionsForProjectCommandTest extends TestCase
 {
     private CommandTester $commandTester;
-    private FindRootPackage&MockObject $findRootpackage;
+    private ComposerFactoryForProject&MockObject $composerFactoryForProject;
     private FindMatchingPackages&MockObject $findMatchingPackages;
     private InstallSelectedPackage&MockObject $installSelectedPackage;
     private QuestionHelper&MockObject $questionHelper;
@@ -63,14 +67,15 @@ final class InstallExtensionsForProjectCommandTest extends TestCase
             },
         );
 
-        $this->findRootpackage        = $this->createMock(FindRootPackage::class);
-        $this->findMatchingPackages   = $this->createMock(FindMatchingPackages::class);
-        $this->installSelectedPackage = $this->createMock(InstallSelectedPackage::class);
-        $this->installPiePackage      = $this->createMock(InstallPiePackageFromPath::class);
-        $this->questionHelper         = $this->createMock(QuestionHelper::class);
+        $this->composerFactoryForProject = $this->createMock(ComposerFactoryForProject::class);
+        $this->findMatchingPackages      = $this->createMock(FindMatchingPackages::class);
+        $this->installSelectedPackage    = $this->createMock(InstallSelectedPackage::class);
+        $this->installPiePackage         = $this->createMock(InstallPiePackageFromPath::class);
+        $this->questionHelper            = $this->createMock(QuestionHelper::class);
 
         $cmd = new InstallExtensionsForProjectCommand(
-            $this->findRootpackage,
+            $this->composerFactoryForProject,
+            new DetermineExtensionsRequired(),
             $this->findMatchingPackages,
             $this->installSelectedPackage,
             $this->installPiePackage,
@@ -89,7 +94,18 @@ final class InstallExtensionsForProjectCommandTest extends TestCase
             'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE),
             'ext-foobar' => new Link('my/project', 'ext-foobar', new Constraint('=', '*'), Link::TYPE_REQUIRE),
         ]);
-        $this->findRootpackage->method('forCwd')->willReturn($rootPackage);
+        $this->composerFactoryForProject->method('rootPackage')->willReturn($rootPackage);
+
+        $installedRepository = new InstalledArrayRepository([$rootPackage]);
+
+        $repositoryManager = $this->createMock(RepositoryManager::class);
+        $repositoryManager->method('getLocalRepository')->willReturn($installedRepository);
+
+        $composer = $this->createMock(Composer::class);
+        $composer->method('getPackage')->willReturn($rootPackage);
+        $composer->method('getRepositoryManager')->willReturn($repositoryManager);
+
+        $this->composerFactoryForProject->method('composer')->willReturn($composer);
 
         $this->findMatchingPackages->method('for')->willReturn([
             ['name' => 'vendor1/foobar', 'description' => 'The official foobar implementation'],
@@ -111,15 +127,15 @@ final class InstallExtensionsForProjectCommandTest extends TestCase
 
         $this->commandTester->assertCommandIsSuccessful();
         self::assertStringContainsString('Checking extensions for your project my/project', $outputString);
-        self::assertStringContainsString('requires: standard ✅ Already installed', $outputString);
-        self::assertStringContainsString('requires: foobar ⚠️  Missing', $outputString);
+        self::assertStringContainsString('requires: my/project requires ext-standard (== *) ✅ Already installed', $outputString);
+        self::assertStringContainsString('requires: my/project requires ext-foobar (== *) ⚠️  Missing', $outputString);
     }
 
     public function testInstallingExtensionsForPieProject(): void
     {
         $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3');
         $rootPackage->setType(ExtensionType::PhpModule->value);
-        $this->findRootpackage->method('forCwd')->willReturn($rootPackage);
+        $this->composerFactoryForProject->method('rootPackage')->willReturn($rootPackage);
 
         $this->installPiePackage
             ->expects(self::once())